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/112] 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/112] 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/112] 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/112] 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/112] 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 ed7db7a7e8efcc614e48c8912d5807f42af87104 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jul 2023 21:28:27 +0200 Subject: [PATCH 006/112] deadline: adding OCIO env var to submit publish job --- .../modules/deadline/plugins/publish/submit_publish_job.py | 3 ++- 1 file changed, 2 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 292fe58cca..e220d96a80 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -146,7 +146,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_SG_USER" + "OPENPYPE_SG_USER", + "OCIO", ] # Add OpenPype version if we are running from build. From 52d643be71d91f0409052a03f85c5b4225b99b13 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jul 2023 21:28:27 +0200 Subject: [PATCH 007/112] deadline: adding OCIO env var to submit publish job From 8497b49951802cda8c62d811ffa1d10cecf52f4f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 12 Jul 2023 21:33:45 +0200 Subject: [PATCH 008/112] adding OCIO env to host submitters - also removing OPENPYPE_VERSION deprecated code --- .../plugins/publish/submit_houdini_render_deadline.py | 2 +- .../deadline/plugins/publish/submit_max_deadline.py | 10 ++++++++-- .../deadline/plugins/publish/submit_maya_deadline.py | 3 ++- .../deadline/plugins/publish/submit_nuke_deadline.py | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) 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 254914a850..1f4770653c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -88,7 +88,7 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", - "OPENPYPE_VERSION" + "OCIO", ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index b6a30e36b7..43c89d2682 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -20,6 +20,7 @@ from openpype.hosts.max.api.lib import ( from openpype.hosts.max.api.lib_rendersettings import RenderSettings from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo +from openpype.lib import is_running_from_build @attr.s @@ -110,9 +111,14 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", - "OPENPYPE_VERSION", - "IS_TEST" + "IS_TEST", + "OCIO", ] + + # Add OpenPype version if we are running from build. + if is_running_from_build(): + keys.append("OPENPYPE_VERSION") + # Add mongo url if it's enabled if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a6cdcb7e71..3370be8815 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -165,7 +165,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV" - "IS_TEST" + "IS_TEST", + "OCIO", ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 4900231783..e52ee632c5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -316,6 +316,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "TOOL_ENV", "FOUNDRY_LICENSE", "OPENPYPE_SG_USER", + "OCIO", ] # Add OpenPype version if we are running from build. From 1b8822a4e0c6a094d77eaa322b98d3b7338921c3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Aug 2023 17:02:23 +0200 Subject: [PATCH 009/112] nuke: removing `customOCIOConfigPath` value in workfile Linux is reversing processing order of preference the way: if workfile is having set value in `customOCIOConfigPath` use it even OCIO env variable is set to some value. This way we are making sure OCIO is read only. --- openpype/hosts/nuke/api/lib.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 364c8eeff4..54e46996d6 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2070,9 +2070,15 @@ class WorkfileSettings(object): str(workfile_settings["OCIO_config"])) else: - # set values to root + # OCIO config path is defined from prelaunch hook self._root_node["colorManagement"].setValue("OCIO") + # restart settings in case some were set previously + # linux is reversing order of preference to prefer what ever + # is set knobs before it apply it form environment variable + if self._root_node["customOCIOConfigPath"].value(): + self._root_node["customOCIOConfigPath"].setValue("") + # we dont need the key anymore workfile_settings.pop("customOCIOConfigPath", None) workfile_settings.pop("colorManagement", None) From 4f9dd21cffe64a29fa49a9a7d76cd2dd5b42f0a0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 10:49:39 +0200 Subject: [PATCH 010/112] removing OCIO env var, since it is not working this way --- .../deadline/plugins/publish/submit_houdini_render_deadline.py | 1 - openpype/modules/deadline/plugins/publish/submit_max_deadline.py | 1 - .../modules/deadline/plugins/publish/submit_maya_deadline.py | 1 - .../modules/deadline/plugins/publish/submit_nuke_deadline.py | 1 - openpype/modules/deadline/plugins/publish/submit_publish_job.py | 1 - 5 files changed, 5 deletions(-) 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 1f4770653c..af341ca8e8 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -88,7 +88,6 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "AVALON_APP_NAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS", - "OCIO", ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 2eb8518618..76fca078e9 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -112,7 +112,6 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "AVALON_APP_NAME", "OPENPYPE_DEV", "IS_TEST", - "OCIO", ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 700d03519c..a0c324ff22 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -207,7 +207,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "AVALON_APP_NAME", "OPENPYPE_DEV" "IS_TEST", - "OCIO", ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index e52ee632c5..4900231783 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -316,7 +316,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, "TOOL_ENV", "FOUNDRY_LICENSE", "OPENPYPE_SG_USER", - "OCIO", ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 963831289a..73a6866d5c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -124,7 +124,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "AVALON_APP_NAME", "OPENPYPE_USERNAME", "OPENPYPE_SG_USER", - "OCIO", ] # Add OpenPype version if we are running from build. From 069fd70546421fa0896fb50b957ec7afac9c795e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 13:49:30 +0200 Subject: [PATCH 011/112] nuke: comunicate there was old residual path set in workfile --- openpype/hosts/nuke/api/lib.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 54e46996d6..cca370ac5e 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2073,11 +2073,12 @@ class WorkfileSettings(object): # OCIO config path is defined from prelaunch hook self._root_node["colorManagement"].setValue("OCIO") - # restart settings in case some were set previously - # linux is reversing order of preference to prefer what ever - # is set knobs before it apply it form environment variable - if self._root_node["customOCIOConfigPath"].value(): - self._root_node["customOCIOConfigPath"].setValue("") + # print previous settings in case some were found in workfile + residual_path = self._root_node["customOCIOConfigPath"].value() + if residual_path: + log.info("Residual OCIO config path found: `{}`".format( + residual_path + )) # we dont need the key anymore workfile_settings.pop("customOCIOConfigPath", None) From 979446ac537feaf15841d66a42841676e7b475d3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 14:08:45 +0200 Subject: [PATCH 012/112] nuke: adding ocio path to workfile making sure it is in environment variable --- openpype/hosts/nuke/api/lib.py | 121 +++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index cca370ac5e..61e42d0d17 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2101,9 +2101,29 @@ class WorkfileSettings(object): # set ocio config path if config_data: - current_ocio_path = os.getenv("OCIO") - if current_ocio_path != config_data["path"]: - message = """ + log.info("OCIO config path found: `{}`".format( + config_data["path"])) + + # check if there's a mismatch between environment and settings + wrong_environment = self._is_settings_different_from_environment( + config_data) + + # if there's no mismatch between environment and settings + if not wrong_environment: + self._set_ocio_config_path_to_workfile(config_data) + + def _is_settings_different_from_environment(self, config_data): + """ Check if OCIO config path is different from environment + + Args: + config_data (dict): OCIO config data from settings + + Returns: + bool: True if there's a mismatch between environment and settings + """ + current_ocio_path = os.getenv("OCIO") + if current_ocio_path != config_data["path"]: + message = """ It seems like there's a mismatch between the OCIO config path set in your Nuke settings and the actual path set in your OCIO environment. @@ -2121,12 +2141,87 @@ Please note the paths for your reference: Reopening Nuke should synchronize these paths and resolve any discrepancies. """ - nuke.message( - message.format( - env_path=current_ocio_path, - settings_path=config_data["path"] - ) + nuke.message( + message.format( + env_path=current_ocio_path, + settings_path=config_data["path"] ) + ) + return True + + def _set_ocio_config_path_to_workfile(self, config_data): + """ Set OCIO config path to workfile + + Path set into nuke workfile. It is trying to replace path with + environment variable if possible. If not, it will set it as it is. + It also saves the script to apply the change, but only if it's not + empty Untitled script. + + Args: + config_data (dict): OCIO config data from settings + + """ + # replace path with env var if possible + ocio_path = self._replace_ocio_path_with_env_var( + config_data["path"] + ) + log.info("Setting OCIO config path to: `{}`".format( + ocio_path)) + + self._root_node["customOCIOConfigPath"].setValue( + ocio_path + ) + self._root_node["OCIO_config"].setValue("custom") + + # only save script if it's not empty + if self._root_node["name"].value() != "": + log.info("Saving script to apply OCIO config path change.") + nuke.scriptSave() + + def _replace_ocio_path_with_env_var(self, path): + """ Replace OCIO config path with environment variable + + Environment variable is added as TCL expression to path. TCL expression + is also replacing backward slashes found in path for windows + formatted values. + + Args: + path (str): OCIO config path + + Returns: + str: OCIO config path with environment variable + """ + # QUESTION: should we also include other names variants + included_vars = [ + "BUILTIN_OCIO_ROOT", + "OPENPYPE_PROJECT_ROOT" + ] + for env_var, env_path in os.environ.items(): + # first check if variable is whitelisted + if all(var_ not in env_var for var_ in included_vars): + # included vars not found in env_var name + continue + + # it has to be directory current process can see + if not os.path.isdir(env_path): + continue + + # make sure paths are in same format + env_path = env_path.replace("\\", "/") + path = path.replace("\\", "/") + + # check if env_path is in path and replace to first found positive + if env_path in path: + # with regsub we make sure path format of slashes is correct + resub_expr = ( + "[regsub -all {{\\\\}} [getenv {}] \"/\"]").format(env_var) + + new_path = path.replace( + env_path, resub_expr + ) + break + + return new_path def set_writes_colorspace(self): ''' Adds correct colorspace to write node dict @@ -2247,11 +2342,11 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. nuke_colorspace = get_nuke_imageio_settings() log.info("Setting colorspace to workfile...") - try: - self.set_root_colorspace(nuke_colorspace) - except AttributeError: - msg = "set_colorspace(): missing `workfile` settings in template" - nuke.message(msg) + # try: + self.set_root_colorspace(nuke_colorspace) + # except AttributeError: + # msg = "set_colorspace(): missing `workfile` settings in template" + # nuke.message(msg) log.info("Setting colorspace to viewers...") try: From 774a1c403137ea68df35d2eccace89d674a044bc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Aug 2023 14:58:46 +0200 Subject: [PATCH 013/112] nuke reverting changes --- .../modules/deadline/plugins/publish/submit_max_deadline.py | 2 +- .../modules/deadline/plugins/publish/submit_maya_deadline.py | 2 +- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 76fca078e9..fff7a4ced5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -111,7 +111,7 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV", - "IS_TEST", + "IS_TEST" ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a0c324ff22..1dfb6e0e5c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -206,7 +206,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, "AVALON_TASK", "AVALON_APP_NAME", "OPENPYPE_DEV" - "IS_TEST", + "IS_TEST" ] # Add OpenPype version if we are running from build. diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 73a6866d5c..2ed21c0621 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -123,7 +123,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_SG_USER", + "OPENPYPE_SG_USER" ] # Add OpenPype version if we are running from build. From 8bdf67b575daf68d0a15a5754a5328c122b12bf7 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 2 Aug 2023 17:16:28 +0100 Subject: [PATCH 014/112] Fix loading hero version for some assets --- .../unreal/plugins/load/load_alembic_animation.py | 8 ++++++-- .../unreal/plugins/load/load_skeletalmesh_abc.py | 8 ++++++-- .../unreal/plugins/load/load_skeletalmesh_fbx.py | 8 ++++++-- .../hosts/unreal/plugins/load/load_staticmesh_abc.py | 11 ++++++----- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py index cb60197a4c..a2aab59cec 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py @@ -76,11 +76,15 @@ class AnimationAlembicLoader(plugin.Loader): asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) - version = context.get('version').get('name') + version = context.get('version') + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version:03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") + f"{root}/{asset}/{name_version}", suffix="") container_name += suffix diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index 0b0030ff77..fc22d4f857 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -78,11 +78,15 @@ class SkeletalMeshAlembicLoader(plugin.Loader): asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) - version = context.get('version').get('name') + version = context.get('version') + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version:03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") + f"{root}/{asset}/{name_version}", suffix="") container_name += suffix diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 09cd37b9db..0cf0bd58dc 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -52,11 +52,15 @@ class SkeletalMeshFBXLoader(plugin.Loader): asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) - version = context.get('version').get('name') + version = context.get('version') + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version:03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") + f"{root}/{asset}/{name_version}", suffix="") container_name += suffix diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index 98e6d962b1..ffded49cd8 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -79,11 +79,12 @@ class StaticMeshAlembicLoader(plugin.Loader): root = "/Game/Ayon/Assets" asset = context.get('asset').get('name') suffix = "_CON" - if asset: - asset_name = "{}_{}".format(asset, name) + asset_name = f"{asset}_{name}" if asset else f"{name}" + version = context.get('version') + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" else: - asset_name = "{}".format(name) - version = context.get('version').get('name') + name_version = f"{name}_v{version:03d}" default_conversion = False if options.get("default_conversion"): @@ -91,7 +92,7 @@ class StaticMeshAlembicLoader(plugin.Loader): tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}_v{version:03d}", suffix="") + f"{root}/{asset}/{name_version}", suffix="") container_name += suffix From 01cf1a45874fd8f8d19fd0b42ddfecaf82ad3f8a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 2 Aug 2023 17:22:21 +0100 Subject: [PATCH 015/112] Add version number for static meshes from fbx --- openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index fa26e252f5..c835bce136 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -78,10 +78,15 @@ class StaticMeshFBXLoader(plugin.Loader): asset_name = "{}_{}".format(asset, name) else: asset_name = "{}".format(name) + version = context.get('version') + if not version.get("name") and version.get('type') == "hero_version": + name_version = f"{name}_hero" + else: + name_version = f"{name}_v{version:03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( - f"{root}/{asset}/{name}", suffix="" + f"{root}/{asset}/{name_version}", suffix="" ) container_name += suffix From 9a0d1d73e0532fd07a82bb202ae1cb3964bb45b2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 3 Aug 2023 09:44:37 +0100 Subject: [PATCH 016/112] Fix loading versioned assets --- openpype/hosts/unreal/plugins/load/load_alembic_animation.py | 2 +- openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py | 2 +- openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py | 2 +- openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py | 2 +- openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py index a2aab59cec..059c1515c0 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py @@ -80,7 +80,7 @@ class AnimationAlembicLoader(plugin.Loader): if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: - name_version = f"{name}_v{version:03d}" + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index fc22d4f857..8848722bd7 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -82,7 +82,7 @@ class SkeletalMeshAlembicLoader(plugin.Loader): if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: - name_version = f"{name}_v{version:03d}" + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 0cf0bd58dc..6fb3476d89 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -56,7 +56,7 @@ class SkeletalMeshFBXLoader(plugin.Loader): if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: - name_version = f"{name}_v{version:03d}" + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index ffded49cd8..20d9a31e03 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -84,7 +84,7 @@ class StaticMeshAlembicLoader(plugin.Loader): if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: - name_version = f"{name}_v{version:03d}" + name_version = f"{name}_v{version.get('name'):03d}" default_conversion = False if options.get("default_conversion"): diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index c835bce136..981003ece2 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -82,7 +82,7 @@ class StaticMeshFBXLoader(plugin.Loader): if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: - name_version = f"{name}_v{version:03d}" + name_version = f"{name}_v{version.get('name'):03d}" tools = unreal.AssetToolsHelpers().get_asset_tools() asset_dir, container_name = tools.create_unique_asset_name( From b376a6710445d0a2108de7244ccae37f61598fb0 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 3 Aug 2023 09:46:36 +0100 Subject: [PATCH 017/112] Added comments --- openpype/hosts/unreal/plugins/load/load_alembic_animation.py | 1 + openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py | 1 + openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py | 1 + openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py | 1 + openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py | 1 + 5 files changed, 5 insertions(+) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py index 059c1515c0..1d60b63f9a 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_animation.py @@ -77,6 +77,7 @@ class AnimationAlembicLoader(plugin.Loader): else: asset_name = "{}".format(name) version = context.get('version') + # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py index 8848722bd7..9285602b64 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_abc.py @@ -79,6 +79,7 @@ class SkeletalMeshAlembicLoader(plugin.Loader): else: asset_name = "{}".format(name) version = context.get('version') + # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: diff --git a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py index 6fb3476d89..9aa0e4d1a8 100644 --- a/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_skeletalmesh_fbx.py @@ -53,6 +53,7 @@ class SkeletalMeshFBXLoader(plugin.Loader): else: asset_name = "{}".format(name) version = context.get('version') + # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py index 20d9a31e03..bb13692f9e 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_abc.py @@ -81,6 +81,7 @@ class StaticMeshAlembicLoader(plugin.Loader): suffix = "_CON" asset_name = f"{asset}_{name}" if asset else f"{name}" version = context.get('version') + # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: diff --git a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py index 981003ece2..ffc68d8375 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmesh_fbx.py @@ -79,6 +79,7 @@ class StaticMeshFBXLoader(plugin.Loader): else: asset_name = "{}".format(name) version = context.get('version') + # Check if version is hero version and use different name if not version.get("name") and version.get('type') == "hero_version": name_version = f"{name}_hero" else: From 697c126ccee9daa204feb2dfb01b433f24ba8064 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 11:29:28 +0200 Subject: [PATCH 018/112] 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 019/112] 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 020/112] 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 021/112] 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 9e49a812933c4bccc885c1978f8112143a8ba3ce Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 15:15:50 +0200 Subject: [PATCH 022/112] double negative comment --- openpype/hosts/nuke/api/lib.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 83bb04c64d..e57608e1e1 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2105,23 +2105,29 @@ class WorkfileSettings(object): config_data["path"])) # check if there's a mismatch between environment and settings - wrong_environment = self._is_settings_different_from_environment( + correct_settings = self._is_settings_matching_environment( config_data) # if there's no mismatch between environment and settings - if not wrong_environment: + if correct_settings: self._set_ocio_config_path_to_workfile(config_data) - def _is_settings_different_from_environment(self, config_data): + def _is_settings_matching_environment(self, config_data): """ Check if OCIO config path is different from environment Args: config_data (dict): OCIO config data from settings Returns: - bool: True if there's a mismatch between environment and settings + bool: True if settings are matching environment, False otherwise """ - current_ocio_path = os.getenv("OCIO") + current_ocio_path = os.environ["OCIO"] + settings_ocio_path = config_data["path"] + + # normalize all paths to forward slashes + current_ocio_path = current_ocio_path.replace("\\", "/") + settings_ocio_path = settings_ocio_path.replace("\\", "/") + if current_ocio_path != config_data["path"]: message = """ It seems like there's a mismatch between the OCIO config path set in your Nuke @@ -2147,7 +2153,9 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. settings_path=config_data["path"] ) ) - return True + return False + + return True def _set_ocio_config_path_to_workfile(self, config_data): """ Set OCIO config path to workfile From 8c0b6dc252d97968cf091bb71399cd84c63b3798 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 4 Aug 2023 16:02:52 +0200 Subject: [PATCH 023/112] accepting environment vars used in config template --- openpype/hosts/nuke/api/lib.py | 56 ++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e57608e1e1..00ce94eccc 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2170,9 +2170,8 @@ 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["path"] - ) + ocio_path = self._replace_ocio_path_with_env_var(config_data) + log.info("Setting OCIO config path to: `{}`".format( ocio_path)) @@ -2186,7 +2185,35 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. log.info("Saving script to apply OCIO config path change.") nuke.scriptSave() - def _replace_ocio_path_with_env_var(self, path): + def _get_included_vars(self, config_template): + """ Get all environment variables included in template + + Args: + config_template (str): OCIO config template from settings + + Returns: + list: list of environment variables included in template + """ + # resolve all environments for whitelist variables + included_vars = [ + "BUILTIN_OCIO_ROOT", + ] + + # include all project root related env vars + for env_var in os.environ: + if env_var.startswith("OPENPYPE_PROJECT_ROOT_"): + included_vars.append(env_var) + + # use regex to find env var in template with format {ENV_VAR} + # this way we make sure only template used env vars are included + env_var_regex = r"\{([A-Z_]+)\}" + env_var = re.findall(env_var_regex, config_template) + if env_var: + included_vars.append(env_var[0]) + + return included_vars + + def _replace_ocio_path_with_env_var(self, config_data): """ Replace OCIO config path with environment variable Environment variable is added as TCL expression to path. TCL expression @@ -2194,19 +2221,22 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. formatted values. Args: - path (str): OCIO config path + config_data (str): OCIO config dict from settings Returns: - str: OCIO config path with environment variable + str: OCIO config path with environment variable TCL expression """ - # QUESTION: should we also include other names variants - included_vars = [ - "BUILTIN_OCIO_ROOT", - "OPENPYPE_PROJECT_ROOT" - ] + config_path = config_data["path"] + config_template = config_data["template"] + + included_vars = self._get_included_vars(config_template) + + # make sure we return original path if no env var is included + new_path = config_path + for env_var, env_path in os.environ.items(): # first check if variable is whitelisted - if all(var_ not in env_var for var_ in included_vars): + if env_var not in included_vars: # included vars not found in env_var name continue @@ -2216,7 +2246,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. # make sure paths are in same format env_path = env_path.replace("\\", "/") - path = path.replace("\\", "/") + path = config_path.replace("\\", "/") # check if env_path is in path and replace to first found positive if env_path in path: From 4af2ddaf49902050faee13ed4e5b5653a0e48782 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Aug 2023 17:22:50 +0200 Subject: [PATCH 024/112] nuke: nicer error communication to users. --- openpype/hosts/nuke/api/lib.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 21036e5b11..5ea6752579 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2379,25 +2379,24 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. knobs["to"])) def set_colorspace(self): - ''' Setting colorpace following presets + ''' Setting colorspace following presets ''' # get imageio nuke_colorspace = get_nuke_imageio_settings() log.info("Setting colorspace to workfile...") - # try: - self.set_root_colorspace(nuke_colorspace) - # except AttributeError: - # msg = "set_colorspace(): missing `workfile` settings in template" - # nuke.message(msg) + try: + self.set_root_colorspace(nuke_colorspace) + except AttributeError as _error: + msg = "Set Colorspace to workfile error: {}".format(_error) + nuke.message(msg) log.info("Setting colorspace to viewers...") try: self.set_viewers_colorspace(nuke_colorspace["viewer"]) - except AttributeError: - msg = "set_colorspace(): missing `viewer` settings in template" + except AttributeError as _error: + msg = "Set Colorspace to viewer error: {}".format(_error) nuke.message(msg) - log.error(msg) log.info("Setting colorspace to write nodes...") try: From 26851293b4bb26805e2070aacb37741f74f32283 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 7 Aug 2023 21:48:52 +0200 Subject: [PATCH 025/112] 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 026/112] 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 027/112] 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 fe2e6276f340740d9386c51cdfb902ba1f4ed7ec Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Aug 2023 15:44:20 +0200 Subject: [PATCH 028/112] nuke: implementation of delete_placeholder to creator and loader plugins --- .../nuke/api/workfile_template_builder.py | 33 +++++++++++-------- .../workfile/workfile_template_builder.py | 18 ++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index a19cb9dfea..5edf53be3b 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -114,6 +114,11 @@ class NukePlaceholderPlugin(PlaceholderPlugin): placeholder_data[key] = value return placeholder_data + def delete_placeholder(self, placeholder, failed): + """Remove placeholder if building was successful""" + placeholder_node = nuke.toNode(placeholder.scene_identifier) + nuke.delete(placeholder_node) + class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): identifier = "nuke.load" @@ -276,13 +281,13 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): placeholder.data["nb_children"] += 1 reset_selection() - # remove placeholders marked as delete - if ( - placeholder.data.get("delete") - and not placeholder.data.get("keep_placeholder") - ): - self.log.debug("Deleting node: {}".format(placeholder_node.name())) - nuke.delete(placeholder_node) + # # remove placeholders marked as delete + # if ( + # placeholder.data.get("delete") + # and not placeholder.data.get("keep_placeholder") + # ): + # self.log.debug("Deleting node: {}".format(placeholder_node.name())) + # nuke.delete(placeholder_node) # go back to root group nuke.root().begin() @@ -690,13 +695,13 @@ class NukePlaceholderCreatePlugin( placeholder.data["nb_children"] += 1 reset_selection() - # remove placeholders marked as delete - if ( - placeholder.data.get("delete") - and not placeholder.data.get("keep_placeholder") - ): - self.log.debug("Deleting node: {}".format(placeholder_node.name())) - nuke.delete(placeholder_node) + # # remove placeholders marked as delete + # if ( + # placeholder.data.get("delete") + # and not placeholder.data.get("keep_placeholder") + # ): + # self.log.debug("Deleting node: {}".format(placeholder_node.name())) + # nuke.delete(placeholder_node) # go back to root group nuke.root().begin() diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index bdb13415bf..25513b4d3c 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1588,7 +1588,7 @@ class PlaceholderLoadMixin(object): ) return if not placeholder.data.get("keep_placeholder", True): - self.delete_placeholder(placeholder) + self.delete_placeholder(placeholder, failed) def load_failed(self, placeholder, representation): if hasattr(placeholder, "load_failed"): @@ -1781,6 +1781,17 @@ class PlaceholderCreateMixin(object): self.post_placeholder_process(placeholder, failed) + if failed: + self.log.debug( + "Placeholder cleanup skipped due to failed placeholder " + "population." + ) + return + + if not placeholder.data.get("keep_placeholder", True): + self.delete_placeholder(placeholder, failed) + + def create_failed(self, placeholder, creator_data): if hasattr(placeholder, "create_failed"): placeholder.create_failed(creator_data) @@ -1800,9 +1811,12 @@ class PlaceholderCreateMixin(object): representation. failed (bool): Loading of representation failed. """ - pass + def delete_placeholder(self, placeholder, failed): + """Called when all item population is done.""" + self.log.debug("Clean up of placeholder is not implemented.") + def _before_instance_create(self, placeholder): """Can be overriden. Is called before instance is created.""" From 7861b028372720fb740cfd4ca73499f2d833edc0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Aug 2023 15:46:43 +0200 Subject: [PATCH 029/112] fixing inconsistency with input arguments --- openpype/hosts/nuke/api/workfile_template_builder.py | 2 +- openpype/pipeline/workfile/workfile_template_builder.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index 5edf53be3b..c33b4d5776 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -114,7 +114,7 @@ class NukePlaceholderPlugin(PlaceholderPlugin): placeholder_data[key] = value return placeholder_data - def delete_placeholder(self, placeholder, failed): + def delete_placeholder(self, placeholder): """Remove placeholder if building was successful""" placeholder_node = nuke.toNode(placeholder.scene_identifier) nuke.delete(placeholder_node) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 25513b4d3c..b218a34868 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -1588,7 +1588,7 @@ class PlaceholderLoadMixin(object): ) return if not placeholder.data.get("keep_placeholder", True): - self.delete_placeholder(placeholder, failed) + self.delete_placeholder(placeholder) def load_failed(self, placeholder, representation): if hasattr(placeholder, "load_failed"): @@ -1612,7 +1612,7 @@ class PlaceholderLoadMixin(object): pass - def delete_placeholder(self, placeholder, failed): + def delete_placeholder(self, placeholder): """Called when all item population is done.""" self.log.debug("Clean up of placeholder is not implemented.") @@ -1789,7 +1789,7 @@ class PlaceholderCreateMixin(object): return if not placeholder.data.get("keep_placeholder", True): - self.delete_placeholder(placeholder, failed) + self.delete_placeholder(placeholder) def create_failed(self, placeholder, creator_data): @@ -1813,7 +1813,7 @@ class PlaceholderCreateMixin(object): """ pass - def delete_placeholder(self, placeholder, failed): + def delete_placeholder(self, placeholder): """Called when all item population is done.""" self.log.debug("Clean up of placeholder is not implemented.") From fa66c9f1e019f55fc1a601763db7151b72206fbe Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 8 Aug 2023 16:20:15 +0200 Subject: [PATCH 030/112] removing residual mess --- .../hosts/nuke/api/workfile_template_builder.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index c33b4d5776..9d7604c58d 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -281,14 +281,6 @@ class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): placeholder.data["nb_children"] += 1 reset_selection() - # # remove placeholders marked as delete - # if ( - # placeholder.data.get("delete") - # and not placeholder.data.get("keep_placeholder") - # ): - # self.log.debug("Deleting node: {}".format(placeholder_node.name())) - # nuke.delete(placeholder_node) - # go back to root group nuke.root().begin() @@ -695,14 +687,6 @@ class NukePlaceholderCreatePlugin( placeholder.data["nb_children"] += 1 reset_selection() - # # remove placeholders marked as delete - # if ( - # placeholder.data.get("delete") - # and not placeholder.data.get("keep_placeholder") - # ): - # self.log.debug("Deleting node: {}".format(placeholder_node.name())) - # nuke.delete(placeholder_node) - # go back to root group nuke.root().begin() From ecbf263feb87d16bc67de2c3a4decd5a49ae479d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 8 Aug 2023 17:04:03 +0200 Subject: [PATCH 031/112] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 5ea6752579..e6ba96ae9f 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2212,7 +2212,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. # use regex to find env var in template with format {ENV_VAR} # this way we make sure only template used env vars are included - env_var_regex = r"\{([A-Z_]+)\}" + env_var_regex = r"\{([A-Z0-9_]+)\}" env_var = re.findall(env_var_regex, config_template) if env_var: included_vars.append(env_var[0]) From a82421f3cc6617e562b7cebb0692f89a38e33206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 8 Aug 2023 17:06:14 +0200 Subject: [PATCH 032/112] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/api/lib.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e6ba96ae9f..ec1dd07ab4 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2240,10 +2240,9 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. # make sure we return original path if no env var is included new_path = config_path - for env_var, env_path in os.environ.items(): - # first check if variable is whitelisted - if env_var not in included_vars: - # included vars not found in env_var name + for env_var in included_vars: + env_path = os.getenv(env_var) + if not env_path: continue # it has to be directory current process can see From 4043f8fed91d6b2cfce67a938c12450f84207559 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 11:21:06 +0200 Subject: [PATCH 033/112] fixing slashes in condition --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index ec1dd07ab4..c103a5d4cc 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2134,7 +2134,7 @@ class WorkfileSettings(object): current_ocio_path = current_ocio_path.replace("\\", "/") settings_ocio_path = settings_ocio_path.replace("\\", "/") - if current_ocio_path != config_data["path"]: + if current_ocio_path != settings_ocio_path: message = """ It seems like there's a mismatch between the OCIO config path set in your Nuke settings and the actual path set in your OCIO environment. From bc59808ef4dc536d80e11c91c803830f4a5c61d0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 11:22:15 +0200 Subject: [PATCH 034/112] additional fix for dialogue --- openpype/hosts/nuke/api/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index c103a5d4cc..42e69c84b6 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2156,7 +2156,7 @@ Reopening Nuke should synchronize these paths and resolve any discrepancies. nuke.message( message.format( env_path=current_ocio_path, - settings_path=config_data["path"] + settings_path=settings_ocio_path ) ) return False From 08e86b50795a3b3cd9fbe5d9a8d8984233cce97e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 9 Aug 2023 15:37:31 +0200 Subject: [PATCH 035/112] 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 036/112] 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 037/112] 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 038/112] 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 31969f394f9d3257845716f361a597a16363d813 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 10 Aug 2023 11:34:54 +0200 Subject: [PATCH 039/112] Settings: Houdini & Maya create plugin settings (#5436) * changed 'defaults' to 'default_variants' in create templates * use 'template_create_plugin' instead of 'schema_maya_create_render' * resave defaults and add Main to default value * updated AYON settings * formatting fixes * unified indentation * renamed 'Default Subsets' to 'Default Variants' --- .../defaults/project_settings/houdini.json | 44 ++++++--- .../defaults/project_settings/maya.json | 26 ++--- .../schemas/schema_houdini_create.json | 94 +++++++++---------- .../schemas/schema_maya_create.json | 32 ++++--- .../schemas/schema_maya_create_render.json | 20 ---- .../schemas/template_create_plugin.json | 4 +- .../server/settings/publish_plugins.py | 34 ++++--- server_addon/maya/server/settings/creators.py | 56 +++++------ 8 files changed, 163 insertions(+), 147 deletions(-) delete mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index a5256aad8b..9d047c28bd 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -14,48 +14,70 @@ "create": { "CreateArnoldAss": { "enabled": true, - "default_variants": [], + "default_variants": [ + "Main" + ], "ext": ".ass" }, "CreateAlembicCamera": { "enabled": true, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateCompositeSequence": { "enabled": true, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreatePointCache": { "enabled": true, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateRedshiftROP": { "enabled": true, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateRemotePublish": { "enabled": true, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateVDBCache": { "enabled": true, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateUSD": { "enabled": false, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateUSDModel": { "enabled": false, - "defaults": [] + "default_variants": [ + "Main" + ] }, "USDCreateShadingWorkspace": { "enabled": false, - "defaults": [] + "default_variants": [ + "Main" + ] }, "CreateUSDRender": { "enabled": false, - "defaults": [] + "default_variants": [ + "Main" + ] } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 342d2bfb2a..e1c6d2d827 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -527,7 +527,7 @@ }, "CreateRender": { "enabled": true, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -627,55 +627,55 @@ }, "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 +683,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 64d157d281..799bc0e81a 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 @@ -19,7 +19,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" }, { @@ -39,51 +39,51 @@ ] }, - { - "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" + } + ] + } ] } 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..b56e381c1d 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 @@ -29,14 +29,20 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" } ] }, - { - "type": "schema", - "name": "schema_maya_create_render" + { + "type": "schema_template", + "name": "template_create_plugin", + "template_data": [ + { + "key": "CreateRender", + "label": "Create Render" + } + ] }, { "type": "dict", @@ -53,7 +59,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" }, { @@ -85,7 +91,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" }, { @@ -148,7 +154,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" } ] @@ -178,7 +184,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" } ] @@ -213,7 +219,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" } ] @@ -243,7 +249,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" } ] @@ -263,7 +269,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" }, { @@ -288,7 +294,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "object_type": "text" }, { @@ -390,7 +396,7 @@ { "type": "list", "key": "default_variants", - "label": "Default Subsets", + "label": "Default Variants", "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 deleted file mode 100644 index 68ad7ad63d..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create_render.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "type": "dict", - "collapsible": true, - "key": "CreateRender", - "label": "Create Render", - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "list", - "key": "defaults", - "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" } ] diff --git a/server_addon/houdini/server/settings/publish_plugins.py b/server_addon/houdini/server/settings/publish_plugins.py index 4155c75eb7..7d35d7e634 100644 --- a/server_addon/houdini/server/settings/publish_plugins.py +++ b/server_addon/houdini/server/settings/publish_plugins.py @@ -6,12 +6,18 @@ from ayon_server.settings import BaseSettingsModel # Creator Plugins class CreatorModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - defaults: list[str] = Field(title="Default Products") + default_variants: list[str] = Field( + title="Default Products", + default_factory=list, + ) class CreateArnoldAssModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - defaults: list[str] = Field(title="Default Products") + default_variants: list[str] = Field( + title="Default Products", + default_factory=list, + ) ext: str = Field(Title="Extension") @@ -54,49 +60,49 @@ class CreatePluginsModel(BaseSettingsModel): DEFAULT_HOUDINI_CREATE_SETTINGS = { "CreateArnoldAss": { "enabled": True, - "default_variants": [], + "default_variants": ["Main"], "ext": ".ass" }, "CreateAlembicCamera": { "enabled": True, - "defaults": [] + "default_variants": ["Main"] }, "CreateCompositeSequence": { "enabled": True, - "defaults": [] + "default_variants": ["Main"] }, "CreatePointCache": { "enabled": True, - "defaults": [] + "default_variants": ["Main"] }, "CreateRedshiftROP": { "enabled": True, - "defaults": [] + "default_variants": ["Main"] }, "CreateRemotePublish": { "enabled": True, - "defaults": [] + "default_variants": ["Main"] }, "CreateVDBCache": { "enabled": True, - "defaults": [] + "default_variants": ["Main"] }, "CreateUSD": { "enabled": False, - "defaults": [] + "default_variants": ["Main"] }, "CreateUSDModel": { "enabled": False, - "defaults": [] + "default_variants": ["Main"] }, "USDCreateShadingWorkspace": { "enabled": False, - "defaults": [] + "default_variants": ["Main"] }, "CreateUSDRender": { "enabled": False, - "defaults": [] - } + "default_variants": ["Main"] + }, } diff --git a/server_addon/maya/server/settings/creators.py b/server_addon/maya/server/settings/creators.py index 039b027898..9b97b92e59 100644 --- a/server_addon/maya/server/settings/creators.py +++ b/server_addon/maya/server/settings/creators.py @@ -7,14 +7,14 @@ class CreateLookModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") make_tx: bool = Field(title="Make tx files") rs_tex: bool = Field(title="Make Redshift texture files") - defaults: list[str] = Field( - default_factory=["Main"], title="Default Products" + default_variants: list[str] = Field( + default_factory=list, title="Default Products" ) class BasicCreatorModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - defaults: list[str] = Field( + default_variants: list[str] = Field( default_factory=list, title="Default Products" ) @@ -22,20 +22,21 @@ class BasicCreatorModel(BaseSettingsModel): class CreateUnrealStaticMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - defaults: list[str] = Field( - default_factory=["", "_Main"], + default_variants: list[str] = Field( + default_factory=list, title="Default Products" ) static_mesh_prefixes: str = Field("S", title="Static Mesh Prefix") collision_prefixes: list[str] = Field( - default_factory=["UBX", "UCP", "USP", "UCX"], + default_factory=list, title="Collision Prefixes" ) class CreateUnrealSkeletalMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") - defaults: list[str] = Field(default_factory=[], title="Default Products") + default_variants: list[str] = Field( + default_factory=list, title="Default Products") joint_hints: str = Field("jnt_org", title="Joint root hint") @@ -48,7 +49,7 @@ class BasicExportMeshModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") write_color_sets: bool = Field(title="Write Color Sets") write_face_sets: bool = Field(title="Write Face Sets") - defaults: list[str] = Field( + default_variants: list[str] = Field( default_factory=list, title="Default Products" ) @@ -61,7 +62,7 @@ class CreateAnimationModel(BaseSettingsModel): title="Include Parent Hierarchy") include_user_defined_attributes: bool = Field( title="Include User Defined Attributes") - defaults: list[str] = Field( + default_variants: list[str] = Field( default_factory=list, title="Default Products" ) @@ -74,8 +75,8 @@ class CreatePointCacheModel(BaseSettingsModel): include_user_defined_attributes: bool = Field( title="Include User Defined Attributes" ) - defaults: list[str] = Field( - default_factory=["Main"], + default_variants: list[str] = Field( + default_factory=list, title="Default Products" ) @@ -84,8 +85,8 @@ class CreateProxyAlembicModel(BaseSettingsModel): enabled: bool = Field(title="Enabled") write_color_sets: bool = Field(title="Write Color Sets") write_face_sets: bool = Field(title="Write Face Sets") - defaults: list[str] = Field( - default_factory=["Main"], + default_variants: list[str] = Field( + default_factory=list, title="Default Products" ) @@ -115,7 +116,8 @@ class CreateVrayProxyModel(BaseSettingsModel): enabled: bool = Field(True) vrmesh: bool = Field(title="VrMesh") alembic: bool = Field(title="Alembic") - defaults: list[str] = Field(default_factory=list, title="Default Products") + default_variants: list[str] = Field( + default_factory=list, title="Default Products") class CreatorsModel(BaseSettingsModel): @@ -230,7 +232,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateRender": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -295,19 +297,19 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateMultiverseUsd": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsdComp": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateMultiverseUsdOver": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ] }, @@ -333,31 +335,31 @@ DEFAULT_CREATORS_SETTINGS = { }, "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" ] }, @@ -370,7 +372,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateRig": { "enabled": True, - "defaults": [ + "default_variants": [ "Main", "Sim", "Cloth" @@ -378,7 +380,7 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateSetDress": { "enabled": True, - "defaults": [ + "default_variants": [ "Main", "Anim" ] @@ -393,13 +395,13 @@ DEFAULT_CREATORS_SETTINGS = { }, "CreateVRayScene": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ] }, "CreateYetiRig": { "enabled": True, - "defaults": [ + "default_variants": [ "Main" ] } From 7973354fefc259c455bc8e61707147805a71d933 Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Thu, 10 Aug 2023 12:31:49 +0100 Subject: [PATCH 040/112] Option to start versioning from 0 (#5262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial version, replaced all hard 1 with 0 * ftrack v0 works only with version cast as str * workfile tools can set 0 * fixed hound stuff * fix for auto versioning not working anymore * fix for not incrementing version * hound fix * Settings determined versioning start * Code cosmetics * Better failsafe for collecting settings. * Initial profiles commit * Hound * Working profiles * Update openpype/hosts/webpublisher/plugins/publish/collect_published_files.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/hosts/webpublisher/plugins/publish/collect_published_files.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/plugins/publish/collect_anatomy_instance_data.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/settings/entities/schemas/projects_schema/schema_project_global.json Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Illicitit feedback * Update openpype/pipeline/context_tools.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Fix collect_published_files * Working version * Hound * Update openpype/pipeline/version_start.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/pipeline/version_start.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/tools/push_to_project/control_integrate.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/hosts/photoshop/plugins/publish/collect_published_version.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/hosts/photoshop/plugins/publish/collect_published_version.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/hosts/webpublisher/plugins/publish/collect_published_files.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/hosts/webpublisher/plugins/publish/collect_published_files.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/hosts/webpublisher/plugins/publish/collect_published_files.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/pipeline/workfile/path_resolving.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Update openpype/settings/__init__.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Hound * Illicitit feedback * Replace host.name * Update openpype/plugins/publish/collect_anatomy_instance_data.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * reuse 'task_name' and 'task_type' * skip hero integration when source version in 0 --------- Co-authored-by: maxpareschi Co-authored-by: Jakub Ježek Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: Jakub Trllo --- .../publish/collect_published_version.py | 13 ++++- .../tvpaint/plugins/load/load_workfile.py | 9 ++- .../publish/collect_published_files.py | 39 +++++++++++-- .../plugins/publish/submit_publish_job.py | 13 ++++- .../plugins/publish/integrate_ftrack_api.py | 2 - openpype/pipeline/__init__.py | 2 +- openpype/pipeline/context_tools.py | 2 +- openpype/pipeline/version_start.py | 37 +++++++++++++ openpype/pipeline/workfile/path_resolving.py | 10 +++- .../publish/collect_anatomy_instance_data.py | 29 +++++++--- .../plugins/publish/integrate_hero_version.py | 6 ++ openpype/scripts/fusion_switch_shot.py | 12 ++-- .../defaults/project_settings/global.json | 3 + .../schema_project_global.json | 55 +++++++++++++++++++ .../push_to_project/control_integrate.py | 21 +++---- .../widgets/widget_family.py | 11 +++- openpype/tools/workfiles/save_as_dialog.py | 19 ++++++- 17 files changed, 239 insertions(+), 44 deletions(-) create mode 100644 openpype/pipeline/version_start.py diff --git a/openpype/hosts/photoshop/plugins/publish/collect_published_version.py b/openpype/hosts/photoshop/plugins/publish/collect_published_version.py index 7371c0564f..eec6f1fae4 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_published_version.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_published_version.py @@ -18,6 +18,7 @@ Provides: import pyblish.api from openpype.client import get_last_version_by_subset_name +from openpype.pipeline.version_start import get_versioning_start class CollectPublishedVersion(pyblish.api.ContextPlugin): @@ -47,9 +48,17 @@ class CollectPublishedVersion(pyblish.api.ContextPlugin): version_doc = get_last_version_by_subset_name(project_name, workfile_subset_name, asset_id) - version_int = 1 + if version_doc: - version_int += int(version_doc["name"]) + version_int = int(version_doc["name"]) + 1 + else: + version_int = get_versioning_start( + project_name, + "photoshop", + task_name=context.data["task"], + task_type=context.data["taskType"], + project_settings=context.data["project_settings"] + ) self.log.debug(f"Setting {version_int} to context.") context.data["version"] = version_int diff --git a/openpype/hosts/tvpaint/plugins/load/load_workfile.py b/openpype/hosts/tvpaint/plugins/load/load_workfile.py index 2155a1bbd5..169bfdcdd8 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_workfile.py +++ b/openpype/hosts/tvpaint/plugins/load/load_workfile.py @@ -18,6 +18,7 @@ from openpype.hosts.tvpaint.api.lib import ( from openpype.hosts.tvpaint.api.pipeline import ( get_current_workfile_context, ) +from openpype.pipeline.version_start import get_versioning_start class LoadWorkfile(plugin.Loader): @@ -95,7 +96,13 @@ class LoadWorkfile(plugin.Loader): )[1] if version is None: - version = 1 + version = get_versioning_start( + project_name, + "tvpaint", + task_name=task_name, + task_type=data["task"]["type"], + family="workfile" + ) else: version += 1 diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 79ed499a20..1416255083 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -25,6 +25,7 @@ from openpype.lib import ( ) from openpype.pipeline.create import get_subset_name from openpype_modules.webpublisher.lib import parse_json +from openpype.pipeline.version_start import get_versioning_start class CollectPublishedFiles(pyblish.api.ContextPlugin): @@ -103,7 +104,13 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): project_settings=context.data["project_settings"] ) version = self._get_next_version( - project_name, asset_doc, subset_name + project_name, + asset_doc, + task_name, + task_type, + family, + subset_name, + context ) next_versions.append(version) @@ -141,8 +148,9 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): try: no_of_frames = self._get_number_of_frames(file_url) if no_of_frames: - frame_end = int(frame_start) + \ - math.ceil(no_of_frames) + frame_end = ( + int(frame_start) + math.ceil(no_of_frames) + ) frame_end = math.ceil(frame_end) - 1 instance.data["frameEnd"] = frame_end self.log.debug("frameEnd:: {}".format( @@ -270,7 +278,16 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): config["families"], config["tags"]) - def _get_next_version(self, project_name, asset_doc, subset_name): + def _get_next_version( + self, + project_name, + asset_doc, + task_name, + task_type, + family, + subset_name, + context + ): """Returns version number or 1 for 'asset' and 'subset'""" version_doc = get_last_version_by_subset_name( @@ -279,9 +296,19 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): asset_doc["_id"], fields=["name"] ) - version = 1 if version_doc: - version += int(version_doc["name"]) + version = int(version_doc["name"]) + 1 + else: + version = get_versioning_start( + project_name, + "webpublisher", + task_name=task_name, + task_type=task_type, + family=family, + subset=subset_name, + project_settings=context.data["project_settings"] + ) + return version def _get_number_of_frames(self, file_url): diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index ec182fcd66..5e8c005d07 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -3,7 +3,7 @@ import os import json import re -from copy import copy, deepcopy +from copy import deepcopy import requests import clique @@ -16,6 +16,7 @@ from openpype.client import ( from openpype.pipeline import publish, legacy_io from openpype.lib import EnumDef, is_running_from_build from openpype.tests.lib import is_in_tests +from openpype.pipeline.version_start import get_versioning_start from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, @@ -566,7 +567,15 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, if version: version = int(version["name"]) + 1 else: - version = 1 + version = get_versioning_start( + project_name, + template_data["app"], + task_name=template_data["task"]["name"], + task_type=template_data["task"]["type"], + family="render", + subset=subset, + project_settings=context.data["project_settings"] + ) host_name = context.data["hostName"] task_info = template_data.get("task") or {} diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index deb8b414f0..6ca5d1d4ef 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -11,10 +11,8 @@ Provides: """ import os -import sys import collections -import six import pyblish.api import clique diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 59f1655f91..8f370d389b 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -94,7 +94,7 @@ from .context_tools import ( get_current_host_name, get_current_project_name, get_current_asset_name, - get_current_task_name, + get_current_task_name ) install = install_host uninstall = uninstall_host diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index c12b76cc74..9ada2d42a4 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -35,7 +35,7 @@ from . import ( register_inventory_action_path, register_creator_plugin_path, deregister_loader_plugin_path, - deregister_inventory_action_path, + deregister_inventory_action_path ) diff --git a/openpype/pipeline/version_start.py b/openpype/pipeline/version_start.py new file mode 100644 index 0000000000..0240ab0c7a --- /dev/null +++ b/openpype/pipeline/version_start.py @@ -0,0 +1,37 @@ +from openpype.lib.profiles_filtering import filter_profiles +from openpype.settings import get_project_settings + + +def get_versioning_start( + project_name, + host_name, + task_name=None, + task_type=None, + family=None, + subset=None, + project_settings=None, +): + """Get anatomy versioning start""" + if not project_settings: + project_settings = get_project_settings(project_name) + + version_start = 1 + settings = project_settings["global"] + profiles = settings.get("version_start_category", {}).get("profiles", []) + + if not profiles: + return version_start + + filtering_criteria = { + "host_names": host_name, + "families": family, + "task_names": task_name, + "task_types": task_type, + "subsets": subset + } + profile = filter_profiles(profiles, filtering_criteria) + + if profile is None: + return version_start + + return profile["version_start"] diff --git a/openpype/pipeline/workfile/path_resolving.py b/openpype/pipeline/workfile/path_resolving.py index 15689f4d99..78acee20da 100644 --- a/openpype/pipeline/workfile/path_resolving.py +++ b/openpype/pipeline/workfile/path_resolving.py @@ -10,7 +10,7 @@ from openpype.lib import ( Logger, StringTemplate, ) -from openpype.pipeline import Anatomy +from openpype.pipeline import version_start, Anatomy from openpype.pipeline.template_data import get_template_data @@ -316,7 +316,13 @@ def get_last_workfile( ) if filename is None: data = copy.deepcopy(fill_data) - data["version"] = 1 + data["version"] = version_start.get_versioning_start( + data["project"]["name"], + data["app"], + task_name=data["task"]["name"], + task_type=data["task"]["type"], + family="workfile" + ) data.pop("comment", None) if not data.get("ext"): data["ext"] = extensions[0] diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 128ad90b4f..ef8f4af8fb 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -32,6 +32,7 @@ from openpype.client import ( get_subsets, get_last_versions ) +from openpype.pipeline.version_start import get_versioning_start class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): @@ -191,15 +192,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): version_number = context.data('version') else: version_number = instance.data.get("version") - # If version is not specified for instance or context - if version_number is None: - # TODO we should be able to change default version by studio - # preferences (like start with version number `0`) - version_number = 1 - # use latest version (+1) if already any exist - latest_version = instance.data["latestVersion"] - if latest_version is not None: - version_number += int(latest_version) anatomy_updates = { "asset": instance.data["asset"], @@ -225,6 +217,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): anatomy_updates["parent"] = parent_name # Task + task_type = None task_name = instance.data.get("task") if task_name: asset_tasks = asset_doc["data"]["tasks"] @@ -240,6 +233,24 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): "short": task_code } + # Define version + # use latest version (+1) if already any exist + if version_number is None: + latest_version = instance.data["latestVersion"] + if latest_version is not None: + version_number = int(latest_version) + 1 + + # If version is not specified for instance or context + if version_number is None: + version_number = get_versioning_start( + context.data["projectName"], + instance.context.data["hostName"], + task_name=task_name, + task_type=task_type, + family=instance.data["family"], + subset=instance.data["subset"] + ) + # Additional data resolution_width = instance.data.get("resolutionWidth") if resolution_width: diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index b7feeac6a4..6c21664b78 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -142,6 +142,12 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): )) return + if AYON_SERVER_ENABLED and src_version_entity["name"] == 0: + self.log.debug( + "Version 0 cannot have hero version. Skipping." + ) + return + all_copied_files = [] transfers = instance.data.get("transfers", list()) for _src, dst in transfers: diff --git a/openpype/scripts/fusion_switch_shot.py b/openpype/scripts/fusion_switch_shot.py index 8ecf4fb5ea..1cc728226f 100644 --- a/openpype/scripts/fusion_switch_shot.py +++ b/openpype/scripts/fusion_switch_shot.py @@ -19,6 +19,7 @@ from openpype.pipeline import ( ) 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") @@ -26,9 +27,6 @@ log = logging.getLogger("Update Slap Comp") def _format_version_folder(folder): """Format a version folder based on the filepath - Assumption here is made that, if the path does not exists the folder - will be "v001" - Args: folder: file path to a folder @@ -36,9 +34,13 @@ def _format_version_folder(folder): str: new version folder name """ - new_version = 1 + new_version = get_versioning_start( + get_current_project_name(), + "fusion", + family="workfile" + ) if os.path.isdir(folder): - re_version = re.compile("v\d+$") + 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: diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index b6eb2f52f1..06a595d1c5 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -1,4 +1,7 @@ { + "version_start_category": { + "profiles": [] + }, "imageio": { "activate_global_color_management": false, "ocio_config": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_global.json b/openpype/settings/entities/schemas/projects_schema/schema_project_global.json index 953361935c..4094632c72 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_global.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_global.json @@ -5,6 +5,61 @@ "label": "Global", "is_file": true, "children": [ + { + "type": "dict", + "key": "version_start_category", + "label": "Version Start", + "collapsible": true, + "collapsible_key": true, + "children": [ + { + "type": "list", + "collapsible": true, + "key": "profiles", + "label": "Profiles", + "object_type": { + "type": "dict", + "children": [ + { + "key": "host_names", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subsets", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "key": "version_start", + "label": "Version Start", + "type": "number", + "minimum": 0 + } + ] + } + } + ] + }, { "key": "imageio", "type": "dict", diff --git a/openpype/tools/push_to_project/control_integrate.py b/openpype/tools/push_to_project/control_integrate.py index 37a0512d59..a822339ccf 100644 --- a/openpype/tools/push_to_project/control_integrate.py +++ b/openpype/tools/push_to_project/control_integrate.py @@ -40,6 +40,7 @@ from openpype.lib import ( from openpype.lib.file_transaction import FileTransaction from openpype.settings import get_project_settings from openpype.pipeline import Anatomy +from openpype.pipeline.version_start import get_versioning_start from openpype.pipeline.template_data import get_template_data from openpype.pipeline.publish import get_publish_template_name from openpype.pipeline.create import get_subset_name @@ -940,9 +941,17 @@ class ProjectPushItemProcess: last_version_doc = get_last_version_by_subset_id( project_name, subset_id ) - version = 1 if last_version_doc: - version += int(last_version_doc["name"]) + version = int(last_version_doc["name"]) + 1 + else: + version = get_versioning_start( + project_name, + self.host_name, + task_name=self.task_info["name"], + task_type=self.task_info["type"], + family=families[0], + subset=subset_doc["name"] + ) existing_version_doc = get_version_by_name( project_name, version, subset_id @@ -966,14 +975,6 @@ class ProjectPushItemProcess: return - if version is None: - last_version_doc = get_last_version_by_subset_id( - project_name, subset_id - ) - version = 1 - if last_version_doc: - version += int(last_version_doc["name"]) - version_doc = new_version_doc( version, subset_id, version_data ) diff --git a/openpype/tools/standalonepublish/widgets/widget_family.py b/openpype/tools/standalonepublish/widgets/widget_family.py index 8c18a93a00..73dc2122db 100644 --- a/openpype/tools/standalonepublish/widgets/widget_family.py +++ b/openpype/tools/standalonepublish/widgets/widget_family.py @@ -10,6 +10,7 @@ from openpype.client import ( ) from openpype.settings import get_project_settings from openpype.pipeline import LegacyCreator +from openpype.pipeline.version_start import get_versioning_start from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, TaskNotSetError, @@ -299,7 +300,15 @@ class FamilyWidget(QtWidgets.QWidget): project_name = self.dbcon.active_project() asset_name = self.asset_name subset_name = self.input_result.text() - version = 1 + plugin = self.list_families.currentItem().data(PluginRole) + family = plugin.family.rsplit(".", 1)[-1] + version = get_versioning_start( + project_name, + "standalonepublisher", + task_name=self.dbcon.Session["AVALON_TASK"], + family=family, + subset=subset_name + ) asset_doc = None subset_doc = None diff --git a/openpype/tools/workfiles/save_as_dialog.py b/openpype/tools/workfiles/save_as_dialog.py index 9f1d1060da..7052eaed06 100644 --- a/openpype/tools/workfiles/save_as_dialog.py +++ b/openpype/tools/workfiles/save_as_dialog.py @@ -12,6 +12,7 @@ from openpype.pipeline import ( from openpype.pipeline.workfile import get_last_workfile_with_version from openpype.pipeline.template_data import get_template_data_with_names from openpype.tools.utils import PlaceholderLineEdit +from openpype.pipeline import version_start, get_current_host_name log = logging.getLogger(__name__) @@ -218,7 +219,15 @@ class SaveAsDialog(QtWidgets.QDialog): # Version number input version_input = QtWidgets.QSpinBox(version_widget) - version_input.setMinimum(1) + version_input.setMinimum( + version_start.get_versioning_start( + self.data["project"]["name"], + get_current_host_name(), + task_name=self.data["task"]["name"], + task_type=self.data["task"]["type"], + family="workfile" + ) + ) version_input.setMaximum(9999) # Last version checkbox @@ -420,7 +429,13 @@ class SaveAsDialog(QtWidgets.QDialog): )[1] if version is None: - version = 1 + version = version_start.get_versioning_start( + data["project"]["name"], + get_current_host_name(), + task_name=self.data["task"]["name"], + task_type=self.data["task"]["type"], + family="workfile" + ) else: version += 1 From 745aacea0c7db07b0da408af990a480dff45bc31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 10 Aug 2023 16:42:29 +0200 Subject: [PATCH 041/112] Chore: Versions post fixes (#5441) * fix how version definition order * added 'folder' to anatomy data --- .../publish/collect_anatomy_instance_data.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index ef8f4af8fb..b4f4d6a16a 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -188,16 +188,13 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): project_task_types = project_doc["config"]["tasks"] for instance in context: - if self.follow_workfile_version: - version_number = context.data('version') - else: - version_number = instance.data.get("version") - anatomy_updates = { "asset": instance.data["asset"], + "folder": { + "name": instance.data["asset"], + }, "family": instance.data["family"], "subset": instance.data["subset"], - "version": version_number } # Hierarchy @@ -234,6 +231,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): } # Define version + if self.follow_workfile_version: + version_number = context.data('version') + else: + version_number = instance.data.get("version") + # use latest version (+1) if already any exist if version_number is None: latest_version = instance.data["latestVersion"] @@ -250,6 +252,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): family=instance.data["family"], subset=instance.data["subset"] ) + anatomy_updates["version"] = version_number # Additional data resolution_width = instance.data.get("resolutionWidth") From 4013148167783590d62e1a6d6882c2d07ada2d65 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 11 Aug 2023 15:29:36 +0800 Subject: [PATCH 042/112] name of the read node should be updated correctly when setting versions and switching assets --- openpype/hosts/nuke/plugins/load/load_image.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index d8c0a82206..225365056a 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -212,6 +212,8 @@ class LoadImage(load.LoaderPlugin): last = first = int(frame_number) # Set the global in to the start frame of the sequence + read_name = self._get_node_name(representation) + node["name"].setValue(read_name) node["file"].setValue(file) node["origfirst"].setValue(first) node["first"].setValue(first) @@ -250,3 +252,17 @@ class LoadImage(load.LoaderPlugin): with viewer_update_and_undo_stop(): nuke.delete(node) + + def _get_node_name(self, representation): + + repre_cont = representation["context"] + name_data = { + "asset": repre_cont["asset"], + "subset": repre_cont["subset"], + "representation": representation["name"], + "ext": repre_cont["representation"], + "id": representation["_id"], + "class_name": self.__class__.__name__ + } + + return self.node_name_template.format(**name_data) From 7b2de9248e795d6a5f1ec5014f9057a8b8b2d070 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Fri, 11 Aug 2023 08:34:33 +0000 Subject: [PATCH 043/112] [Automated] Release --- CHANGELOG.md | 832 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 834 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2930d45eb..c6d8f01234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,838 @@ # Changelog +## [3.16.3](https://github.com/ynput/OpenPype/tree/3.16.3) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.2...3.16.3) + +### **🆕 New features** + + +
+AYON: 3rd party addon usage #5300 + +Prepare OpenPype code to be able use `ayon-third-party` addon which supply ffmpeg and OpenImageIO executables. Because they both can support to define custom arguments (more than one) a new functions were needed to supply.New functions are `get_ffmpeg_tool_args` and `get_oiio_tool_args`. They work similar to previous but instead of string are returning list of strings. All places using previous functions `get_ffmpeg_tool_path` and `get_oiio_tool_path` are now using new ones. They should be backwards compatible and even with addon if returns single argument. + + +___ + +
+ + +
+AYON: Addon settings in OpenPype #5347 + +Moved settings addons to OpenPype server addon. Modified create package to create zip files for server for each settings addon and for openpype addon. + + +___ + +
+ + +
+AYON: Add folder to template data #5417 + +Added `folder` to template data, so `{folder[name]}` can be used in templates. + + +___ + +
+ + +
+Option to start versioning from 0 #5262 + +This PR adds a settings option to start all versioning from 0.This PR will replace #4455. + + +___ + +
+ + +
+Ayon: deadline implementation #5321 + +Quick implementation of deadline in Ayon. New Ayon plugin added for Deadline repository + + +___ + +
+ + +
+AYON: Remove AYON launch logic from OpenPype #5348 + +Removed AYON launch logic from OpenPype. The logic is outdated at this moment and is replaced by `ayon-launcher`. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Bug: Error on multiple instance rig with maya #5310 + +I change endswith method by startswith method because the set are automacaly name out_SET, out_SET1, out_SET2 ... + + +___ + +
+ + +
+Applications: Use prelaunch hooks to extract environments #5387 + +Environment variable preparation is based on prelaunch hooks. This should allow to pass OCIO environment variables to farm jobs. + + +___ + +
+ + +
+Applications: Launch hooks cleanup #5395 + +Use `set` instead of `list` for filtering attributes in launch hooks. Celaction hooks dir does not contain `__init__.py`. Celaction prelaunch hook is reusing `CELACTION_ROOT_DIR`. Launch hooks are using full import from `openpype.lib.applications`. + + +___ + +
+ + +
+Applications: Environment variables order #5245 + +Changed order of set environment variables. First are set context environment variables and then project environment overrides. Also asset and task environemnt variables are optional. + + +___ + +
+ + +
+Autosave preferences can be read after Nuke opens the script #5295 + +Looks like I need to open the script in Nuke to be able to correctly load the autosave preferences.This PR reads the Nuke script in context, and offers owerwriting the current script with autosaved one if autosave exists. + + +___ + +
+ + +
+Resolve: Update with compatible resolve version and latest docs #5317 + +Missing information about compatible Resolve version and latest docs from https://github.com/ynput/OpenPype/tree/develop/openpype/hosts/resolve + + +___ + +
+ + +
+Chore: Remove deprecated functions #5323 + +Removed functions/classes that are deprecated and marked to be removed. + + +___ + +
+ + +
+Nuke Render and Prerender nodes Process Order - OP-3555 #5332 + +This PR exposes control over the order of processing of the instances, by sorting the instances created. The sorting happens on the `render_order` and subset name. If the knob `render_order` is found on the instance, we'll sort by that first before sorting by subset name.`render_order` instances are processed before nodes without `render_order`. This could be extended in the future by querying other knobs but I dont know of a usecase for this.Hardcoded the creator `order` attribute of the `prerender` class to be before the `render`. Could be exposed to the user/studio but dont know of a use case for this. + + +___ + +
+ + +
+Unreal: Python Environment Improvements #5344 + +Automatically set `UE_PYTHONPATH` as `PYTHONPATH` when launching Unreal. + + +___ + +
+ + +
+Unreal: Custom location for Unreal Ayon Plugin #5346 + +Added a new environment variable `AYON_BUILT_UNREAL_PLUGIN` to set an already existing and built Ayon Plugin for Unreal. + + +___ + +
+ + +
+Unreal: Better handling of Exceptions in UE Worker threads #5349 + +Implemented a new `UEWorker` base class to handle exception during the execution of UE Workers. + + +___ + +
+ + +
+Houdini: Add farm toggle on creation menu #5350 + +Deadline Farm publishing and Rendering for Houdini was possible with this PR #4825 farm publishing is enabled by default some ROP nodes which may surprise new users (like me).I think adding a toggle (on by default) on creation UI is better so that users will be aware that there's a farm option for this publish instance.ROPs Modified : +- [x] Mantra ROP +- [x] Karma ROP +- [x] Arnold ROP +- [x] Redshift ROP +- [x] Vray ROP + + +___ + +
+ + +
+Ftrack: Sync to avalon settings #5353 + +Added roles settings for sync to avalon action. + + +___ + +
+ + +
+Chore: Schemas inside OpenPype #5354 + +Moved/copied schemas from repository root inside openpype/pipeline. + + +___ + +
+ + +
+AYON: Addons creation enhancements #5356 + +Enhanced AYON addons creation. Fix issue with `Pattern` typehint. Zip filenames contain version. OpenPype package is skipping modules that are already separated in AYON. Updated settings of addons. + + +___ + +
+ + +
+AYON: Update staging icons #5372 + +Updated staging icons for staging mode. + + +___ + +
+ + +
+Enhancement: Houdini Update pointcache labels #5373 + +To me it's logical to find pointcaches types listed one after another, but they were named differentlySo, I made this PR to update their labels + + +___ + +
+ + +
+nuke: split write node product instance features #5389 + +Improving Write node product instances by allowing precise activation of specific features. + + +___ + +
+ + +
+Max: Use the empty modifiers in container to store AYON Parameter #5396 + +Instead of adding AYON/OP Parameter along with other attributes inside the container, empty modifiers would be created to store AYON/OP custom attributes + + +___ + +
+ + +
+AfterEffects: Removed unused imports #5397 + +Removed unused import from extract local render plugin file. + + +___ + +
+ + +
+Nuke: adding BBox knob type to settings #5405 + +Nuke knob types in settings having new `Box` type for reposition nodes like Crop or Reformat. + + +___ + +
+ + +
+SyncServer: Existence of module is optional #5413 + +Existence of SyncServer module is optional and not required. Added `sync_server` module back to ignored modules when openpype addon is created for AYON. Command `syncserver` is marked as deprecated and redirected to sync server cli. + + +___ + +
+ + +
+Webpublisher: Self contain test publish logic #5414 + +Moved test logic of publishing to webpublisher. Simplified `remote_publish` to remove webpublisher specific logic. + + +___ + +
+ + +
+Webpublisher: Cleanup targets #5418 + +Removed `remote` target from webpublisher and replaced it with 2 targets `webpublisher` and `automated`. + + +___ + +
+ + +
+nuke: update server addon settings with box #5419 + +updtaing nuke ayon server settings for Box option in knob types. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: fix validate frame range on review attached to other instances #5296 + +Fixes situation where frame range validator can't be turned off on models if they are attached to reviewable camera in Maya. + + +___ + +
+ + +
+Maya: Apply project settings to creators #5303 + +Project settings were not applied to the creators. + + +___ + +
+ + +
+Maya: Validate Model Content #5336 + +`assemblies` in `cmds.ls` does not seem to work; +```python +from maya import cmds + + +content_instance = ['|group2|pSphere1_GEO', '|group2|pSphere1_GEO|pSphere1_GEOShape', '|group1|pSphere1_GEO', '|group1|pSphere1_GEO|pSphere1_GEOShape'] +assemblies = cmds.ls(content_instance, assemblies=True, long=True) +print(assemblies) +```Fixing with string splitting instead. + + +___ + +
+ + +
+Bugfix: Maya update defaults variable #5368 + +So, something was forgotten while moving out from `LegacyCreator` to `NewCreator``LegacyCreator` used `defaults` to list suggested subset names which was changed into `default_variants` in the the `NewCreator`and setting `defaults` to any values has no effect!This update affects: +- [x] Model +- [x] Set Dress + + +___ + +
+ + +
+Chore: Python 2 support fix #5375 + +Fix Python 2 support by adding `click` into python 2 dependencies and removing f-string from maya. + + +___ + +
+ + +
+Maya: do not create top level group on reference #5402 + +This PR allows to not wrapping loaded referenced assets in top level group either explicitly for artist or by configuration in Settings.Artists can control group creation in ReferenceLoader options.Default no group creation could be set by emptying `Group Name` in `project_settings/maya/load/reference_loader` + + +___ + +
+ + +
+Settings: Houdini & Maya create plugin settings #5436 + +Fixes related to Maya and Houdini settings. Renamed `defaults` to `default_variants` in plugin settings to match attribute name on create plugin in both OpenPype and AYON settings. Fixed Houdini AYON settings where were missing settings for defautlt varaints and fixed Maya AYON settings where default factory had wrong assignment. + + +___ + +
+ + +
+Maya: Hide CreateAnimation #5297 + +When converting `animation` family or loading a `rig` family, need to include the `animation` creator but hide it in creator context. + + +___ + +
+ + +
+Nuke Anamorphic slate - Read pixel aspect from input #5304 + +When asset pixel aspect differs from rendered pixel aspect, Nuke slate pixel aspect is not longer taken from asset, but is readed via ffprobe. + + +___ + +
+ + +
+Nuke - Allow ExtractReviewDataMov with no timecode knob #5305 + +ExtractReviewDataMov allows to specify file type. Trying to write some other extension than mov fails on generate_mov assuming that mov64_write_timecode knob exists. + + +___ + +
+ + +
+Nuke: removing settings schema with defaults for OpenPype #5306 + +continuation of https://github.com/ynput/OpenPype/pull/5275 + + +___ + +
+ + +
+Bugfix: Dependency without 'inputLinks' not downloaded #5337 + +Remove condition that avoids downloading dependency without `inputLinks`. + + +___ + +
+ + +
+Bugfix: Houdini Creator use selection even if it was toggled off #5359 + +When creating many product types (families) one after another without refreshing the creator window manually if you toggled `Use selection` once, all the later product types will use selection even if it was toggled offHere's Before it will keep use selection even if it was toggled off, unless you refresh window manuallyhttps://github.com/ynput/OpenPype/assets/20871534/8b890122-5b53-4c6b-897d-6a2f3aa3388aHere's After it works as expectedhttps://github.com/ynput/OpenPype/assets/20871534/6b1db990-de1b-428e-8828-04ab59a44e28 + + +___ + +
+ + +
+Houdini: Correct camera selection for karma renderer when using selected node #5360 + +When user creates the karma rop with selected camera by use selection, it will give the error message of "no render camera found in selection".This PR is to fix the bug of creating karma rop when using selected camera node in Houdini + + +___ + +
+ + +
+AYON: Environment variables and functions #5361 + +Prepare code for ayon-launcher compatibility. Fix ayon launcher subprocess calls, added more checks for `AYON_SERVER_ENABLED`, use ayon launcher suitable environment variables in AYON mode and changed outputs of some functions. Replaced usages of `OPENPYPE_REPOS_ROOT` environment variable with `PACKAGE_DIR` variable -> correct paths are used. + + +___ + +
+ + +
+Nuke: farm rendering of prerender ignore roots in nuke #5366 + +`prerender` family was using wrong subset, same as `render` which should be different. + + +___ + +
+ + +
+Bugfix: Houdini update defaults variable #5367 + +So, something was forgotten while moving out from `LegacyCreator` to `NewCreator``LegacyCreator` used `defaults` to list suggested subset names which was changed into `default_variants` in the the `NewCreator`and setting `defaults` to any values has no effect!This update affects: +- [x] Arnold ASS +- [x] Arnold ROP +- [x] Karma ROP +- [x] Mantra ROP +- [x] Redshift ROP +- [x] VRay ROP + + +___ + +
+ + +
+Publisher: Fix create/publish animation #5369 + +Use geometry movement instead of changing min/max width. + + +___ + +
+ + +
+Unreal: Move unreal splash screen to unreal #5370 + +Moved splash screen code to unreal integration and removed import from Igniter. + + +___ + +
+ + +
+Nuke: returned not cleaning of renders folder on the farm #5374 + +Previous PR enabled explicit cleanup of `renders` folder after farm publishing. This is not matching customer's workflows. Customer wants to have access to files in `renders` folder and potentially redo some frames for long frame sequences.This PR extends logic of marking rendered files for deletion only if instance doesn't have `stagingDir_persistent`.For backwards compatibility all Nuke instances have `stagingDir_persistent` set to True, eg. `renders` folder won't be cleaned after farm publish. + + +___ + +
+ + +
+Nuke: loading sequences is working #5376 + +Loading image sequences was broken after the latest release, version 3.16. However, I am pleased to inform you that it is now functioning as expected. + + +___ + +
+ + +
+AYON: Fix settings conversion for ayon addons #5377 + +AYON addon settings are available in system settings and does not have available the same values in `"modules"` subkey. + + +___ + +
+ + +
+Nuke: OCIO env var workflow #5379 + +The OCIO environment variable needs to be consistently handled across all platforms. Nuke resolves the custom OCIO config path differently depending on the platform, so we included the ocio config path in the workfile with a partial replacement using an environment variable. Additionally, for Windows sessions, we replaced backward slashes with a TCL expression. + + +___ + +
+ + +
+Unreal: Fix Unreal build script #5381 + +Define 'AYON_UNREAL_ROOT' environment variable in unreal addon. + + +___ + +
+ + +
+3dsMax: Use relative path to MAX_HOST_DIR #5382 + +Use `MAX_HOST_DIR` to calculate startup script path instead of use relative path to `OPENPYPE_ROOT` environment variable. + + +___ + +
+ + +
+Bugfix: Houdini abc validator error message #5386 + +When ABC path validator fails, it prints node objects not node paths or namesThis bug happened because of updating `get_invalid` method to return nodes instead of node pathsBeforeAfter + + +___ + +
+ + +
+Nuke: node name influence product (subset) name #5392 + +Nuke now allows users to duplicate publishing instances, making the workflow easier. By duplicating a node and changing its name, users can set the product (subset) name in the publishing context.Users now have the ability to change the variant name in Publisher, which will automatically rename the associated instance node. + + +___ + +
+ + +
+Houdini: delete redundant bgeo sop validator #5394 + +I found out that this `Validate BGEO SOP Path` validator is redundant, it catches two cases that are already implemented in "Validate Output Node". "Validate Output Node" works with `bgeo` as well as `abc` because `"pointcache"` is listed in its families + + +___ + +
+ + +
+Nuke: workfile is not reopening after change of context #5399 + +Nuke no longer reopens the latest workfile when the context is changed to a different task using the Workfile tool. The issue also affected the Script Clean (from Nuke File menu) and Close feature, but it has now been fixed. + + +___ + +
+ + +
+Bugfix: houdini hard coded project settings #5400 + +I made this PR to solve the issue with hard-coded settings in houdini + + +___ + +
+ + +
+AYON: 3dsMax settings #5401 + +Keep `adsk_3dsmax` group in applications settings. + + +___ + +
+ + +
+Bugfix: update defaults to default_variants in maya and houdini OP DCC settings #5407 + +On moving out to new creator in Maya and Houdini updating settings was missed. + + +___ + +
+ + +
+Applications: Attributes creation #5408 + +Applications addon does not cause infinite server restart loop. + + +___ + +
+ + +
+Max: fix the bug of handling Object deletion in OP Parameter #5410 + +If the object is added to the OP parameter and user delete it in the scene thereafter, it will error out the container with OP attributes. This PR resolves the bug.This PR also fixes the bug of not adding the attribute into OP parameter correctly when the user enables "use selections" to link the object into the OP parameter. + + +___ + +
+ + +
+Colorspace: including environments from launcher process #5411 + +Fixed bug in GitHub PR where the OCIO config template was not properly formatting environment variables from System Settings `general/environment`. + + +___ + +
+ + +
+Nuke: workfile template fixes #5428 + +Some bunch of small bugs needed to be fixed + + +___ + +
+ + +
+Houdini, Max: Fix missed function interface change #5430 + +This PR https://github.com/ynput/OpenPype/pull/5321/files from @kalisp missed updating the `add_render_job_env_var` in Houdini and Max as they are passing an extra arg: +``` +TypeError: add_render_job_env_var() takes 1 positional argument but 2 were given +``` + + +___ + +
+ + +
+Scene Inventory: Fix issue with 'sync_server' #5431 + +Fix accesss to `sync_server` attribute in scene inventory. + + +___ + +
+ + +
+Unpack project: Fix import issue #5433 + +Added `load_json_file`, `replace_project_documents` and `store_project_documents` to mongo init. + + +___ + +
+ + +
+Chore: Versions post fixes #5441 + +Fixed issues caused by my fault. Filled right version value to anatomy data. + + +___ + +
+ +### **📃 Testing** + + +
+Tests: Copy file_handler as it will be removed by purging ayon code #5357 + +Ayon code will get purged in the future from this repo/addon, therefore all `ayon_common` will be gone. `file_handler` gets internalized to tests as it is not used anywhere else. + + +___ + +
+ + + + ## [3.16.2](https://github.com/ynput/OpenPype/tree/3.16.2) diff --git a/openpype/version.py b/openpype/version.py index 393074c773..d7c8a71343 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.3-nightly.5" +__version__ = "3.16.3" diff --git a/pyproject.toml b/pyproject.toml index c4596a7edd..5e7938751e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.2" # OpenPype +version = "3.16.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From ea3f26031d7ff9cd3a6ca6d1fb4f7cca0835e79c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 11 Aug 2023 08:35:29 +0000 Subject: [PATCH 044/112] 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 5826d99d38..84f954c71b 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.3 - 3.16.3-nightly.5 - 3.16.3-nightly.4 - 3.16.3-nightly.3 @@ -134,7 +135,6 @@ body: - 3.14.7-nightly.7 - 3.14.7-nightly.6 - 3.14.7-nightly.5 - - 3.14.7-nightly.4 validations: required: true - type: dropdown From a9d8e57db32cbf546390f961471fbd108eea1ee1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 11 Aug 2023 10:52:11 +0200 Subject: [PATCH 045/112] fixing changelog --- CHANGELOG.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d8f01234..80d6a0d99d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,7 +261,7 @@ ___
Enhancement: Houdini Update pointcache labels #5373 -To me it's logical to find pointcaches types listed one after another, but they were named differentlySo, I made this PR to update their labels +To me it's logical to find pointcaches types listed one after another, but they were named differentlySo, I made this PR to update their labels ___ @@ -386,13 +386,16 @@ ___ `assemblies` in `cmds.ls` does not seem to work; ```python + from maya import cmds content_instance = ['|group2|pSphere1_GEO', '|group2|pSphere1_GEO|pSphere1_GEOShape', '|group1|pSphere1_GEO', '|group1|pSphere1_GEO|pSphere1_GEOShape'] assemblies = cmds.ls(content_instance, assemblies=True, long=True) print(assemblies) -```Fixing with string splitting instead. +``` + +Fixing with string splitting instead. ___ @@ -653,7 +656,7 @@ ___
Bugfix: Houdini abc validator error message #5386 -When ABC path validator fails, it prints node objects not node paths or namesThis bug happened because of updating `get_invalid` method to return nodes instead of node pathsBeforeAfter +When ABC path validator fails, it prints node objects not node paths or namesThis bug happened because of updating `get_invalid` method to return nodes instead of node pathsBeforeAfter ___ @@ -1189,7 +1192,7 @@ ___ Add functional base for API Documentation using Sphinx and AutoAPI. -After unsuccessful #2512, #834 and #210 this is yet another try. But this time without ambition to solve the whole issue. This is making Shinx script to work and nothing else. Any changes and improvements in API docs should be made in subsequent PRs. +After unsuccessful #2512, #834 and #210 this is yet another try. But this time without ambition to solve the whole issue. This is making Shinx script to work and nothing else. Any changes and improvements in API docs should be made in subsequent PRs. ## How to use it @@ -1200,7 +1203,7 @@ cd .\docs make.bat html ``` -or +or ```sh cd ./docs @@ -1215,7 +1218,7 @@ During the build you'll see tons of red errors that are pointing to our issues: Invalid import are usually wrong relative imports (too deep) or circular imports. 2) **Invalid doc-strings** - Doc-strings to be processed into documentation needs to follow some syntax - this can be checked by running + Doc-strings to be processed into documentation needs to follow some syntax - this can be checked by running `pydocstyle` that is already included with OpenPype 3) **Invalid markdown/rst files** md/rst files can be included inside rst files using `.. include::` directive. But they have to be properly formatted. @@ -2402,11 +2405,11 @@ ___
Houdini: Redshift ROP image format bug #5218 -Problem : -"RS_outputFileFormat" parm value was missing -and there were more "image_format" than redshift rop supports +Problem : +"RS_outputFileFormat" parm value was missing +and there were more "image_format" than redshift rop supports -Fix: +Fix: 1) removed unnecessary formats from `image_format_enum` 2) add the selected format value to `RS_outputFileFormat` ___ @@ -4583,7 +4586,7 @@ ___
Maya Load References - Add Display Handle Setting #4904 -When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings. +When we load a reference in Maya using OpenPype loader, display handle is checked by default and prevent us to select easily the object in the viewport. I understand that some productions like to keep this option, so I propose to add display handle to the reference loader settings. ___ @@ -4691,7 +4694,7 @@ ___
Patchelf version locked #4853 -For Centos dockerfile it is necessary to lock the patchelf version to the older, otherwise the build process fails. +For Centos dockerfile it is necessary to lock the patchelf version to the older, otherwise the build process fails. ___ From 899482c0af7094781a0263b4a91e1b2e1a7d65d9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Aug 2023 11:17:38 +0200 Subject: [PATCH 046/112] Add automated targets for tests (#5443) Without it plugins with 'automated' targets won't be triggered (eg `CloseAE` etc.) --- openpype/pipeline/context_tools.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 9ada2d42a4..f567118062 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -21,6 +21,7 @@ from openpype.client import ( from openpype.lib.events import emit_event from openpype.modules import load_modules, ModulesManager from openpype.settings import get_project_settings +from openpype.tests.lib import is_in_tests from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy @@ -142,6 +143,10 @@ def install_host(host): else: pyblish.api.register_target("local") + if is_in_tests(): + print("Registering pyblish target: automated") + pyblish.api.register_target("automated") + project_name = os.environ.get("AVALON_PROJECT") host_name = os.environ.get("AVALON_APP") From 43796c2c1c14fee0f33e8d1e2480deb0e3c19256 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 11 Aug 2023 18:23:28 +0800 Subject: [PATCH 047/112] roy's comment --- openpype/hosts/nuke/plugins/load/load_image.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 225365056a..0dd3a940db 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -96,7 +96,8 @@ class LoadImage(load.LoaderPlugin): file = file.replace("\\", "/") - repr_cont = context["representation"]["context"] + representation = context["representation"] + repr_cont = representation["context"] frame = repr_cont.get("frame") if frame: padding = len(frame) @@ -104,16 +105,7 @@ class LoadImage(load.LoaderPlugin): frame, format(frame_number, "0{}".format(padding))) - name_data = { - "asset": repr_cont["asset"], - "subset": repr_cont["subset"], - "representation": context["representation"]["name"], - "ext": repr_cont["representation"], - "id": context["representation"]["_id"], - "class_name": self.__class__.__name__ - } - - read_name = self.node_name_template.format(**name_data) + read_name = self._get_node_name(representation) # Create the Loader with the filename path set with viewer_update_and_undo_stop(): From fdc8ccd4194dbe1d8a79233d96fa9fdd4aa685d7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 11 Aug 2023 14:14:56 +0200 Subject: [PATCH 048/112] farm: asymmetric handles fixed --- openpype/pipeline/farm/pyblish_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 9278b0efc5..8b9058359e 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -116,8 +116,8 @@ def get_time_data_from_instance_or_context(instance): instance.context.data.get("fps")), handle_start=(instance.data.get("handleStart") or instance.context.data.get("handleStart")), # noqa: E501 - handle_end=(instance.data.get("handleStart") or - instance.context.data.get("handleStart")) + handle_end=(instance.data.get("handleEnd") or + instance.context.data.get("handleEnd")) ) From 80114b24fa6571087a979906f8a8a83337bf8182 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 11 Aug 2023 14:50:05 +0200 Subject: [PATCH 049/112] TVPaint: Fix 'repeat' behavior (#5412) * adde frame start to repreat frame matching * removed "loop" from behaviors --- openpype/hosts/tvpaint/api/lib.py | 4 ++-- openpype/hosts/tvpaint/lib.py | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index 49846d7f29..f8b8c29cdb 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -233,7 +233,7 @@ def get_layers_pre_post_behavior(layer_ids, communicator=None): Pre and Post behaviors is enumerator of possible values: - "none" - - "repeat" / "loop" + - "repeat" - "pingpong" - "hold" @@ -242,7 +242,7 @@ def get_layers_pre_post_behavior(layer_ids, communicator=None): { 0: { "pre": "none", - "post": "loop" + "post": "repeat" } } ``` diff --git a/openpype/hosts/tvpaint/lib.py b/openpype/hosts/tvpaint/lib.py index 95653b6ecb..97cf8d3633 100644 --- a/openpype/hosts/tvpaint/lib.py +++ b/openpype/hosts/tvpaint/lib.py @@ -77,13 +77,15 @@ def _calculate_pre_behavior_copy( for frame_idx in range(range_start, layer_frame_start): output_idx_by_frame_idx[frame_idx] = first_exposure_frame - elif pre_beh in ("loop", "repeat"): + elif pre_beh == "repeat": # Loop backwards from last frame of layer for frame_idx in reversed(range(range_start, layer_frame_start)): eq_frame_idx_offset = ( (layer_frame_end - frame_idx) % frame_count ) - eq_frame_idx = layer_frame_end - eq_frame_idx_offset + eq_frame_idx = layer_frame_start + ( + layer_frame_end - eq_frame_idx_offset + ) output_idx_by_frame_idx[frame_idx] = eq_frame_idx elif pre_beh == "pingpong": @@ -139,10 +141,10 @@ def _calculate_post_behavior_copy( for frame_idx in range(layer_frame_end + 1, range_end + 1): output_idx_by_frame_idx[frame_idx] = last_exposure_frame - elif post_beh in ("loop", "repeat"): + elif post_beh == "repeat": # Loop backwards from last frame of layer for frame_idx in range(layer_frame_end + 1, range_end + 1): - eq_frame_idx = frame_idx % frame_count + eq_frame_idx = layer_frame_start + (frame_idx % frame_count) output_idx_by_frame_idx[frame_idx] = eq_frame_idx elif post_beh == "pingpong": From a2a35e8252edf94c7610e84130703e0a7d3f4b4f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 11 Aug 2023 14:01:37 +0100 Subject: [PATCH 050/112] General: Navigation to Folder from Launcher (#5404) * Basic implementation of navigation to folder from launcher * Allow the action to appear without a task selected * Added multiplatform support * Improved code to open file browser in different platforms Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Fixed missing import * Improved implementation to get path Co-authored-by: Roy Nieterau * Hound fixes * Use qtpy instead of Qt * Changed icon and label * Fix navigation not navigating to task folder * Implemented suggestions Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Add comment for clarity * change behavior to strictly use task of asset path without finding first available path * require asset name * raise exceptions to show a message to user --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Co-authored-by: Roy Nieterau Co-authored-by: Jakub Trllo --- .../plugins/actions/open_file_explorer.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 openpype/plugins/actions/open_file_explorer.py diff --git a/openpype/plugins/actions/open_file_explorer.py b/openpype/plugins/actions/open_file_explorer.py new file mode 100644 index 0000000000..e4fbd91143 --- /dev/null +++ b/openpype/plugins/actions/open_file_explorer.py @@ -0,0 +1,125 @@ +import os +import platform +import subprocess + +from string import Formatter +from openpype.client import ( + get_project, + get_asset_by_name, +) +from openpype.pipeline import ( + Anatomy, + LauncherAction, +) +from openpype.pipeline.template_data import get_template_data + + +class OpenTaskPath(LauncherAction): + name = "open_task_path" + label = "Explore here" + icon = "folder-open" + order = 500 + + def is_compatible(self, session): + """Return whether the action is compatible with the session""" + return bool(session.get("AVALON_ASSET")) + + def process(self, session, **kwargs): + from qtpy import QtCore, QtWidgets + + project_name = session["AVALON_PROJECT"] + asset_name = session["AVALON_ASSET"] + task_name = session.get("AVALON_TASK", None) + + path = self._get_workdir(project_name, asset_name, task_name) + if not path: + return + + app = QtWidgets.QApplication.instance() + ctrl_pressed = QtCore.Qt.ControlModifier & app.keyboardModifiers() + if ctrl_pressed: + # Copy path to clipboard + self.copy_path_to_clipboard(path) + else: + self.open_in_explorer(path) + + def _find_first_filled_path(self, path): + if not path: + return "" + + fields = set() + for item in Formatter().parse(path): + _, field_name, format_spec, conversion = item + if not field_name: + continue + conversion = "!{}".format(conversion) if conversion else "" + format_spec = ":{}".format(format_spec) if format_spec else "" + orig_key = "{{{}{}{}}}".format( + field_name, conversion, format_spec) + fields.add(orig_key) + + for field in fields: + path = path.split(field, 1)[0] + return path + + def _get_workdir(self, project_name, asset_name, task_name): + project = get_project(project_name) + asset = get_asset_by_name(project_name, asset_name) + + data = get_template_data(project, asset, task_name) + + anatomy = Anatomy(project_name) + workdir = anatomy.templates_obj["work"]["folder"].format(data) + + # Remove any potential un-formatted parts of the path + valid_workdir = self._find_first_filled_path(workdir) + + # Path is not filled at all + if not valid_workdir: + raise AssertionError("Failed to calculate workdir.") + + # Normalize + valid_workdir = os.path.normpath(valid_workdir) + if os.path.exists(valid_workdir): + return valid_workdir + + # If task was selected, try to find asset path only to asset + if not task_name: + raise AssertionError("Folder does not exist.") + + data.pop("task", None) + workdir = anatomy.templates_obj["work"]["folder"].format(data) + valid_workdir = self._find_first_filled_path(workdir) + if valid_workdir: + # Normalize + valid_workdir = os.path.normpath(valid_workdir) + if os.path.exists(valid_workdir): + return valid_workdir + raise AssertionError("Folder does not exist.") + + @staticmethod + def open_in_explorer(path): + platform_name = platform.system().lower() + if platform_name == "windows": + args = ["start", path] + elif platform_name == "darwin": + args = ["open", "-na", path] + elif platform_name == "linux": + args = ["xdg-open", path] + else: + raise RuntimeError(f"Unknown platform {platform.system()}") + # Make sure path is converted correctly for 'os.system' + os.system(subprocess.list2cmdline(args)) + + @staticmethod + def copy_path_to_clipboard(path): + from qtpy import QtWidgets + + path = path.replace("\\", "/") + print(f"Copied to clipboard: {path}") + app = QtWidgets.QApplication.instance() + assert app, "Must have running QApplication instance" + + # Set to Clipboard + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(os.path.normpath(path)) From 8b128d91bcff2570712ff442c9ea35feecb09c84 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Aug 2023 16:12:05 +0200 Subject: [PATCH 051/112] Maya: allow not creation of group for Import loaders (#5427) * OP-6357 - removed unneeded import * OP-6357 - extracted logic for getting custom group and namespace from Settings Mimicing logic in ReferenceLoader, eg. group could be left empty >> no groupping of imported subset. * OP-6357 - same logic for abc animation as Reference * OP-6357 - same logic for yeti rig as ReferenceLoder Allows to not create wrapping group. * OP-6357 - added separate import_loader to settings Could be used to not creating wrapping groups when Group kept empty. * OP-6357 - added product subset conversion for ayon settings * OP-6357 - fix using correct variable Artist input comes from `data` not directly from self.options * OP-6357 - add attach_to_root to options to allow control by same key * OP-6357 - added docstring * Added settings for Import loaders in maya * OP-6357 - refactored formatting --- openpype/hosts/maya/api/plugin.py | 86 ++++++++++++------- .../maya/plugins/load/_load_animation.py | 11 ++- openpype/hosts/maya/plugins/load/actions.py | 23 ++--- .../hosts/maya/plugins/load/load_reference.py | 3 +- .../hosts/maya/plugins/load/load_yeti_rig.py | 11 ++- openpype/settings/ayon_settings.py | 7 ++ .../defaults/project_settings/maya.json | 4 + .../schemas/schema_maya_load.json | 22 +++++ server_addon/maya/server/settings/loaders.py | 9 ++ server_addon/maya/server/version.py | 2 +- 10 files changed, 128 insertions(+), 50 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 4d467840dd..f705133e4f 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -523,6 +523,55 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): class Loader(LoaderPlugin): hosts = ["maya"] + def get_custom_namespace_and_group(self, context, options, loader_key): + """Queries Settings to get custom template for namespace and group. + + Group template might be empty >> this forces to not wrap imported items + into separate group. + + Args: + context (dict) + options (dict): artist modifiable options from dialog + loader_key (str): key to get separate configuration from Settings + ('reference_loader'|'import_loader') + """ + options["attach_to_root"] = True + + asset = context['asset'] + subset = context['subset'] + settings = get_project_settings(context['project']['name']) + custom_naming = settings['maya']['load'][loader_key] + + if not custom_naming['namespace']: + raise LoadError("No namespace specified in " + "Maya ReferenceLoader settings") + elif not custom_naming['group_name']: + self.log.debug("No custom group_name, no group will be created.") + options["attach_to_root"] = False + + formatting_data = { + "asset_name": asset['name'], + "asset_type": asset['type'], + "folder": { + "name": asset["name"], + }, + "subset": subset['name'], + "family": ( + subset['data'].get('family') or + subset['data']['families'][0] + ) + } + + custom_namespace = custom_naming['namespace'].format( + **formatting_data + ) + + custom_group_name = custom_naming['group_name'].format( + **formatting_data + ) + + return custom_group_name, custom_namespace, options + class ReferenceLoader(Loader): """A basic ReferenceLoader for Maya @@ -565,42 +614,13 @@ class ReferenceLoader(Loader): path = self.filepath_from_context(context) assert os.path.exists(path), "%s does not exist." % path - asset = context['asset'] - subset = context['subset'] - settings = get_project_settings(context['project']['name']) - custom_naming = settings['maya']['load']['reference_loader'] - loaded_containers = [] - - if not custom_naming['namespace']: - raise LoadError("No namespace specified in " - "Maya ReferenceLoader settings") - elif not custom_naming['group_name']: - self.log.debug("No custom group_name, no group will be created.") - options["attach_to_root"] = False - - formatting_data = { - "asset_name": asset['name'], - "asset_type": asset['type'], - "folder": { - "name": asset["name"], - }, - "subset": subset['name'], - "family": ( - subset['data'].get('family') or - subset['data']['families'][0] - ) - } - - custom_namespace = custom_naming['namespace'].format( - **formatting_data - ) - - custom_group_name = custom_naming['group_name'].format( - **formatting_data - ) + custom_group_name, custom_namespace, options = \ + self.get_custom_namespace_and_group(context, options, + "reference_loader") count = options.get("count") or 1 + loaded_containers = [] for c in range(0, count): namespace = lib.get_custom_namespace(custom_namespace) group_name = "{}:{}".format( diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 49792b2806..981b9ef434 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -33,6 +33,13 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): suffix="_abc" ) + attach_to_root = options.get("attach_to_root", True) + group_name = options["group_name"] + + # no group shall be created + if not attach_to_root: + group_name = namespace + # hero_001 (abc) # asset_counter{optional} path = self.filepath_from_context(context) @@ -41,8 +48,8 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): nodes = cmds.file(file_url, namespace=namespace, sharedReferenceFile=False, - groupReference=True, - groupName=options['group_name'], + groupReference=attach_to_root, + groupName=group_name, reference=True, returnNewNodes=True) diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 348657e592..d347ef0d08 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -5,8 +5,9 @@ import qargparse from openpype.pipeline import load from openpype.hosts.maya.api.lib import ( maintained_selection, - unique_namespace + get_custom_namespace ) +import openpype.hosts.maya.api.plugin class SetFrameRangeLoader(load.LoaderPlugin): @@ -83,7 +84,7 @@ class SetFrameRangeWithHandlesLoader(load.LoaderPlugin): animationEndTime=end) -class ImportMayaLoader(load.LoaderPlugin): +class ImportMayaLoader(openpype.hosts.maya.api.plugin.Loader): """Import action for Maya (unmanaged) Warning: @@ -130,13 +131,14 @@ class ImportMayaLoader(load.LoaderPlugin): if choice is False: return - asset = context['asset'] + custom_group_name, custom_namespace, options = \ + self.get_custom_namespace_and_group(context, data, + "import_loader") - namespace = namespace or unique_namespace( - asset["name"] + "_", - prefix="_" if asset["name"][0].isdigit() else "", - suffix="_", - ) + namespace = get_custom_namespace(custom_namespace) + + if not options.get("attach_to_root", True): + custom_group_name = namespace path = self.filepath_from_context(context) with maintained_selection(): @@ -145,8 +147,9 @@ class ImportMayaLoader(load.LoaderPlugin): preserveReferences=True, namespace=namespace, returnNewNodes=True, - groupReference=True, - groupName="{}:{}".format(namespace, name)) + groupReference=options.get("attach_to_root", + True), + groupName=custom_group_name) if data.get("clean_import", False): remove_attributes = ["cbId"] diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index c8d3b3128a..91767249e0 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -9,8 +9,7 @@ from openpype.hosts.maya.api.lib import ( maintained_selection, get_container_members, parent_nodes, - create_rig_animation_instance, - get_reference_node + create_rig_animation_instance ) diff --git a/openpype/hosts/maya/plugins/load/load_yeti_rig.py b/openpype/hosts/maya/plugins/load/load_yeti_rig.py index c9dfe9478b..6cfcffe27d 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_rig.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_rig.py @@ -19,8 +19,15 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference( self, context, name=None, namespace=None, options=None ): - group_name = options['group_name'] path = self.filepath_from_context(context) + + attach_to_root = options.get("attach_to_root", True) + group_name = options["group_name"] + + # no group shall be created + if not attach_to_root: + group_name = namespace + with lib.maintained_selection(): file_url = self.prepare_root_value( path, context["project"]["name"] @@ -30,7 +37,7 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): namespace=namespace, reference=True, returnNewNodes=True, - groupReference=True, + groupReference=attach_to_root, groupName=group_name ) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 78eed359a3..6237756943 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -602,6 +602,13 @@ def _convert_maya_project_settings(ayon_settings, output): .replace("{product[name]}", "{subset}") ) + if ayon_maya_load.get("import_loader"): + import_loader = ayon_maya_load["import_loader"] + import_loader["namespace"] = ( + import_loader["namespace"] + .replace("{product[name]}", "{subset}") + ) + output["maya"] = ayon_maya diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index e1c6d2d827..d2fb7b0864 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1463,6 +1463,10 @@ "namespace": "{asset_name}_{subset}_##_", "group_name": "_GRP", "display_handle": true + }, + "import_loader": { + "namespace": "{asset_name}_{subset}_##_", + "group_name": "_GRP" } }, "workfile_build": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 4b6b97ab4e..e73d39c06d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -121,6 +121,28 @@ "label": "Display Handle On Load References" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "import_loader", + "label": "Import Loader", + "children": [ + { + "type": "text", + "label": "Namespace", + "key": "namespace" + }, + { + "type": "text", + "label": "Group name", + "key": "group_name" + }, + { + "type": "label", + "label": "Here's a link to the doc where you can find explanations about customing the naming of referenced assets: https://openpype.io/docs/admin_hosts_maya#load-plugins" + } + ] } ] } diff --git a/server_addon/maya/server/settings/loaders.py b/server_addon/maya/server/settings/loaders.py index 60fc2a1cdd..29966bb6dd 100644 --- a/server_addon/maya/server/settings/loaders.py +++ b/server_addon/maya/server/settings/loaders.py @@ -45,6 +45,11 @@ class ReferenceLoaderModel(BaseSettingsModel): display_handle: bool = Field(title="Display Handle On Load References") +class ImportLoaderModel(BaseSettingsModel): + namespace: str = Field(title="Namespace") + group_name: str = Field(title="Group name") + + class LoadersModel(BaseSettingsModel): colors: ColorsSetting = Field( default_factory=ColorsSetting, @@ -55,6 +60,10 @@ class LoadersModel(BaseSettingsModel): title="Reference Loader" ) + import_loader: ImportLoaderModel = Field( + default_factory=ImportLoaderModel, + title="Import Loader" + ) DEFAULT_LOADERS_SETTING = { "colors": { diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index df0c92f1e2..e57ad00718 100644 --- a/server_addon/maya/server/version.py +++ b/server_addon/maya/server/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring addon version.""" -__version__ = "0.1.2" +__version__ = "0.1.3" From eaf248fefedca34052445f7e1ed18aa44a6ed35d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 11 Aug 2023 16:22:07 +0200 Subject: [PATCH 052/112] AYON: Thumbnails cache and api prep (#5437) * moved thumbnails cache from ayon api to server codebase * use cache in AYON thumbnail resolver and prepare it for new api methods --- openpype/client/server/thumbnails.py | 229 +++++++++++++++++++++++++++ openpype/pipeline/thumbnail.py | 49 ++++-- 2 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 openpype/client/server/thumbnails.py diff --git a/openpype/client/server/thumbnails.py b/openpype/client/server/thumbnails.py new file mode 100644 index 0000000000..dc649b9651 --- /dev/null +++ b/openpype/client/server/thumbnails.py @@ -0,0 +1,229 @@ +"""Cache of thumbnails downloaded from AYON server. + +Thumbnails are cached to appdirs to predefined directory. + +This should be moved to thumbnails logic in pipeline but because it would +overflow OpenPype logic it's here for now. +""" + +import os +import time +import collections + +import appdirs + +FileInfo = collections.namedtuple( + "FileInfo", + ("path", "size", "modification_time") +) + + +class AYONThumbnailCache: + """Cache of thumbnails on local storage. + + Thumbnails are cached to appdirs to predefined directory. Each project has + own subfolder with thumbnails -> that's because each project has own + thumbnail id validation and file names are thumbnail ids with matching + extension. Extensions are predefined (.png and .jpeg). + + Cache has cleanup mechanism which is triggered on initialized by default. + + The cleanup has 2 levels: + 1. soft cleanup which remove all files that are older then 'days_alive' + 2. max size cleanup which remove all files until the thumbnails folder + contains less then 'max_filesize' + - this is time consuming so it's not triggered automatically + + Args: + cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails). + """ + + # Lifetime of thumbnails (in seconds) + # - default 3 days + days_alive = 3 + # Max size of thumbnail directory (in bytes) + # - default 2 Gb + max_filesize = 2 * 1024 * 1024 * 1024 + + def __init__(self, cleanup=True): + self._thumbnails_dir = None + self._days_alive_secs = self.days_alive * 24 * 60 * 60 + if cleanup: + self.cleanup() + + def get_thumbnails_dir(self): + """Root directory where thumbnails are stored. + + Returns: + str: Path to thumbnails root. + """ + + if self._thumbnails_dir is None: + # TODO use generic function + directory = appdirs.user_data_dir("AYON", "Ynput") + self._thumbnails_dir = os.path.join(directory, "thumbnails") + return self._thumbnails_dir + + thumbnails_dir = property(get_thumbnails_dir) + + def get_thumbnails_dir_file_info(self): + """Get information about all files in thumbnails directory. + + Returns: + List[FileInfo]: List of file information about all files. + """ + + thumbnails_dir = self.thumbnails_dir + files_info = [] + if not os.path.exists(thumbnails_dir): + return files_info + + for root, _, filenames in os.walk(thumbnails_dir): + for filename in filenames: + path = os.path.join(root, filename) + files_info.append(FileInfo( + path, os.path.getsize(path), os.path.getmtime(path) + )) + return files_info + + def get_thumbnails_dir_size(self, files_info=None): + """Got full size of thumbnail directory. + + Args: + files_info (List[FileInfo]): Prepared file information about + files in thumbnail directory. + + Returns: + int: File size of all files in thumbnail directory. + """ + + if files_info is None: + files_info = self.get_thumbnails_dir_file_info() + + if not files_info: + return 0 + + return sum( + file_info.size + for file_info in files_info + ) + + def cleanup(self, check_max_size=False): + """Cleanup thumbnails directory. + + Args: + check_max_size (bool): Also cleanup files to match max size of + thumbnails directory. + """ + + thumbnails_dir = self.get_thumbnails_dir() + # Skip if thumbnails dir does not exists yet + if not os.path.exists(thumbnails_dir): + return + + self._soft_cleanup(thumbnails_dir) + if check_max_size: + self._max_size_cleanup(thumbnails_dir) + + def _soft_cleanup(self, thumbnails_dir): + current_time = time.time() + for root, _, filenames in os.walk(thumbnails_dir): + for filename in filenames: + path = os.path.join(root, filename) + modification_time = os.path.getmtime(path) + if current_time - modification_time > self._days_alive_secs: + os.remove(path) + + def _max_size_cleanup(self, thumbnails_dir): + files_info = self.get_thumbnails_dir_file_info() + size = self.get_thumbnails_dir_size(files_info) + if size < self.max_filesize: + return + + sorted_file_info = collections.deque( + sorted(files_info, key=lambda item: item.modification_time) + ) + diff = size - self.max_filesize + while diff > 0: + if not sorted_file_info: + break + + file_info = sorted_file_info.popleft() + diff -= file_info.size + os.remove(file_info.path) + + def get_thumbnail_filepath(self, project_name, thumbnail_id): + """Get thumbnail by thumbnail id. + + Args: + project_name (str): Name of project. + thumbnail_id (str): Thumbnail id. + + Returns: + Union[str, None]: Path to thumbnail image or None if thumbnail + is not cached yet. + """ + + if not thumbnail_id: + return None + + for ext in ( + ".png", + ".jpeg", + ): + filepath = os.path.join( + self.thumbnails_dir, project_name, thumbnail_id + ext + ) + if os.path.exists(filepath): + return filepath + return None + + def get_project_dir(self, project_name): + """Path to root directory for specific project. + + Args: + project_name (str): Name of project for which root directory path + should be returned. + + Returns: + str: Path to root of project's thumbnails. + """ + + return os.path.join(self.thumbnails_dir, project_name) + + def make_sure_project_dir_exists(self, project_name): + project_dir = self.get_project_dir(project_name) + if not os.path.exists(project_dir): + os.makedirs(project_dir) + return project_dir + + def store_thumbnail(self, project_name, thumbnail_id, content, mime_type): + """Store thumbnail to cache folder. + + Args: + project_name (str): Project where the thumbnail belong to. + thumbnail_id (str): Id of thumbnail. + content (bytes): Byte content of thumbnail file. + mime_data (str): Type of content. + + Returns: + str: Path to cached thumbnail image file. + """ + + if mime_type == "image/png": + ext = ".png" + elif mime_type == "image/jpeg": + ext = ".jpeg" + else: + raise ValueError( + "Unknown mime type for thumbnail \"{}\"".format(mime_type)) + + project_dir = self.make_sure_project_dir_exists(project_name) + thumbnail_path = os.path.join(project_dir, thumbnail_id + ext) + with open(thumbnail_path, "wb") as stream: + stream.write(content) + + current_time = time.time() + os.utime(thumbnail_path, (current_time, current_time)) + + return thumbnail_path diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index 9d4a6f3e48..b2b3679450 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -3,6 +3,7 @@ import copy import logging from openpype import AYON_SERVER_ENABLED +from openpype.lib import Logger from openpype.client import get_project from . import legacy_io from .anatomy import Anatomy @@ -11,13 +12,13 @@ from .plugin_discover import ( register_plugin, register_plugin_path, ) -log = logging.getLogger(__name__) def get_thumbnail_binary(thumbnail_entity, thumbnail_type, dbcon=None): if not thumbnail_entity: return + log = Logger.get_logger(__name__) resolvers = discover_thumbnail_resolvers() resolvers = sorted(resolvers, key=lambda cls: cls.priority) if dbcon is None: @@ -133,6 +134,16 @@ class BinaryThumbnail(ThumbnailResolver): class ServerThumbnailResolver(ThumbnailResolver): + _cache = None + + @classmethod + def _get_cache(cls): + if cls._cache is None: + from openpype.client.server.thumbnails import AYONThumbnailCache + + cls._cache = AYONThumbnailCache() + return cls._cache + def process(self, thumbnail_entity, thumbnail_type): if not AYON_SERVER_ENABLED: return None @@ -142,20 +153,40 @@ class ServerThumbnailResolver(ThumbnailResolver): if not entity_type or not entity_id: return None - from openpype.client.server.server_api import get_server_api_connection + import ayon_api project_name = self.dbcon.active_project() thumbnail_id = thumbnail_entity["_id"] - con = get_server_api_connection() - filepath = con.get_thumbnail( - project_name, entity_type, entity_id, thumbnail_id - ) - content = None + + cache = self._get_cache() + filepath = cache.get_thumbnail_filepath(project_name, thumbnail_id) if filepath: with open(filepath, "rb") as stream: - content = stream.read() + return stream.read() - return content + # This is new way how thumbnails can be received from server + # - output is 'ThumbnailContent' object + if hasattr(ayon_api, "get_thumbnail_by_id"): + result = ayon_api.get_thumbnail_by_id(thumbnail_id) + if result.is_valid: + filepath = cache.store_thumbnail( + project_name, + thumbnail_id, + result.content, + result.content_type + ) + else: + # Backwards compatibility for ayon api where 'get_thumbnail_by_id' + # is not implemented and output is filepath + filepath = ayon_api.get_thumbnail( + project_name, entity_type, entity_id, thumbnail_id + ) + + if not filepath: + return None + + with open(filepath, "rb") as stream: + return stream.read() # Thumbnail resolvers From dd27f4e839abe491d54b0183b58dc0dbbe16dc6a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 11 Aug 2023 16:23:27 +0200 Subject: [PATCH 053/112] AYON: Apply unknown ayon settings first (#5435) * apply unknown ayon settings first * added "Main" to empty default variants * use 'default_variants' in aftereffects creator --- .../hosts/aftereffects/plugins/create/create_render.py | 5 ++++- openpype/settings/ayon_settings.py | 8 ++++++++ .../settings/defaults/project_settings/aftereffects.json | 2 +- openpype/settings/defaults/project_settings/maya.json | 4 +++- .../projects_schema/schema_project_aftereffects.json | 2 +- 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index fa79fac78f..dcf424b44f 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -28,7 +28,6 @@ class RenderCreator(Creator): create_allow_context_change = True # Settings - default_variants = [] mark_for_review = True def create(self, subset_name_from_ui, data, pre_create_data): @@ -171,6 +170,10 @@ class RenderCreator(Creator): ) self.mark_for_review = plugin_settings["mark_for_review"] + self.default_variants = plugin_settings.get( + "default_variants", + plugin_settings.get("defaults") or [] + ) def get_detail_description(self): return """Creator for Render instances diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 6237756943..50abfe4839 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -301,6 +301,10 @@ def convert_system_settings(ayon_settings, default_settings, addon_versions): if "core" in ayon_settings: _convert_general(ayon_settings, output, default_settings) + for key, value in ayon_settings.items(): + if key not in output: + output[key] = value + for key, value in default_settings.items(): if key not in output: output[key] = value @@ -1272,6 +1276,10 @@ def convert_project_settings(ayon_settings, default_settings): _convert_global_project_settings(ayon_settings, output, default_settings) + for key, value in ayon_settings.items(): + if key not in output: + output[key] = value + for key, value in default_settings.items(): if key not in output: output[key] = value diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json index 63f544e536..77ccb74410 100644 --- a/openpype/settings/defaults/project_settings/aftereffects.json +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -12,7 +12,7 @@ }, "create": { "RenderCreator": { - "defaults": [ + "default_variants": [ "Main" ], "mark_for_review": true diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index d2fb7b0864..38f14ec022 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -547,7 +547,9 @@ }, "CreateUnrealSkeletalMesh": { "enabled": true, - "default_variants": [], + "default_variants": [ + "Main" + ], "joint_hints": "jnt_org" }, "CreateMultiverseLook": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json index 35b8fede86..72f09a641d 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json @@ -32,7 +32,7 @@ "children": [ { "type": "list", - "key": "defaults", + "key": "default_variants", "label": "Default Variants", "object_type": "text", "docstring": "Fill default variant(s) (like 'Main' or 'Default') used in subset name creation." From f5314db3ad8c765b44b578cbfba5f1959c1f91a5 Mon Sep 17 00:00:00 2001 From: FadyFS <135602303+FadyFS@users.noreply.github.com> Date: Fri, 11 Aug 2023 16:37:53 +0200 Subject: [PATCH 054/112] site config added (#5220) --- openpype/tools/settings/local_settings/projects_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/local_settings/projects_widget.py b/openpype/tools/settings/local_settings/projects_widget.py index 68e144f87b..f2b6535115 100644 --- a/openpype/tools/settings/local_settings/projects_widget.py +++ b/openpype/tools/settings/local_settings/projects_widget.py @@ -286,7 +286,7 @@ class SitesWidget(QtWidgets.QWidget): continue site_inputs = [] - site_config = site_configs[site_name] + site_config = site_configs.get(site_name, {}) for root_name, path_entity in site_config.get("root", {}).items(): if not path_entity: continue From fc5e52e9ab5f81e07b126e4b195461a1d5a3f44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20David?= Date: Fri, 11 Aug 2023 16:47:14 +0200 Subject: [PATCH 055/112] Feature: Download last published workfile specify version (#4998) Co-authored-by: Petr Kalis --- .../pre_copy_last_published_workfile.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py index 77f6933756..047e35e3ac 100644 --- a/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py +++ b/openpype/modules/sync_server/launch_hooks/pre_copy_last_published_workfile.py @@ -116,6 +116,18 @@ class CopyLastPublishedWorkfile(PreLaunchHook): "task": {"name": task_name, "type": task_type} } + # Add version filter + workfile_version = self.launch_context.data.get("workfile_version", -1) + if workfile_version > 0 and workfile_version not in {None, "last"}: + context_filters["version"] = self.launch_context.data[ + "workfile_version" + ] + + # Only one version will be matched + version_index = 0 + else: + version_index = workfile_version + workfile_representations = list(get_representations( project_name, context_filters=context_filters @@ -133,9 +145,10 @@ class CopyLastPublishedWorkfile(PreLaunchHook): lambda r: r["context"].get("version") is not None, workfile_representations ) - workfile_representation = max( + # Get workfile version + workfile_representation = sorted( filtered_repres, key=lambda r: r["context"]["version"] - ) + )[version_index] # Copy file and substitute path last_published_workfile_path = download_last_published_workfile( From d0ac9c1f2ec267ee83a31f07b787a2c98051d894 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 11 Aug 2023 18:12:29 +0200 Subject: [PATCH 056/112] Added missing defaults for import_loader (#5447) Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server_addon/maya/server/settings/loaders.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server_addon/maya/server/settings/loaders.py b/server_addon/maya/server/settings/loaders.py index 29966bb6dd..ed6b6fd2ac 100644 --- a/server_addon/maya/server/settings/loaders.py +++ b/server_addon/maya/server/settings/loaders.py @@ -120,5 +120,10 @@ DEFAULT_LOADERS_SETTING = { "namespace": "{folder[name]}_{product[name]}_##_", "group_name": "_GRP", "display_handle": True + }, + "import_loader": { + "namespace": "{folder[name]}_{product[name]}_##_", + "group_name": "_GRP", + "display_handle": True } } From f9babce983a78a57ef0d58dff68a381d8339ad61 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 12 Aug 2023 03:24:25 +0000 Subject: [PATCH 057/112] [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 d7c8a71343..afbac53385 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.3" +__version__ = "3.16.4-nightly.1" From 949b6ae33866c19e744039a8fde0092ca7174a61 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 12 Aug 2023 03:25:08 +0000 Subject: [PATCH 058/112] 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 84f954c71b..96fcc38d13 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.4-nightly.1 - 3.16.3 - 3.16.3-nightly.5 - 3.16.3-nightly.4 @@ -134,7 +135,6 @@ body: - 3.14.7-nightly.8 - 3.14.7-nightly.7 - 3.14.7-nightly.6 - - 3.14.7-nightly.5 validations: required: true - type: dropdown From 04b36e961180e455605dd513f626895b8e818f31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:52:56 +0200 Subject: [PATCH 059/112] fix provider icons access (#5450) --- openpype/tools/sceneinventory/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 64c439712c..4fd82f04a4 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -85,7 +85,7 @@ class InventoryModel(TreeModel): self.remote_provider = remote_provider self._site_icons = { provider: QtGui.QIcon(icon_path) - for provider, icon_path in self.get_site_icons().items() + for provider, icon_path in sync_server.get_site_icons().items() } if "active_site" not in self.Columns: self.Columns.append("active_site") From cf565a205e9c3aaa7ae54ab729d74b4111e89a11 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:36:40 +0200 Subject: [PATCH 060/112] Chore: Default variant in create plugin (#5429) * define constant 'DEFAULT_VARIANT_VALUE' * 'get_default_variant' always returns string * added 'default_variant' property for backwards compatibility * added more options to receive default variant * added backwards compatibility for 'default_variant' attribute * better autofix for backwards compatibility * use 'DEFAULT_VARIANT_VALUE' in publisher UI * fix docstring * Use 'Main' instead of 'main' for default variant --- openpype/pipeline/create/__init__.py | 2 + openpype/pipeline/create/constants.py | 2 + openpype/pipeline/create/creator_plugins.py | 79 ++++++++++++++++--- .../tools/publisher/widgets/create_widget.py | 5 +- 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index 6755224c19..94d575a776 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -2,6 +2,7 @@ from .constants import ( SUBSET_NAME_ALLOWED_SYMBOLS, DEFAULT_SUBSET_TEMPLATE, PRE_CREATE_THUMBNAIL_KEY, + DEFAULT_VARIANT_VALUE, ) from .utils import ( @@ -50,6 +51,7 @@ __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "DEFAULT_VARIANT_VALUE", "get_last_versions_for_instances", "get_next_versions_for_instances", diff --git a/openpype/pipeline/create/constants.py b/openpype/pipeline/create/constants.py index 375cfc4a12..7d1d0154e9 100644 --- a/openpype/pipeline/create/constants.py +++ b/openpype/pipeline/create/constants.py @@ -1,10 +1,12 @@ SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_." DEFAULT_SUBSET_TEMPLATE = "{family}{Variant}" PRE_CREATE_THUMBNAIL_KEY = "thumbnail_source" +DEFAULT_VARIANT_VALUE = "Main" __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "DEFAULT_VARIANT_VALUE", ) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index c9edbbfd71..38d6b6f465 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,4 +1,3 @@ -import os import copy import collections @@ -20,6 +19,7 @@ from openpype.pipeline.plugin_discover import ( deregister_plugin_path ) +from .constants import DEFAULT_VARIANT_VALUE from .subset_name import get_subset_name from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator @@ -517,7 +517,7 @@ class Creator(BaseCreator): default_variants = [] # Default variant used in 'get_default_variant' - default_variant = None + _default_variant = None # Short description of family # - may not be used if `get_description` is overriden @@ -543,6 +543,21 @@ class Creator(BaseCreator): # - similar to instance attribute definitions pre_create_attr_defs = [] + def __init__(self, *args, **kwargs): + cls = self.__class__ + + # Fix backwards compatibility for plugins which override + # 'default_variant' attribute directly + if not isinstance(cls.default_variant, property): + # Move value from 'default_variant' to '_default_variant' + self._default_variant = self.default_variant + # Create property 'default_variant' on the class + cls.default_variant = property( + cls._get_default_variant_wrap, + cls._set_default_variant_wrap + ) + super(Creator, self).__init__(*args, **kwargs) + @property def show_order(self): """Order in which is creator shown in UI. @@ -595,10 +610,10 @@ class Creator(BaseCreator): def get_default_variants(self): """Default variant values for UI tooltips. - Replacement of `defatults` attribute. Using method gives ability to - have some "logic" other than attribute values. + Replacement of `default_variants` attribute. Using method gives + ability to have some "logic" other than attribute values. - By default returns `default_variants` value. + By default, returns `default_variants` value. Returns: List[str]: Whisper variants for user input. @@ -606,17 +621,63 @@ class Creator(BaseCreator): return copy.deepcopy(self.default_variants) - def get_default_variant(self): + def get_default_variant(self, only_explicit=False): """Default variant value that will be used to prefill variant input. This is for user input and value may not be content of result from `get_default_variants`. - Can return `None`. In that case first element from - `get_default_variants` should be used. + Note: + This method does not allow to have empty string as + default variant. + + Args: + only_explicit (Optional[bool]): If True, only explicit default + variant from '_default_variant' will be returned. + + Returns: + str: Variant value. """ - return self.default_variant + if only_explicit or self._default_variant: + return self._default_variant + + for variant in self.get_default_variants(): + return variant + return DEFAULT_VARIANT_VALUE + + def _get_default_variant_wrap(self): + """Default variant value that will be used to prefill variant input. + + Wrapper for 'get_default_variant'. + + Notes: + This method is wrapper for 'get_default_variant' + for 'default_variant' property, so creator can override + the method. + + Returns: + str: Variant value. + """ + + return self.get_default_variant() + + def _set_default_variant_wrap(self, variant): + """Set default variant value. + + This method is needed for automated settings overrides which are + changing attributes based on keys in settings. + + Args: + variant (str): New default variant value. + """ + + self._default_variant = variant + + default_variant = property( + _get_default_variant_wrap, + _set_default_variant_wrap + ) def get_pre_create_attr_defs(self): """Plugin attribute definitions needed for creation. diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 1940d16eb8..64fed1d70c 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -6,6 +6,7 @@ from openpype import AYON_SERVER_ENABLED from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, PRE_CREATE_THUMBNAIL_KEY, + DEFAULT_VARIANT_VALUE, TaskNotSetError, ) @@ -626,7 +627,7 @@ class CreateWidget(QtWidgets.QWidget): default_variants = creator_item.default_variants if not default_variants: - default_variants = ["Main"] + default_variants = [DEFAULT_VARIANT_VALUE] default_variant = creator_item.default_variant if not default_variant: @@ -642,7 +643,7 @@ class CreateWidget(QtWidgets.QWidget): elif variant: self.variant_hints_menu.addAction(variant) - variant_text = default_variant or "Main" + variant_text = default_variant or DEFAULT_VARIANT_VALUE # Make sure subset name is updated to new plugin if variant_text == self.variant_input.text(): self._on_variant_change() From 4d96eff2ed7d272179337e65ed370b71ce2fa441 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 16 Aug 2023 03:24:46 +0000 Subject: [PATCH 061/112] [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 afbac53385..70eb32baff 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.4-nightly.1" +__version__ = "3.16.4-nightly.2" From bdc42761bdfbb06f0b167d7cf0ac49b87ced1a6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 16 Aug 2023 03:25:32 +0000 Subject: [PATCH 062/112] 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 96fcc38d13..d2a4067a6a 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.4-nightly.2 - 3.16.4-nightly.1 - 3.16.3 - 3.16.3-nightly.5 @@ -134,7 +135,6 @@ body: - 3.14.7 - 3.14.7-nightly.8 - 3.14.7-nightly.7 - - 3.14.7-nightly.6 validations: required: true - type: dropdown From 7b4a59e3338b1f3455e4362a876a3474454cf15b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 16 Aug 2023 13:42:02 +0200 Subject: [PATCH 063/112] 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 064/112] 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 328c3d9c7fa499ca39b1351b94f2d0ae0d261a69 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Aug 2023 16:16:21 +0200 Subject: [PATCH 065/112] OP-6567 - fix setting of version to workfile instance (#5452) If there are multiple instances of renderlayer published, previous logic resulted in unpredictable rewrite of instance family to 'workfile' --- openpype/hosts/maya/plugins/publish/collect_render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index c37b54ea9a..c17a8789e4 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -304,9 +304,9 @@ class CollectMayaRender(pyblish.api.InstancePlugin): if self.sync_workfile_version: data["version"] = context.data["version"] - for instance in context: - if instance.data['family'] == "workfile": - instance.data["version"] = context.data["version"] + for _instance in context: + if _instance.data['family'] == "workfile": + _instance.data["version"] = context.data["version"] # Define nice label label = "{0} ({1})".format(layer_name, instance.data["asset"]) From c5d882c7eae662deb1a6477bb93fe7884f033dca Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Aug 2023 10:33:52 +0200 Subject: [PATCH 066/112] Maya: Fix wrong subset name of render family in deadline (#5442) * Use existing subset_name as group_name by default New publisher already carries real subset name (`renderModelingMain`), it should build group name only if subset_name is weird. * Let legacy conversion of render instance recreate subset_name Without it would create subset names like `renderingMain` which are not matching to newly created `renderMain` instances. This would cause issue in version restarts. * Let Render Creator for Maya create proper subset_name It was using hardcoded logic not matching other DCCs. * Hound * Fix method calls * Fix typos * Do not import unnecessary * Capitalize is wrong function for here * Overwrite get_subset_name for standardized results It makes sense to override this method for other parts of code getting same results. * Force change It seems that GH doesn't recognize changes with adding() * Update openpype/hosts/maya/plugins/create/convert_legacy.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Hound --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/maya/api/plugin.py | 37 +++++++++++++++---- .../maya/plugins/create/convert_legacy.py | 14 +++++++ openpype/pipeline/farm/pyblish_functions.py | 12 ++++-- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index f705133e4f..00d6602ef9 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -22,10 +22,10 @@ from openpype.pipeline import ( LegacyCreator, LoaderPlugin, get_representation_path, - - legacy_io, ) from openpype.pipeline.load import LoadError +from openpype.client import get_asset_by_name +from openpype.pipeline.create import get_subset_name from . import lib from .lib import imprint, read @@ -405,14 +405,21 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): # No existing scene instance node for this layer. Note that # this instance will not have the `instance_node` data yet # until it's been saved/persisted at least once. - # TODO: Correctly define the subset name using templates - prefix = self.layer_instance_prefix or self.family - subset_name = "{}{}".format(prefix, layer.name()) + project_name = self.create_context.get_current_project_name() + instance_data = { - "asset": legacy_io.Session["AVALON_ASSET"], - "task": legacy_io.Session["AVALON_TASK"], + "asset": self.create_context.get_current_asset_name(), + "task": self.create_context.get_current_task_name(), "variant": layer.name(), } + asset_doc = get_asset_by_name(project_name, + instance_data["asset"]) + subset_name = self.get_subset_name( + layer.name(), + instance_data["task"], + asset_doc, + project_name) + instance = CreatedInstance( family=self.family, subset_name=subset_name, @@ -519,6 +526,22 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): if node and cmds.objExists(node): cmds.delete(node) + def get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + # creator.family != 'render' as expected + return get_subset_name(self.layer_instance_prefix, + variant, + task_name, + asset_doc, + project_name) + class Loader(LoaderPlugin): hosts = ["maya"] diff --git a/openpype/hosts/maya/plugins/create/convert_legacy.py b/openpype/hosts/maya/plugins/create/convert_legacy.py index 33a1e020dd..cd8faf291b 100644 --- a/openpype/hosts/maya/plugins/create/convert_legacy.py +++ b/openpype/hosts/maya/plugins/create/convert_legacy.py @@ -2,6 +2,8 @@ from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.maya.api import plugin from openpype.hosts.maya.api.lib import read +from openpype.client import get_asset_by_name + from maya import cmds from maya.app.renderSetup.model import renderSetup @@ -135,6 +137,18 @@ class MayaLegacyConvertor(SubsetConvertorPlugin, # "rendering" family being converted to "renderlayer" family) original_data["family"] = creator.family + # recreate subset name as without it would be + # `renderingMain` vs correct `renderMain` + project_name = self.create_context.get_current_project_name() + asset_doc = get_asset_by_name(project_name, + original_data["asset"]) + subset_name = creator.get_subset_name( + original_data["variant"], + data["task"], + asset_doc, + project_name) + original_data["subset"] = subset_name + # Convert to creator attributes when relevant creator_attributes = {} for key in list(original_data.keys()): diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 8b9058359e..288602b77c 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -568,9 +568,15 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, col = list(cols[0]) # create subset name `familyTaskSubset_AOV` - group_name = 'render{}{}{}{}'.format( - task[0].upper(), task[1:], - subset[0].upper(), subset[1:]) + # TODO refactor/remove me + family = skeleton["family"] + if not subset.startswith(family): + group_name = '{}{}{}{}{}'.format( + family, + task[0].upper(), task[1:], + subset[0].upper(), subset[1:]) + else: + group_name = subset # if there are multiple cameras, we need to add camera name if isinstance(col, (list, tuple)): From 447921b22e51f0fbc412dcbae72ab543c60a93a9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 17 Aug 2023 10:38:21 +0200 Subject: [PATCH 067/112] Publisher: Thumbnail widget enhancements (#5439) * screenshot widget from @BigRoy * small tweaks of screen capture logic * added take screenshot button to thumbnail widget * added tooltips * Use constants from class * adde PySide 6 support * minimize window when on take screenshot * Keep origin state of window. Co-authored-by: Roy Nieterau * Fix support for Qt version below 5.10 * draw pixel with alpha when disabled * clear image cache on resize * added more buttons and options button with animation * removed unnecessary options widget * fix escape button * keep icons visible all the time --------- Co-authored-by: Roy Nieterau --- .../tools/publisher/widgets/images/browse.png | Bin 0 -> 12225 bytes .../publisher/widgets/images/options.png | Bin 0 -> 3216 bytes .../tools/publisher/widgets/images/paste.png | Bin 0 -> 6513 bytes .../widgets/images/take_screenshot.png | Bin 0 -> 11003 bytes .../publisher/widgets/screenshot_widget.py | 314 ++++++++++++++++++ .../publisher/widgets/thumbnail_widget.py | 126 ++++++- openpype/tools/utils/widgets.py | 18 + 7 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 openpype/tools/publisher/widgets/images/browse.png create mode 100644 openpype/tools/publisher/widgets/images/options.png create mode 100644 openpype/tools/publisher/widgets/images/paste.png create mode 100644 openpype/tools/publisher/widgets/images/take_screenshot.png create mode 100644 openpype/tools/publisher/widgets/screenshot_widget.py diff --git a/openpype/tools/publisher/widgets/images/browse.png b/openpype/tools/publisher/widgets/images/browse.png new file mode 100644 index 0000000000000000000000000000000000000000..b115bb67662ead09af54700e8b7f91b580672916 GIT binary patch literal 12225 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 H*x3IA=zj~i literal 0 HcmV?d00001 diff --git a/openpype/tools/publisher/widgets/images/options.png b/openpype/tools/publisher/widgets/images/options.png new file mode 100644 index 0000000000000000000000000000000000000000..b394dbd4ce517004dfeb6126e99ba2fee2b0a34a GIT binary patch literal 3216 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 literal 0 HcmV?d00001 diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py new file mode 100644 index 0000000000..4ccf920571 --- /dev/null +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -0,0 +1,314 @@ +import os +import tempfile + +from qtpy import QtCore, QtGui, QtWidgets + + +class ScreenMarquee(QtWidgets.QDialog): + """Dialog to interactively define screen area. + + This allows to select a screen area through a marquee selection. + + You can use any of its classmethods for easily saving an image, + capturing to QClipboard or returning a QPixmap, respectively + `capture_to_file`, `capture_to_clipboard` and `capture_to_pixmap`. + """ + + def __init__(self, parent=None): + super(ScreenMarquee, self).__init__(parent=parent) + + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint + | QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.Tool) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + 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.start(QtCore.QAbstractAnimation.DeleteWhenStopped) + + fade_anim.valueChanged.connect(self._on_fade_anim) + + app = QtWidgets.QApplication.instance() + if hasattr(app, "screenAdded"): + app.screenAdded.connect(self._on_screen_added) + app.screenRemoved.connect(self._fit_screen_geometry) + elif hasattr(app, "desktop"): + desktop = app.desktop() + desktop.screenCountChanged.connect(self._fit_screen_geometry) + + for screen in QtWidgets.QApplication.screens(): + screen.geometryChanged.connect(self._fit_screen_geometry) + + self._opacity = fade_anim.currentValue() + 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() + + return self.get_desktop_pixmap(self._capture_rect) + + def paintEvent(self, event): + """Paint event""" + + # Convert click and current mouse positions to local space. + mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) + click_pos = None + if self._click_pos is not None: + click_pos = self.mapFromGlobal(self._click_pos) + + painter = QtGui.QPainter(self) + + # 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()) + + # Clear the capture area + if click_pos is not None: + capture_rect = QtCore.QRect(click_pos, mouse_pos) + painter.setCompositionMode( + QtGui.QPainter.CompositionMode_Clear) + painter.drawRect(capture_rect) + painter.setCompositionMode( + QtGui.QPainter.CompositionMode_SourceOver) + + pen_color = QtGui.QColor(255, 255, 255, 64) + 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(), + rect.right(), click_pos.y() + ) + painter.drawLine( + click_pos.x(), rect.top(), + click_pos.x(), rect.bottom() + ) + + # Draw cropping markers at current mouse position + painter.drawLine( + rect.left(), mouse_pos.y(), + rect.right(), mouse_pos.y() + ) + painter.drawLine( + mouse_pos.x(), rect.top(), + mouse_pos.x(), rect.bottom() + ) + + def mousePressEvent(self, event): + """Mouse click event""" + + if event.button() == QtCore.Qt.LeftButton: + # Begin click drag operation + self._click_pos = event.globalPos() + + def mouseReleaseEvent(self, event): + """Mouse release event""" + if ( + self._click_pos is not None + and event.button() == QtCore.Qt.LeftButton + ): + # End click drag operation and commit the current capture rect + self._capture_rect = QtCore.QRect( + self._click_pos, event.globalPos() + ).normalized() + self._click_pos = None + self.close() + + def mouseMoveEvent(self, event): + """Mouse move event""" + self.repaint() + + def keyPressEvent(self, event): + """Mouse press event""" + if event.key() == QtCore.Qt.Key_Escape: + self._click_pos = None + self._capture_rect = None + self.close() + return + return super(ScreenMarquee, self).mousePressEvent(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. + workspace_rect = QtCore.QRect() + for screen in QtWidgets.QApplication.screens(): + 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) + + @classmethod + def get_desktop_pixmap(cls, rect): + """Performs a screen capture on the specified rectangle. + + Args: + rect (QtCore.QRect): The rectangle to capture. + + Returns: + QtGui.QPixmap: Captured pixmap image + """ + + if rect.width() < 1 or rect.height() < 1: + return QtGui.QPixmap() + + screen_pixes = [] + for screen in QtWidgets.QApplication.screens(): + screen_geo = screen.geometry() + if not screen_geo.intersects(rect): + continue + + screen_pix_rect = screen_geo.intersected(rect) + screen_pix = screen.grabWindow( + 0, + screen_pix_rect.x() - screen_geo.x(), + screen_pix_rect.y() - screen_geo.y(), + screen_pix_rect.width(), screen_pix_rect.height() + ) + paste_point = QtCore.QPoint( + screen_pix_rect.x() - rect.x(), + screen_pix_rect.y() - rect.y() + ) + screen_pixes.append((screen_pix, paste_point)) + + output_pix = QtGui.QPixmap(rect.width(), rect.height()) + output_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(output_pix) + for item in screen_pixes: + (screen_pix, offset) = item + pix_painter.drawPixmap(offset, screen_pix) + + pix_painter.end() + + return output_pix + + @classmethod + def capture_to_pixmap(cls): + """Take screenshot with marquee into pixmap. + + Note: + The pixmap can be invalid (use 'isNull' to check). + + Returns: + QtGui.QPixmap: Captured pixmap image. + """ + + tool = cls() + tool.exec_() + return tool.get_captured_pixmap() + + @classmethod + def capture_to_file(cls, filepath=None): + """Take screenshot with marquee into file. + + Args: + filepath (Optional[str]): Path where screenshot will be saved. + + Returns: + Union[str, None]: Path to the saved screenshot, or None if user + cancelled the operation. + """ + + pixmap = cls.capture_to_pixmap() + if pixmap.isNull(): + return None + + if filepath is None: + with tempfile.NamedTemporaryFile( + prefix="screenshot_", suffix=".png", delete=False + ) as tmpfile: + filepath = tmpfile.name + + else: + output_dir = os.path.dirname(filepath) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + pixmap.save(filepath) + return filepath + + @classmethod + def capture_to_clipboard(cls): + """Take screenshot with marquee into clipboard. + + Notes: + Screenshot is not in clipboard if user cancelled the operation. + + Returns: + bool: Screenshot was added to clipboard. + """ + + clipboard = QtWidgets.QApplication.clipboard() + pixmap = cls.capture_to_pixmap() + if pixmap.isNull(): + return False + image = pixmap.toImage() + clipboard.setImage(image, QtGui.QClipboard.Clipboard) + return True + + +def capture_to_pixmap(): + """Take screenshot with marquee into pixmap. + + Note: + The pixmap can be invalid (use 'isNull' to check). + + Returns: + QtGui.QPixmap: Captured pixmap image. + """ + + return ScreenMarquee.capture_to_pixmap() + + +def capture_to_file(filepath=None): + """Take screenshot with marquee into file. + + Args: + filepath (Optional[str]): Path where screenshot will be saved. + + Returns: + Union[str, None]: Path to the saved screenshot, or None if user + cancelled the operation. + """ + + return ScreenMarquee.capture_to_file(filepath) + + +def capture_to_clipboard(): + """Take screenshot with marquee into clipboard. + + Notes: + Screenshot is not in clipboard if user cancelled the operation. + + Returns: + bool: Screenshot was added to clipboard. + """ + + return ScreenMarquee.capture_to_clipboard() diff --git a/openpype/tools/publisher/widgets/thumbnail_widget.py b/openpype/tools/publisher/widgets/thumbnail_widget.py index 80d156185b..60970710d8 100644 --- a/openpype/tools/publisher/widgets/thumbnail_widget.py +++ b/openpype/tools/publisher/widgets/thumbnail_widget.py @@ -22,6 +22,7 @@ from openpype.tools.utils import ( from openpype.tools.publisher.control import CardMessageTypes from .icons import get_image +from .screenshot_widget import capture_to_file class ThumbnailPainterWidget(QtWidgets.QWidget): @@ -306,20 +307,43 @@ class ThumbnailWidget(QtWidgets.QWidget): thumbnail_painter = ThumbnailPainterWidget(self) + icon_color = get_objected_colors("bg-view-selection").get_qcolor() + icon_color.setAlpha(255) + buttons_widget = QtWidgets.QWidget(self) buttons_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - icon_color = get_objected_colors("bg-view-selection").get_qcolor() - icon_color.setAlpha(255) clear_image = get_image("clear_thumbnail") clear_pix = paint_image_with_color(clear_image, icon_color) - clear_button = PixmapButton(clear_pix, buttons_widget) clear_button.setObjectName("ThumbnailPixmapHoverButton") + clear_button.setToolTip("Clear thumbnail") + + take_screenshot_image = get_image("take_screenshot") + take_screenshot_pix = paint_image_with_color( + take_screenshot_image, icon_color) + take_screenshot_btn = PixmapButton( + take_screenshot_pix, buttons_widget) + take_screenshot_btn.setObjectName("ThumbnailPixmapHoverButton") + take_screenshot_btn.setToolTip("Take screenshot") + + paste_image = get_image("paste") + paste_pix = paint_image_with_color(paste_image, icon_color) + paste_btn = PixmapButton(paste_pix, buttons_widget) + paste_btn.setObjectName("ThumbnailPixmapHoverButton") + paste_btn.setToolTip("Paste from clipboard") + + browse_image = get_image("browse") + browse_pix = paint_image_with_color(browse_image, icon_color) + browse_btn = PixmapButton(browse_pix, buttons_widget) + browse_btn.setObjectName("ThumbnailPixmapHoverButton") + browse_btn.setToolTip("Browse...") buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) - buttons_layout.setContentsMargins(3, 3, 3, 3) - buttons_layout.addStretch(1) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(take_screenshot_btn, 0) + buttons_layout.addWidget(paste_btn, 0) + buttons_layout.addWidget(browse_btn, 0) buttons_layout.addWidget(clear_button, 0) layout = QtWidgets.QHBoxLayout(self) @@ -327,6 +351,9 @@ class ThumbnailWidget(QtWidgets.QWidget): layout.addWidget(thumbnail_painter) clear_button.clicked.connect(self._on_clear_clicked) + take_screenshot_btn.clicked.connect(self._on_take_screenshot) + paste_btn.clicked.connect(self._on_paste_from_clipboard) + browse_btn.clicked.connect(self._on_browse_clicked) self._controller = controller self._output_dir = controller.get_thumbnail_temp_dir_path() @@ -338,9 +365,16 @@ class ThumbnailWidget(QtWidgets.QWidget): self._adapted_to_size = True self._last_width = None self._last_height = None + self._hide_on_finish = False self._buttons_widget = buttons_widget self._thumbnail_painter = thumbnail_painter + self._clear_button = clear_button + self._take_screenshot_btn = take_screenshot_btn + self._paste_btn = paste_btn + self._browse_btn = browse_btn + + clear_button.setEnabled(False) @property def width_ratio(self): @@ -430,13 +464,75 @@ class ThumbnailWidget(QtWidgets.QWidget): self._thumbnail_painter.clear_cache() + def _set_current_thumbails(self, thumbnail_paths): + self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) + self._update_buttons_position() + def set_current_thumbnails(self, thumbnail_paths=None): self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) self._update_buttons_position() + self._clear_button.setEnabled(self._thumbnail_painter.has_pixes) def _on_clear_clicked(self): self.set_current_thumbnails() self.thumbnail_cleared.emit() + self._clear_button.setEnabled(False) + + def _on_take_screenshot(self): + window = self.window() + state = window.windowState() + window.setWindowState(QtCore.Qt.WindowMinimized) + output_path = os.path.join( + self._output_dir, uuid.uuid4().hex + ".png") + if capture_to_file(output_path): + self.thumbnail_created.emit(output_path) + # restore original window state + window.setWindowState(state) + + def _on_paste_from_clipboard(self): + """Set thumbnail from a pixmap image in the system clipboard""" + + clipboard = QtWidgets.QApplication.clipboard() + pixmap = clipboard.pixmap() + if pixmap.isNull(): + return + + # Save as temporary file + output_path = os.path.join( + self._output_dir, uuid.uuid4().hex + ".png") + + output_dir = os.path.dirname(output_path) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + if pixmap.save(output_path): + self.thumbnail_created.emit(output_path) + + def _on_browse_clicked(self): + ext_filter = "Source (*{0})".format( + " *".join(self._review_extensions) + ) + filepath, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Choose thumbnail", os.path.expanduser("~"), ext_filter + ) + if not filepath: + return + valid_path = False + ext = os.path.splitext(filepath)[-1].lower() + if ext in self._review_extensions: + valid_path = True + + output = None + if valid_path: + output = export_thumbnail(filepath, self._output_dir) + + if output: + self.thumbnail_created.emit(output) + else: + self._controller.emit_card_message( + "Couldn't convert the source for thumbnail", + CardMessageTypes.error + ) def _adapt_to_size(self): if not self._adapted_to_size: @@ -452,13 +548,25 @@ class ThumbnailWidget(QtWidgets.QWidget): self._thumbnail_painter.clear_cache() def _update_buttons_position(self): - self._buttons_widget.setVisible(self._thumbnail_painter.has_pixes) size = self.size() + my_width = size.width() my_height = size.height() - height = self._buttons_widget.sizeHint().height() + buttons_sh = self._buttons_widget.sizeHint() + buttons_height = buttons_sh.height() + buttons_width = buttons_sh.width() + pos_x = my_width - (buttons_width + 3) + pos_y = my_height - (buttons_height + 3) + if pos_x < 0: + pos_x = 0 + buttons_width = my_width + if pos_y < 0: + pos_y = 0 + buttons_height = my_height self._buttons_widget.setGeometry( - 0, my_height - height, - size.width(), height + pos_x, + pos_y, + buttons_width, + buttons_height ) def resizeEvent(self, event): diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 5a8104611b..a70437cc65 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -410,6 +410,18 @@ class PixmapButtonPainter(QtWidgets.QWidget): self._pixmap = pixmap self._cached_pixmap = None + self._disabled = False + + def resizeEvent(self, event): + super(PixmapButtonPainter, self).resizeEvent(event) + self._cached_pixmap = None + self.repaint() + + def set_enabled(self, enabled): + if self._disabled != enabled: + return + self._disabled = not enabled + self.repaint() def set_pixmap(self, pixmap): self._pixmap = pixmap @@ -444,6 +456,8 @@ class PixmapButtonPainter(QtWidgets.QWidget): if self._cached_pixmap is None: self._cache_pixmap() + if self._disabled: + painter.setOpacity(0.5) painter.drawPixmap(0, 0, self._cached_pixmap) painter.end() @@ -464,6 +478,10 @@ class PixmapButton(ClickableFrame): layout.setContentsMargins(*args) self._update_painter_geo() + def setEnabled(self, enabled): + self._button_painter.set_enabled(enabled) + super(PixmapButton, self).setEnabled(enabled) + def set_pixmap(self, pixmap): self._button_painter.set_pixmap(pixmap) From bd9a79427421c664021bc296baa852b698800269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Thu, 17 Aug 2023 10:41:34 +0200 Subject: [PATCH 068/112] Fix typo on deadline OP plugin name (#5453) --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 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..da96b429ce 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -211,7 +211,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" environment["OPENPYPE_REMOTE_PUBLISH"] = "0" - deadline_plugin = "Openpype" + deadline_plugin = "OpenPype" # Add OpenPype version if we are running from build. if is_running_from_build(): self.environ_keys.append("OPENPYPE_VERSION") From 6ae58875b5a9e0dde4a045e248e821a61997349b Mon Sep 17 00:00:00 2001 From: Kayla Man <64118225+moonyuet@users.noreply.github.com> Date: Thu, 17 Aug 2023 16:57:00 +0800 Subject: [PATCH 069/112] 3dsMax: Settings for Ayon (#5388) * 3dsmax settings for ayon * lower version to '0.1.0' * remove arguments from max application settings * RenderSettings instead of render_settings for max --------- Co-authored-by: Jakub Trllo --- .../applications/server/applications.json | 4 +- server_addon/max/server/__init__.py | 17 ++++++ server_addon/max/server/settings/__init__.py | 10 ++++ server_addon/max/server/settings/imageio.py | 48 +++++++++++++++ server_addon/max/server/settings/main.py | 60 +++++++++++++++++++ .../max/server/settings/publishers.py | 26 ++++++++ .../max/server/settings/render_settings.py | 49 +++++++++++++++ server_addon/max/server/version.py | 1 + 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 server_addon/max/server/__init__.py create mode 100644 server_addon/max/server/settings/__init__.py create mode 100644 server_addon/max/server/settings/imageio.py create mode 100644 server_addon/max/server/settings/main.py create mode 100644 server_addon/max/server/settings/publishers.py create mode 100644 server_addon/max/server/settings/render_settings.py create mode 100644 server_addon/max/server/version.py diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index b19308ee7c..8e5b28623e 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -127,9 +127,7 @@ "linux": [] }, "arguments": { - "windows": [ - "-U MAXScript {OPENPYPE_ROOT}\\openpype\\hosts\\max\\startup\\startup.ms" - ], + "windows": [], "darwin": [], "linux": [] }, diff --git a/server_addon/max/server/__init__.py b/server_addon/max/server/__init__.py new file mode 100644 index 0000000000..31c694a084 --- /dev/null +++ b/server_addon/max/server/__init__.py @@ -0,0 +1,17 @@ +from typing import Type + +from ayon_server.addons import BaseServerAddon + +from .version import __version__ +from .settings import MaxSettings, DEFAULT_VALUES + + +class MaxAddon(BaseServerAddon): + name = "max" + title = "Max" + version = __version__ + settings_model: Type[MaxSettings] = MaxSettings + + async def get_default_settings(self): + settings_model_cls = self.get_settings_model() + return settings_model_cls(**DEFAULT_VALUES) diff --git a/server_addon/max/server/settings/__init__.py b/server_addon/max/server/settings/__init__.py new file mode 100644 index 0000000000..986b1903a5 --- /dev/null +++ b/server_addon/max/server/settings/__init__.py @@ -0,0 +1,10 @@ +from .main import ( + MaxSettings, + DEFAULT_VALUES, +) + + +__all__ = ( + "MaxSettings", + "DEFAULT_VALUES", +) diff --git a/server_addon/max/server/settings/imageio.py b/server_addon/max/server/settings/imageio.py new file mode 100644 index 0000000000..5e46104fa7 --- /dev/null +++ b/server_addon/max/server/settings/imageio.py @@ -0,0 +1,48 @@ +from pydantic import Field, validator +from ayon_server.settings import BaseSettingsModel +from ayon_server.settings.validators import ensure_unique_names + + +class ImageIOConfigModel(BaseSettingsModel): + override_global_config: bool = Field( + False, + title="Override global OCIO config" + ) + filepath: list[str] = Field( + default_factory=list, + title="Config path" + ) + + +class ImageIOFileRuleModel(BaseSettingsModel): + name: str = Field("", title="Rule name") + pattern: str = Field("", title="Regex pattern") + colorspace: str = Field("", title="Colorspace name") + ext: str = Field("", title="File extension") + + +class ImageIOFileRulesModel(BaseSettingsModel): + activate_host_rules: bool = Field(False) + rules: list[ImageIOFileRuleModel] = Field( + default_factory=list, + title="Rules" + ) + + @validator("rules") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ImageIOSettings(BaseSettingsModel): + activate_host_color_management: bool = Field( + True, title="Enable Color Management" + ) + ocio_config: ImageIOConfigModel = Field( + default_factory=ImageIOConfigModel, + title="OCIO config" + ) + file_rules: ImageIOFileRulesModel = Field( + default_factory=ImageIOFileRulesModel, + title="File Rules" + ) diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py new file mode 100644 index 0000000000..7f4561cbb1 --- /dev/null +++ b/server_addon/max/server/settings/main.py @@ -0,0 +1,60 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel +from .imageio import ImageIOSettings +from .render_settings import ( + RenderSettingsModel, DEFAULT_RENDER_SETTINGS +) +from .publishers import ( + PublishersModel, DEFAULT_PUBLISH_SETTINGS +) + + +class PRTAttributesModel(BaseSettingsModel): + _layout = "compact" + name: str = Field(title="Name") + value: str = Field(title="Attribute") + + +class PointCloudSettings(BaseSettingsModel): + attribute: list[PRTAttributesModel] = Field( + default_factory=list, title="Channel Attribute") + + +class MaxSettings(BaseSettingsModel): + imageio: ImageIOSettings = Field( + default_factory=ImageIOSettings, + title="Color Management (ImageIO)" + ) + RenderSettings: RenderSettingsModel = Field( + default_factory=RenderSettingsModel, + title="Render Settings" + ) + PointCloud: PointCloudSettings = Field( + default_factory=PointCloudSettings, + title="Point Cloud" + ) + publish: PublishersModel = Field( + default_factory=PublishersModel, + title="Publish Plugins") + + +DEFAULT_VALUES = { + "RenderSettings": DEFAULT_RENDER_SETTINGS, + "PointCloud": { + "attribute": [ + {"name": "Age", "value": "age"}, + {"name": "Radius", "value": "radius"}, + {"name": "Position", "value": "position"}, + {"name": "Rotation", "value": "rotation"}, + {"name": "Scale", "value": "scale"}, + {"name": "Velocity", "value": "velocity"}, + {"name": "Color", "value": "color"}, + {"name": "TextureCoordinate", "value": "texcoord"}, + {"name": "MaterialID", "value": "matid"}, + {"name": "custFloats", "value": "custFloats"}, + {"name": "custVecs", "value": "custVecs"}, + ] + }, + "publish": DEFAULT_PUBLISH_SETTINGS + +} diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py new file mode 100644 index 0000000000..a695b85e89 --- /dev/null +++ b/server_addon/max/server/settings/publishers.py @@ -0,0 +1,26 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +class BasicValidateModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + + +class PublishersModel(BaseSettingsModel): + ValidateFrameRange: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Frame Range", + section="Validators" + ) + + +DEFAULT_PUBLISH_SETTINGS = { + "ValidateFrameRange": { + "enabled": True, + "optional": True, + "active": True + } +} diff --git a/server_addon/max/server/settings/render_settings.py b/server_addon/max/server/settings/render_settings.py new file mode 100644 index 0000000000..6c236d9f12 --- /dev/null +++ b/server_addon/max/server/settings/render_settings.py @@ -0,0 +1,49 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def aov_separators_enum(): + return [ + {"value": "dash", "label": "- (dash)"}, + {"value": "underscore", "label": "_ (underscore)"}, + {"value": "dot", "label": ". (dot)"} + ] + + +def image_format_enum(): + """Return enumerator for image output formats.""" + return [ + {"label": "bmp", "value": "bmp"}, + {"label": "exr", "value": "exr"}, + {"label": "tif", "value": "tif"}, + {"label": "tiff", "value": "tiff"}, + {"label": "jpg", "value": "jpg"}, + {"label": "png", "value": "png"}, + {"label": "tga", "value": "tga"}, + {"label": "dds", "value": "dds"} + ] + + +class RenderSettingsModel(BaseSettingsModel): + default_render_image_folder: str = Field( + title="Default render image folder" + ) + aov_separator: str = Field( + "underscore", + title="AOV Separator character", + enum_resolver=aov_separators_enum + ) + image_format: str = Field( + enum_resolver=image_format_enum, + title="Output Image Format" + ) + multipass: bool = Field(title="multipass") + + +DEFAULT_RENDER_SETTINGS = { + "default_render_image_folder": "renders/3dsmax", + "aov_separator": "underscore", + "image_format": "png", + "multipass": True +} diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/server_addon/max/server/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" From ecf16356378ee6daf4f2abcd771144df7b4990d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 17 Aug 2023 12:46:57 +0200 Subject: [PATCH 070/112] updated ayon api to '0.3.5' (#5460) --- .../vendor/python/common/ayon_api/__init__.py | 22 + .../vendor/python/common/ayon_api/_api.py | 62 ++ .../python/common/ayon_api/constants.py | 19 + .../python/common/ayon_api/entity_hub.py | 748 +++++++++++++++++- .../python/common/ayon_api/graphql_queries.py | 25 + .../python/common/ayon_api/operations.py | 117 ++- .../python/common/ayon_api/server_api.py | 416 +++++++--- .../python/common/ayon_api/thumbnails.py | 219 ----- .../vendor/python/common/ayon_api/utils.py | 39 + .../vendor/python/common/ayon_api/version.py | 2 +- 10 files changed, 1320 insertions(+), 349 deletions(-) delete mode 100644 openpype/vendor/python/common/ayon_api/thumbnails.py diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 0540d7692d..027e7a3da2 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -30,6 +30,8 @@ from ._api import ( set_client_version, get_default_settings_variant, set_default_settings_variant, + get_sender, + set_sender, get_base_url, get_rest_url, @@ -92,6 +94,7 @@ from ._api import ( get_users, get_attributes_for_type, + get_attributes_fields_for_type, get_default_fields_for_type, get_project_anatomy_preset, @@ -110,6 +113,11 @@ from ._api import ( get_addons_project_settings, get_addons_settings, + get_secrets, + get_secret, + save_secret, + delete_secret, + get_project_names, get_projects, get_project, @@ -124,6 +132,8 @@ from ._api import ( get_folders_hierarchy, get_tasks, + get_task_by_id, + get_task_by_name, get_folder_ids_with_products, get_product_by_id, @@ -154,6 +164,7 @@ from ._api import ( get_workfile_info, get_workfile_info_by_id, + get_thumbnail_by_id, get_thumbnail, get_folder_thumbnail, get_version_thumbnail, @@ -216,6 +227,8 @@ __all__ = ( "set_client_version", "get_default_settings_variant", "set_default_settings_variant", + "get_sender", + "set_sender", "get_base_url", "get_rest_url", @@ -278,6 +291,7 @@ __all__ = ( "get_users", "get_attributes_for_type", + "get_attributes_fields_for_type", "get_default_fields_for_type", "get_project_anatomy_preset", @@ -295,6 +309,11 @@ __all__ = ( "get_addons_project_settings", "get_addons_settings", + "get_secrets", + "get_secret", + "save_secret", + "delete_secret", + "get_project_names", "get_projects", "get_project", @@ -308,6 +327,8 @@ __all__ = ( "get_folders", "get_tasks", + "get_task_by_id", + "get_task_by_name", "get_folder_ids_with_products", "get_product_by_id", @@ -338,6 +359,7 @@ __all__ = ( "get_workfile_info", "get_workfile_info_by_id", + "get_thumbnail_by_id", "get_thumbnail", "get_folder_thumbnail", "get_version_thumbnail", diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 26a4b1530a..1d7b1837f1 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -392,6 +392,28 @@ def set_default_settings_variant(variant): return con.set_default_settings_variant(variant) +def get_sender(): + """Sender used to send requests. + + Returns: + Union[str, None]: Sender name or None. + """ + + con = get_server_api_connection() + return con.get_sender() + + +def set_sender(sender): + """Change sender used for requests. + + Args: + sender (Union[str, None]): Sender name or None. + """ + + con = get_server_api_connection() + return con.set_sender(sender) + + def get_base_url(): con = get_server_api_connection() return con.get_base_url() @@ -704,6 +726,26 @@ def get_addons_settings(*args, **kwargs): return con.get_addons_settings(*args, **kwargs) +def get_secrets(*args, **kwargs): + con = get_server_api_connection() + return con.get_secrets(*args, **kwargs) + + +def get_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + +def save_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + +def delete_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + def get_project_names(*args, **kwargs): con = get_server_api_connection() return con.get_project_names(*args, **kwargs) @@ -734,6 +776,16 @@ def get_tasks(*args, **kwargs): return con.get_tasks(*args, **kwargs) +def get_task_by_id(*args, **kwargs): + con = get_server_api_connection() + return con.get_task_by_id(*args, **kwargs) + + +def get_task_by_name(*args, **kwargs): + con = get_server_api_connection() + return con.get_task_by_name(*args, **kwargs) + + def get_folder_by_id(*args, **kwargs): con = get_server_api_connection() return con.get_folder_by_id(*args, **kwargs) @@ -904,6 +956,11 @@ def delete_project(project_name): return con.delete_project(project_name) +def get_thumbnail_by_id(project_name, thumbnail_id): + con = get_server_api_connection() + con.get_thumbnail_by_id(project_name, thumbnail_id) + + def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None): con = get_server_api_connection() con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id) @@ -934,6 +991,11 @@ def update_thumbnail(project_name, thumbnail_id, src_filepath): return con.update_thumbnail(project_name, thumbnail_id, src_filepath) +def get_attributes_fields_for_type(entity_type): + con = get_server_api_connection() + return con.get_attributes_fields_for_type(entity_type) + + def get_default_fields_for_type(entity_type): con = get_server_api_connection() return con.get_default_fields_for_type(entity_type) diff --git a/openpype/vendor/python/common/ayon_api/constants.py b/openpype/vendor/python/common/ayon_api/constants.py index e2b05a5cae..eb1ace0590 100644 --- a/openpype/vendor/python/common/ayon_api/constants.py +++ b/openpype/vendor/python/common/ayon_api/constants.py @@ -4,6 +4,25 @@ SERVER_API_ENV_KEY = "AYON_API_KEY" # Backwards compatibility SERVER_TOKEN_ENV_KEY = SERVER_API_ENV_KEY +# --- User --- +DEFAULT_USER_FIELDS = { + "roles", + "name", + "isService", + "isManager", + "isGuest", + "isAdmin", + "defaultRoles", + "createdAt", + "active", + "hasPassword", + "updatedAt", + "apiKeyPreview", + "attrib.avatarUrl", + "attrib.email", + "attrib.fullName", +} + # --- Product types --- DEFAULT_PRODUCT_TYPE_FIELDS = { "name", diff --git a/openpype/vendor/python/common/ayon_api/entity_hub.py b/openpype/vendor/python/common/ayon_api/entity_hub.py index ab1e2584d7..b9b017bac5 100644 --- a/openpype/vendor/python/common/ayon_api/entity_hub.py +++ b/openpype/vendor/python/common/ayon_api/entity_hub.py @@ -1,10 +1,11 @@ +import re import copy import collections from abc import ABCMeta, abstractmethod import six from ._api import get_server_api_connection -from .utils import create_entity_id, convert_entity_id +from .utils import create_entity_id, convert_entity_id, slugify_string UNKNOWN_VALUE = object() PROJECT_PARENT_ID = object() @@ -545,6 +546,7 @@ class EntityHub(object): library=project["library"], folder_types=project["folderTypes"], task_types=project["taskTypes"], + statuses=project["statuses"], name=project["name"], attribs=project["ownAttrib"], data=project["data"], @@ -775,8 +777,7 @@ class EntityHub(object): "projects/{}".format(self.project_name), **project_changes ) - if response.status_code != 204: - raise ValueError("Failed to update project") + response.raise_for_status() self.project_entity.lock() @@ -1485,6 +1486,722 @@ class BaseEntity(object): self._children_ids = set(children_ids) +class ProjectStatus: + """Project status class. + + Args: + name (str): Name of the status. e.g. 'In progress' + short_name (Optional[str]): Short name of the status. e.g. 'IP' + state (Optional[Literal[not_started, in_progress, done, blocked]]): A + state of the status. + icon (Optional[str]): Icon of the status. e.g. 'play_arrow'. + color (Optional[str]): Color of the status. e.g. '#eeeeee'. + index (Optional[int]): Index of the status. + project_statuses (Optional[_ProjectStatuses]): Project statuses + wrapper. + """ + + valid_states = ("not_started", "in_progress", "done", "blocked") + color_regex = re.compile(r"#([a-f0-9]{6})$") + default_state = "in_progress" + default_color = "#eeeeee" + + def __init__( + self, + name, + short_name=None, + state=None, + icon=None, + color=None, + index=None, + project_statuses=None, + is_new=None, + ): + short_name = short_name or "" + icon = icon or "" + state = state or self.default_state + color = color or self.default_color + self._name = name + self._short_name = short_name + self._icon = icon + self._slugified_name = None + self._state = None + self._color = None + self.set_state(state) + self.set_color(color) + + self._original_name = name + self._original_short_name = short_name + self._original_icon = icon + self._original_state = state + self._original_color = color + self._original_index = index + + self._index = index + self._project_statuses = project_statuses + if is_new is None: + is_new = index is None or project_statuses is None + self._is_new = is_new + + def __str__(self): + short_name = "" + if self.short_name: + short_name = "({})".format(self.short_name) + return "<{} {}{}>".format( + self.__class__.__name__, self.name, short_name + ) + + def __repr__(self): + return str(self) + + def __getitem__(self, key): + if key in { + "name", "short_name", "icon", "state", "color", "slugified_name" + }: + return getattr(self, key) + raise KeyError(key) + + def __setitem__(self, key, value): + if key in {"name", "short_name", "icon", "state", "color"}: + return setattr(self, key, value) + raise KeyError(key) + + def lock(self): + """Lock status. + + Changes were commited and current values are now the original values. + """ + + self._is_new = False + self._original_name = self.name + self._original_short_name = self.short_name + self._original_icon = self.icon + self._original_state = self.state + self._original_color = self.color + self._original_index = self.index + + @staticmethod + def slugify_name(name): + """Slugify status name for name comparison. + + Args: + name (str): Name of the status. + + Returns: + str: Slugified name. + """ + + return slugify_string(name.lower()) + + def get_project_statuses(self): + """Internal logic method. + + Returns: + _ProjectStatuses: Project statuses object. + """ + + return self._project_statuses + + def set_project_statuses(self, project_statuses): + """Internal logic method to change parent object. + + Args: + project_statuses (_ProjectStatuses): Project statuses object. + """ + + self._project_statuses = project_statuses + + def unset_project_statuses(self, project_statuses): + """Internal logic method to unset parent object. + + Args: + project_statuses (_ProjectStatuses): Project statuses object. + """ + + if self._project_statuses is project_statuses: + self._project_statuses = None + self._index = None + + @property + def changed(self): + """Status has changed. + + Returns: + bool: Status has changed. + """ + + return ( + self._is_new + or self._original_name != self._name + or self._original_short_name != self._short_name + or self._original_index != self._index + or self._original_state != self._state + or self._original_icon != self._icon + or self._original_color != self._color + ) + + def delete(self): + """Remove status from project statuses object.""" + + if self._project_statuses is not None: + self._project_statuses.remove(self) + + def get_index(self): + """Get index of status. + + Returns: + Union[int, None]: Index of status or None if status is not under + project. + """ + + return self._index + + def set_index(self, index, **kwargs): + """Change status index. + + Returns: + Union[int, None]: Index of status or None if status is not under + project. + """ + + if kwargs.get("from_parent"): + self._index = index + else: + self._project_statuses.set_status_index(self, index) + + def get_name(self): + """Status name. + + Returns: + str: Status name. + """ + + return self._name + + def set_name(self, name): + """Change status name. + + Args: + name (str): New status name. + """ + + if not isinstance(name, six.string_types): + raise TypeError("Name must be a string.") + if name == self._name: + return + self._name = name + self._slugified_name = None + + def get_short_name(self): + """Status short name 3 letters tops. + + Returns: + str: Status short name. + """ + + return self._short_name + + def set_short_name(self, short_name): + """Change status short name. + + Args: + short_name (str): New status short name. 3 letters tops. + """ + + if not isinstance(short_name, six.string_types): + raise TypeError("Short name must be a string.") + self._short_name = short_name + + def get_icon(self): + """Name of icon to use for status. + + Returns: + str: Name of the icon. + """ + + return self._icon + + def set_icon(self, icon): + """Change status icon name. + + Args: + icon (str): Name of the icon. + """ + + if icon is None: + icon = "" + if not isinstance(icon, six.string_types): + raise TypeError("Icon name must be a string.") + self._icon = icon + + @property + def slugified_name(self): + """Slugified and lowere status name. + + Can be used for comparison of existing statuses. e.g. 'In Progress' + vs. 'in-progress'. + + Returns: + str: Slugified and lower status name. + """ + + if self._slugified_name is None: + self._slugified_name = self.slugify_name(self.name) + return self._slugified_name + + def get_state(self): + """Get state of project status. + + Return: + Literal[not_started, in_progress, done, blocked]: General + state of status. + """ + + return self._state + + def set_state(self, state): + """Set color of project status. + + Args: + state (Literal[not_started, in_progress, done, blocked]): General + state of status. + """ + + if state not in self.valid_states: + raise ValueError("Invalid state '{}'".format(str(state))) + self._state = state + + def get_color(self): + """Get color of project status. + + Returns: + str: Status color. + """ + + return self._color + + def set_color(self, color): + """Set color of project status. + + Args: + color (str): Color in hex format. Example: '#ff0000'. + """ + + if not isinstance(color, six.string_types): + raise TypeError( + "Color must be string got '{}'".format(type(color))) + color = color.lower() + if self.color_regex.fullmatch(color) is None: + raise ValueError("Invalid color value '{}'".format(color)) + self._color = color + + name = property(get_name, set_name) + short_name = property(get_short_name, set_short_name) + project_statuses = property(get_project_statuses, set_project_statuses) + index = property(get_index, set_index) + state = property(get_state, set_state) + color = property(get_color, set_color) + icon = property(get_icon, set_icon) + + def _validate_other_p_statuses(self, other): + """Validate if other status can be used for move. + + To be able to work with other status, and position them in relation, + they must belong to same existing object of '_ProjectStatuses'. + + Args: + other (ProjectStatus): Other status to validate. + """ + + o_project_statuses = other.project_statuses + m_project_statuses = self.project_statuses + if o_project_statuses is None and m_project_statuses is None: + raise ValueError("Both statuses are not assigned to a project.") + + missing_status = None + if o_project_statuses is None: + missing_status = other + elif m_project_statuses is None: + missing_status = self + if missing_status is not None: + raise ValueError( + "Status '{}' is not assigned to a project.".format( + missing_status.name)) + if m_project_statuses is not o_project_statuses: + raise ValueError( + "Statuse are assigned to different projects." + " Cannot execute move." + ) + + def move_before(self, other): + """Move status before other status. + + Args: + other (ProjectStatus): Status to move before. + """ + + self._validate_other_p_statuses(other) + self._project_statuses.set_status_index(self, other.index) + + def move_after(self, other): + """Move status after other status. + + Args: + other (ProjectStatus): Status to move after. + """ + + self._validate_other_p_statuses(other) + self._project_statuses.set_status_index(self, other.index + 1) + + def to_data(self): + """Convert status to data. + + Returns: + dict[str, str]: Status data. + """ + + output = { + "name": self.name, + "shortName": self.short_name, + "state": self.state, + "icon": self.icon, + "color": self.color, + } + if ( + not self._is_new + and self._original_name + and self.name != self._original_name + ): + output["original_name"] = self._original_name + return output + + @classmethod + def from_data(cls, data, index=None, project_statuses=None): + """Create project status from data. + + Args: + data (dict[str, str]): Status data. + index (Optional[int]): Status index. + project_statuses (Optional[ProjectStatuses]): Project statuses + object which wraps the status for a project. + """ + + return cls( + data["name"], + data.get("shortName", data.get("short_name")), + data.get("state"), + data.get("icon"), + data.get("color"), + index=index, + project_statuses=project_statuses + ) + + +class _ProjectStatuses: + """Wrapper for project statuses. + + Supports basic methods to add, change or remove statuses from a project. + + To add new statuses use 'create' or 'add_status' methods. To change + statuses receive them by one of the getter methods and change their + values. + + Todos: + Validate if statuses are duplicated. + """ + + def __init__(self, statuses): + self._statuses = [ + ProjectStatus.from_data(status, idx, self) + for idx, status in enumerate(statuses) + ] + self._orig_status_length = len(self._statuses) + self._set_called = False + + def __len__(self): + return len(self._statuses) + + def __iter__(self): + """Iterate over statuses. + + Yields: + ProjectStatus: Project status. + """ + + for status in self._statuses: + yield status + + def create( + self, + name, + short_name=None, + state=None, + icon=None, + color=None, + ): + """Create project status. + + Args: + name (str): Name of the status. e.g. 'In progress' + short_name (Optional[str]): Short name of the status. e.g. 'IP' + state (Optional[Literal[not_started, in_progress, done, blocked]]): A + state of the status. + icon (Optional[str]): Icon of the status. e.g. 'play_arrow'. + color (Optional[str]): Color of the status. e.g. '#eeeeee'. + + Returns: + ProjectStatus: Created project status. + """ + + status = ProjectStatus( + name, short_name, state, icon, color, is_new=True + ) + self.append(status) + return status + + def lock(self): + """Lock statuses. + + Changes were commited and current values are now the original values. + """ + + self._orig_status_length = len(self._statuses) + self._set_called = False + for status in self._statuses: + status.lock() + + def to_data(self): + """Convert to project statuses data.""" + + return [ + status.to_data() + for status in self._statuses + ] + + def set(self, statuses): + """Explicitly override statuses. + + This method does not handle if statuses changed or not. + + Args: + statuses (list[dict[str, str]]): List of statuses data. + """ + + self._set_called = True + self._statuses = [ + ProjectStatus.from_data(status, idx, self) + for idx, status in enumerate(statuses) + ] + + @property + def changed(self): + """Statuses have changed. + + Returns: + bool: True if statuses changed, False otherwise. + """ + + if self._set_called: + return True + + # Check if status length changed + # - when all statuses are removed it is a changed + if self._orig_status_length != len(self._statuses): + return True + # Go through all statuses and check if any of them changed + for status in self._statuses: + if status.changed: + return True + return False + + def get(self, name, default=None): + """Get status by name. + + Args: + name (str): Status name. + default (Any): Default value of status is not found. + + Returns: + Union[ProjectStatus, Any]: Status or default value. + """ + + return next( + ( + status + for status in self._statuses + if status.name == name + ), + default + ) + + get_status_by_name = get + + def index(self, status, **kwargs): + """Get status index. + + Args: + status (ProjectStatus): Status to get index of. + default (Optional[Any]): Default value if status is not found. + + Returns: + Union[int, Any]: Status index. + + Raises: + ValueError: If status is not found and default value is not + defined. + """ + + output = next( + ( + idx + for idx, st in enumerate(self._statuses) + if st is status + ), + None + ) + if output is not None: + return output + + if "default" in kwargs: + return kwargs["default"] + raise ValueError("Status '{}' not found".format(status.name)) + + def get_status_by_slugified_name(self, name): + """Get status by slugified name. + + Args: + name (str): Status name. Is slugified before search. + + Returns: + Union[ProjectStatus, None]: Status or None if not found. + """ + + slugified_name = ProjectStatus.slugify_name(name) + return next( + ( + status + for status in self._statuses + if status.slugified_name == slugified_name + ), + None + ) + + def remove_by_name(self, name, ignore_missing=False): + """Remove status by name. + + Args: + name (str): Status name. + ignore_missing (Optional[bool]): If True, no error is raised if + status is not found. + + Returns: + ProjectStatus: Removed status. + """ + + matching_status = self.get(name) + if matching_status is None: + if ignore_missing: + return + raise ValueError( + "Status '{}' not found in project".format(name)) + return self.remove(matching_status) + + def remove(self, status, ignore_missing=False): + """Remove status. + + Args: + status (ProjectStatus): Status to remove. + ignore_missing (Optional[bool]): If True, no error is raised if + status is not found. + + Returns: + Union[ProjectStatus, None]: Removed status. + """ + + index = self.index(status, default=None) + if index is None: + if ignore_missing: + return None + raise ValueError("Status '{}' not in project".format(status)) + + return self.pop(index) + + def pop(self, index): + """Remove status by index. + + Args: + index (int): Status index. + + Returns: + ProjectStatus: Removed status. + """ + + status = self._statuses.pop(index) + status.unset_project_statuses(self) + for st in self._statuses[index:]: + st.set_index(st.index - 1, from_parent=True) + return status + + def insert(self, index, status): + """Insert status at index. + + Args: + index (int): Status index. + status (Union[ProjectStatus, dict[str, str]]): Status to insert. + Can be either status object or status data. + + Returns: + ProjectStatus: Inserted status. + """ + + if not isinstance(status, ProjectStatus): + status = ProjectStatus.from_data(status) + + start_index = index + end_index = len(self._statuses) + 1 + matching_index = self.index(status, default=None) + if matching_index is not None: + if matching_index == index: + status.set_index(index, from_parent=True) + return + + self._statuses.pop(matching_index) + if matching_index < index: + start_index = matching_index + end_index = index + 1 + else: + end_index -= 1 + + status.set_project_statuses(self) + self._statuses.insert(index, status) + for idx, st in enumerate(self._statuses[start_index:end_index]): + st.set_index(start_index + idx, from_parent=True) + return status + + def append(self, status): + """Add new status to the end of the list. + + Args: + status (Union[ProjectStatus, dict[str, str]]): Status to insert. + Can be either status object or status data. + + Returns: + ProjectStatus: Inserted status. + """ + + return self.insert(len(self._statuses), status) + + def set_status_index(self, status, index): + """Set status index. + + Args: + status (ProjectStatus): Status to set index. + index (int): New status index. + """ + + return self.insert(index, status) + + class ProjectEntity(BaseEntity): """Entity representing project on AYON server. @@ -1514,7 +2231,14 @@ class ProjectEntity(BaseEntity): default_task_type_icon = "task_alt" def __init__( - self, project_code, library, folder_types, task_types, *args, **kwargs + self, + project_code, + library, + folder_types, + task_types, + statuses, + *args, + **kwargs ): super(ProjectEntity, self).__init__(*args, **kwargs) @@ -1522,11 +2246,13 @@ class ProjectEntity(BaseEntity): self._library_project = library self._folder_types = folder_types self._task_types = task_types + self._statuses_obj = _ProjectStatuses(statuses) self._orig_project_code = project_code self._orig_library_project = library self._orig_folder_types = copy.deepcopy(folder_types) self._orig_task_types = copy.deepcopy(task_types) + self._orig_statuses = copy.deepcopy(statuses) def _prepare_entity_id(self, entity_id): if entity_id != self.project_name: @@ -1573,13 +2299,24 @@ class ProjectEntity(BaseEntity): new_task_types.append(task_type) self._task_types = new_task_types + def get_orig_statuses(self): + return copy.deepcopy(self._orig_statuses) + + def get_statuses(self): + return self._statuses_obj + + def set_statuses(self, statuses): + self._statuses_obj.set(statuses) + folder_types = property(get_folder_types, set_folder_types) task_types = property(get_task_types, set_task_types) + statuses = property(get_statuses, set_statuses) def lock(self): super(ProjectEntity, self).lock() self._orig_folder_types = copy.deepcopy(self._folder_types) self._orig_task_types = copy.deepcopy(self._task_types) + self._statuses_obj.lock() @property def changes(self): @@ -1590,6 +2327,9 @@ class ProjectEntity(BaseEntity): if self._orig_task_types != self._task_types: changes["taskTypes"] = self.get_task_types() + if self._statuses_obj.changed: + changes["statuses"] = self._statuses_obj.to_data() + return changes @classmethod diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py index 4af8c53e4e..f31134a04d 100644 --- a/openpype/vendor/python/common/ayon_api/graphql_queries.py +++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py @@ -462,3 +462,28 @@ def events_graphql_query(fields): for k, v in value.items(): query_queue.append((k, v, field)) return query + + +def users_graphql_query(fields): + query = GraphQlQuery("Users") + names_var = query.add_variable("userNames", "[String!]") + + users_field = query.add_field_with_edges("users") + users_field.set_filter("names", names_var) + + nested_fields = fields_to_dict(set(fields)) + + query_queue = collections.deque() + for key, value in nested_fields.items(): + query_queue.append((key, value, users_field)) + + while query_queue: + item = query_queue.popleft() + key, value, parent = item + field = parent.add_field(key) + if value is FIELD_VALUE: + continue + + for k, v in value.items(): + query_queue.append((k, v, field)) + return query diff --git a/openpype/vendor/python/common/ayon_api/operations.py b/openpype/vendor/python/common/ayon_api/operations.py index 7cf610a566..eb2ca8afe3 100644 --- a/openpype/vendor/python/common/ayon_api/operations.py +++ b/openpype/vendor/python/common/ayon_api/operations.py @@ -1,3 +1,4 @@ +import os import copy import collections import uuid @@ -22,6 +23,8 @@ def new_folder_entity( name, folder_type, parent_id=None, + status=None, + tags=None, attribs=None, data=None, thumbnail_id=None, @@ -32,12 +35,14 @@ def new_folder_entity( Args: name (str): Is considered as unique identifier of folder in project. folder_type (str): Type of folder. - parent_id (Optional[str]]): Id of parent folder. + parent_id (Optional[str]): Parent folder id. + status (Optional[str]): Product status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of folder. data (Optional[Dict[str, Any]]): Custom folder data. Empty dictionary is used if not passed. - thumbnail_id (Optional[str]): Id of thumbnail related to folder. + thumbnail_id (Optional[str]): Thumbnail id related to folder. entity_id (Optional[str]): Predefined id of entity. New id is created if not passed. @@ -54,7 +59,7 @@ def new_folder_entity( if parent_id is not None: parent_id = _create_or_convert_to_id(parent_id) - return { + output = { "id": _create_or_convert_to_id(entity_id), "name": name, # This will be ignored @@ -64,6 +69,11 @@ def new_folder_entity( "attrib": attribs, "thumbnailId": thumbnail_id } + if status: + output["status"] = status + if tags: + output["tags"] = tags + return output def new_product_entity( @@ -71,6 +81,7 @@ def new_product_entity( product_type, folder_id, status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -81,8 +92,9 @@ def new_product_entity( name (str): Is considered as unique identifier of product under folder. product_type (str): Product type. - folder_id (str): Id of parent folder. + folder_id (str): Parent folder id. status (Optional[str]): Product status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of product. data (Optional[Dict[str, Any]]): product entity data. Empty dictionary @@ -110,6 +122,8 @@ def new_product_entity( } if status: output["status"] = status + if tags: + output["tags"] = tags return output @@ -119,6 +133,8 @@ def new_version_entity( task_id=None, thumbnail_id=None, author=None, + status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -128,10 +144,12 @@ def new_version_entity( Args: version (int): Is considered as unique identifier of version under product. - product_id (str): Id of parent product. - task_id (Optional[str]]): Id of task under which product was created. - thumbnail_id (Optional[str]]): Thumbnail related to version. - author (Optional[str]]): Name of version author. + product_id (str): Parent product id. + task_id (Optional[str]): Task id under which product was created. + thumbnail_id (Optional[str]): Thumbnail related to version. + author (Optional[str]): Name of version author. + status (Optional[str]): Version status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of version. data (Optional[Dict[str, Any]]): Version entity custom data. @@ -164,6 +182,10 @@ def new_version_entity( output["thumbnailId"] = thumbnail_id if author: output["author"] = author + if tags: + output["tags"] = tags + if status: + output["status"] = status return output @@ -173,6 +195,8 @@ def new_hero_version_entity( task_id=None, thumbnail_id=None, author=None, + status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -182,10 +206,12 @@ def new_hero_version_entity( Args: version (int): Is considered as unique identifier of version under product. Should be same as standard version if there is any. - product_id (str): Id of parent product. - task_id (Optional[str]): Id of task under which product was created. + product_id (str): Parent product id. + task_id (Optional[str]): Task id under which product was created. thumbnail_id (Optional[str]): Thumbnail related to version. author (Optional[str]): Name of version author. + status (Optional[str]): Version status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of version. data (Optional[Dict[str, Any]]): Version entity data. @@ -215,18 +241,32 @@ def new_hero_version_entity( output["thumbnailId"] = thumbnail_id if author: output["author"] = author + if tags: + output["tags"] = tags + if status: + output["status"] = status return output def new_representation_entity( - name, version_id, attribs=None, data=None, entity_id=None + name, + version_id, + files, + status=None, + tags=None, + attribs=None, + data=None, + entity_id=None ): """Create skeleton data of representation entity. Args: name (str): Representation name considered as unique identifier of representation under version. - version_id (str): Id of parent version. + version_id (str): Parent version id. + files (list[dict[str, str]]): List of files in representation. + status (Optional[str]): Representation status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of representation. data (Optional[Dict[str, Any]]): Representation entity data. @@ -243,27 +283,42 @@ def new_representation_entity( if data is None: data = {} - return { + output = { "id": _create_or_convert_to_id(entity_id), "versionId": _create_or_convert_to_id(version_id), + "files": files, "name": name, "data": data, "attrib": attribs } + if tags: + output["tags"] = tags + if status: + output["status"] = status + return output -def new_workfile_info_doc( - filename, folder_id, task_name, files, data=None, entity_id=None +def new_workfile_info( + filepath, + task_id, + status=None, + tags=None, + attribs=None, + description=None, + data=None, + entity_id=None ): """Create skeleton data of workfile info entity. Workfile entity is at this moment used primarily for artist notes. Args: - filename (str): Filename of workfile. - folder_id (str): Id of folder under which workfile live. - task_name (str): Task under which was workfile created. - files (List[str]): List of rootless filepaths related to workfile. + filepath (str): Rootless workfile filepath. + task_id (str): Task under which was workfile created. + status (Optional[str]): Workfile status. + tags (Optional[List[str]]): Workfile tags. + attribs (Options[dic[str, Any]]): Explicitly set attributes. + description (Optional[str]): Workfile description. data (Optional[Dict[str, Any]]): Additional metadata. entity_id (Optional[str]): Predefined id of entity. New id is created if not passed. @@ -272,17 +327,31 @@ def new_workfile_info_doc( Dict[str, Any]: Skeleton of workfile info entity. """ + if attribs is None: + attribs = {} + + if "extension" not in attribs: + attribs["extension"] = os.path.splitext(filepath)[-1] + + if description: + attribs["description"] = description + if not data: data = {} - return { + output = { "id": _create_or_convert_to_id(entity_id), - "parent": _create_or_convert_to_id(folder_id), - "task_name": task_name, - "filename": filename, + "taskId": task_id, + "path": filepath, "data": data, - "files": files + "attrib": attribs } + if status: + output["status"] = status + + if tags: + output["tags"] = tags + return output @six.add_metaclass(ABCMeta) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index c578124cfc..f2689e88dc 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -14,7 +14,16 @@ except ImportError: HTTPStatus = None import requests -from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +try: + # This should be used if 'requests' have it available + from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +except ImportError: + # Older versions of 'requests' don't have custom exception for json + # decode error + try: + from simplejson import JSONDecodeError as RequestsJSONDecodeError + except ImportError: + from json import JSONDecodeError as RequestsJSONDecodeError from .constants import ( DEFAULT_PRODUCT_TYPE_FIELDS, @@ -27,8 +36,8 @@ from .constants import ( REPRESENTATION_FILES_FIELDS, DEFAULT_WORKFILE_INFO_FIELDS, DEFAULT_EVENT_FIELDS, + DEFAULT_USER_FIELDS, ) -from .thumbnails import ThumbnailCache from .graphql import GraphQlQuery, INTROSPECTION_QUERY from .graphql_queries import ( project_graphql_query, @@ -43,6 +52,7 @@ from .graphql_queries import ( representations_parents_qraphql_query, workfiles_info_graphql_query, events_graphql_query, + users_graphql_query, ) from .exceptions import ( FailedOperations, @@ -61,6 +71,7 @@ from .utils import ( failed_json_default, TransferProgress, create_dependency_package_basename, + ThumbnailContent, ) PatternType = type(re.compile("")) @@ -319,6 +330,8 @@ class ServerAPI(object): default_settings_variant (Optional[Literal["production", "staging"]]): Settings variant used by default if a method for settings won't get any (by default is 'production'). + sender (Optional[str]): Sender of requests. Used in server logs and + propagated into events. ssl_verify (Union[bool, str, None]): Verify SSL certificate Looks for env variable value 'AYON_CA_FILE' by default. If not available then 'True' is used. @@ -335,6 +348,7 @@ class ServerAPI(object): site_id=None, client_version=None, default_settings_variant=None, + sender=None, ssl_verify=None, cert=None, create_session=True, @@ -354,6 +368,7 @@ class ServerAPI(object): default_settings_variant or "production" ) + self._sender = sender if ssl_verify is None: # Custom AYON env variable for CA file or 'True' @@ -390,7 +405,6 @@ class ServerAPI(object): self._entity_type_attributes_cache = {} self._as_user_stack = _AsUserStack() - self._thumbnail_cache = ThumbnailCache(True) # Create session if self._access_token and create_session: @@ -559,6 +573,29 @@ class ServerAPI(object): set_default_settings_variant ) + def get_sender(self): + """Sender used to send requests. + + Returns: + Union[str, None]: Sender name or None. + """ + + return self._sender + + def set_sender(self, sender): + """Change sender used for requests. + + Args: + sender (Union[str, None]): Sender name or None. + """ + + if sender == self._sender: + return + self._sender = sender + self._update_session_headers() + + sender = property(get_sender, set_sender) + def get_default_service_username(self): """Default username used for callbacks when used with service API key. @@ -742,6 +779,7 @@ class ServerAPI(object): ("X-as-user", self._as_user_stack.username), ("x-ayon-version", self._client_version), ("x-ayon-site-id", self._site_id), + ("x-sender", self._sender), ): if value is not None: self._session.headers[key] = value @@ -826,10 +864,36 @@ class ServerAPI(object): self._access_token_is_service = None return None - def get_users(self): - # TODO how to find out if user have permission? - users = self.get("users") - return users.data + def get_users(self, usernames=None, fields=None): + """Get Users. + + Args: + usernames (Optional[Iterable[str]]): Filter by usernames. + fields (Optional[Iterable[str]]): fields to be queried + for users. + + Returns: + Generator[dict[str, Any]]: Queried users. + """ + + filters = {} + if usernames is not None: + usernames = set(usernames) + if not usernames: + return + filters["userNames"] = list(usernames) + + if not fields: + fields = self.get_default_fields_for_type("user") + + query = users_graphql_query(set(fields)) + for attr, filter_value in filters.items(): + query.set_variable_value(attr, filter_value) + + for parsed_data in query.continuous_query(self): + for user in parsed_data["users"]: + user["roles"] = json.loads(user["roles"]) + yield user def get_user(self, username=None): output = None @@ -859,6 +923,9 @@ class ServerAPI(object): if self._client_version is not None: headers["x-ayon-version"] = self._client_version + if self._sender is not None: + headers["x-sender"] = self._sender + if self._access_token: if self._access_token_is_service: headers["X-Api-Key"] = self._access_token @@ -900,18 +967,24 @@ class ServerAPI(object): self.validate_server_availability() - response = self.post( - "auth/login", - name=username, - password=password - ) - if response.status_code != 200: - _detail = response.data.get("detail") - details = "" - if _detail: - details = " {}".format(_detail) + self._token_validation_started = True - raise AuthenticationError("Login failed {}".format(details)) + try: + response = self.post( + "auth/login", + name=username, + password=password + ) + if response.status_code != 200: + _detail = response.data.get("detail") + details = "" + if _detail: + details = " {}".format(_detail) + + raise AuthenticationError("Login failed {}".format(details)) + + finally: + self._token_validation_started = False self._access_token = response["token"] @@ -1127,7 +1200,7 @@ class ServerAPI(object): filters["includeLogsFilter"] = include_logs if not fields: - fields = DEFAULT_EVENT_FIELDS + fields = self.get_default_fields_for_type("event") query = events_graphql_query(set(fields)) for attr, filter_value in filters.items(): @@ -1228,7 +1301,8 @@ class ServerAPI(object): target_topic, sender, description=None, - sequential=None + sequential=None, + events_filter=None, ): """Enroll job based on events. @@ -1270,6 +1344,8 @@ class ServerAPI(object): in target event. sequential (Optional[bool]): The source topic must be processed in sequence. + events_filter (Optional[ayon_server.sqlfilter.Filter]): A dict-like + with conditions to filter the source event. Returns: Union[None, dict[str, Any]]: None if there is no event matching @@ -1285,6 +1361,8 @@ class ServerAPI(object): kwargs["sequential"] = sequential if description is not None: kwargs["description"] = description + if events_filter is not None: + kwargs["filter"] = events_filter response = self.post("enroll", **kwargs) if response.status_code == 204: return None @@ -1612,6 +1690,19 @@ class ServerAPI(object): return copy.deepcopy(attributes) + def get_attributes_fields_for_type(self, entity_type): + """Prepare attribute fields for entity type. + + Returns: + set[str]: Attributes fields for entity type. + """ + + attributes = self.get_attributes_for_type(entity_type) + return { + "attrib.{}".format(attr) + for attr in attributes + } + def get_default_fields_for_type(self, entity_type): """Default fields for entity type. @@ -1624,51 +1715,46 @@ class ServerAPI(object): set[str]: Fields that should be queried from server. """ - attributes = self.get_attributes_for_type(entity_type) + # Event does not have attributes + if entity_type == "event": + return set(DEFAULT_EVENT_FIELDS) + if entity_type == "project": - return DEFAULT_PROJECT_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + entity_type_defaults = DEFAULT_PROJECT_FIELDS - if entity_type == "folder": - return DEFAULT_FOLDER_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "folder": + entity_type_defaults = DEFAULT_FOLDER_FIELDS - if entity_type == "task": - return DEFAULT_TASK_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "task": + entity_type_defaults = DEFAULT_TASK_FIELDS - if entity_type == "product": - return DEFAULT_PRODUCT_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "product": + entity_type_defaults = DEFAULT_PRODUCT_FIELDS - if entity_type == "version": - return DEFAULT_VERSION_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "version": + entity_type_defaults = DEFAULT_VERSION_FIELDS - if entity_type == "representation": - return ( + elif entity_type == "representation": + entity_type_defaults = ( DEFAULT_REPRESENTATION_FIELDS | REPRESENTATION_FILES_FIELDS - | { - "attrib.{}".format(attr) - for attr in attributes - } ) - if entity_type == "productType": - return DEFAULT_PRODUCT_TYPE_FIELDS + elif entity_type == "productType": + entity_type_defaults = DEFAULT_PRODUCT_TYPE_FIELDS - raise ValueError("Unknown entity type \"{}\"".format(entity_type)) + elif entity_type == "workfile": + entity_type_defaults = DEFAULT_WORKFILE_INFO_FIELDS + + elif entity_type == "user": + entity_type_defaults = DEFAULT_USER_FIELDS + + else: + raise ValueError("Unknown entity type \"{}\"".format(entity_type)) + return ( + entity_type_defaults + | self.get_attributes_fields_for_type(entity_type) + ) def get_addons_info(self, details=True): """Get information about addons available on server. @@ -2926,6 +3012,79 @@ class ServerAPI(object): only_values=only_values ) + def get_secrets(self): + """Get all secrets. + + Example output: + [ + { + "name": "secret_1", + "value": "secret_value_1", + }, + { + "name": "secret_2", + "value": "secret_value_2", + } + ] + + Returns: + list[dict[str, str]]: List of secret entities. + """ + + response = self.get("secrets") + response.raise_for_status() + return response.data + + def get_secret(self, secret_name): + """Get secret by name. + + Example output: + { + "name": "secret_name", + "value": "secret_value", + } + + Args: + secret_name (str): Name of secret. + + Returns: + dict[str, str]: Secret entity data. + """ + + response = self.get("secrets/{}".format(secret_name)) + response.raise_for_status() + return response.data + + def save_secret(self, secret_name, secret_value): + """Save secret. + + This endpoint can create and update secret. + + Args: + secret_name (str): Name of secret. + secret_value (str): Value of secret. + """ + + response = self.put( + "secrets/{}".format(secret_name), + name=secret_name, + value=secret_value, + ) + response.raise_for_status() + return response.data + + + def delete_secret(self, secret_name): + """Delete secret by name. + + Args: + secret_name (str): Name of secret to delete. + """ + + response = self.delete("secrets/{}".format(secret_name)) + response.raise_for_status() + return response.data + # Entity getters def get_rest_project(self, project_name): """Query project by name. @@ -3070,8 +3229,6 @@ class ServerAPI(object): else: use_rest = False fields = set(fields) - if own_attributes: - fields.add("ownAttrib") for field in fields: if field.startswith("config"): use_rest = True @@ -3084,6 +3241,13 @@ class ServerAPI(object): yield project else: + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("project") + + if own_attributes: + fields.add("ownAttrib") + query = projects_graphql_query(fields) for parsed_data in query.continuous_query(self): for project in parsed_data["projects"]: @@ -3124,8 +3288,12 @@ class ServerAPI(object): fill_own_attribs(project) return project + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("project") + if own_attributes: - field.add("ownAttrib") + fields.add("ownAttrib") query = project_graphql_query(fields) query.set_variable_value("projectName", project_name) @@ -3282,10 +3450,13 @@ class ServerAPI(object): filters["parentFolderIds"] = list(parent_ids) - if fields: - fields = set(fields) - else: + if not fields: fields = self.get_default_fields_for_type("folder") + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("folder") use_rest = False if "data" in fields: @@ -3519,8 +3690,11 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("task") - - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("task") use_rest = False if "data" in fields: @@ -3705,6 +3879,9 @@ class ServerAPI(object): # Convert fields and add minimum required fields if fields: fields = set(fields) | {"id"} + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("folder") else: fields = self.get_default_fields_for_type("product") @@ -3961,7 +4138,11 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("version") - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("version") if active is not None: fields.add("active") @@ -4419,7 +4600,11 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("representation") - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("representation") use_rest = False if "data" in fields: @@ -4765,8 +4950,15 @@ class ServerAPI(object): filters["workfileIds"] = list(workfile_ids) if not fields: - fields = DEFAULT_WORKFILE_INFO_FIELDS + fields = self.get_default_fields_for_type("workfile") + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= { + "attrib.{}".format(attr) + for attr in self.get_attributes_for_type("workfile") + } if own_attributes: fields.add("ownAttrib") @@ -4843,18 +5035,61 @@ class ServerAPI(object): return workfile_info return None + def _prepare_thumbnail_content(self, project_name, response): + content = None + content_type = response.content_type + + # It is expected the response contains thumbnail id otherwise the + # content cannot be cached and filepath returned + thumbnail_id = response.headers.get("X-Thumbnail-Id") + if thumbnail_id is not None: + content = response.content + + return ThumbnailContent( + project_name, thumbnail_id, content, content_type + ) + + def get_thumbnail_by_id(self, project_name, thumbnail_id): + """Get thumbnail from server by id. + + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity type is required + to be passed. + + Notes: + It is recommended to use one of prepared entity type specific + methods 'get_folder_thumbnail', 'get_version_thumbnail' or + 'get_workfile_thumbnail'. + We do recommend pass thumbnail id if you have access to it. Each + entity that allows thumbnails has 'thumbnailId' field, so it + can be queried. + + Args: + project_name (str): Project under which the entity is located. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. + + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. + """ + + response = self.raw_get( + "projects/{}/thumbnails/{}".format( + project_name, + thumbnail_id + ) + ) + return self._prepare_thumbnail_content(project_name, response) + def get_thumbnail( self, project_name, entity_type, entity_id, thumbnail_id=None ): """Get thumbnail from server. - Permissions of thumbnails are related to entities so thumbnails must be - queried per entity. So an entity type and entity type is required to - be passed. - - If thumbnail id is passed logic can look into locally cached thumbnails - before calling server which can enhance loading time. If thumbnail id - is not passed the thumbnail is always downloaded even if is available. + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity type is required + to be passed. Notes: It is recommended to use one of prepared entity type specific @@ -4868,20 +5103,16 @@ class ServerAPI(object): project_name (str): Project under which the entity is located. entity_type (str): Entity type which passed entity id represents. entity_id (str): Entity id for which thumbnail should be returned. - thumbnail_id (Optional[str]): Prepared thumbnail id from entity. - Used only to check if thumbnail was already cached. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. Returns: - Union[str, None]: Path to downloaded thumbnail or none if entity - does not have any (or if user does not have permissions). + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ - # Look for thumbnail into cache and return the path if was found - filepath = self._thumbnail_cache.get_thumbnail_filepath( - project_name, thumbnail_id - ) - if filepath: - return filepath + if thumbnail_id: + return self.get_thumbnail_by_id(project_name, thumbnail_id) if entity_type in ( "folder", @@ -4890,29 +5121,12 @@ class ServerAPI(object): ): entity_type += "s" - # Receive thumbnail content from server - result = self.raw_get("projects/{}/{}/{}/thumbnail".format( + response = self.raw_get("projects/{}/{}/{}/thumbnail".format( project_name, entity_type, entity_id )) - - if result.content_type is None: - return None - - # It is expected the response contains thumbnail id otherwise the - # content cannot be cached and filepath returned - thumbnail_id = result.headers.get("X-Thumbnail-Id") - if thumbnail_id is None: - return None - - # Cache thumbnail and return path - return self._thumbnail_cache.store_thumbnail( - project_name, - thumbnail_id, - result.content, - result.content_type - ) + return self._prepare_thumbnail_content(project_name, response) def get_folder_thumbnail( self, project_name, folder_id, thumbnail_id=None diff --git a/openpype/vendor/python/common/ayon_api/thumbnails.py b/openpype/vendor/python/common/ayon_api/thumbnails.py deleted file mode 100644 index 50acd94dcb..0000000000 --- a/openpype/vendor/python/common/ayon_api/thumbnails.py +++ /dev/null @@ -1,219 +0,0 @@ -import os -import time -import collections - -import appdirs - -FileInfo = collections.namedtuple( - "FileInfo", - ("path", "size", "modification_time") -) - - -class ThumbnailCache: - """Cache of thumbnails on local storage. - - Thumbnails are cached to appdirs to predefined directory. Each project has - own subfolder with thumbnails -> that's because each project has own - thumbnail id validation and file names are thumbnail ids with matching - extension. Extensions are predefined (.png and .jpeg). - - Cache has cleanup mechanism which is triggered on initialized by default. - - The cleanup has 2 levels: - 1. soft cleanup which remove all files that are older then 'days_alive' - 2. max size cleanup which remove all files until the thumbnails folder - contains less then 'max_filesize' - - this is time consuming so it's not triggered automatically - - Args: - cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails). - """ - - # Lifetime of thumbnails (in seconds) - # - default 3 days - days_alive = 3 * 24 * 60 * 60 - # Max size of thumbnail directory (in bytes) - # - default 2 Gb - max_filesize = 2 * 1024 * 1024 * 1024 - - def __init__(self, cleanup=True): - self._thumbnails_dir = None - if cleanup: - self.cleanup() - - def get_thumbnails_dir(self): - """Root directory where thumbnails are stored. - - Returns: - str: Path to thumbnails root. - """ - - if self._thumbnails_dir is None: - directory = appdirs.user_data_dir("ayon", "ynput") - self._thumbnails_dir = os.path.join(directory, "thumbnails") - return self._thumbnails_dir - - thumbnails_dir = property(get_thumbnails_dir) - - def get_thumbnails_dir_file_info(self): - """Get information about all files in thumbnails directory. - - Returns: - List[FileInfo]: List of file information about all files. - """ - - thumbnails_dir = self.thumbnails_dir - files_info = [] - if not os.path.exists(thumbnails_dir): - return files_info - - for root, _, filenames in os.walk(thumbnails_dir): - for filename in filenames: - path = os.path.join(root, filename) - files_info.append(FileInfo( - path, os.path.getsize(path), os.path.getmtime(path) - )) - return files_info - - def get_thumbnails_dir_size(self, files_info=None): - """Got full size of thumbnail directory. - - Args: - files_info (List[FileInfo]): Prepared file information about - files in thumbnail directory. - - Returns: - int: File size of all files in thumbnail directory. - """ - - if files_info is None: - files_info = self.get_thumbnails_dir_file_info() - - if not files_info: - return 0 - - return sum( - file_info.size - for file_info in files_info - ) - - def cleanup(self, check_max_size=False): - """Cleanup thumbnails directory. - - Args: - check_max_size (bool): Also cleanup files to match max size of - thumbnails directory. - """ - - thumbnails_dir = self.get_thumbnails_dir() - # Skip if thumbnails dir does not exists yet - if not os.path.exists(thumbnails_dir): - return - - self._soft_cleanup(thumbnails_dir) - if check_max_size: - self._max_size_cleanup(thumbnails_dir) - - def _soft_cleanup(self, thumbnails_dir): - current_time = time.time() - for root, _, filenames in os.walk(thumbnails_dir): - for filename in filenames: - path = os.path.join(root, filename) - modification_time = os.path.getmtime(path) - if current_time - modification_time > self.days_alive: - os.remove(path) - - def _max_size_cleanup(self, thumbnails_dir): - files_info = self.get_thumbnails_dir_file_info() - size = self.get_thumbnails_dir_size(files_info) - if size < self.max_filesize: - return - - sorted_file_info = collections.deque( - sorted(files_info, key=lambda item: item.modification_time) - ) - diff = size - self.max_filesize - while diff > 0: - if not sorted_file_info: - break - - file_info = sorted_file_info.popleft() - diff -= file_info.size - os.remove(file_info.path) - - def get_thumbnail_filepath(self, project_name, thumbnail_id): - """Get thumbnail by thumbnail id. - - Args: - project_name (str): Name of project. - thumbnail_id (str): Thumbnail id. - - Returns: - Union[str, None]: Path to thumbnail image or None if thumbnail - is not cached yet. - """ - - if not thumbnail_id: - return None - - for ext in ( - ".png", - ".jpeg", - ): - filepath = os.path.join( - self.thumbnails_dir, project_name, thumbnail_id + ext - ) - if os.path.exists(filepath): - return filepath - return None - - def get_project_dir(self, project_name): - """Path to root directory for specific project. - - Args: - project_name (str): Name of project for which root directory path - should be returned. - - Returns: - str: Path to root of project's thumbnails. - """ - - return os.path.join(self.thumbnails_dir, project_name) - - def make_sure_project_dir_exists(self, project_name): - project_dir = self.get_project_dir(project_name) - if not os.path.exists(project_dir): - os.makedirs(project_dir) - return project_dir - - def store_thumbnail(self, project_name, thumbnail_id, content, mime_type): - """Store thumbnail to cache folder. - - Args: - project_name (str): Project where the thumbnail belong to. - thumbnail_id (str): Id of thumbnail. - content (bytes): Byte content of thumbnail file. - mime_data (str): Type of content. - - Returns: - str: Path to cached thumbnail image file. - """ - - if mime_type == "image/png": - ext = ".png" - elif mime_type == "image/jpeg": - ext = ".jpeg" - else: - raise ValueError( - "Unknown mime type for thumbnail \"{}\"".format(mime_type)) - - project_dir = self.make_sure_project_dir_exists(project_name) - thumbnail_path = os.path.join(project_dir, thumbnail_id + ext) - with open(thumbnail_path, "wb") as stream: - stream.write(content) - - current_time = time.time() - os.utime(thumbnail_path, (current_time, current_time)) - - return thumbnail_path diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py index 93822a58ac..314d13faec 100644 --- a/openpype/vendor/python/common/ayon_api/utils.py +++ b/openpype/vendor/python/common/ayon_api/utils.py @@ -27,6 +27,45 @@ RepresentationParents = collections.namedtuple( ) +class ThumbnailContent: + """Wrapper for thumbnail content. + + Args: + project_name (str): Project name. + thumbnail_id (Union[str, None]): Thumbnail id. + content_type (Union[str, None]): Content type e.g. 'image/png'. + content (Union[bytes, None]): Thumbnail content. + """ + + def __init__(self, project_name, thumbnail_id, content, content_type): + self.project_name = project_name + self.thumbnail_id = thumbnail_id + self.content_type = content_type + self.content = content or b"" + + @property + def id(self): + """Wrapper for thumbnail id. + + Returns: + + """ + + return self.thumbnail_id + + @property + def is_valid(self): + """Content of thumbnail is valid. + + Returns: + bool: Content is valid and can be used. + """ + return ( + self.thumbnail_id is not None + and self.content_type is not None + ) + + def prepare_query_string(key_values): """Prepare data to query string. diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index 93024ea5f2..df841e0829 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.3" +__version__ = "0.3.5" From 3ba4e7cbffed7ff7ae396872ba500bcc92292348 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 19 Aug 2023 03:24:22 +0000 Subject: [PATCH 071/112] [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 70eb32baff..444721e19c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.4-nightly.2" +__version__ = "3.16.4-nightly.3" From a25d1742a7a981f39f5580e1e27e0b1aa2eb9a33 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 19 Aug 2023 03:25:04 +0000 Subject: [PATCH 072/112] 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 d2a4067a6a..326c9e8c86 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.4-nightly.3 - 3.16.4-nightly.2 - 3.16.4-nightly.1 - 3.16.3 @@ -134,7 +135,6 @@ body: - 3.14.8-nightly.1 - 3.14.7 - 3.14.7-nightly.8 - - 3.14.7-nightly.7 validations: required: true - type: dropdown From a63fef653d536167aace6267d2b6246ee3581205 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 21 Aug 2023 10:32:32 +0200 Subject: [PATCH 073/112] Context plugin shouldn't be tied to family (#5464) --- openpype/hosts/maya/plugins/publish/collect_current_file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_current_file.py b/openpype/hosts/maya/plugins/publish/collect_current_file.py index e777a209d4..c7105a7f3c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_current_file.py +++ b/openpype/hosts/maya/plugins/publish/collect_current_file.py @@ -10,7 +10,6 @@ class CollectCurrentFile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.4 label = "Maya Current File" hosts = ['maya'] - families = ["workfile"] def process(self, context): """Inject the current working file""" From 20c1c1ce829b8d217ff0e91a452eba73e7861488 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Aug 2023 18:17:38 +0200 Subject: [PATCH 074/112] AYON: Fix version attributes update (#5472) * fix attrib update * proper fix of attrib updates --- openpype/client/server/conversion_utils.py | 26 +++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index 42df337b6d..a6c190a0fc 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -1074,7 +1074,7 @@ def convert_update_folder_to_v4(project_name, asset_id, update_data, con): parent_id = None tasks = None new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if "type" in update_data: new_update_data["active"] = update_data["type"] == "asset" @@ -1113,6 +1113,9 @@ def convert_update_folder_to_v4(project_name, asset_id, update_data, con): print("Folder has new data: {}".format(new_data)) new_update_data["data"] = new_data + if attribs: + new_update_data["attrib"] = attribs + if has_task_changes: raise ValueError("Task changes of folder are not implemented") @@ -1126,7 +1129,7 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con): full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if data: if "family" in data: family = data.pop("family") @@ -1148,9 +1151,6 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con): elif value is not REMOVED_VALUE: new_data[key] = value - if attribs: - new_update_data["attribs"] = attribs - if "name" in update_data: new_update_data["name"] = update_data["name"] @@ -1165,6 +1165,9 @@ def convert_update_subset_to_v4(project_name, subset_id, update_data, con): new_update_data["folderId"] = update_data["parent"] flat_data = _to_flat_dict(new_update_data) + if attribs: + flat_data["attrib"] = attribs + if new_data: print("Subset has new data: {}".format(new_data)) flat_data["data"] = new_data @@ -1179,7 +1182,7 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con): full_update_data = _from_flat_dict(update_data) data = full_update_data.get("data") new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if data: if "author" in data: new_update_data["author"] = data.pop("author") @@ -1196,9 +1199,6 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con): elif value is not REMOVED_VALUE: new_data[key] = value - if attribs: - new_update_data["attribs"] = attribs - if "name" in update_data: new_update_data["version"] = update_data["name"] @@ -1213,6 +1213,9 @@ def convert_update_version_to_v4(project_name, version_id, update_data, con): new_update_data["productId"] = update_data["parent"] flat_data = _to_flat_dict(new_update_data) + if attribs: + flat_data["attrib"] = attribs + if new_data: print("Version has new data: {}".format(new_data)) flat_data["data"] = new_data @@ -1252,7 +1255,7 @@ def convert_update_representation_to_v4( data = full_update_data.get("data") new_data = {} - attribs = {} + attribs = full_update_data.pop("attrib", {}) if data: for key, value in data.items(): if key in folder_attributes: @@ -1309,6 +1312,9 @@ def convert_update_representation_to_v4( new_update_data["files"] = new_files flat_data = _to_flat_dict(new_update_data) + if attribs: + flat_data["attrib"] = attribs + if new_data: print("Representation has new data: {}".format(new_data)) flat_data["data"] = new_data From ec054ce939383f768fbcc3a7c622088e1fd4c1cb Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Tue, 22 Aug 2023 13:00:36 +0100 Subject: [PATCH 075/112] 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 ee31b305d30ff2bb7951af489d6a32ca7ac305e6 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 22 Aug 2023 14:41:23 +0000 Subject: [PATCH 076/112] [Automated] Release --- CHANGELOG.md | 307 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 309 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d6a0d99d..f1948b1a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,313 @@ # Changelog +## [3.16.4](https://github.com/ynput/OpenPype/tree/3.16.4) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.16.3...3.16.4) + +### **🆕 New features** + + +

+Feature: Download last published workfile specify version #4998 + +Setting `workfile_version` key to hook's `self.launch_context.data` allow you to specify the workfile version you want sync service to download if none is matched locally. This is helpful if the last version hasn't been correctly published/synchronized, and you want to recover the previous one (or some you'd like).Version could be set in two ways: +- OP's absolute version, matching the `version` index in DB. +- Relative version in reverse order from the last one: `-2`, `-3`...I don't know where I should write documentation about that. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: allow not creation of group for Import loaders #5427 + +This PR enhances previous one. All ReferenceLoaders could not wrap imported products into explicit group.Also `Import` Loaders have same options. Control for this is separate in Settings, eg. Reference might wrap loaded items in group, `Import` might not. + + +___ + +
+ + +
+3dsMax: Settings for Ayon #5388 + +Max Addon Setting for Ayon + + +___ + +
+ + +
+General: Navigation to Folder from Launcher #5404 + +Adds an action in launcher to open the directory of the asset. + + +___ + +
+ + +
+Chore: Default variant in create plugin #5429 + +Attribute `default_variant` on create plugins always returns string and if default variant is not filled other ways how to get one are implemented. + + +___ + +
+ + +
+Publisher: Thumbnail widget enhancements #5439 + +Thumbnails widget in Publisher has new 3 options to choose from: Paste (from clipboard), Take screenshot and Browse. Clear button and new options are not visible by default, user must expand options button to show them. + + +___ + +
+ + +
+AYON: Update ayon api to '0.3.5' #5460 + +Updated ayon-python-api to 0.3.5. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+AYON: Apply unknown ayon settings first #5435 + +Settings of custom addons are available in converted settings. + + +___ + +
+ + +
+Maya: Fix wrong subset name of render family in deadline #5442 + +New Publisher is creating different subset names than previously which resulted in duplication of `render` string in final subset name of `render` family published on Deadline.This PR solves that, it also fixes issues with legacy instances from old publisher, it matches the subset name as was before.This solves same issue in Max implementation. + + +___ + +
+ + +
+Maya: Fix setting of version to workfile instance #5452 + +If there are multiple instances of renderlayer published, previous logic resulted in unpredictable rewrite of instance family to 'workfile' if `Sync render version with workfile` was on. + + +___ + +
+ + +
+Maya: Context plugin shouldn't be tied to family #5464 + +`Maya Current File` collector was tied to `workfile` unnecessary. It should run even if `workile` instance is not being published. + + +___ + +
+ + +
+Unreal: Fix loading hero version for static and skeletal meshes #5393 + +Fixed a problem with loading hero versions for static ans skeletal meshes. + + +___ + +
+ + +
+TVPaint: Fix 'repeat' behavior #5412 + +Calculation of frames for repeat behavior is working correctly. + + +___ + +
+ + +
+AYON: Thumbnails cache and api prep #5437 + +Moved thumbnails cache from ayon python api to OpenPype and prepare AYON thumbnail resolver for new api functions. Current implementation should work with old and new ayon-python-api. + + +___ + +
+ + +
+Nuke: Name of the Read Node should be updated correctly when switching versions or assets. #5444 + +Bug fixing of the read node's name not being updated correctly when setting version or switching asset. + + +___ + +
+ + +
+Farm publishing: asymmetric handles fixed #5446 + +Handles are now set correctly on farm published product version if asymmetric were set to shot attributes. + + +___ + +
+ + +
+Scene Inventory: Provider icons fix #5450 + +Fix how provider icons are accessed in scene inventory. + + +___ + +
+ + +
+Fix typo on Deadline OP plugin name #5453 + +Surprised that no one has hit this bug yet... but it seems like there was a typo on the name of the OP Deadline plugin when submitting jobs to it. + + +___ + +
+ + +
+AYON: Fix version attributes update #5472 + +Fixed updates of attribs in AYON mode. + + +___ + +
+ +### **Merged pull requests** + + +
+Added missing defaults for import_loader #5447 + + +___ + +
+ + +
+Bug: Local settings don't open on 3.14.7 #5220 + +### Before posting a new ticket, have you looked through the documentation to find an answer? + +Yes I have + +### Have you looked through the existing tickets to find any related issues ? + +Not yet + +### Author of the bug + +@FadyFS + +### Version + +3.15.11-nightly.3 + +### What platform you are running OpenPype on? + +Linux / Centos + +### Current Behavior: + +the previous behavior (bug) : +![image](https://github.com/quadproduction/OpenPype/assets/135602303/09bff9d5-3f8b-4339-a1e5-30c04ade828c) + + +### Expected Behavior: + +![image](https://github.com/quadproduction/OpenPype/assets/135602303/c505a103-7965-4796-bcdf-73bcc48a469b) + + +### What type of bug is it ? + +Happened only once in a particular configuration + +### Which project / workfile / asset / ... + +open settings with 3.14.7 + +### Steps To Reproduce: + +1. Run openpype on the 3.15.11-nightly.3 version +2. Open settings in 3.14.7 version + +### Relevant log output: + +_No response_ + +### Additional context: + +_No response_ + +___ + +
+ + +
+Tests: Add automated targets for tests #5443 + +Without it plugins with 'automated' targets won't be triggered (eg `CloseAE` etc.) + + +___ + +
+ + + + ## [3.16.3](https://github.com/ynput/OpenPype/tree/3.16.3) diff --git a/openpype/version.py b/openpype/version.py index 444721e19c..857a9574d8 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.4-nightly.3" +__version__ = "3.16.4" diff --git a/pyproject.toml b/pyproject.toml index 5e7938751e..a07c547123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.16.3" # OpenPype +version = "3.16.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From ae53caacc057e77271b2fc2389362bb04bb53e14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Aug 2023 14:42:21 +0000 Subject: [PATCH 077/112] 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 326c9e8c86..5c264e4d98 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.4 - 3.16.4-nightly.3 - 3.16.4-nightly.2 - 3.16.4-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.8-nightly.2 - 3.14.8-nightly.1 - 3.14.7 - - 3.14.7-nightly.8 validations: required: true - type: dropdown From 99ceef33e32c14cef403ff3852b0f15f40d7807a Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 23 Aug 2023 03:24:31 +0000 Subject: [PATCH 078/112] [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 857a9574d8..f8a49f8466 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.4" +__version__ = "3.16.5-nightly.1" From 88f1d839f1d2186349b72c12059a51aeb7d7dd3a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Aug 2023 10:07:13 +0200 Subject: [PATCH 079/112] Added super call to init (#5480) DL 10.3 requires plugin inheriting from DeadlinePlugin to call super's __init__ explicitly. --- .../repository/custom/plugins/Ayon/Ayon.py | 1 + .../HarmonyOpenPype/HarmonyOpenPype.py | 59 ++++++++++--------- .../custom/plugins/OpenPype/OpenPype.py | 3 +- .../OpenPypeTileAssembler.py | 1 + 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index 16149d7e20..1544acc2a4 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -38,6 +38,7 @@ class AyonDeadlinePlugin(DeadlinePlugin): for publish process. """ def __init__(self): + super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument diff --git a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py index 0615af95dd..2f6e9cf379 100644 --- a/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/HarmonyOpenPype/HarmonyOpenPype.py @@ -8,13 +8,14 @@ from Deadline.Scripting import * def GetDeadlinePlugin(): return HarmonyOpenPypePlugin() - + def CleanupDeadlinePlugin( deadlinePlugin ): deadlinePlugin.Cleanup() - + class HarmonyOpenPypePlugin( DeadlinePlugin ): def __init__( self ): + super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument @@ -24,11 +25,11 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): print("Cleanup") for stdoutHandler in self.StdoutHandlers: del stdoutHandler.HandleCallback - + del self.InitializeProcessCallback del self.RenderExecutableCallback del self.RenderArgumentCallback - + def CheckExitCode( self, exitCode ): print("check code") if exitCode != 0: @@ -36,20 +37,20 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): self.LogInfo( "Renderer reported an error with error code 100. This will be ignored, since the option to ignore it is specified in the Job Properties." ) else: self.FailRender( "Renderer returned non-zero error code %d. Check the renderer's output." % exitCode ) - + def InitializeProcess( self ): self.PluginType = PluginType.Simple self.StdoutHandling = True self.PopupHandling = True - + self.AddStdoutHandlerCallback( "Rendered frame ([0-9]+)" ).HandleCallback += self.HandleStdoutProgress - + def HandleStdoutProgress( self ): startFrame = self.GetStartFrame() endFrame = self.GetEndFrame() if( endFrame - startFrame + 1 != 0 ): self.SetProgress( 100 * ( int(self.GetRegexMatch(1)) - startFrame + 1 ) / ( endFrame - startFrame + 1 ) ) - + def RenderExecutable( self ): version = int( self.GetPluginInfoEntry( "Version" ) ) exe = "" @@ -58,7 +59,7 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): if( exe == "" ): self.FailRender( "Harmony render executable was not found in the configured separated list \"" + exeList + "\". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor." ) return exe - + def RenderArgument( self ): renderArguments = "-batch" @@ -72,20 +73,20 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): resolutionX = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionX", -1 ) resolutionY = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionY", -1 ) fov = self.GetFloatPluginInfoEntryWithDefault( "FieldOfView", -1 ) - + if resolutionX > 0 and resolutionY > 0 and fov > 0: renderArguments += " -res " + str( resolutionX ) + " " + str( resolutionY ) + " " + str( fov ) - + camera = self.GetPluginInfoEntryWithDefault( "Camera", "" ) - + if not camera == "": renderArguments += " -camera " + camera - + startFrame = str( self.GetStartFrame() ) endFrame = str( self.GetEndFrame() ) - + renderArguments += " -frames " + startFrame + " " + endFrame - + if not self.GetBooleanPluginInfoEntryWithDefault( "IsDatabase", False ): sceneFilename = self.GetPluginInfoEntryWithDefault( "SceneFile", self.GetDataFilename() ) sceneFilename = RepositoryUtils.CheckPathMapping( sceneFilename ) @@ -99,12 +100,12 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): renderArguments += " -scene " + scene version = self.GetPluginInfoEntryWithDefault( "SceneVersion", "" ) renderArguments += " -version " + version - + #tempSceneDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) - #preRenderScript = + #preRenderScript = rendernodeNum = 0 scriptBuilder = StringBuilder() - + while True: nodeName = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Node", "" ) if nodeName == "": @@ -115,35 +116,35 @@ class HarmonyOpenPypePlugin( DeadlinePlugin ): nodeLeadingZero = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "LeadingZero", "" ) nodeFormat = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Format", "" ) nodeStartFrame = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "StartFrame", "" ) - + if not nodePath == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingName\", 1, \"" + nodePath + "\" );") - + if not nodeLeadingZero == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"leadingZeros\", 1, \"" + nodeLeadingZero + "\" );") - + if not nodeFormat == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingType\", 1, \"" + nodeFormat + "\" );") - + if not nodeStartFrame == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"start\", 1, \"" + nodeStartFrame + "\" );") - + if nodeType == "Movie": nodePath = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Path", "" ) if not nodePath == "": scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"moviePath\", 1, \"" + nodePath + "\" );") - + rendernodeNum += 1 - + tempDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) preRenderScriptName = Path.Combine( tempDirectory, "preRenderScript.txt" ) - + File.WriteAllText( preRenderScriptName, scriptBuilder.ToString() ) - + preRenderInlineScript = self.GetPluginInfoEntryWithDefault( "PreRenderInlineScript", "" ) if preRenderInlineScript: renderArguments += " -preRenderInlineScript \"" + preRenderInlineScript +"\"" - + renderArguments += " -preRenderScript \"" + preRenderScriptName +"\"" - + return renderArguments diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py index 6e1b973fb9..004c58d346 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPype/OpenPype.py @@ -38,6 +38,7 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): for publish process. """ def __init__(self): + super().__init__() self.InitializeProcessCallback += self.InitializeProcess self.RenderExecutableCallback += self.RenderExecutable self.RenderArgumentCallback += self.RenderArgument @@ -107,7 +108,7 @@ class OpenPypeDeadlinePlugin(DeadlinePlugin): "Scanning for compatible requested " f"version {requested_version}")) dir_list = self.GetConfigEntry("OpenPypeInstallationDirs") - + # clean '\ ' for MacOS pasting if platform.system().lower() == "darwin": dir_list = dir_list.replace("\\ ", " ") diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index b51daffbc8..9641c16d20 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -249,6 +249,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): def __init__(self): """Init.""" + super().__init__() self.InitializeProcessCallback += self.initialize_process self.RenderExecutableCallback += self.render_executable self.RenderArgumentCallback += self.render_argument From 0f8cc0301fd2e44259c524eefe39b7db733fecaa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Aug 2023 14:47:10 +0200 Subject: [PATCH 080/112] 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 ed5c299c515b46bd0efc1da705bb484746688370 Mon Sep 17 00:00:00 2001 From: Libor Batek Date: Wed, 23 Aug 2023 15:59:45 +0200 Subject: [PATCH 081/112] 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 082/112] 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 083/112] 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 084/112] 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 2477287095d1d3e517d15c609a3796901043f0e2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Aug 2023 16:19:49 +0200 Subject: [PATCH 085/112] 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 086/112] 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 087/112] 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 088/112] [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 089/112] 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 090/112] 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 091/112] 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 092/112] 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 093/112] 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 e6bbb0c038abb0476d66cbc1277dd170343f684b Mon Sep 17 00:00:00 2001 From: Mustafa-Zarkash Date: Mon, 28 Aug 2023 22:47:18 +0300 Subject: [PATCH 094/112] 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 095/112] 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 096/112] 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 097/112] 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 098/112] 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 099/112] 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 100/112] 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 101/112] 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 102/112] 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 103/112] 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 104/112] 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 105/112] 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 948687e7a236a3767b07c78eba07a13663234be0 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 30 Aug 2023 03:24:53 +0000 Subject: [PATCH 106/112] [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 107/112] 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 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 108/112] 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 109/112] 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 110/112] 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 111/112] 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 84e89aa422728daaac3f2550358cdc18d97a89f7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Aug 2023 11:39:07 +0200 Subject: [PATCH 112/112] 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: