diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5d464d0532..1280e6a6e5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,9 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.15.12-nightly.4 + - 3.15.12-nightly.3 + - 3.15.12-nightly.2 - 3.15.12-nightly.1 - 3.15.11 - 3.15.11-nightly.5 @@ -132,9 +135,6 @@ body: - 3.14.5-nightly.1 - 3.14.4 - 3.14.4-nightly.4 - - 3.14.4-nightly.3 - - 3.14.4-nightly.2 - - 3.14.4-nightly.1 validations: required: true - type: dropdown diff --git a/openpype/action.py b/openpype/action.py index 15c96404b6..6114c65fd4 100644 --- a/openpype/action.py +++ b/openpype/action.py @@ -49,7 +49,7 @@ def deprecated(new_destination): @deprecated("openpype.pipeline.publish.get_errored_instances_from_context") -def get_errored_instances_from_context(context): +def get_errored_instances_from_context(context, plugin=None): """ Deprecated: Since 3.14.* will be removed in 3.16.* or later. @@ -57,7 +57,7 @@ def get_errored_instances_from_context(context): from openpype.pipeline.publish import get_errored_instances_from_context - return get_errored_instances_from_context(context) + return get_errored_instances_from_context(context, plugin=plugin) @deprecated("openpype.pipeline.publish.get_errored_plugins_from_context") @@ -97,11 +97,9 @@ class RepairAction(pyblish.api.Action): # Get the errored instances self.log.info("Finding failed instances..") - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) - for instance in instances: + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) + for instance in errored_instances: plugin.repair(instance) diff --git a/openpype/hosts/blender/api/action.py b/openpype/hosts/blender/api/action.py index fe0833e39f..dc49d6d9ae 100644 --- a/openpype/hosts/blender/api/action.py +++ b/openpype/hosts/blender/api/action.py @@ -12,13 +12,13 @@ class SelectInvalidAction(pyblish.api.Action): icon = "search" def process(self, context, plugin): - errored_instances = get_errored_instances_from_context(context) - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes...") invalid = list() - for instance in instances: + for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): diff --git a/openpype/hosts/fusion/api/action.py b/openpype/hosts/fusion/api/action.py index ff5dd14caa..347d552108 100644 --- a/openpype/hosts/fusion/api/action.py +++ b/openpype/hosts/fusion/api/action.py @@ -18,15 +18,13 @@ class SelectInvalidAction(pyblish.api.Action): icon = "search" # Icon from Awesome Icon def process(self, context, plugin): - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() - for instance in instances: + for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): diff --git a/openpype/hosts/houdini/api/action.py b/openpype/hosts/houdini/api/action.py index 27e8ce55bb..77966d6d5c 100644 --- a/openpype/hosts/houdini/api/action.py +++ b/openpype/hosts/houdini/api/action.py @@ -17,15 +17,13 @@ class SelectInvalidAction(pyblish.api.Action): def process(self, context, plugin): - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() - for instance in instances: + for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): @@ -44,3 +42,42 @@ class SelectInvalidAction(pyblish.api.Action): node.setCurrent(True) else: self.log.info("No invalid nodes found.") + + +class SelectROPAction(pyblish.api.Action): + """Select ROP. + + It's used to select the associated ROPs with the errored instances. + """ + + label = "Select ROP" + on = "failed" # This action is only available on a failed plug-in + icon = "mdi.cursor-default-click" + + def process(self, context, plugin): + errored_instances = get_errored_instances_from_context(context, plugin) + + # Get the invalid nodes for the plug-ins + self.log.info("Finding ROP nodes..") + rop_nodes = list() + for instance in errored_instances: + node_path = instance.data.get("instance_node") + if not node_path: + continue + + node = hou.node(node_path) + if not node: + continue + + rop_nodes.append(node) + + hou.clearAllSelected() + if rop_nodes: + self.log.info("Selecting ROP nodes: {}".format( + ", ".join(node.path() for node in rop_nodes) + )) + for node in rop_nodes: + node.setSelected(True) + node.setCurrent(True) + else: + self.log.info("No ROP nodes found.") diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index a33ba7aad2..a32e9d8d61 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -633,23 +633,8 @@ def evalParmNoFrame(node, parm, pad_character="#"): def get_color_management_preferences(): """Get default OCIO preferences""" - data = { - "config": hou.Color.ocio_configPath() - + return { + "config": hou.Color.ocio_configPath(), + "display": hou.Color.ocio_defaultDisplay(), + "view": hou.Color.ocio_defaultView() } - - # Get default display and view from OCIO - display = hou.Color.ocio_defaultDisplay() - disp_regex = re.compile(r"^(?P.+-)(?P.+)$") - disp_match = disp_regex.match(display) - - view = hou.Color.ocio_defaultView() - view_regex = re.compile(r"^(?P.+- )(?P.+)$") - view_match = view_regex.match(view) - data.update({ - "display": disp_match.group("display"), - "view": view_match.group("view") - - }) - - return data diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index b8b8fefb52..8a26bbb504 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -93,7 +93,7 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): import hdefereval # noqa, hdefereval is only available in ui mode hdefereval.executeDeferred(creator_node_shelves.install) - def has_unsaved_changes(self): + def workfile_has_unsaved_changes(self): return hou.hipFile.hasUnsavedChanges() def get_workfile_extensions(self): diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index df74070fee..554d5f2016 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache alembics.""" from openpype.hosts.houdini.api import plugin -from openpype.pipeline import CreatedInstance import hou @@ -14,15 +13,13 @@ class CreatePointCache(plugin.HoudiniCreator): icon = "gears" def create(self, subset_name, instance_data, pre_create_data): - import hou - instance_data.pop("active", None) instance_data.update({"node_type": "alembic"}) instance = super(CreatePointCache, self).create( subset_name, instance_data, - pre_create_data) # type: CreatedInstance + pre_create_data) instance_node = hou.node(instance.get("instance_node")) parms = { @@ -37,13 +34,44 @@ class CreatePointCache(plugin.HoudiniCreator): } if self.selected_nodes: - parms["sop_path"] = self.selected_nodes[0].path() + selected_node = self.selected_nodes[0] - # try to find output node - for child in self.selected_nodes[0].children(): - if child.type().name() == "output": - parms["sop_path"] = child.path() - break + # 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): + parms["sop_path"] = selected_node.path() + self.log.debug( + "Valid SopNode selection, 'SOP Path' in ROP will be set to '%s'." + % 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() in ["geo"]: + + # get the output node with the minimum + # 'outputidx' or the node with display flag + sop_path = self.get_obj_output(selected_node) + + if sop_path: + parms["sop_path"] = sop_path.path() + self.log.debug( + "Valid ObjNode selection, 'SOP Path' in ROP will be set to " + "the child path '%s'." + % sop_path.path() + ) + + if not parms.get("sop_path", None): + self.log.debug( + "Selection isn't valid. 'SOP Path' in ROP will be empty." + ) + else: + self.log.debug( + "No Selection. 'SOP Path' in ROP will be empty." + ) instance_node.setParms(parms) instance_node.parm("trange").set(1) @@ -57,3 +85,23 @@ class CreatePointCache(plugin.HoudiniCreator): hou.ropNodeTypeCategory(), hou.sopNodeTypeCategory() ] + + def get_obj_output(self, obj_node): + """Find output node with the smallest 'outputidx'.""" + + outputs = obj_node.subnetOutputs() + + # if obj_node is empty + if not outputs: + return + + # if obj_node has one output child whether its + # sop output node or a node with the render flag + elif len(outputs) == 1: + return outputs[0] + + # if there are more than one, then it have multiple ouput nodes + # return the one with the minimum 'outputidx' + else: + return min(outputs, + key=lambda node: node.evalParm('outputidx')) diff --git a/openpype/hosts/houdini/plugins/publish/save_scene.py b/openpype/hosts/houdini/plugins/publish/save_scene.py index 703d3e4895..3ae3fa3220 100644 --- a/openpype/hosts/houdini/plugins/publish/save_scene.py +++ b/openpype/hosts/houdini/plugins/publish/save_scene.py @@ -19,7 +19,7 @@ class SaveCurrentScene(pyblish.api.ContextPlugin): "Collected filename from current scene name." ) - if host.has_unsaved_changes(): + if host.workfile_has_unsaved_changes(): self.log.info("Saving current file: {}".format(current_file)) host.save_workfile(current_file) else: diff --git a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py index 86e92a052f..bef8db45a4 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py +++ b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py @@ -73,6 +73,14 @@ class ValidateAbcPrimitiveToDetail(pyblish.api.InstancePlugin): cls.log.debug("Checking Primitive to Detail pattern: %s" % pattern) cls.log.debug("Checking with path attribute: %s" % path_attr) + if not hasattr(output_node, "geometry"): + # In the case someone has explicitly set an Object + # node instead of a SOP node in Geometry context + # then for now we ignore - this allows us to also + # export object transforms. + cls.log.warning("No geometry output node found, skipping check..") + return + # Check if the primitive attribute exists frame = instance.data.get("frameStart", 0) geo = output_node.geometryAtFrame(frame) diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index d3a4c0cfbf..cd5e724ab3 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -60,6 +60,14 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): cls.log.debug("Checking for attribute: %s" % path_attr) + if not hasattr(output_node, "geometry"): + # In the case someone has explicitly set an Object + # node instead of a SOP node in Geometry context + # then for now we ignore - this allows us to also + # export object transforms. + cls.log.warning("No geometry output node found, skipping check..") + return + # Check if the primitive attribute exists frame = instance.data.get("frameStart", 0) geo = output_node.geometryAtFrame(frame) diff --git a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py index ed7f438729..d9dee38680 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_sop_output_node.py @@ -1,6 +1,12 @@ # -*- coding: utf-8 -*- import pyblish.api from openpype.pipeline import PublishValidationError +from openpype.hosts.houdini.api.action import ( + SelectInvalidAction, + SelectROPAction, +) + +import hou class ValidateSopOutputNode(pyblish.api.InstancePlugin): @@ -19,6 +25,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): families = ["pointcache", "vdbcache"] hosts = ["houdini"] label = "Validate Output Node" + actions = [SelectROPAction, SelectInvalidAction] def process(self, instance): @@ -31,9 +38,6 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - - import hou - output_node = instance.data.get("output_node") if output_node is None: @@ -43,7 +47,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): "Ensure a valid SOP output path is set." % node.path() ) - return [node.path()] + return [node] # Output node must be a Sop node. if not isinstance(output_node, hou.SopNode): @@ -53,7 +57,7 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): "instead found category type: %s" % (output_node.path(), output_node.type().category().name()) ) - return [output_node.path()] + return [output_node] # For the sake of completeness also assert the category type # is Sop to avoid potential edge case scenarios even though @@ -73,11 +77,11 @@ class ValidateSopOutputNode(pyblish.api.InstancePlugin): except hou.Error as exc: cls.log.error("Cook failed: %s" % exc) cls.log.error(output_node.errors()[0]) - return [output_node.path()] + return [output_node] # Ensure the output node has at least Geometry data if not output_node.geometry(): cls.log.error( "Output node `%s` has no geometry data." % output_node.path() ) - return [output_node.path()] + return [output_node] diff --git a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py index 674782179c..b51e1007f0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_vdb_output_node.py @@ -53,6 +53,8 @@ def update_mode_context(mode): def get_geometry_at_frame(sop_node, frame, force=True): """Return geometry at frame but force a cooked value.""" + if not hasattr(sop_node, "geometry"): + return with update_mode_context(hou.updateMode.AutoUpdate): sop_node.cook(force=force, frame_range=(frame, frame)) return sop_node.geometryAtFrame(frame) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index dbaa6e4a24..ccd4cd67e1 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -78,6 +78,14 @@ def read(container) -> dict: value.startswith(JSON_PREFIX): with contextlib.suppress(json.JSONDecodeError): value = json.loads(value[len(JSON_PREFIX):]) + + # default value behavior + # convert maxscript boolean values + if value == "true": + value = True + elif value == "false": + value = False + data[key.strip()] = value data["instance_node"] = container.Name @@ -284,6 +292,21 @@ def get_max_version(): return max_info[7] +@contextlib.contextmanager +def viewport_camera(camera): + original = rt.viewport.getCamera() + if not original: + # if there is no original camera + # use the current camera as original + original = rt.getNodeByName(camera) + review_camera = rt.getNodeByName(camera) + try: + rt.viewport.setCamera(review_camera) + yield + finally: + rt.viewport.setCamera(original) + + def set_timeline(frameStart, frameEnd): """Set frame range for timeline editor in Max """ diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 71a0b94e1f..14b0653f40 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -42,6 +42,10 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" ( handle_name = node_to_name c node_ref = NodeTransformMonitor node:c + idx = finditem list_node.items handle_name + if idx do ( + continue + ) append temp_arr handle_name append i_node_arr node_ref ) diff --git a/openpype/hosts/max/plugins/create/create_review.py b/openpype/hosts/max/plugins/create/create_review.py new file mode 100644 index 0000000000..5737114fcc --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_review.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating review in Max.""" +from openpype.hosts.max.api import plugin +from openpype.lib import BoolDef, EnumDef, NumberDef + + +class CreateReview(plugin.MaxCreator): + """Review in 3dsMax""" + + identifier = "io.openpype.creators.max.review" + label = "Review" + family = "review" + icon = "video-camera" + + def create(self, subset_name, instance_data, pre_create_data): + + instance_data["imageFormat"] = pre_create_data.get("imageFormat") + instance_data["keepImages"] = pre_create_data.get("keepImages") + instance_data["percentSize"] = pre_create_data.get("percentSize") + instance_data["rndLevel"] = pre_create_data.get("rndLevel") + + super(CreateReview, self).create( + subset_name, + instance_data, + pre_create_data) + + def get_pre_create_attr_defs(self): + attrs = super(CreateReview, self).get_pre_create_attr_defs() + + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "hdr", "rgb", "png", + "rla", "rpf", "dds", "sgi", "tga", "tif", "vrimg" + ] + + rndLevel_enum = [ + "smoothhighlights", "smooth", "facethighlights", + "facet", "flat", "litwireframe", "wireframe", "box" + ] + + return attrs + [ + BoolDef("keepImages", + label="Keep Image Sequences", + default=False), + EnumDef("imageFormat", + image_format_enum, + default="png", + label="Image Format Options"), + NumberDef("percentSize", + label="Percent of Output", + default=100, + minimum=1, + decimals=0), + EnumDef("rndLevel", + rndLevel_enum, + default="smoothhighlights", + label="Preference") + ] diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py new file mode 100644 index 0000000000..7aeb45f46b --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -0,0 +1,92 @@ +# dont forget getting the focal length for burnin +"""Collect Review""" +import pyblish.api + +from pymxs import runtime as rt +from openpype.lib import BoolDef +from openpype.pipeline.publish import OpenPypePyblishPluginMixin + + +class CollectReview(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Collect Review Data for Preview Animation""" + + order = pyblish.api.CollectorOrder + 0.02 + label = "Collect Review Data" + hosts = ['max'] + families = ["review"] + + def process(self, instance): + nodes = instance.data["members"] + focal_length = None + camera_name = None + for node in nodes: + if rt.classOf(node) in rt.Camera.classes: + camera_name = node.name + focal_length = node.fov + + attr_values = self.get_attr_values_from_data(instance.data) + data = { + "review_camera": camera_name, + "frameStart": instance.context.data["frameStart"], + "frameEnd": instance.context.data["frameEnd"], + "fps": instance.context.data["fps"], + "dspGeometry": attr_values.get("dspGeometry"), + "dspShapes": attr_values.get("dspShapes"), + "dspLights": attr_values.get("dspLights"), + "dspCameras": attr_values.get("dspCameras"), + "dspHelpers": attr_values.get("dspHelpers"), + "dspParticles": attr_values.get("dspParticles"), + "dspBones": attr_values.get("dspBones"), + "dspBkg": attr_values.get("dspBkg"), + "dspGrid": attr_values.get("dspGrid"), + "dspSafeFrame": attr_values.get("dspSafeFrame"), + "dspFrameNums": attr_values.get("dspFrameNums") + } + # Enable ftrack functionality + instance.data.setdefault("families", []).append('ftrack') + + burnin_members = instance.data.setdefault("burninDataMembers", {}) + burnin_members["focalLength"] = focal_length + + self.log.debug(f"data:{data}") + instance.data.update(data) + + @classmethod + def get_attribute_defs(cls): + + return [ + BoolDef("dspGeometry", + label="Geometry", + default=True), + BoolDef("dspShapes", + label="Shapes", + default=False), + BoolDef("dspLights", + label="Lights", + default=False), + BoolDef("dspCameras", + label="Cameras", + default=False), + BoolDef("dspHelpers", + label="Helpers", + default=False), + BoolDef("dspParticles", + label="Particle Systems", + default=True), + BoolDef("dspBones", + label="Bone Objects", + default=False), + BoolDef("dspBkg", + label="Background", + default=True), + BoolDef("dspGrid", + label="Active Grid", + default=False), + BoolDef("dspSafeFrame", + label="Safe Frames", + default=False), + BoolDef("dspFrameNums", + label="Frame Numbers", + default=False) + ] diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py new file mode 100644 index 0000000000..8e06e52b5c --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -0,0 +1,102 @@ +import os +import pyblish.api +from pymxs import runtime as rt +from openpype.pipeline import publish +from openpype.hosts.max.api.lib import viewport_camera, get_max_version + + +class ExtractReviewAnimation(publish.Extractor): + """ + Extract Review by Review Animation + """ + + order = pyblish.api.ExtractorOrder + 0.001 + label = "Extract Review Animation" + hosts = ["max"] + families = ["review"] + + def process(self, instance): + staging_dir = self.staging_dir(instance) + ext = instance.data.get("imageFormat") + filename = "{0}..{1}".format(instance.name, ext) + start = int(instance.data["frameStart"]) + end = int(instance.data["frameEnd"]) + fps = int(instance.data["fps"]) + filepath = os.path.join(staging_dir, filename) + filepath = filepath.replace("\\", "/") + filenames = self.get_files( + instance.name, start, end, ext) + + self.log.debug( + "Writing Review Animation to" + " '%s' to '%s'" % (filename, staging_dir)) + + review_camera = instance.data["review_camera"] + with viewport_camera(review_camera): + preview_arg = self.set_preview_arg( + instance, filepath, start, end, fps) + rt.execute(preview_arg) + + tags = ["review"] + if not instance.data.get("keepImages"): + tags.append("delete") + + self.log.debug("Performing Extraction ...") + + representation = { + "name": instance.data["imageFormat"], + "ext": instance.data["imageFormat"], + "files": filenames, + "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"], + "tags": tags, + "preview": True, + "camera_name": review_camera + } + self.log.debug(f"{representation}") + + if "representations" not in instance.data: + instance.data["representations"] = [] + instance.data["representations"].append(representation) + + def get_files(self, filename, start, end, ext): + file_list = [] + for frame in range(int(start), int(end) + 1): + actual_name = "{}.{:04}.{}".format( + filename, frame, ext) + file_list.append(actual_name) + + return file_list + + def set_preview_arg(self, instance, filepath, + start, end, fps): + job_args = list() + default_option = f'CreatePreview filename:"{filepath}"' + job_args.append(default_option) + frame_option = f"outputAVI:false start:{start} end:{end} fps:{fps}" # noqa + job_args.append(frame_option) + rndLevel = instance.data.get("rndLevel") + if rndLevel: + option = f"rndLevel:#{rndLevel}" + job_args.append(option) + options = [ + "percentSize", "dspGeometry", "dspShapes", + "dspLights", "dspCameras", "dspHelpers", "dspParticles", + "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" + ] + + for key in options: + enabled = instance.data.get(key) + if enabled: + job_args.append(f"{key}:{enabled}") + + if get_max_version() == 2024: + # hardcoded for current stage + auto_play_option = "autoPlay:false" + job_args.append(auto_play_option) + + job_str = " ".join(job_args) + self.log.debug(job_str) + + return job_str diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py new file mode 100644 index 0000000000..82f4fc7a8b --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -0,0 +1,91 @@ +import os +import tempfile +import pyblish.api +from pymxs import runtime as rt +from openpype.pipeline import publish +from openpype.hosts.max.api.lib import viewport_camera, get_max_version + + +class ExtractThumbnail(publish.Extractor): + """ + Extract Thumbnail for Review + """ + + order = pyblish.api.ExtractorOrder + label = "Extract Thumbnail" + hosts = ["max"] + families = ["review"] + + def process(self, instance): + # TODO: Create temp directory for thumbnail + # - this is to avoid "override" of source file + tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") + self.log.debug( + f"Create temp directory {tmp_staging} for thumbnail" + ) + fps = int(instance.data["fps"]) + frame = int(instance.data["frameStart"]) + instance.context.data["cleanupFullPaths"].append(tmp_staging) + filename = "{name}_thumbnail..png".format(**instance.data) + filepath = os.path.join(tmp_staging, filename) + filepath = filepath.replace("\\", "/") + thumbnail = self.get_filename(instance.name, frame) + + self.log.debug( + "Writing Thumbnail to" + " '%s' to '%s'" % (filename, tmp_staging)) + review_camera = instance.data["review_camera"] + with viewport_camera(review_camera): + preview_arg = self.set_preview_arg( + instance, filepath, fps, frame) + rt.execute(preview_arg) + + representation = { + "name": "thumbnail", + "ext": "png", + "files": thumbnail, + "stagingDir": tmp_staging, + "thumbnail": True + } + + self.log.debug(f"{representation}") + + if "representations" not in instance.data: + instance.data["representations"] = [] + instance.data["representations"].append(representation) + + def get_filename(self, filename, target_frame): + thumbnail_name = "{}_thumbnail.{:04}.png".format( + filename, target_frame + ) + return thumbnail_name + + def set_preview_arg(self, instance, filepath, fps, frame): + job_args = list() + default_option = f'CreatePreview filename:"{filepath}"' + job_args.append(default_option) + frame_option = f"outputAVI:false start:{frame} end:{frame} fps:{fps}" # noqa + job_args.append(frame_option) + rndLevel = instance.data.get("rndLevel") + if rndLevel: + option = f"rndLevel:#{rndLevel}" + job_args.append(option) + options = [ + "percentSize", "dspGeometry", "dspShapes", + "dspLights", "dspCameras", "dspHelpers", "dspParticles", + "dspBones", "dspBkg", "dspGrid", "dspSafeFrame", "dspFrameNums" + ] + + for key in options: + enabled = instance.data.get(key) + if enabled: + job_args.append(f"{key}:{enabled}") + if get_max_version() == 2024: + # hardcoded for current stage + auto_play_option = "autoPlay:false" + job_args.append(auto_play_option) + + job_str = " ".join(job_args) + self.log.debug(job_str) + + return job_str diff --git a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py new file mode 100644 index 0000000000..2a9483c763 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py @@ -0,0 +1,48 @@ +import pyblish.api + +from pymxs import runtime as rt +from openpype.pipeline.publish import ( + RepairAction, + ValidateContentsOrder, + PublishValidationError +) +from openpype.hosts.max.api.lib import get_frame_range, set_timeline + + +class ValidateAnimationTimeline(pyblish.api.InstancePlugin): + """ + Validates Animation Timeline for Preview Animation in Max + """ + + label = "Animation Timeline for Review" + order = ValidateContentsOrder + families = ["review"] + hosts = ["max"] + actions = [RepairAction] + + def process(self, instance): + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) + if rt.animationRange.start != frame_start_handle or ( + rt.animationRange.end != frame_end_handle + ): + raise PublishValidationError("Incorrect animation timeline " + "set for preview animation.. " + "\nYou can use repair action to " + "the correct animation timeline") + + @classmethod + def repair(cls, instance): + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) + set_timeline(frame_start_handle, frame_end_handle) diff --git a/openpype/hosts/max/plugins/publish/validate_camera_contents.py b/openpype/hosts/max/plugins/publish/validate_camera_contents.py index ab13e5dc05..0c61e6431d 100644 --- a/openpype/hosts/max/plugins/publish/validate_camera_contents.py +++ b/openpype/hosts/max/plugins/publish/validate_camera_contents.py @@ -11,7 +11,7 @@ class ValidateCameraContent(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["camera"] + families = ["camera", "review"] hosts = ["max"] label = "Camera Contents" camera_type = ["$Free_Camera", "$Target_Camera", diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py index ba4a6882c2..c6a27dace3 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -13,7 +13,8 @@ class ValidateMaxContents(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder families = ["camera", "maxScene", - "maxrender"] + "maxrender", + "review"] hosts = ["max"] label = "Max Scene Contents" diff --git a/openpype/hosts/maya/api/action.py b/openpype/hosts/maya/api/action.py index 065fdf3691..3b8e2c1848 100644 --- a/openpype/hosts/maya/api/action.py +++ b/openpype/hosts/maya/api/action.py @@ -111,15 +111,13 @@ class SelectInvalidAction(pyblish.api.Action): except ImportError: raise ImportError("Current host is not Maya") - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() - for instance in instances: + for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b09088799c..ef8ddf8bac 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -3,7 +3,6 @@ import os from pprint import pformat import sys -import platform import uuid import re @@ -1544,7 +1543,15 @@ def set_attribute(attribute, value, node): cmds.addAttr(node, longName=attribute, **kwargs) node_attr = "{}.{}".format(node, attribute) - if "dataType" in kwargs: + enum_type = cmds.attributeQuery(attribute, node=node, enum=True) + if enum_type and value_type == "str": + enum_string_values = cmds.attributeQuery( + attribute, node=node, listEnum=True + )[0].split(":") + cmds.setAttr( + "{}.{}".format(node, attribute), enum_string_values.index(value) + ) + elif "dataType" in kwargs: attr_type = kwargs["dataType"] cmds.setAttr(node_attr, value, type=attr_type) else: @@ -2832,19 +2839,22 @@ def get_attr_in_layer(attr, layer): def fix_incompatible_containers(): """Backwards compatibility: old containers to use new ReferenceLoader""" - + old_loaders = { + "MayaAsciiLoader", + "AbcLoader", + "ModelLoader", + "CameraLoader", + "RigLoader", + "FBXLoader" + } host = registered_host() for container in host.ls(): loader = container['loader'] - - print(container['loader']) - - if loader in ["MayaAsciiLoader", - "AbcLoader", - "ModelLoader", - "CameraLoader", - "RigLoader", - "FBXLoader"]: + if loader in old_loaders: + log.info( + "Converting legacy container loader {} to " + "ReferenceLoader: {}".format(loader, container["objectName"]) + ) cmds.setAttr(container["objectName"] + ".loader", "ReferenceLoader", type="string") @@ -2972,7 +2982,7 @@ def _get_render_instances(): list: list of instances """ - objectset = cmds.ls("*.id", long=True, type="objectSet", + objectset = cmds.ls("*.id", long=True, exactType="objectSet", recursive=True, objectsOnly=True) instances = [] @@ -3259,36 +3269,21 @@ def iter_shader_edits(relationships, shader_nodes, nodes_by_id, label=None): def set_colorspace(): - """Set Colorspace from project configuration - """ + """Set Colorspace from project configuration""" - # set color spaces for rendering space and view transforms - def _colormanage(**kwargs): - """Wrapper around `cmds.colorManagementPrefs`. - - This logs errors instead of raising an error so color management - settings get applied as much as possible. - - """ - assert len(kwargs) == 1, "Must receive one keyword argument" - try: - cmds.colorManagementPrefs(edit=True, **kwargs) - log.debug("Setting Color Management Preference: {}".format(kwargs)) - except RuntimeError as exc: - log.error(exc) - - project_name = os.getenv("AVALON_PROJECT") + project_name = get_current_project_name() imageio = get_project_settings(project_name)["maya"]["imageio"] # ocio compatibility variables ocio_v2_maya_version = 2022 maya_version = int(cmds.about(version=True)) ocio_v2_support = use_ocio_v2 = maya_version >= ocio_v2_maya_version + is_ocio_set = bool(os.environ.get("OCIO")) - root_dict = {} use_workfile_settings = imageio.get("workfile", {}).get("enabled") - if use_workfile_settings: + root_dict = imageio["workfile"] + else: # TODO: deprecated code from 3.15.5 - remove # Maya 2022+ introduces new OCIO v2 color management settings that # can override the old color management preferences. OpenPype has @@ -3311,40 +3306,63 @@ def set_colorspace(): if not isinstance(root_dict, dict): msg = "set_colorspace(): argument should be dictionary" log.error(msg) + return - else: - root_dict = imageio["workfile"] + # backward compatibility + # TODO: deprecated code from 3.15.5 - remove with deprecated code above + view_name = root_dict.get("viewTransform") + if view_name is None: + view_name = root_dict.get("viewName") log.debug(">> root_dict: {}".format(pformat(root_dict))) + if not root_dict: + return - if root_dict: - # enable color management - cmds.colorManagementPrefs(e=True, cmEnabled=True) - cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) + # set color spaces for rendering space and view transforms + def _colormanage(**kwargs): + """Wrapper around `cmds.colorManagementPrefs`. - # backward compatibility - # TODO: deprecated code from 3.15.5 - refactor to use new settings - view_name = root_dict.get("viewTransform") - if view_name is None: - view_name = root_dict.get("viewName") + This logs errors instead of raising an error so color management + settings get applied as much as possible. - if use_ocio_v2: - # Use Maya 2022+ default OCIO v2 config + """ + assert len(kwargs) == 1, "Must receive one keyword argument" + try: + cmds.colorManagementPrefs(edit=True, **kwargs) + log.debug("Setting Color Management Preference: {}".format(kwargs)) + except RuntimeError as exc: + log.error(exc) + + # enable color management + cmds.colorManagementPrefs(edit=True, cmEnabled=True) + cmds.colorManagementPrefs(edit=True, ocioRulesEnabled=True) + + if use_ocio_v2: + log.info("Using Maya OCIO v2") + if not is_ocio_set: + # Set the Maya 2022+ default OCIO v2 config file path log.info("Setting default Maya OCIO v2 config") - cmds.colorManagementPrefs(edit=True, configFilePath="") + # Note: Setting "" as value also sets this default however + # introduces a bug where launching a file on startup will prompt + # to save the empty scene before it, so we set using the path. + # This value has been the same for 2022, 2023 and 2024 + path = "/OCIO-configs/Maya2022-default/config.ocio" + cmds.colorManagementPrefs(edit=True, configFilePath=path) - # set rendering space and view transform - _colormanage(renderingSpaceName=root_dict["renderSpace"]) - _colormanage(viewName=view_name) - _colormanage(displayName=root_dict["displayName"]) - else: + # set rendering space and view transform + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + _colormanage(viewName=view_name) + _colormanage(displayName=root_dict["displayName"]) + else: + log.info("Using Maya OCIO v1 (legacy)") + if not is_ocio_set: # Set the Maya default config file path log.info("Setting default Maya OCIO v1 legacy config") cmds.colorManagementPrefs(edit=True, configFilePath="legacy") - # set rendering space and view transform - _colormanage(renderingSpaceName=root_dict["renderSpace"]) - _colormanage(viewTransformName=view_name) + # set rendering space and view transform + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + _colormanage(viewTransformName=view_name) @contextlib.contextmanager diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index eb5a2a4381..b5b71a5a36 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -274,12 +274,14 @@ class ARenderProducts: "Unsupported renderer {}".format(self.renderer) ) + # Note: When this attribute is never set (e.g. on maya launch) then + # this can return None even though it is a string attribute prefix = self._get_attr(prefix_attr) if not prefix: # Fall back to scene name by default - log.debug("Image prefix not set, using ") - file_prefix = "" + log.warning("Image prefix not set, using ") + prefix = "" return prefix diff --git a/openpype/hosts/maya/api/obj.py b/openpype/hosts/maya/api/obj.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index c84855214c..2f2ab83f79 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -4,6 +4,7 @@ import os import errno import logging import contextlib +import shutil from maya import utils, cmds, OpenMaya import maya.api.OpenMaya as om @@ -116,6 +117,9 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_event_callback("taskChanged", on_task_changed) register_event_callback("workfile.open.before", before_workfile_open) register_event_callback("workfile.save.before", before_workfile_save) + register_event_callback( + "workfile.save.before", workfile_save_before_xgen + ) register_event_callback("workfile.save.after", after_workfile_save) def open_workfile(self, filepath): @@ -497,18 +501,16 @@ def on_init(): # Force load objExport plug-in (requested by artists) cmds.loadPlugin("objExport", quiet=True) - from .customize import ( - override_component_mask_commands, - override_toolbox_ui - ) - safe_deferred(override_component_mask_commands) - - launch_workfiles = os.environ.get("WORKFILES_STARTUP") - - if launch_workfiles: - safe_deferred(host_tools.show_workfiles) - if not lib.IS_HEADLESS: + launch_workfiles = os.environ.get("WORKFILES_STARTUP") + if launch_workfiles: + safe_deferred(host_tools.show_workfiles) + + from .customize import ( + override_component_mask_commands, + override_toolbox_ui + ) + safe_deferred(override_component_mask_commands) safe_deferred(override_toolbox_ui) @@ -566,37 +568,29 @@ def on_save(): Any transform of a mesh, without an existing ID, is given one automatically on file save. """ - log.info("Running callback on save..") # remove lockfile if users jumps over from one scene to another _remove_workfile_lock() - # # Update current task for the current scene - # update_task_from_path(cmds.file(query=True, sceneName=True)) - # Generate ids of the current context on nodes in the scene nodes = lib.get_id_required_nodes(referenced_nodes=False) for node, new_id in lib.generate_ids(nodes): lib.set_id(node, new_id, overwrite=False) +def _update_render_layer_observers(): + # Helper to trigger update for all renderlayer observer logic + lib.remove_render_layer_observer() + lib.add_render_layer_observer() + lib.add_render_layer_change_observer() + + def on_open(): """On scene open let's assume the containers have changed.""" - from qtpy import QtWidgets from openpype.widgets import popup - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.remove_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_change_observer()") - # # Update current task for the current scene - # update_task_from_path(cmds.file(query=True, sceneName=True)) + utils.executeDeferred(_update_render_layer_observers) # Validate FPS after update_task_from_path to # ensure it is using correct FPS for the asset @@ -607,10 +601,7 @@ def on_open(): log.warning("Scene has outdated content.") # Find maya main window - top_level_widgets = {w.objectName(): w for w in - QtWidgets.QApplication.topLevelWidgets()} - parent = top_level_widgets.get("MayaWindow", None) - + parent = lib.get_main_window() if parent is None: log.info("Skipping outdated content pop-up " "because Maya window can't be found.") @@ -635,16 +626,9 @@ def on_new(): """Set project resolution and fps when create a new file""" log.info("Running callback on new..") with lib.suspended_refresh(): - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.remove_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_change_observer()") lib.set_context_settings() + + utils.executeDeferred(_update_render_layer_observers) _remove_workfile_lock() @@ -698,6 +682,91 @@ def before_workfile_save(event): create_workspace_mel(workdir_path, project_name) +def workfile_save_before_xgen(event): + """Manage Xgen external files when switching context. + + Xgen has various external files that needs to be unique and relative to the + workfile, so we need to copy and potentially overwrite these files when + switching context. + + Args: + event (Event) - openpype/lib/events.py + """ + if not cmds.pluginInfo("xgenToolkit", query=True, loaded=True): + return + + import xgenm + + current_work_dir = legacy_io.Session["AVALON_WORKDIR"].replace("\\", "/") + expected_work_dir = event.data["workdir_path"].replace("\\", "/") + if current_work_dir == expected_work_dir: + return + + palettes = cmds.ls(type="xgmPalette", long=True) + if not palettes: + return + + transfers = [] + overwrites = [] + attribute_changes = {} + attrs = ["xgFileName", "xgBaseFile"] + for palette in palettes: + sanitized_palette = palette.replace("|", "") + project_path = xgenm.getAttr("xgProjectPath", sanitized_palette) + _, maya_extension = os.path.splitext(event.data["filename"]) + + for attr in attrs: + node_attr = "{}.{}".format(palette, attr) + attr_value = cmds.getAttr(node_attr) + + if not attr_value: + continue + + source = os.path.join(project_path, attr_value) + + attr_value = event.data["filename"].replace( + maya_extension, + "__{}{}".format( + sanitized_palette.replace(":", "__"), + os.path.splitext(attr_value)[1] + ) + ) + target = os.path.join(expected_work_dir, attr_value) + + transfers.append((source, target)) + attribute_changes[node_attr] = attr_value + + relative_path = xgenm.getAttr( + "xgDataPath", sanitized_palette + ).split(os.pathsep)[0] + absolute_path = relative_path.replace("${PROJECT}", project_path) + for root, _, files in os.walk(absolute_path): + for f in files: + source = os.path.join(root, f).replace("\\", "/") + target = source.replace(project_path, expected_work_dir + "/") + transfers.append((source, target)) + if os.path.exists(target): + overwrites.append(target) + + # Ask user about overwriting files. + if overwrites: + log.warning( + "WARNING! Potential loss of data.\n\n" + "Found duplicate Xgen files in new context.\n{}".format( + "\n".join(overwrites) + ) + ) + return + + for source, destination in transfers: + if not os.path.exists(os.path.dirname(destination)): + os.makedirs(os.path.dirname(destination)) + shutil.copy(source, destination) + + for attribute, value in attribute_changes.items(): + cmds.setAttr(attribute, value, type="string") + + def after_workfile_save(event): workfile_name = event["filename"] if ( diff --git a/openpype/hosts/maya/api/render_setup_tools.py b/openpype/hosts/maya/api/render_setup_tools.py index 2ad59810d0..a6b46e1e9a 100644 --- a/openpype/hosts/maya/api/render_setup_tools.py +++ b/openpype/hosts/maya/api/render_setup_tools.py @@ -15,7 +15,6 @@ import contextlib from maya import cmds from maya.app.renderSetup.model import renderSetup -# from colorbleed.maya import lib from .lib import pairwise diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index e2f30f46d0..865f497710 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -272,7 +272,12 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): return roots = cmds.sets(container, q=True) - ref_node = get_reference_node(roots) + ref_node = None + try: + ref_node = get_reference_node(roots) + except AssertionError as e: + self.log.info(e.args[0]) + nodes_to_parent = [] for root in roots: if ref_node: diff --git a/openpype/hosts/maya/plugins/publish/extract_obj.py b/openpype/hosts/maya/plugins/publish/extract_obj.py index edfe0b9439..518b0f0ff8 100644 --- a/openpype/hosts/maya/plugins/publish/extract_obj.py +++ b/openpype/hosts/maya/plugins/publish/extract_obj.py @@ -2,7 +2,6 @@ import os from maya import cmds -# import maya.mel as mel import pyblish.api from openpype.pipeline import publish from openpype.hosts.maya.api import lib diff --git a/openpype/hosts/maya/tools/mayalookassigner/app.py b/openpype/hosts/maya/tools/mayalookassigner/app.py index 13da999c2d..64fc04dfc4 100644 --- a/openpype/hosts/maya/tools/mayalookassigner/app.py +++ b/openpype/hosts/maya/tools/mayalookassigner/app.py @@ -8,7 +8,10 @@ from openpype.client import get_last_version_by_subset_id from openpype import style from openpype.pipeline import legacy_io from openpype.tools.utils.lib import qt_app_context -from openpype.hosts.maya.api.lib import assign_look_by_version +from openpype.hosts.maya.api.lib import ( + assign_look_by_version, + get_main_window +) from maya import cmds # old api for MFileIO @@ -297,9 +300,7 @@ def show(): pass # Get Maya main window - top_level_widgets = QtWidgets.QApplication.topLevelWidgets() - mainwindow = next(widget for widget in top_level_widgets - if widget.objectName() == "MayaWindow") + mainwindow = get_main_window() with qt_app_context(): window = MayaLookAssignerWindow(parent=mainwindow) diff --git a/openpype/hosts/nuke/api/actions.py b/openpype/hosts/nuke/api/actions.py index 92b83560da..c955a85acc 100644 --- a/openpype/hosts/nuke/api/actions.py +++ b/openpype/hosts/nuke/api/actions.py @@ -25,15 +25,13 @@ class SelectInvalidAction(pyblish.api.Action): except ImportError: raise ImportError("Current host is not Nuke") - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid nodes..") invalid = list() - for instance in instances: + for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 685ab3019d..0efc46edaf 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -553,7 +553,9 @@ def add_write_node_legacy(name, **kwarg): w = nuke.createNode( "Write", - "name {}".format(name)) + "name {}".format(name), + inpanel=False + ) w["file"].setValue(kwarg["file"]) @@ -589,7 +591,9 @@ def add_write_node(name, file_path, knobs, **kwarg): w = nuke.createNode( "Write", - "name {}".format(name)) + "name {}".format(name), + inpanel=False + ) w["file"].setValue(file_path) @@ -1192,8 +1196,10 @@ def create_prenodes( # create node now_node = nuke.createNode( - nodeclass, "name {}".format(name)) - now_node.hideControlPanel() + nodeclass, + "name {}".format(name), + inpanel=False + ) # add for dependency linking for_dependency[name] = { @@ -1317,12 +1323,17 @@ def create_write_node( input_name = str(input.name()).replace(" ", "") # if connected input node was defined prev_node = nuke.createNode( - "Input", "name {}".format(input_name)) + "Input", + "name {}".format(input_name), + inpanel=False + ) else: # generic input node connected to nothing prev_node = nuke.createNode( - "Input", "name {}".format("rgba")) - prev_node.hideControlPanel() + "Input", + "name {}".format("rgba"), + inpanel=False + ) # creating pre-write nodes `prenodes` last_prenode = create_prenodes( @@ -1342,15 +1353,13 @@ def create_write_node( imageio_writes["knobs"], **data ) - write_node.hideControlPanel() # connect to previous node now_node.setInput(0, prev_node) # switch actual node to previous prev_node = now_node - now_node = nuke.createNode("Output", "name Output1") - now_node.hideControlPanel() + now_node = nuke.createNode("Output", "name Output1", inpanel=False) # connect to previous node now_node.setInput(0, prev_node) @@ -1517,8 +1526,10 @@ def create_write_node_legacy( else: # generic input node connected to nothing prev_node = nuke.createNode( - "Input", "name {}".format("rgba")) - prev_node.hideControlPanel() + "Input", + "name {}".format("rgba"), + inpanel=False + ) # creating pre-write nodes `prenodes` if prenodes: for node in prenodes: @@ -1530,8 +1541,10 @@ def create_write_node_legacy( # create node now_node = nuke.createNode( - klass, "name {}".format(pre_node_name)) - now_node.hideControlPanel() + klass, + "name {}".format(pre_node_name), + inpanel=False + ) # add data to knob for _knob in knobs: @@ -1561,14 +1574,18 @@ def create_write_node_legacy( if isinstance(dependent, (tuple or list)): for i, node_name in enumerate(dependent): input_node = nuke.createNode( - "Input", "name {}".format(node_name)) - input_node.hideControlPanel() + "Input", + "name {}".format(node_name), + inpanel=False + ) now_node.setInput(1, input_node) elif isinstance(dependent, str): input_node = nuke.createNode( - "Input", "name {}".format(node_name)) - input_node.hideControlPanel() + "Input", + "name {}".format(node_name), + inpanel=False + ) now_node.setInput(0, input_node) else: @@ -1583,15 +1600,13 @@ def create_write_node_legacy( "inside_{}".format(name), **_data ) - write_node.hideControlPanel() # connect to previous node now_node.setInput(0, prev_node) # switch actual node to previous prev_node = now_node - now_node = nuke.createNode("Output", "name Output1") - now_node.hideControlPanel() + now_node = nuke.createNode("Output", "name Output1", inpanel=False) # connect to previous node now_node.setInput(0, prev_node) diff --git a/openpype/hosts/nuke/plugins/load/load_camera_abc.py b/openpype/hosts/nuke/plugins/load/load_camera_abc.py index 11cc63d25c..40822c9eb7 100644 --- a/openpype/hosts/nuke/plugins/load/load_camera_abc.py +++ b/openpype/hosts/nuke/plugins/load/load_camera_abc.py @@ -66,8 +66,6 @@ class AlembicCameraLoader(load.LoaderPlugin): object_name, file), inpanel=False ) - # hide property panel - camera_node.hideControlPanel() camera_node.forceValidate() camera_node["frame_rate"].setValue(float(fps)) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index cb3da79ef5..ee74582544 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -144,10 +144,9 @@ class LoadClip(plugin.NukeLoader): # Create the Loader with the filename path set read_node = nuke.createNode( "Read", - "name {}".format(read_name)) - - # hide property panel - read_node.hideControlPanel() + "name {}".format(read_name), + inpanel=False + ) # to avoid multiple undo steps for rest of process # we will switch off undo-ing diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index d49f87a094..eb1c905c4d 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -88,10 +88,9 @@ class LoadEffects(load.LoaderPlugin): GN = nuke.createNode( "Group", - "name {}_1".format(object_name)) - - # hide property panel - GN.hideControlPanel() + "name {}_1".format(object_name), + inpanel=False + ) # adding content to the group node with GN: diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index bfe32c1ed9..03be8654ed 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -89,10 +89,9 @@ class LoadEffectsInputProcess(load.LoaderPlugin): GN = nuke.createNode( "Group", - "name {}_1".format(object_name)) - - # hide property panel - GN.hideControlPanel() + "name {}_1".format(object_name), + inpanel=False + ) # adding content to the group node with GN: diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index f82ee4db88..0a79ddada7 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -119,10 +119,9 @@ class LoadImage(load.LoaderPlugin): with viewer_update_and_undo_stop(): r = nuke.createNode( "Read", - "name {}".format(read_name)) - - # hide property panel - r.hideControlPanel() + "name {}".format(read_name), + inpanel=False + ) r["file"].setValue(file) diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index f968da8475..36781993ea 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -65,9 +65,6 @@ class AlembicModelLoader(load.LoaderPlugin): inpanel=False ) - # hide property panel - model_node.hideControlPanel() - model_node.forceValidate() # Ensure all items are imported and selected. diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index 53e9a76003..b74fdf481a 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -70,10 +70,9 @@ class LinkAsGroup(load.LoaderPlugin): # P = nuke.nodes.LiveGroup("file {}".format(file)) P = nuke.createNode( "Precomp", - "file {}".format(file)) - - # hide property panel - P.hideControlPanel() + "file {}".format(file), + inpanel=False + ) # Set colorspace defined in version data colorspace = context["version"]["data"].get("colorspace", None) diff --git a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py index 1c22c5b9d0..45c20412c8 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py +++ b/openpype/hosts/nuke/plugins/publish/validate_rendered_frames.py @@ -2,6 +2,7 @@ import os import pyblish.api import clique from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline.publish import get_errored_instances_from_context class RepairActionBase(pyblish.api.Action): @@ -11,14 +12,7 @@ class RepairActionBase(pyblish.api.Action): @staticmethod def get_instance(context, plugin): # Get the errored instances - failed = [] - for result in context.data["results"]: - if (result["error"] is not None and result["instance"] is not None - and result["instance"] not in failed): - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - return pyblish.api.instances_by_plugin(failed, plugin) + return get_errored_instances_from_context(context, plugin=plugin) def repair_knob(self, instances, state): for instance in instances: diff --git a/openpype/hosts/resolve/api/action.py b/openpype/hosts/resolve/api/action.py index ceedc2cc54..d1dffca7cc 100644 --- a/openpype/hosts/resolve/api/action.py +++ b/openpype/hosts/resolve/api/action.py @@ -27,15 +27,13 @@ class SelectInvalidAction(pyblish.api.Action): except ImportError: raise ImportError("Current host is not Resolve") - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) # Get the invalid nodes for the plug-ins self.log.info("Finding invalid clips..") invalid = list() - for instance in instances: + for instance in errored_instances: invalid_nodes = plugin.get_invalid(instance) if invalid_nodes: if isinstance(invalid_nodes, (list, tuple)): diff --git a/openpype/hosts/substancepainter/api/pipeline.py b/openpype/hosts/substancepainter/api/pipeline.py index 9406fb8edb..e96064b2bf 100644 --- a/openpype/hosts/substancepainter/api/pipeline.py +++ b/openpype/hosts/substancepainter/api/pipeline.py @@ -86,7 +86,7 @@ class SubstanceHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): self._uninstall_menu() self._deregister_callbacks() - def has_unsaved_changes(self): + def workfile_has_unsaved_changes(self): if not substance_painter.project.is_open(): return False diff --git a/openpype/hosts/substancepainter/plugins/create/create_textures.py b/openpype/hosts/substancepainter/plugins/create/create_textures.py index dece4b2cc1..6972ba2794 100644 --- a/openpype/hosts/substancepainter/plugins/create/create_textures.py +++ b/openpype/hosts/substancepainter/plugins/create/create_textures.py @@ -34,6 +34,18 @@ class CreateTextures(Creator): if not substance_painter.project.is_open(): raise CreatorError("Can't create a Texture Set instance without " "an open project.") + # Transfer settings from pre create to instance + creator_attributes = instance_data.setdefault( + "creator_attributes", dict()) + for key in [ + "exportPresetUrl", + "exportFileFormat", + "exportSize", + "exportPadding", + "exportDilationDistance" + ]: + if key in pre_create_data: + creator_attributes[key] = pre_create_data[key] instance = self.create_instance_in_context(subset_name, instance_data) diff --git a/openpype/hosts/substancepainter/plugins/publish/save_workfile.py b/openpype/hosts/substancepainter/plugins/publish/save_workfile.py index 9662f31922..517f5fd17f 100644 --- a/openpype/hosts/substancepainter/plugins/publish/save_workfile.py +++ b/openpype/hosts/substancepainter/plugins/publish/save_workfile.py @@ -20,7 +20,7 @@ class SaveCurrentWorkfile(pyblish.api.ContextPlugin): if context.data["currentFile"] != current: raise KnownPublishError("Workfile has changed during publishing!") - if host.has_unsaved_changes(): + if host.workfile_has_unsaved_changes(): self.log.info("Saving current file: {}".format(current)) host.save_workfile() else: diff --git a/openpype/hosts/unreal/addon.py b/openpype/hosts/unreal/addon.py index ed23950b35..b5c978d98f 100644 --- a/openpype/hosts/unreal/addon.py +++ b/openpype/hosts/unreal/addon.py @@ -1,7 +1,6 @@ import os import re from openpype.modules import IHostAddon, OpenPypeModule -from openpype.widgets.message_window import Window UNREAL_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -21,6 +20,8 @@ class UnrealAddon(OpenPypeModule, IHostAddon): from .lib import get_compatible_integration + from openpype.widgets.message_window import Window + pattern = re.compile(r'^\d+-\d+$') if not pattern.match(app.name): diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 6ff9afbc42..d9d250fe9e 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -23,7 +23,7 @@ from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin ) from openpype.pipeline.publish.lib import ( - replace_published_scene + replace_with_published_scene_path ) JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) @@ -528,7 +528,8 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin, published. """ - return replace_published_scene(self._instance, replace_in_path=True) + return replace_with_published_scene_path( + self._instance, replace_in_path=replace_in_path) def assemble_payload( self, job_info=None, plugin_info=None, aux_files=None): diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 1bc7ea0161..9e3ad20b44 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -122,7 +122,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_VERSION", "OPENPYPE_SG_USER" ] diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index d5f2624155..3f2d4891c1 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -1,7 +1,6 @@ from copy import deepcopy import re import os -import sys import json import platform import contextlib @@ -237,12 +236,13 @@ def get_data_subprocess(config_path, data_type): return json.loads(return_json_data) -def compatible_python(): - """Only 3.9 or higher can directly use PyOpenColorIO in ocio_wrapper""" - compatible = False - if sys.version_info.major == 3 and sys.version_info.minor >= 9: - compatible = True - return compatible +def compatibility_check(): + """Making sure PyOpenColorIO is importable""" + try: + import PyOpenColorIO # noqa: F401 + except ImportError: + return False + return True def get_ocio_config_colorspaces(config_path): @@ -257,12 +257,15 @@ def get_ocio_config_colorspaces(config_path): Returns: dict: colorspace and family in couple """ - if compatible_python(): - from ..scripts.ocio_wrapper import _get_colorspace_data - return _get_colorspace_data(config_path) - else: + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess return get_colorspace_data_subprocess(config_path) + from openpype.scripts.ocio_wrapper import _get_colorspace_data + + return _get_colorspace_data(config_path) + def get_colorspace_data_subprocess(config_path): """Get colorspace data via subprocess @@ -290,12 +293,15 @@ def get_ocio_config_views(config_path): Returns: dict: `display/viewer` and viewer data """ - if compatible_python(): - from ..scripts.ocio_wrapper import _get_views_data - return _get_views_data(config_path) - else: + if not compatibility_check(): + # python environment is not compatible with PyOpenColorIO + # needs to be run in subprocess return get_views_data_subprocess(config_path) + from openpype.scripts.ocio_wrapper import _get_views_data + + return _get_views_data(config_path) + def get_views_data_subprocess(config_path): """Get viewers data via subprocess diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index d6542f01ff..6755224c19 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -4,6 +4,11 @@ from .constants import ( PRE_CREATE_THUMBNAIL_KEY, ) +from .utils import ( + get_last_versions_for_instances, + get_next_versions_for_instances, +) + from .subset_name import ( TaskNotSetError, get_subset_name_template, @@ -46,6 +51,9 @@ __all__ = ( "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "get_last_versions_for_instances", + "get_next_versions_for_instances", + "TaskNotSetError", "get_subset_name_template", "get_subset_name", diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 332e271b0d..98fcee5fe5 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -1122,10 +1122,10 @@ class CreatedInstance: @property def creator_attribute_defs(self): - """Attribute defintions defined by creator plugin. + """Attribute definitions defined by creator plugin. Returns: - List[AbstractAttrDef]: Attribute defitions. + List[AbstractAttrDef]: Attribute definitions. """ return self.creator_attributes.attr_defs diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 9e47e9cc12..947a90ef08 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -21,6 +21,7 @@ from openpype.pipeline.plugin_discover import ( ) from .subset_name import get_subset_name +from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator @@ -483,6 +484,27 @@ class BaseCreator: thumbnail_path ) + def get_next_versions_for_instances(self, instances): + """Prepare next versions for instances. + + This is helper method to receive next possible versions for instances. + It is using context information on instance to receive them, 'asset' + and 'subset'. + + Output will contain version by each instance id. + + Args: + instances (list[CreatedInstance]): Instances for which to get next + versions. + + Returns: + Dict[str, int]: Next versions by instance id. + """ + + return get_next_versions_for_instances( + self.create_context.project_name, instances + ) + class Creator(BaseCreator): """Creator that has more information for artist to show in UI. diff --git a/openpype/pipeline/create/legacy_create.py b/openpype/pipeline/create/legacy_create.py index 7380e9f9c7..50ef274633 100644 --- a/openpype/pipeline/create/legacy_create.py +++ b/openpype/pipeline/create/legacy_create.py @@ -74,12 +74,12 @@ class LegacyCreator(object): if not plugin_settings: return - print(">>> We have preset for {}".format(plugin_name)) + cls.log.debug(">>> We have preset for {}".format(plugin_name)) for option, value in plugin_settings.items(): if option == "enabled" and value is False: - print(" - is disabled by preset") + cls.log.debug(" - is disabled by preset") else: - print(" - setting `{}`: `{}`".format(option, value)) + cls.log.debug(" - setting `{}`: `{}`".format(option, value)) setattr(cls, option, value) def process(self): diff --git a/openpype/pipeline/create/utils.py b/openpype/pipeline/create/utils.py new file mode 100644 index 0000000000..2ef1f02bd6 --- /dev/null +++ b/openpype/pipeline/create/utils.py @@ -0,0 +1,122 @@ +import collections + +from openpype.client import get_assets, get_subsets, get_last_versions + + +def get_last_versions_for_instances( + project_name, instances, use_value_for_missing=False +): + """Get last versions for instances by their asset and subset name. + + Args: + project_name (str): Project name. + instances (list[CreatedInstance]): Instances to get next versions for. + use_value_for_missing (Optional[bool]): Missing values are replaced + with negative value if True. Otherwise None is used. -2 is used + for instances without filled asset or subset name. -1 is used + for missing entities. + + Returns: + dict[str, Union[int, None]]: Last versions by instance id. + """ + + output = { + instance.id: -1 if use_value_for_missing else None + for instance in instances + } + subset_names_by_asset_name = collections.defaultdict(set) + instances_by_hierarchy = {} + for instance in instances: + asset_name = instance.data.get("asset") + subset_name = instance.subset_name + if not asset_name or not subset_name: + if use_value_for_missing: + output[instance.id] = -2 + continue + + ( + instances_by_hierarchy + .setdefault(asset_name, {}) + .setdefault(subset_name, []) + .append(instance) + ) + subset_names_by_asset_name[asset_name].add(subset_name) + + subset_names = set() + for names in subset_names_by_asset_name.values(): + subset_names |= names + + if not subset_names: + return output + + asset_docs = get_assets( + project_name, + asset_names=subset_names_by_asset_name.keys(), + fields=["name", "_id"] + ) + asset_names_by_id = { + asset_doc["_id"]: asset_doc["name"] + for asset_doc in asset_docs + } + if not asset_names_by_id: + return output + + subset_docs = get_subsets( + project_name, + asset_ids=asset_names_by_id.keys(), + subset_names=subset_names, + fields=["_id", "name", "parent"] + ) + subset_docs_by_id = {} + for subset_doc in subset_docs: + # Filter subset docs by subset names under parent + asset_id = subset_doc["parent"] + asset_name = asset_names_by_id[asset_id] + subset_name = subset_doc["name"] + if subset_name not in subset_names_by_asset_name[asset_name]: + continue + subset_docs_by_id[subset_doc["_id"]] = subset_doc + + if not subset_docs_by_id: + return output + + last_versions_by_subset_id = get_last_versions( + project_name, + subset_docs_by_id.keys(), + fields=["name", "parent"] + ) + for subset_id, version_doc in last_versions_by_subset_id.items(): + subset_doc = subset_docs_by_id[subset_id] + asset_id = subset_doc["parent"] + asset_name = asset_names_by_id[asset_id] + _instances = instances_by_hierarchy[asset_name][subset_doc["name"]] + for instance in _instances: + output[instance.id] = version_doc["name"] + + return output + + +def get_next_versions_for_instances(project_name, instances): + """Get next versions for instances by their asset and subset name. + + Args: + project_name (str): Project name. + instances (list[CreatedInstance]): Instances to get next versions for. + + Returns: + dict[str, Union[int, None]]: Next versions by instance id. Version is + 'None' if instance has no asset or subset name. + """ + + last_versions = get_last_versions_for_instances( + project_name, instances, True) + + output = {} + for instance_id, version in last_versions.items(): + if version == -2: + output[instance_id] = None + elif version == -1: + output[instance_id] = 1 + else: + output[instance_id] = version + 1 + return output diff --git a/openpype/pipeline/delivery.py b/openpype/pipeline/delivery.py index 500f54040a..ddde45d4da 100644 --- a/openpype/pipeline/delivery.py +++ b/openpype/pipeline/delivery.py @@ -157,6 +157,8 @@ def deliver_single_file( delivery_path = delivery_path.replace("..", ".") # Make sure path is valid for all platforms delivery_path = os.path.normpath(delivery_path.replace("\\", "/")) + # Remove newlines from the end of the string to avoid OSError during copy + delivery_path = delivery_path.rstrip() delivery_folder = os.path.dirname(delivery_path) if not os.path.exists(delivery_folder): diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 3834524faa..76ee5832d6 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -831,8 +831,6 @@ def attach_instances_to_subset(attach_to, instances): list: List of attached instances. """ - # - new_instances = [] for attach_instance in attach_to: for i in instances: diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index e9ac0df924..7320a9f0e8 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -4,6 +4,8 @@ from .utils import ( LoadError, IncompatibleLoaderError, InvalidRepresentationContext, + LoaderSwitchNotImplementedError, + LoaderNotFoundError, get_repres_contexts, get_contexts_for_repre_docs, @@ -55,6 +57,8 @@ __all__ = ( "LoadError", "IncompatibleLoaderError", "InvalidRepresentationContext", + "LoaderSwitchNotImplementedError", + "LoaderNotFoundError", "get_repres_contexts", "get_contexts_for_repre_docs", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index fefdb8537b..2c40280ccd 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -79,6 +79,16 @@ class InvalidRepresentationContext(ValueError): pass +class LoaderSwitchNotImplementedError(NotImplementedError): + """Error when `switch` is used with Loader that has no implementation.""" + pass + + +class LoaderNotFoundError(RuntimeError): + """Error when Loader plugin for a loader name is not found.""" + pass + + def get_repres_contexts(representation_ids, dbcon=None): """Return parenthood context for representation. @@ -432,7 +442,10 @@ def remove_container(container): Loader = _get_container_loader(container) if not Loader: - raise RuntimeError("Can't remove container. See log for details.") + raise LoaderNotFoundError( + "Can't remove container because loader '{}' was not found." + .format(container.get("loader")) + ) loader = Loader(get_representation_context(container["representation"])) return loader.remove(container) @@ -480,7 +493,10 @@ def update_container(container, version=-1): # Run update on the Loader for this container Loader = _get_container_loader(container) if not Loader: - raise RuntimeError("Can't update container. See log for details.") + raise LoaderNotFoundError( + "Can't update container because loader '{}' was not found." + .format(container.get("loader")) + ) loader = Loader(get_representation_context(container["representation"])) return loader.update(container, new_representation) @@ -502,15 +518,18 @@ def switch_container(container, representation, loader_plugin=None): loader_plugin = _get_container_loader(container) if not loader_plugin: - raise RuntimeError("Can't switch container. See log for details.") + raise LoaderNotFoundError( + "Can't switch container because loader '{}' was not found." + .format(container.get("loader")) + ) if not hasattr(loader_plugin, "switch"): # Backwards compatibility (classes without switch support # might be better to just have "switch" raise NotImplementedError # on the base class of Loader\ - raise RuntimeError("Loader '{}' does not support 'switch'".format( - loader_plugin.label - )) + raise LoaderSwitchNotImplementedError( + "Loader {} does not support 'switch'".format(loader_plugin.label) + ) # Get the new representation to switch to project_name = legacy_io.active_project() @@ -520,7 +539,11 @@ def switch_container(container, representation, loader_plugin=None): new_context = get_representation_context(new_representation) if not is_compatible_loader(loader_plugin, new_context): - raise AssertionError("Must be compatible Loader") + raise IncompatibleLoaderError( + "Loader {} is incompatible with {}".format( + loader_plugin.__name__, new_context["subset"]["name"] + ) + ) loader = loader_plugin(new_context) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index a03303bc39..2768fe3fa1 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -577,12 +577,14 @@ def remote_publish(log, close_plugin_name=None, raise_error=False): raise RuntimeError(error_message) -def get_errored_instances_from_context(context): +def get_errored_instances_from_context(context, plugin=None): """Collect failed instances from pyblish context. Args: context (pyblish.api.Context): Publish context where we're looking for failed instances. + plugin (pyblish.api.Plugin): If provided then only consider errors + related to that plug-in. Returns: List[pyblish.lib.Instance]: Instances which failed during processing. @@ -594,6 +596,9 @@ def get_errored_instances_from_context(context): # When instance is None we are on the "context" result continue + if plugin is not None and result.get("plugin") != plugin: + continue + if result["error"]: instances.append(result["instance"]) @@ -882,10 +887,11 @@ def get_published_workfile_instance(context): return i -def replace_published_scene(instance, replace_in_path=True): - """Switch work scene for published scene. +def replace_with_published_scene_path(instance, replace_in_path=True): + """Switch work scene path for published scene. If rendering/exporting from published scenes is enabled, this will replace paths from working scene to published scene. + This only works if publish contains workfile instance! Args: instance (pyblish.api.Instance): Pyblish instance. replace_in_path (bool): if True, it will try to find diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index 1eec0760a1..ba3be6397e 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -234,11 +234,9 @@ class RepairAction(pyblish.api.Action): # Get the errored instances self.log.debug("Finding failed instances..") - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) - for instance in instances: + errored_instances = get_errored_instances_from_context(context, + plugin=plugin) + for instance in errored_instances: self.log.debug( "Attempting repair for instance: {} ...".format(instance) ) diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index d1d5659118..4bd4f6e9cf 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -1,4 +1,5 @@ import copy +import platform from collections import defaultdict from qtpy import QtWidgets, QtCore, QtGui @@ -83,6 +84,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self.templates = self._get_templates(self.anatomy) for name, _ in self.templates.items(): dropdown.addItem(name) + if self.templates and platform.system() == "Darwin": + # fix macos QCombobox Style + dropdown.setItemDelegate(QtWidgets.QStyledItemDelegate()) + # update combo box length to longest entry + longest_key = max(self.templates.keys(), key=len) + dropdown.setMinimumContentsLength(len(longest_key)) template_label = QtWidgets.QLabel() template_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) @@ -115,7 +122,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): input_layout.addRow("Representations", repre_checkboxes_layout) btn_delivery = QtWidgets.QPushButton("Deliver") - btn_delivery.setEnabled(bool(dropdown.currentText())) + btn_delivery.setEnabled(False) progress_bar = QtWidgets.QProgressBar(self) progress_bar.setMinimum = 0 @@ -152,6 +159,15 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): btn_delivery.clicked.connect(self.deliver) dropdown.currentIndexChanged.connect(self._update_template_value) + if not self.dropdown.count(): + self.text_area.setVisible(True) + error_message = ( + "No Delivery Templates found!\n" + "Add Template in [project_anatomy/templates/delivery]" + ) + self.text_area.setText(error_message) + self.log.error(error_message.replace("\n", " ")) + def deliver(self): """Main method to loop through all selected representations""" self.progress_bar.setVisible(True) @@ -287,14 +303,17 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self.files_selected, self.size_selected = \ self._get_counts(selected_repres) self.selected_label.setText(self._prepare_label()) + # update delivery button state if any templates found + if self.dropdown.count(): + self.btn_delivery.setEnabled(bool(selected_repres)) def _update_template_value(self, _index=None): """Sets template value to label after selection in dropdown.""" name = self.dropdown.currentText() template_value = self.templates.get(name) if template_value: - self.btn_delivery.setEnabled(True) self.template_label.setText(template_value) + self.btn_delivery.setEnabled(bool(self._get_selected_repres())) def _update_progress(self, uploaded): """Update progress bar after each repre copied.""" diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 41d6cf81fc..e67739e842 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -51,7 +51,8 @@ class ExtractBurnin(publish.Extractor): "aftereffects", "photoshop", "flame", - "houdini" + "houdini", + "max" # "resolve" ] diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index d04893fa7e..f053d1b500 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -49,6 +49,7 @@ class ExtractReview(pyblish.api.InstancePlugin): "maya", "blender", "houdini", + "max", "shell", "hiero", "premiere", diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index a78c5cb7ac..802b964375 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -262,7 +262,8 @@ ], "hosts": [ "maya", - "houdini" + "houdini", + "max" ], "task_types": [], "task_names": [], diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index af0f74d2af..a25775e592 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -421,9 +421,9 @@ }, "workfile": { "enabled": false, - "renderSpace": "ACEScg", - "displayName": "sRGB", - "viewName": "ACES 1.0 SDR-video" + "renderSpace": "ACES - ACEScg", + "displayName": "ACES", + "viewName": "sRGB" }, "colorManagementPreference_v2": { "enabled": true, diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 4aaad38bbc..ce2272df57 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -19,6 +19,9 @@ from openpype.pipeline.load import ( switch_container, get_repres_contexts, loaders_from_repre_context, + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError ) from .widgets import ( @@ -1298,19 +1301,28 @@ class SwitchAssetDialog(QtWidgets.QDialog): else: repre_doc = repres_by_name[container_repre_name] + error = None try: switch_container(container, repre_doc, loader) + except ( + LoaderSwitchNotImplementedError, + IncompatibleLoaderError, + LoaderNotFoundError, + ) as exc: + error = str(exc) except Exception: - msg = ( + error = ( + "Switch asset failed. " + "Search console log for more details." + ) + if error is not None: + log.warning(( "Couldn't switch asset." "See traceback for more information." - ) - log.warning(msg, exc_info=True) + ), exc_info=True) dialog = QtWidgets.QMessageBox(self) dialog.setWindowTitle("Switch asset failed") - dialog.setText( - "Switch asset failed. Search console log for more details" - ) + dialog.setText(error) dialog.exec_() self.switched.emit() diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 73d33392b9..57e6e24411 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -1,5 +1,6 @@ import collections import logging +import itertools from functools import partial from qtpy import QtWidgets, QtCore @@ -195,20 +196,17 @@ class SceneInventoryView(QtWidgets.QTreeView): version_name_by_id[version_doc["_id"]] = \ version_doc["name"] + # Specify version per item to update to + update_items = [] + update_versions = [] for item in items: repre_id = item["representation"] version_id = version_id_by_repre_id.get(repre_id) version_name = version_name_by_id.get(version_id) if version_name is not None: - try: - update_container(item, version_name) - except AssertionError: - self._show_version_error_dialog( - version_name, [item] - ) - log.warning("Update failed", exc_info=True) - - self.data_changed.emit() + update_items.append(item) + update_versions.append(version_name) + self._update_containers(update_items, update_versions) update_icon = qtawesome.icon( "fa.asterisk", @@ -225,16 +223,6 @@ class SceneInventoryView(QtWidgets.QTreeView): update_to_latest_action = None if has_outdated or has_loaded_hero_versions: - # update to latest version - def _on_update_to_latest(items): - for item in items: - try: - update_container(item, -1) - except AssertionError: - self._show_version_error_dialog(None, [item]) - log.warning("Update failed", exc_info=True) - self.data_changed.emit() - update_icon = qtawesome.icon( "fa.angle-double-up", color=DEFAULT_COLOR @@ -245,21 +233,11 @@ class SceneInventoryView(QtWidgets.QTreeView): menu ) update_to_latest_action.triggered.connect( - lambda: _on_update_to_latest(items) + lambda: self._update_containers(items, version=-1) ) change_to_hero = None if has_available_hero_version: - # change to hero version - def _on_update_to_hero(items): - for item in items: - try: - update_container(item, HeroVersionType(-1)) - except AssertionError: - self._show_version_error_dialog('hero', [item]) - log.warning("Update failed", exc_info=True) - self.data_changed.emit() - # TODO change icon change_icon = qtawesome.icon( "fa.asterisk", @@ -271,7 +249,8 @@ class SceneInventoryView(QtWidgets.QTreeView): menu ) change_to_hero.triggered.connect( - lambda: _on_update_to_hero(items) + lambda: self._update_containers(items, + version=HeroVersionType(-1)) ) # set version @@ -740,14 +719,7 @@ class SceneInventoryView(QtWidgets.QTreeView): if label: version = versions_by_label[label] - for item in items: - try: - update_container(item, version) - except AssertionError: - self._show_version_error_dialog(version, [item]) - log.warning("Update failed", exc_info=True) - # refresh model when done - self.data_changed.emit() + self._update_containers(items, version) def _show_switch_dialog(self, items): """Display Switch dialog""" @@ -782,9 +754,9 @@ class SceneInventoryView(QtWidgets.QTreeView): Args: version: str or int or None """ - if not version: + if version == -1: version_str = "latest" - elif version == "hero": + elif isinstance(version, HeroVersionType): version_str = "hero" elif isinstance(version, int): version_str = "v{:03d}".format(version) @@ -841,10 +813,43 @@ class SceneInventoryView(QtWidgets.QTreeView): return # Trigger update to latest - for item in outdated_items: - try: - update_container(item, -1) - except AssertionError: - self._show_version_error_dialog(None, [item]) - log.warning("Update failed", exc_info=True) - self.data_changed.emit() + self._update_containers(outdated_items, version=-1) + + def _update_containers(self, items, version): + """Helper to update items to given version (or version per item) + + If at least one item is specified this will always try to refresh + the inventory even if errors occurred on any of the items. + + Arguments: + items (list): Items to update + version (int or list): Version to set to. + This can be a list specifying a version for each item. + Like `update_container` version -1 sets the latest version + and HeroTypeVersion instances set the hero version. + + """ + + if isinstance(version, (list, tuple)): + # We allow a unique version to be specified per item. In that case + # the length must match with the items + assert len(items) == len(version), ( + "Number of items mismatches number of versions: " + "{} items - {} versions".format(len(items), len(version)) + ) + versions = version + else: + # Repeat the same version infinitely + versions = itertools.repeat(version) + + # Trigger update to latest + try: + for item, item_version in zip(items, versions): + try: + update_container(item, item_version) + except AssertionError: + self._show_version_error_dialog(item_version, [item]) + log.warning("Update failed", exc_info=True) + finally: + # Always update the scene inventory view, even if errors occurred + self.data_changed.emit() diff --git a/openpype/version.py b/openpype/version.py index c19af05373..cdd546c4a0 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.12-nightly.1" +__version__ = "3.15.12-nightly.4"