diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9fb7bbc66c..e3ca8262e5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.2-nightly.2 - 3.17.2-nightly.1 - 3.17.1 - 3.17.1-nightly.3 @@ -134,7 +135,6 @@ body: - 3.14.10-nightly.9 - 3.14.10-nightly.8 - 3.14.10-nightly.7 - - 3.14.10-nightly.6 validations: required: true - type: dropdown diff --git a/openpype/cli.py b/openpype/cli.py index 0df277fb0a..7422f32f13 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -290,11 +290,15 @@ def run(script): "--setup_only", help="Only create dbs, do not run tests", default=None) +@click.option("--mongo_url", + help="MongoDB for testing.", + default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only): + timeout, setup_only, mongo_url): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, - persist, app_variant, timeout, setup_only) + persist, app_variant, timeout, setup_only, + mongo_url) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/hooks/pre_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py index add3a0adaf..e695cf3fe8 100644 --- a/openpype/hooks/pre_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -13,7 +13,7 @@ class OCIOEnvHook(PreLaunchHook): "fusion", "blender", "aftereffects", - "max", + "3dsmax", "houdini", "maya", "nuke", diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index fa41f4374b..25d6568889 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -244,7 +244,7 @@ class BlendLoader(plugin.AssetLoader): for parent in parent_containers: parent.get(AVALON_PROPERTY)["members"] = list(filter( lambda i: i not in members, - parent.get(AVALON_PROPERTY)["members"])) + parent.get(AVALON_PROPERTY).get("members", []))) for attr in attrs: for data in getattr(bpy.data, attr): diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index 39edca4de3..fccd8b2965 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -123,6 +123,9 @@ class CreateSaver(NewCreator): def _imprint(self, tool, data): # Save all data in a "openpype.{key}" = value data + # Instance id is the tool's name so we don't need to imprint as data + data.pop("instance_id", None) + active = data.pop("active", None) if active is not None: # Use active value to set the passthrough state @@ -188,6 +191,10 @@ class CreateSaver(NewCreator): passthrough = attrs["TOOLB_PassThrough"] data["active"] = not passthrough + # Override publisher's UUID generation because tool names are + # already unique in Fusion in a comp + data["instance_id"] = tool.Name + return data def get_pre_create_attr_defs(self): diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 730a627dc3..a0a7dcc2e4 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -187,13 +187,14 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.customize_node_look(instance_node) instance_data["instance_node"] = instance_node.path() + instance_data["instance_id"] = instance_node.path() instance = CreatedInstance( self.family, subset_name, instance_data, self) self._add_instance_to_context(instance) - imprint(instance_node, instance.data_to_store()) + self.imprint(instance_node, instance.data_to_store()) return instance except hou.Error as er: @@ -222,25 +223,41 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase): self.cache_subsets(self.collection_shared_data) for instance in self.collection_shared_data[ "houdini_cached_subsets"].get(self.identifier, []): + + node_data = read(instance) + + # Node paths are always the full node path since that is unique + # Because it's the node's path it's not written into attributes + # but explicitly collected + node_path = instance.path() + node_data["instance_id"] = node_path + node_data["instance_node"] = node_path + created_instance = CreatedInstance.from_existing( - read(instance), self + node_data, self ) self._add_instance_to_context(created_instance) def update_instances(self, update_list): for created_inst, changes in update_list: instance_node = hou.node(created_inst.get("instance_node")) - new_values = { key: changes[key].new_value for key in changes.changed_keys } - imprint( + self.imprint( instance_node, new_values, update=True ) + def imprint(self, node, values, update=False): + # Never store instance node and instance id since that data comes + # from the node's path + values.pop("instance_node", None) + values.pop("instance_id", None) + imprint(node, values, update=update) + def remove_instances(self, instances): """Remove specified instance from the scene. diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 4a150067e1..8b70b3ced7 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,15 +1,35 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" import contextlib +import logging import json from typing import Any, Dict, Union import six +from openpype.pipeline import get_current_project_name, colorspace +from openpype.settings import get_project_settings from openpype.pipeline.context_tools import ( get_current_project, get_current_project_asset) +from openpype.style import load_stylesheet from pymxs import runtime as rt + JSON_PREFIX = "JSON::" +log = logging.getLogger("openpype.hosts.max") + + +def get_main_window(): + """Acquire Max's main window""" + from qtpy import QtWidgets + top_widgets = QtWidgets.QApplication.topLevelWidgets() + name = "QmaxApplicationWindow" + for widget in top_widgets: + if ( + widget.inherits("QMainWindow") + and widget.metaObject().className() == name + ): + return widget + raise RuntimeError('Count not find 3dsMax main window.') def imprint(node_name: str, data: dict) -> bool: @@ -277,6 +297,7 @@ def set_context_setting(): """ reset_scene_resolution() reset_frame_range() + reset_colorspace() def get_max_version(): @@ -292,6 +313,14 @@ def get_max_version(): return max_info[7] +def is_headless(): + """Check if 3dsMax runs in batch mode. + If it returns True, it runs in 3dsbatch.exe + If it returns False, it runs in 3dsmax.exe + """ + return rt.maxops.isInNonInteractiveMode() + + @contextlib.contextmanager def viewport_camera(camera): original = rt.viewport.getCamera() @@ -314,6 +343,51 @@ def set_timeline(frameStart, frameEnd): return rt.animationRange +def reset_colorspace(): + """OCIO Configuration + Supports in 3dsMax 2024+ + + """ + if int(get_max_version()) < 2024: + return + project_name = get_current_project_name() + colorspace_mgr = rt.ColorPipelineMgr + project_settings = get_project_settings(project_name) + + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data: + ocio_config_path = max_config_data["path"] + colorspace_mgr = rt.ColorPipelineMgr + colorspace_mgr.Mode = rt.Name("OCIO_Custom") + colorspace_mgr.OCIOConfigPath = ocio_config_path + + colorspace_mgr.OCIOConfigPath = ocio_config_path + + +def check_colorspace(): + parent = get_main_window() + if parent is None: + log.info("Skipping outdated pop-up " + "because Max main window can't be found.") + if int(get_max_version()) >= 2024: + color_mgr = rt.ColorPipelineMgr + project_name = get_current_project_name() + project_settings = get_project_settings(project_name) + max_config_data = colorspace.get_imageio_config( + project_name, "max", project_settings) + if max_config_data and color_mgr.Mode != rt.Name("OCIO_Custom"): + if not is_headless(): + from openpype.widgets import popup + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Warning: Wrong OCIO Mode") + dialog.setMessage("This scene has wrong OCIO " + "Mode setting.") + dialog.setButtonText("Fix") + dialog.setStyleSheet(load_stylesheet()) + dialog.on_clicked.connect(reset_colorspace) + dialog.show() + def unique_namespace(namespace, format="%02d", prefix="", suffix="", con_suffix="CON"): """Return unique namespace diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index 066cc90039..364f9cd5c5 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -119,6 +119,10 @@ class OpenPypeMenu(object): frame_action.triggered.connect(self.frame_range_callback) openpype_menu.addAction(frame_action) + colorspace_action = QtWidgets.QAction("Set Colorspace", openpype_menu) + colorspace_action.triggered.connect(self.colorspace_callback) + openpype_menu.addAction(colorspace_action) + return openpype_menu def load_callback(self): @@ -148,3 +152,7 @@ class OpenPypeMenu(object): def frame_range_callback(self): """Callback to reset frame range""" return lib.reset_frame_range() + + def colorspace_callback(self): + """Callback to reset colorspace""" + return lib.reset_colorspace() diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index bd680a3d84..e46c4cabe7 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -57,6 +57,9 @@ class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): rt.callbacks.addScript(rt.Name('systemPostNew'), context_setting) + rt.callbacks.addScript(rt.Name('filePostOpen'), + lib.check_colorspace) + def has_unsaved_changes(self): # TODO: how to get it from 3dsmax? return True diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index 2dfa1520a9..a359e61921 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -34,6 +34,12 @@ class CollectRender(pyblish.api.InstancePlugin): files_by_aov.update(aovs) camera = rt.viewport.GetCamera() + if instance.data.get("members"): + camera_list = [member for member in instance.data["members"] + if rt.ClassOf(member) == rt.Camera.Classes] + if camera_list: + camera = camera_list[-1] + instance.data["cameras"] = [camera.name] if camera else None # noqa if "expectedFiles" not in instance.data: @@ -63,6 +69,17 @@ class CollectRender(pyblish.api.InstancePlugin): instance.data["colorspaceConfig"] = "" instance.data["colorspaceDisplay"] = "sRGB" instance.data["colorspaceView"] = "ACES 1.0 SDR-video" + + if int(get_max_version()) >= 2024: + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + instance.data["renderProducts"] = colorspace.ARenderProduct() instance.data["publishJobState"] = "Suspended" instance.data["attachTo"] = [] diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 7aeb45f46b..8e27a857d7 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -4,6 +4,7 @@ import pyblish.api from pymxs import runtime as rt from openpype.lib import BoolDef +from openpype.hosts.max.api.lib import get_max_version from openpype.pipeline.publish import OpenPypePyblishPluginMixin @@ -43,6 +44,17 @@ class CollectReview(pyblish.api.InstancePlugin, "dspSafeFrame": attr_values.get("dspSafeFrame"), "dspFrameNums": attr_values.get("dspFrameNums") } + + if int(get_max_version()) >= 2024: + colorspace_mgr = rt.ColorPipelineMgr # noqa + display = next( + (display for display in colorspace_mgr.GetDisplayList())) + view_transform = next( + (view for view in colorspace_mgr.GetViewList(display))) + instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath + instance.data["colorspaceDisplay"] = display + instance.data["colorspaceView"] = view_transform + # Enable ftrack functionality instance.data.setdefault("families", []).append('ftrack') @@ -54,7 +66,6 @@ class CollectReview(pyblish.api.InstancePlugin, @classmethod def get_attribute_defs(cls): - return [ BoolDef("dspGeometry", label="Geometry", diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 260241f5fc..dbb3578f08 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -6,6 +6,7 @@ from pyblish.api import Instance from maya import cmds # noqa import maya.mel as mel # noqa +from openpype.hosts.maya.api.lib import maintained_selection class FBXExtractor: @@ -53,7 +54,6 @@ class FBXExtractor: "bakeComplexEnd": int, "bakeComplexStep": int, "bakeResampleAnimation": bool, - "animationOnly": bool, "useSceneName": bool, "quaternion": str, # "euler" "shapes": bool, @@ -63,7 +63,10 @@ class FBXExtractor: "embeddedTextures": bool, "inputConnections": bool, "upAxis": str, # x, y or z, - "triangulate": bool + "triangulate": bool, + "fileVersion": str, + "skeletonDefinitions": bool, + "referencedAssetsContent": bool } @property @@ -94,7 +97,6 @@ class FBXExtractor: "bakeComplexEnd": end_frame, "bakeComplexStep": 1, "bakeResampleAnimation": True, - "animationOnly": False, "useSceneName": False, "quaternion": "euler", "shapes": True, @@ -104,7 +106,10 @@ class FBXExtractor: "embeddedTextures": False, "inputConnections": True, "upAxis": "y", - "triangulate": False + "triangulate": False, + "fileVersion": "FBX202000", + "skeletonDefinitions": False, + "referencedAssetsContent": False } def __init__(self, log=None): @@ -198,5 +203,9 @@ class FBXExtractor: path (str): Path to use for export. """ - cmds.select(members, r=True, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace("\\", "/") + with maintained_selection(): + cmds.select(members, r=True, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index a197e5b592..510d4ecc85 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -183,6 +183,51 @@ def maintained_selection(): cmds.select(clear=True) +def get_namespace(node): + """Return namespace of given node""" + node_name = node.rsplit("|", 1)[-1] + if ":" in node_name: + return node_name.rsplit(":", 1)[0] + else: + return "" + + +def strip_namespace(node, namespace): + """Strip given namespace from node path. + + The namespace will only be stripped from names + if it starts with that namespace. If the namespace + occurs within another namespace it's not removed. + + Examples: + >>> strip_namespace("namespace:node", namespace="namespace:") + "node" + >>> strip_namespace("hello:world:node", namespace="hello:world") + "node" + >>> strip_namespace("hello:world:node", namespace="hello") + "world:node" + >>> strip_namespace("hello:world:node", namespace="world") + "hello:world:node" + >>> strip_namespace("ns:group|ns:node", namespace="ns") + "group|node" + + Returns: + str: Node name without given starting namespace. + + """ + + # Ensure namespace ends with `:` + if not namespace.endswith(":"): + namespace = "{}:".format(namespace) + + # The long path for a node can also have the namespace + # in its parents so we need to remove it from each + return "|".join( + name[len(namespace):] if name.startswith(namespace) else name + for name in node.split("|") + ) + + def get_custom_namespace(custom_namespace): """Return unique namespace. @@ -922,7 +967,7 @@ def no_display_layers(nodes): @contextlib.contextmanager -def namespaced(namespace, new=True): +def namespaced(namespace, new=True, relative_names=None): """Work inside namespace during context Args: @@ -934,15 +979,19 @@ def namespaced(namespace, new=True): """ original = cmds.namespaceInfo(cur=True, absoluteName=True) + original_relative_names = cmds.namespace(query=True, relativeNames=True) if new: namespace = unique_namespace(namespace) cmds.namespace(add=namespace) - + if relative_names is not None: + cmds.namespace(relativeNames=relative_names) try: cmds.namespace(set=namespace) yield namespace finally: cmds.namespace(set=original) + if relative_names is not None: + cmds.namespace(relativeNames=original_relative_names) @contextlib.contextmanager @@ -4100,14 +4149,19 @@ def create_rig_animation_instance( """ if options is None: options = {} - + name = context["representation"]["name"] output = next((node for node in nodes if node.endswith("out_SET")), None) controls = next((node for node in nodes if node.endswith("controls_SET")), None) + if name != "fbx": + assert output, "No out_SET in rig, this is a bug." + assert controls, "No controls_SET in rig, this is a bug." - assert output, "No out_SET in rig, this is a bug." - assert controls, "No controls_SET in rig, this is a bug." + anim_skeleton = next((node for node in nodes if + node.endswith("skeletonAnim_SET")), None) + skeleton_mesh = next((node for node in nodes if + node.endswith("skeletonMesh_SET")), None) # Find the roots amongst the loaded nodes roots = ( @@ -4119,9 +4173,7 @@ def create_rig_animation_instance( custom_subset = options.get("animationSubsetName") if custom_subset: formatting_data = { - # TODO remove 'asset_type' and replace 'asset_name' with 'asset' - "asset_name": context['asset']['name'], - "asset_type": context['asset']['type'], + "asset": context["asset"], "subset": context['subset']['name'], "family": ( context['subset']['data'].get('family') or @@ -4142,10 +4194,12 @@ def create_rig_animation_instance( host = registered_host() create_context = CreateContext(host) - # Create the animation instance + rig_sets = [output, controls, anim_skeleton, skeleton_mesh] + # Remove sets that this particular rig does not have + rig_sets = [s for s in rig_sets if s is not None] with maintained_selection(): - cmds.select([output, controls] + roots, noExpand=True) + cmds.select(rig_sets + roots, noExpand=True) create_context.create( creator_identifier=creator_identifier, variant=namespace, diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 79fcf9bc8b..8468985178 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -151,6 +151,7 @@ class MayaCreatorBase(object): # We never store the instance_node as value on the node since # it's the node name itself data.pop("instance_node", None) + data.pop("instance_id", None) # Don't store `families` since it's up to the creator itself # to define the initial publish families - not a stored attribute of @@ -227,6 +228,7 @@ class MayaCreatorBase(object): # Explicitly re-parse the node name node_data["instance_node"] = node + node_data["instance_id"] = node # If the creator plug-in specifies families = self.get_publish_families() diff --git a/openpype/hosts/maya/plugins/create/create_rig.py b/openpype/hosts/maya/plugins/create/create_rig.py index 345ab6c00d..acd5c98f89 100644 --- a/openpype/hosts/maya/plugins/create/create_rig.py +++ b/openpype/hosts/maya/plugins/create/create_rig.py @@ -20,6 +20,13 @@ class CreateRig(plugin.MayaCreator): instance_node = instance.get("instance_node") self.log.info("Creating Rig instance set up ...") + # TODO:change name (_controls_SET -> _rigs_SET) controls = cmds.sets(name=subset_name + "_controls_SET", empty=True) + # TODO:change name (_out_SET -> _geo_SET) pointcache = cmds.sets(name=subset_name + "_out_SET", empty=True) - cmds.sets([controls, pointcache], forceElement=instance_node) + skeleton = cmds.sets( + name=subset_name + "_skeletonAnim_SET", empty=True) + skeleton_mesh = cmds.sets( + name=subset_name + "_skeletonMesh_SET", empty=True) + cmds.sets([controls, pointcache, + skeleton, skeleton_mesh], forceElement=instance_node) diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index 981b9ef434..0781735bc4 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -1,4 +1,46 @@ import openpype.hosts.maya.api.plugin +import maya.cmds as cmds + + +def _process_reference(file_url, name, namespace, options): + """Load files by referencing scene in Maya. + + Args: + file_url (str): fileapth of the objects to be loaded + name (str): subset name + namespace (str): namespace + options (dict): dict of storing the param + + Returns: + list: list of object nodes + """ + from openpype.hosts.maya.api.lib import unique_namespace + # Get name from asset being loaded + # Assuming name is subset name from the animation, we split the number + # suffix from the name to ensure the namespace is unique + name = name.split("_")[0] + ext = file_url.split(".")[-1] + namespace = unique_namespace( + "{}_".format(name), + format="%03d", + suffix="_{}".format(ext) + ) + + 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 + + nodes = cmds.file(file_url, + namespace=namespace, + sharedReferenceFile=False, + groupReference=attach_to_root, + groupName=group_name, + reference=True, + returnNewNodes=True) + return nodes class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -16,44 +58,42 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, options): - import maya.cmds as cmds - from openpype.hosts.maya.api.lib import unique_namespace - cmds.loadPlugin("AbcImport.mll", quiet=True) - # Prevent identical alembic nodes from being shared - # Create unique namespace for the cameras - - # Get name from asset being loaded - # Assuming name is subset name from the animation, we split the number - # suffix from the name to ensure the namespace is unique - name = name.split("_")[0] - namespace = unique_namespace( - "{}_".format(name), - format="%03d", - 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) file_url = self.prepare_root_value(path, context["project"]["name"]) - nodes = cmds.file(file_url, - namespace=namespace, - sharedReferenceFile=False, - groupReference=attach_to_root, - groupName=group_name, - reference=True, - returnNewNodes=True) + nodes = _process_reference(file_url, name, namespace, options) # load colorbleed ID attribute self[:] = nodes return nodes + + +class FbxLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): + """Loader to reference an Fbx files""" + + families = ["animation", + "camera"] + representations = ["fbx"] + + label = "Reference animation" + order = -10 + icon = "code-fork" + color = "orange" + + def process_reference(self, context, name, namespace, options): + + cmds.loadPlugin("fbx4maya.mll", quiet=True) + + path = self.filepath_from_context(context) + file_url = self.prepare_root_value(path, + context["project"]["name"]) + + nodes = _process_reference(file_url, name, namespace, options) + + self[:] = nodes + + return nodes diff --git a/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py new file mode 100644 index 0000000000..aef8765e9c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_fbx_animation.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api +from openpype.pipeline import OptionalPyblishPluginMixin + + +class CollectFbxAnimation(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Collect Animated Rig Data for FBX Extractor.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Fbx Animation" + hosts = ["maya"] + families = ["animation"] + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + skeleton_sets = [ + i for i in instance + if i.endswith("skeletonAnim_SET") + ] + if not skeleton_sets: + return + + instance.data["families"].append("animation.fbx") + instance.data["animated_skeleton"] = [] + for skeleton_set in skeleton_sets: + skeleton_content = cmds.sets(skeleton_set, query=True) + self.log.debug( + "Collected animated skeleton data: {}".format( + skeleton_content + )) + if skeleton_content: + instance.data["animated_skeleton"] = skeleton_content diff --git a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py index 36a4211af1..34ff26a8b8 100644 --- a/openpype/hosts/maya/plugins/publish/collect_rig_sets.py +++ b/openpype/hosts/maya/plugins/publish/collect_rig_sets.py @@ -22,7 +22,8 @@ class CollectRigSets(pyblish.api.InstancePlugin): def process(self, instance): # Find required sets by suffix - searching = {"controls_SET", "out_SET"} + searching = {"controls_SET", "out_SET", + "skeletonAnim_SET", "skeletonMesh_SET"} found = {} for node in cmds.ls(instance, exactType="objectSet"): for suffix in searching: diff --git a/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py new file mode 100644 index 0000000000..31f0eca88c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_skeleton_mesh.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api + + +class CollectSkeletonMesh(pyblish.api.InstancePlugin): + """Collect Static Rig Data for FBX Extractor.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Skeleton Mesh" + hosts = ["maya"] + families = ["rig"] + + def process(self, instance): + skeleton_mesh_set = instance.data["rig_sets"].get( + "skeletonMesh_SET") + if not skeleton_mesh_set: + self.log.debug( + "No skeletonMesh_SET found. " + "Skipping collecting of skeleton mesh..." + ) + return + + # Store current frame to ensure single frame export + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame + + instance.data["skeleton_mesh"] = [] + + skeleton_mesh_content = cmds.sets( + skeleton_mesh_set, query=True) or [] + if not skeleton_mesh_content: + self.log.debug( + "No object nodes in skeletonMesh_SET. " + "Skipping collecting of skeleton mesh..." + ) + return + instance.data["families"] += ["rig.fbx"] + instance.data["skeleton_mesh"] = skeleton_mesh_content + self.log.debug( + "Collected skeletonMesh_SET members: {}".format( + skeleton_mesh_content + )) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py new file mode 100644 index 0000000000..8288bc9329 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_fbx_animation.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.maya.api import fbx +from openpype.hosts.maya.api.lib import ( + namespaced, get_namespace, strip_namespace +) + + +class ExtractFBXAnimation(publish.Extractor): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Animation (FBX)" + hosts = ["maya"] + families = ["animation.fbx"] + + def process(self, instance): + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + path = path.replace("\\", "/") + + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_members = instance.data.get("animated_skeleton", []) + # Export + instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True + instance.data["referencedAssetsContent"] = True + fbx_exporter.set_options_from_instance(instance) + # Export from the rig's namespace so that the exported + # FBX does not include the namespace but preserves the node + # names as existing in the rig workfile + namespace = get_namespace(out_members[0]) + relative_out_members = [ + strip_namespace(node, namespace) for node in out_members + ] + with namespaced( + ":" + namespace, + new=False, + relative_names=True + ) as namespace: + fbx_exporter.export(relative_out_members, path) + + representations = instance.data.setdefault("representations", []) + representations.append({ + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir + }) + + self.log.debug( + "Extracted FBX animation to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py new file mode 100644 index 0000000000..50c1fb3bde --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_skeleton_mesh.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +import os + +from maya import cmds # noqa +import pyblish.api + +from openpype.pipeline import publish +from openpype.pipeline.publish import OptionalPyblishPluginMixin +from openpype.hosts.maya.api import fbx + + +class ExtractSkeletonMesh(publish.Extractor, + OptionalPyblishPluginMixin): + """Extract Rig in FBX format from Maya. + + This extracts the rig in fbx with the constraints + and referenced asset content included. + This also optionally extract animated rig in fbx with + geometries included. + + """ + order = pyblish.api.ExtractorOrder + label = "Extract Skeleton Mesh" + hosts = ["maya"] + families = ["rig.fbx"] + + def process(self, instance): + if not self.is_active(instance.data): + return + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + fbx_exporter = fbx.FBXExtractor(log=self.log) + out_set = instance.data.get("skeleton_mesh", []) + + instance.data["constraints"] = True + instance.data["skeletonDefinitions"] = True + + fbx_exporter.set_options_from_instance(instance) + + # Export + fbx_exporter.export(out_set, path) + + representations = instance.data.setdefault("representations", []) + representations.append({ + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir + }) + + self.log.debug("Extract FBX to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_animated_reference.py b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py new file mode 100644 index 0000000000..4537892d6d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_animated_reference.py @@ -0,0 +1,66 @@ +import pyblish.api +import openpype.hosts.maya.api.action +from openpype.pipeline.publish import ( + PublishValidationError, + ValidateContentsOrder +) +from maya import cmds + + +class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin): + """Validate all nodes in skeletonAnim_SET are referenced""" + + order = ValidateContentsOrder + hosts = ["maya"] + families = ["animation.fbx"] + label = "Animated Reference Rig" + accepted_controllers = ["transform", "locator"] + actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + + def process(self, instance): + animated_sets = instance.data.get("animated_skeleton", []) + if not animated_sets: + self.log.debug( + "No nodes found in skeletonAnim_SET. " + "Skipping validation of animated reference rig..." + ) + return + + for animated_reference in animated_sets: + is_referenced = cmds.referenceQuery( + animated_reference, isNodeReferenced=True) + if not bool(is_referenced): + raise PublishValidationError( + "All the content in skeletonAnim_SET" + " should be referenced nodes" + ) + invalid_controls = self.validate_controls(animated_sets) + if invalid_controls: + raise PublishValidationError( + "All the content in skeletonAnim_SET" + " should be transforms" + ) + + @classmethod + def validate_controls(self, set_members): + """Check if the controller set contains only accepted node types. + + Checks if all its set members are within the hierarchy of the root + Checks if the node types of the set members valid + + Args: + set_members: list of nodes of the skeleton_anim_set + hierarchy: list of nodes which reside under the root node + + Returns: + errors (list) + """ + + # Validate control types + invalid = [] + set_members = cmds.ls(set_members, long=True) + for node in set_members: + if cmds.nodeType(node) not in self.accepted_controllers: + invalid.append(node) + + return invalid diff --git a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py index 9f47bf7a3d..cb5c68e4ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_plugin_path_attributes.py @@ -30,18 +30,21 @@ class ValidatePluginPathAttributes(pyblish.api.InstancePlugin): def get_invalid(cls, instance): invalid = list() - file_attr = cls.attribute - if not file_attr: + file_attrs = cls.attribute + if not file_attrs: return invalid # Consider only valid node types to avoid "Unknown object type" warning all_node_types = set(cmds.allNodeTypes()) - node_types = [key for key in file_attr.keys() if key in all_node_types] + node_types = [ + key for key in file_attrs.keys() + if key in all_node_types + ] for node, node_type in pairwise(cmds.ls(type=node_types, showType=True)): # get the filepath - file_attr = "{}.{}".format(node, file_attr[node_type]) + file_attr = "{}.{}".format(node, file_attrs[node_type]) filepath = cmds.getAttr(file_attr) if filepath and not os.path.exists(filepath): diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py index 23f031a5db..106b4024e2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_contents.py @@ -1,6 +1,6 @@ import pyblish.api from maya import cmds - +import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( PublishValidationError, ValidateContentsOrder @@ -20,33 +20,27 @@ class ValidateRigContents(pyblish.api.InstancePlugin): label = "Rig Contents" hosts = ["maya"] families = ["rig"] + action = [openpype.hosts.maya.api.action.SelectInvalidAction] accepted_output = ["mesh", "transform"] accepted_controllers = ["transform"] def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError( + "Invalid rig content. See log for details.") + + @classmethod + def get_invalid(cls, instance): # Find required sets by suffix - required = ["controls_SET", "out_SET"] - missing = [ - key for key in required if key not in instance.data["rig_sets"] - ] - if missing: - raise PublishValidationError( - "%s is missing sets: %s" % (instance, ", ".join(missing)) - ) + required, rig_sets = cls.get_nodes(instance) - controls_set = instance.data["rig_sets"]["controls_SET"] - out_set = instance.data["rig_sets"]["out_SET"] + cls.validate_missing_objectsets(instance, required, rig_sets) - # Ensure there are at least some transforms or dag nodes - # in the rig instance - set_members = instance.data['setMembers'] - if not cmds.ls(set_members, type="dagNode", long=True): - raise PublishValidationError( - "No dag nodes in the pointcache instance. " - "(Empty instance?)" - ) + controls_set = rig_sets["controls_SET"] + out_set = rig_sets["out_SET"] # Ensure contents in sets and retrieve long path for all objects output_content = cmds.sets(out_set, query=True) or [] @@ -61,49 +55,92 @@ class ValidateRigContents(pyblish.api.InstancePlugin): ) controls_content = cmds.ls(controls_content, long=True) - # Validate members are inside the hierarchy from root node - root_nodes = cmds.ls(set_members, assemblies=True, long=True) - hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, - fullPath=True) + root_nodes - hierarchy = set(hierarchy) - - invalid_hierarchy = [] - for node in output_content: - if node not in hierarchy: - invalid_hierarchy.append(node) - for node in controls_content: - if node not in hierarchy: - invalid_hierarchy.append(node) + rig_content = output_content + controls_content + invalid_hierarchy = cls.invalid_hierarchy(instance, rig_content) # Additional validations - invalid_geometry = self.validate_geometry(output_content) - invalid_controls = self.validate_controls(controls_content) + invalid_geometry = cls.validate_geometry(output_content) + invalid_controls = cls.validate_controls(controls_content) error = False if invalid_hierarchy: - self.log.error("Found nodes which reside outside of root group " + cls.log.error("Found nodes which reside outside of root group " "while they are set up for publishing." "\n%s" % invalid_hierarchy) error = True if invalid_controls: - self.log.error("Only transforms can be part of the controls_SET." + cls.log.error("Only transforms can be part of the controls_SET." "\n%s" % invalid_controls) error = True if invalid_geometry: - self.log.error("Only meshes can be part of the out_SET\n%s" + cls.log.error("Only meshes can be part of the out_SET\n%s" % invalid_geometry) error = True - if error: + return invalid_hierarchy + invalid_controls + invalid_geometry + + @classmethod + def validate_missing_objectsets(cls, instance, + required_objsets, rig_sets): + """Validate missing objectsets in rig sets + + Args: + instance (str): instance + required_objsets (list): list of objectset names + rig_sets (list): list of rig sets + + Raises: + PublishValidationError: When the error is raised, it will show + which instance has the missing object sets + """ + missing = [ + key for key in required_objsets if key not in rig_sets + ] + if missing: raise PublishValidationError( - "Invalid rig content. See log for details.") + "%s is missing sets: %s" % (instance, ", ".join(missing)) + ) - def validate_geometry(self, set_members): - """Check if the out set passes the validations + @classmethod + def invalid_hierarchy(cls, instance, content): + """ + Check if all rig set members are within the hierarchy of the rig root - Checks if all its set members are within the hierarchy of the root + Args: + instance (str): instance + content (list): list of content from rig sets + + Raises: + PublishValidationError: It means no dag nodes in + the rig instance + + Returns: + list: invalid hierarchy + """ + # Ensure there are at least some transforms or dag nodes + # in the rig instance + set_members = instance.data['setMembers'] + if not cmds.ls(set_members, type="dagNode", long=True): + raise PublishValidationError( + "No dag nodes in the rig instance. " + "(Empty instance?)" + ) + # Validate members are inside the hierarchy from root node + root_nodes = cmds.ls(set_members, assemblies=True, long=True) + hierarchy = cmds.listRelatives(root_nodes, allDescendents=True, + fullPath=True) + root_nodes + hierarchy = set(hierarchy) + invalid_hierarchy = [] + for node in content: + if node not in hierarchy: + invalid_hierarchy.append(node) + return invalid_hierarchy + + @classmethod + def validate_geometry(cls, set_members): + """ Checks if the node types of the set members valid Args: @@ -122,15 +159,13 @@ class ValidateRigContents(pyblish.api.InstancePlugin): fullPath=True) or [] all_shapes = cmds.ls(set_members + shapes, long=True, shapes=True) for shape in all_shapes: - if cmds.nodeType(shape) not in self.accepted_output: + if cmds.nodeType(shape) not in cls.accepted_output: invalid.append(shape) - return invalid - - def validate_controls(self, set_members): - """Check if the controller set passes the validations - - Checks if all its set members are within the hierarchy of the root + @classmethod + def validate_controls(cls, set_members): + """ + Checks if the control set members are allowed node types. Checks if the node types of the set members valid Args: @@ -144,7 +179,80 @@ class ValidateRigContents(pyblish.api.InstancePlugin): # Validate control types invalid = [] for node in set_members: - if cmds.nodeType(node) not in self.accepted_controllers: + if cmds.nodeType(node) not in cls.accepted_controllers: invalid.append(node) return invalid + + @classmethod + def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + tuple: 2-tuple of list of objectsets, + list of rig sets nodes + """ + objectsets = ["controls_SET", "out_SET"] + rig_sets_nodes = instance.data.get("rig_sets", []) + return objectsets, rig_sets_nodes + + +class ValidateSkeletonRigContents(ValidateRigContents): + """Ensure skeleton rigs contains pipeline-critical content + + The rigs optionally contain at least two object sets: + "skeletonMesh_SET" - Set of the skinned meshes + with bone hierarchies + + """ + + order = ValidateContentsOrder + label = "Skeleton Rig Contents" + hosts = ["maya"] + families = ["rig.fbx"] + + @classmethod + def get_invalid(cls, instance): + objectsets, skeleton_mesh_nodes = cls.get_nodes(instance) + cls.validate_missing_objectsets( + instance, objectsets, instance.data["rig_sets"]) + + # Ensure contents in sets and retrieve long path for all objects + output_content = instance.data.get("skeleton_mesh", []) + output_content = cmds.ls(skeleton_mesh_nodes, long=True) + + invalid_hierarchy = cls.invalid_hierarchy( + instance, output_content) + invalid_geometry = cls.validate_geometry(output_content) + + error = False + if invalid_hierarchy: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if invalid_geometry: + cls.log.error("Found nodes which reside outside of root group " + "while they are set up for publishing." + "\n%s" % invalid_hierarchy) + error = True + if error: + return invalid_hierarchy + invalid_geometry + + @classmethod + def get_nodes(cls, instance): + """Get the target objectsets and rig sets nodes + + Args: + instance (str): instance + + Returns: + tuple: 2-tuple of list of objectsets, + list of rig sets nodes + """ + objectsets = ["skeletonMesh_SET"] + skeleton_mesh_nodes = instance.data.get("skeleton_mesh", []) + return objectsets, skeleton_mesh_nodes diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index a3828f871b..82248c57b3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -59,7 +59,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - controls_set = instance.data["rig_sets"].get("controls_SET") + controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Must have 'controls_SET' in rig instance" @@ -189,7 +189,7 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - controls_set = instance.data["rig_sets"].get("controls_SET") + controls_set = cls.get_node(instance) if not controls_set: cls.log.error( "Unable to repair because no 'controls_SET' found in rig " @@ -228,3 +228,64 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): default = cls.CONTROLLER_DEFAULTS[attr] cls.log.info("Setting %s to %s" % (plug, default)) cmds.setAttr(plug, default) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from controls_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from controls_SET + """ + return instance.data["rig_sets"].get("controls_SET") + + +class ValidateSkeletonRigControllers(ValidateRigControllers): + """Validate rig controller for skeletonAnim_SET + + Controls must have the transformation attributes on their default + values of translate zero, rotate zero and scale one when they are + unlocked attributes. + + Unlocked keyable attributes may not have any incoming connections. If + these connections are required for the rig then lock the attributes. + + The visibility attribute must be locked. + + Note that `repair` will: + - Lock all visibility attributes + - Reset all default values for translate, rotate, scale + - Break all incoming connections to keyable attributes + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Controllers" + hosts = ["maya"] + families = ["rig.fbx"] + + # Default controller values + CONTROLLER_DEFAULTS = { + "translateX": 0, + "translateY": 0, + "translateZ": 0, + "rotateX": 0, + "rotateY": 0, + "rotateZ": 0, + "scaleX": 1, + "scaleY": 1, + "scaleZ": 1 + } + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index fbd510c683..80ac0f27e6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -46,7 +46,7 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): def get_invalid(cls, instance): """Get all nodes which do not match the criteria""" - out_set = instance.data["rig_sets"].get("out_SET") + out_set = cls.get_node(instance) if not out_set: return [] @@ -85,3 +85,45 @@ class ValidateRigOutSetNodeIds(pyblish.api.InstancePlugin): continue lib.set_id(node, sibling_id, overwrite=True) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutSetNodeIds(ValidateRigOutSetNodeIds): + """Validate if deformed shapes have related IDs to the original shapes + from skeleton set. + + When a deformer is applied in the scene on a referenced mesh that already + had deformers then Maya will create a new shape node for the mesh that + does not have the original id. This validator checks whether the ids are + valid on all the shape nodes in the instance. + + """ + + order = ValidateContentsOrder + families = ["rig.fbx"] + hosts = ['maya'] + label = 'Skeleton Rig Out Set Node Ids' + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get( + "skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index 24fb36eb8b..343d8e6924 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -47,7 +47,7 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): invalid = {} if compute: - out_set = instance.data["rig_sets"].get("out_SET") + out_set = cls.get_node(instance) if not out_set: instance.data["mismatched_output_ids"] = invalid return invalid @@ -115,3 +115,40 @@ class ValidateRigOutputIds(pyblish.api.InstancePlugin): "Multiple matched ids found. Please repair manually: " "{}".format(multiple_ids_match) ) + + @classmethod + def get_node(cls, instance): + """Get target object nodes from out_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from out_SET + """ + return instance.data["rig_sets"].get("out_SET") + + +class ValidateSkeletonRigOutputIds(ValidateRigOutputIds): + """Validate rig output ids from the skeleton sets. + + Ids must share the same id as similarly named nodes in the scene. This is + to ensure the id from the model is preserved through animation. + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Output Ids" + hosts = ["maya"] + families = ["rig.fbx"] + + @classmethod + def get_node(cls, instance): + """Get target object nodes from skeletonMesh_SET + + Args: + instance (str): instance + + Returns: + list: list of object nodes from skeletonMesh_SET + """ + return instance.data["rig_sets"].get("skeletonMesh_SET") diff --git a/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py new file mode 100644 index 0000000000..1dbe1c454c --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeleton_top_group_hierarchy.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Plugin for validating naming conventions.""" +from maya import cmds + +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + OptionalPyblishPluginMixin, + PublishValidationError +) + + +class ValidateSkeletonTopGroupHierarchy(pyblish.api.InstancePlugin, + OptionalPyblishPluginMixin): + """Validates top group hierarchy in the SETs + Make sure the object inside the SETs are always top + group of the hierarchy + + """ + order = ValidateContentsOrder + 0.05 + label = "Skeleton Rig Top Group Hierarchy" + families = ["rig.fbx"] + + def process(self, instance): + invalid = [] + skeleton_mesh_data = instance.data("skeleton_mesh", []) + if skeleton_mesh_data: + invalid = self.get_top_hierarchy(skeleton_mesh_data) + if invalid: + raise PublishValidationError( + "The skeletonMesh_SET includes the object which " + "is not at the top hierarchy: {}".format(invalid)) + + def get_top_hierarchy(self, targets): + targets = cmds.ls(targets, long=True) # ensure long names + non_top_hierarchy_list = [ + target for target in targets if target.count("|") > 2 + ] + return non_top_hierarchy_list diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 07f394ec00..390545b806 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -3425,34 +3425,6 @@ def create_viewer_profile_string(viewer, display=None, path_like=False): return "{} ({})".format(viewer, display) -def get_head_filename_without_hashes(original_path, name): - """Function to get the renamed head filename without frame hashes - To avoid the system being confused on finding the filename with - frame hashes if the head of the filename has the hashed symbol - - Examples: - >>> get_head_filename_without_hashes("render.####.exr", "baking") - render.baking.####.exr - >>> get_head_filename_without_hashes("render.%04d.exr", "tag") - render.tag.%d.exr - >>> get_head_filename_without_hashes("exr.####.exr", "foo") - exr.foo.%04d.exr - - Args: - original_path (str): the filename with frame hashes - name (str): the name of the tags - - Returns: - str: the renamed filename with the tag - """ - filename = os.path.basename(original_path) - - def insert_name(matchobj): - return "{}.{}".format(name, matchobj.group(0)) - - return re.sub(r"(%\d*d)|#+", insert_name, filename) - - def get_filenames_without_hash(filename, frame_start, frame_end): """Get filenames without frame hash i.e. "renderCompositingMain.baking.0001.exr" diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 81841d17be..c39e3c339d 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -39,7 +39,6 @@ from .lib import ( get_view_process_node, get_viewer_config_from_string, deprecated, - get_head_filename_without_hashes, get_filenames_without_hash ) from .pipeline import ( @@ -816,19 +815,20 @@ class ExporterReviewMov(ExporterReview): self.log.info("File info was set...") - self.file = self.fhead + self.name + ".{}".format(self.ext) - if ".{}".format(self.ext) not in VIDEO_EXTENSIONS: - # filename would be with frame hashes if - # the file extension is not in video format - filename = get_head_filename_without_hashes( - self.path_in, self.name) - self.file = filename - # make sure the filename are in - # correct image output format - if ".{}".format(self.ext) not in self.file: - filename_no_ext, _ = os.path.splitext(filename) - self.file = "{}.{}".format(filename_no_ext, self.ext) - + if ".{}".format(self.ext) in VIDEO_EXTENSIONS: + self.file = "{}{}.{}".format( + self.fhead, self.name, self.ext) + else: + # Output is image (or image sequence) + # When the file is an image it's possible it + # has extra information after the `fhead` that + # we want to preserve, e.g. like frame numbers + # or frames hashes like `####` + filename_no_ext = os.path.splitext( + os.path.basename(self.path_in))[0] + after_head = filename_no_ext[len(self.fhead):] + self.file = "{}{}.{}.{}".format( + self.fhead, self.name, after_head, self.ext) self.path = os.path.join( self.staging_dir, self.file).replace("\\", "/") 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 b0f69e8ab8..449a1cc935 100644 --- a/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py +++ b/openpype/hosts/nuke/plugins/publish/collect_nuke_instance_data.py @@ -2,7 +2,7 @@ import nuke import pyblish.api -class CollectNukeInstanceData(pyblish.api.InstancePlugin): +class CollectInstanceData(pyblish.api.InstancePlugin): """Collect Nuke instance data """ diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 63c6e4a0c7..073da3019a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -238,9 +238,10 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, plugin_data["redshift_SeparateAovFiles"] = instance.data.get( "separateAovFiles") if instance.data["cameras"]: - plugin_info["Camera0"] = None - plugin_info["Camera"] = instance.data["cameras"][0] - plugin_info["Camera1"] = instance.data["cameras"][0] + camera = instance.data["cameras"][0] + plugin_info["Camera0"] = camera + plugin_info["Camera"] = camera + plugin_info["Camera1"] = camera self.log.debug("plugin data:{}".format(plugin_data)) plugin_info.update(plugin_data) diff --git a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py index a29acf9823..2c55e7c951 100644 --- a/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py +++ b/openpype/modules/deadline/repository/custom/plugins/Ayon/Ayon.py @@ -96,7 +96,7 @@ class AyonDeadlinePlugin(DeadlinePlugin): for path in exe_list.split(";"): if path.startswith("~"): path = os.path.expanduser(path) - expanded_paths.append(path) + expanded_paths.append(path) exe = FileUtils.SearchFileList(";".join(expanded_paths)) if exe == "": diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 7f1c3b01e2..7adebbbc97 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -213,7 +213,8 @@ class PypeCommands: pass def run_tests(self, folder, mark, pyargs, - test_data_folder, persist, app_variant, timeout, setup_only): + test_data_folder, persist, app_variant, timeout, setup_only, + mongo_url): """ Runs tests from 'folder' @@ -226,6 +227,10 @@ class PypeCommands: end app_variant (str): variant (eg 2020 for AE), empty if use latest installed version + timeout (int): explicit timeout for single test + setup_only (bool): if only preparation steps should be + triggered, no tests (useful for debugging/development) + mongo_url (str): url to Openpype Mongo database """ print("run_tests") if folder: @@ -264,6 +269,15 @@ class PypeCommands: if setup_only: args.extend(["--setup_only", setup_only]) + if mongo_url: + args.extend(["--mongo_url", mongo_url]) + else: + msg = ( + "Either provide uri to MongoDB through environment variable" + " OPENPYPE_MONGO or the command flag --mongo_url" + ) + assert not os.environ.get("OPENPYPE_MONGO"), msg + print("run_tests args: {}".format(args)) import pytest pytest.main(args) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 83ca6fecef..300d63985b 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -707,6 +707,9 @@ "CollectMayaRender": { "sync_workfile_version": false }, + "CollectFbxAnimation": { + "enabled": true + }, "CollectFbxCamera": { "enabled": false }, @@ -1120,6 +1123,11 @@ "optional": true, "active": true }, + "ValidateAnimatedReferenceRig": { + "enabled": true, + "optional": false, + "active": true + }, "ValidateAnimationContent": { "enabled": true, "optional": false, @@ -1140,6 +1148,16 @@ "optional": false, "active": true }, + "ValidateSkeletonRigContents": { + "enabled": true, + "optional": true, + "active": true + }, + "ValidateSkeletonRigControllers": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateSkinclusterDeformerSet": { "enabled": true, "optional": false, @@ -1150,6 +1168,21 @@ "optional": false, "allow_history_only": false }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": false, + "optional": false, + "allow_history_only": false + }, + "ValidateSkeletonRigOutputIds": { + "enabled": false, + "optional": true, + "active": true + }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateCameraAttributes": { "enabled": false, "optional": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 13c00ff183..8a0815c185 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -21,6 +21,20 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectFbxAnimation", + "label": "Collect Fbx Animation", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "collapsible": true, @@ -793,6 +807,10 @@ "key": "ValidateRigControllers", "label": "Validate Rig Controllers" }, + { + "key": "ValidateAnimatedReferenceRig", + "label": "Validate Animated Reference Rig" + }, { "key": "ValidateAnimationContent", "label": "Validate Animation Content" @@ -809,9 +827,51 @@ "key": "ValidateSkeletalMeshHierarchy", "label": "Validate Skeletal Mesh Top Node" }, - { + { + "key": "ValidateSkeletonRigContents", + "label": "Validate Skeleton Rig Contents" + }, + { + "key": "ValidateSkeletonRigControllers", + "label": "Validate Skeleton Rig Controllers" + }, + { "key": "ValidateSkinclusterDeformerSet", "label": "Validate Skincluster Deformer Relationships" + }, + { + "key": "ValidateSkeletonRigOutputIds", + "label": "Validate Skeleton Rig Output Ids" + }, + { + "key": "ValidateSkeletonTopGroupHierarchy", + "label": "Validate Skeleton Top Group Hierarchy" + } + ] + }, + + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ValidateRigOutSetNodeIds", + "label": "Validate Rig Out Set Node Ids", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "allow_history_only", + "label": "Allow history only" } ] }, @@ -819,8 +879,8 @@ "type": "dict", "collapsible": true, "checkbox_key": "enabled", - "key": "ValidateRigOutSetNodeIds", - "label": "Validate Rig Out Set Node Ids", + "key": "ValidateSkeletonRigOutSetNodeIds", + "label": "Validate Skeleton Rig Out Set Node Ids", "is_group": true, "children": [ { diff --git a/openpype/version.py b/openpype/version.py index 8234258f19..399c1404b1 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.2-nightly.1" +__version__ = "3.17.2-nightly.2" diff --git a/server_addon/maya/server/settings/publishers.py b/server_addon/maya/server/settings/publishers.py index bd7ccdf4d5..6c5baa3900 100644 --- a/server_addon/maya/server/settings/publishers.py +++ b/server_addon/maya/server/settings/publishers.py @@ -129,6 +129,10 @@ class CollectMayaRenderModel(BaseSettingsModel): ) +class CollectFbxAnimationModel(BaseSettingsModel): + enabled: bool = Field(title="Collect Fbx Animation") + + class CollectFbxCameraModel(BaseSettingsModel): enabled: bool = Field(title="CollectFbxCamera") @@ -364,6 +368,10 @@ class PublishersModel(BaseSettingsModel): title="Collect Render Layers", section="Collectors" ) + CollectFbxAnimation: CollectFbxAnimationModel = Field( + default_factory=CollectFbxAnimationModel, + title="Collect FBX Animation", + ) CollectFbxCamera: CollectFbxCameraModel = Field( default_factory=CollectFbxCameraModel, title="Collect Camera for FBX export", @@ -644,6 +652,10 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Rig Controllers", ) + ValidateAnimatedReferenceRig: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Animated Reference Rig", + ) ValidateAnimationContent: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Animation Content", @@ -660,14 +672,34 @@ class PublishersModel(BaseSettingsModel): default_factory=BasicValidateModel, title="Validate Skeletal Mesh Top Node", ) + ValidateSkeletonRigContents: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Contents" + ) + ValidateSkeletonRigControllers: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Controllers" + ) ValidateSkinclusterDeformerSet: BasicValidateModel = Field( default_factory=BasicValidateModel, title="Validate Skincluster Deformer Relationships", ) + ValidateSkeletonRigOutputIds: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Rig Output Ids" + ) + ValidateSkeletonTopGroupHierarchy: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Skeleton Top Group Hierarchy", + ) ValidateRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( default_factory=ValidateRigOutSetNodeIdsModel, title="Validate Rig Out Set Node Ids", ) + ValidateSkeletonRigOutSetNodeIds: ValidateRigOutSetNodeIdsModel = Field( + default_factory=ValidateRigOutSetNodeIdsModel, + title="Validate Skeleton Rig Out Set Node Ids", + ) # Rig - END ValidateCameraAttributes: BasicValidateModel = Field( default_factory=BasicValidateModel, @@ -748,6 +780,9 @@ DEFAULT_PUBLISH_SETTINGS = { "CollectMayaRender": { "sync_workfile_version": False }, + "CollectFbxAnimation": { + "enabled": True + }, "CollectFbxCamera": { "enabled": False }, @@ -1143,6 +1178,11 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": True, "active": True }, + "ValidateAnimatedReferenceRig": { + "enabled": True, + "optional": False, + "active": True + }, "ValidateAnimationContent": { "enabled": True, "optional": False, @@ -1163,6 +1203,16 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "active": True }, + "ValidateSkeletonRigContents": { + "enabled": True, + "optional": True, + "active": True + }, + "ValidateSkeletonRigControllers": { + "enabled": False, + "optional": True, + "active": True + }, "ValidateSkinclusterDeformerSet": { "enabled": True, "optional": False, @@ -1173,6 +1223,21 @@ DEFAULT_PUBLISH_SETTINGS = { "optional": False, "allow_history_only": False }, + "ValidateSkeletonRigOutSetNodeIds": { + "enabled": False, + "optional": False, + "allow_history_only": False + }, + "ValidateSkeletonRigOutputIds": { + "enabled": False, + "optional": True, + "active": True + }, + "ValidateSkeletonTopGroupHierarchy": { + "enabled": True, + "optional": True, + "active": True + }, "ValidateCameraAttributes": { "enabled": False, "optional": True, diff --git a/server_addon/maya/server/version.py b/server_addon/maya/server/version.py index e57ad00718..de699158fd 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.3" +__version__ = "0.1.4" diff --git a/tests/conftest.py b/tests/conftest.py index 4f7c17244b..6e82c9917d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,6 +29,11 @@ def pytest_addoption(parser): help="True - only setup test, do not run any tests" ) + parser.addoption( + "--mongo_url", action="store", default=None, + help="Provide url of the Mongo database." + ) + @pytest.fixture(scope="module") def test_data_folder(request): @@ -55,6 +60,11 @@ def setup_only(request): return request.config.getoption("--setup_only") +@pytest.fixture(scope="module") +def mongo_url(request): + return request.config.getoption("--mongo_url") + + @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # execute all other hooks to obtain the report object diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 2af4af02de..e82e438e54 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -147,11 +147,11 @@ class ModuleUnitTest(BaseTest): @pytest.fixture(scope="module") def db_setup(self, download_test_data, env_var, monkeypatch_session, - request): + request, mongo_url): """Restore prepared MongoDB dumps into selected DB.""" backup_dir = os.path.join(download_test_data, "input", "dumps") - uri = os.environ.get("OPENPYPE_MONGO") + uri = mongo_url or os.environ.get("OPENPYPE_MONGO") db_handler = DBHandler(uri) db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir, overwrite=True,