From ff1e8960d4473c8e12dc91c13c427f51c0f1d5f8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 2 Jul 2024 17:01:57 +0200 Subject: [PATCH] Transfer over `enhancement/maya_usd` PR from ayon-core --- client/ayon_maya/api/lib.py | 4 + client/ayon_maya/api/pipeline.py | 47 ++++ client/ayon_maya/api/usdlib.py | 80 +++++++ .../plugins/create/create_maya_usd.py | 152 ++++++++++++- .../plugins/create/create_maya_usd_layer.py | 59 +++++ .../load/load_maya_usd_add_maya_reference.py | 156 +++++++++++++ .../load/load_maya_usd_add_reference.py | 146 ++++++++++++ .../collect_user_defined_attributes.py | 2 +- .../plugins/publish/collect_yeti_rig.py | 39 ++-- .../plugins/publish/extract_maya_usd.py | 208 +++++++++++++++--- .../plugins/publish/extract_maya_usd_layer.py | 63 ++++++ .../publish/validate_instance_has_members.py | 11 +- server/settings/publishers.py | 45 ++++ 13 files changed, 967 insertions(+), 45 deletions(-) create mode 100644 client/ayon_maya/api/usdlib.py create mode 100644 client/ayon_maya/plugins/create/create_maya_usd_layer.py create mode 100644 client/ayon_maya/plugins/load/load_maya_usd_add_maya_reference.py create mode 100644 client/ayon_maya/plugins/load/load_maya_usd_add_reference.py create mode 100644 client/ayon_maya/plugins/publish/extract_maya_usd_layer.py diff --git a/client/ayon_maya/api/lib.py b/client/ayon_maya/api/lib.py index 0242dafc..a74b0338 100644 --- a/client/ayon_maya/api/lib.py +++ b/client/ayon_maya/api/lib.py @@ -1754,6 +1754,10 @@ def get_container_members(container): # Assume it's a container dictionary container = container["objectName"] + if "," in container: + # Assume it's a UFE path - return it as the only member + return [container] + members = cmds.sets(container, query=True) or [] members = cmds.ls(members, long=True, objectsOnly=True) or [] all_members = set(members) diff --git a/client/ayon_maya/api/pipeline.py b/client/ayon_maya/api/pipeline.py index 84268cc6..020129ab 100644 --- a/client/ayon_maya/api/pipeline.py +++ b/client/ayon_maya/api/pipeline.py @@ -361,6 +361,35 @@ def parse_container(container): return data +def parse_usd_prim_container(prim, proxy): + """Parse instance container from UsdPrim if it is marked as one + + Args: + prim (pxr.Usd.Prim): USD Primitive + proxy (str): The maya usd stage proxy shape node the primitive + belongs to. + + Returns: + dict: The container schema data for this container node. + + """ + data = prim.GetCustomDataByKey("ayon") + if not data or not data.get("id") == AYON_CONTAINER_ID: + return + + # Store transient data + data["prim"] = prim + data["proxy"] = proxy + + # Store the maya UFE path as objectName + prim_path = str(prim.GetPath()) + data["objectName"] = "{},{}".format(proxy, prim_path) + data["namespace"] = prim_path + data["name"] = proxy + + return data + + def _ls(): """Yields AYON container node names. @@ -402,6 +431,24 @@ def _maya_iterate(iterator): if value in ids: yield fn_dep.name() + for container in ls_maya_usd_proxy_prims(): + yield container + + +def ls_maya_usd_proxy_prims(): + # TODO: This might be nicer once the Loader API gets a refactor where + # the loaders themselves can return the containers from the scene + if cmds.pluginInfo("mayaUsdPlugin", query=True, loaded=True): + usd_proxies = cmds.ls(type="mayaUsdProxyShape", long=True) + if usd_proxies: + import mayaUsd.ufe + for proxy in usd_proxies: + stage = mayaUsd.ufe.getStage('|world' + proxy) + for prim in stage.TraverseAll(): + container = parse_usd_prim_container(prim, proxy=proxy) + if container: + yield container + def ls(): """Yields containers from active Maya scene diff --git a/client/ayon_maya/api/usdlib.py b/client/ayon_maya/api/usdlib.py new file mode 100644 index 00000000..77bfebbc --- /dev/null +++ b/client/ayon_maya/api/usdlib.py @@ -0,0 +1,80 @@ +from ayon_core.pipeline.constants import AVALON_CONTAINER_ID +from maya import cmds +from pxr import Sdf + + +def remove_spec(spec): + """Delete Sdf.PrimSpec or Sdf.PropertySpec + + Also see: + https://forum.aousd.org/t/api-basics-for-designing-a-manage-edits-editor-for-usd/676/1 # noqa + https://gist.github.com/BigRoy/4d2bf2eef6c6a83f4fda3c58db1489a5 + + """ + if spec.expired: + return + + if isinstance(spec, Sdf.PrimSpec): + # PrimSpec + parent = spec.nameParent + if parent: + view = parent.nameChildren + else: + # Assume PrimSpec is root prim + view = spec.layer.rootPrims + del view[spec.name] + + elif isinstance(spec, Sdf.PropertySpec): + # Relationship and Attribute specs + del spec.owner.properties[spec.name] + else: + raise TypeError(f"Unsupported spec type: {spec}") + + +def iter_ufe_usd_selection(): + """Yield Maya USD Proxy Shape related UFE paths in selection. + + The returned path are the Maya node name joined by a command to the + USD prim path. + + Yields: + str: Path to UFE path in USD stage in selection. + + """ + for path in cmds.ls(selection=True, ufeObjects=True, long=True, + absoluteName=True): + if "," not in path: + continue + + node, ufe_path = path.split(",", 1) + if cmds.nodeType(node) != "mayaUsdProxyShape": + continue + + yield path + + +def containerise_prim(prim, + name, + namespace, + context, + loader): + """Containerise a USD prim. + + Arguments: + prim (pxr.Usd.Prim): The prim to containerise. + name (str): Name to containerize. + namespace (str): Namespace to containerize. + context (dict): Load context (incl. representation). + name (str): Name to containerize. + loader (str): Loader name. + + """ + for key, value in { + "ayon:schema": "openpype:container-2.0", + "ayon:id": AVALON_CONTAINER_ID, + "ayon:name": name, + "ayon:namespace": namespace, + "ayon:loader": loader, + "ayon:representation": context["representation"]["id"], + }.items(): + prim.SetCustomDataByKey(key, str(value)) diff --git a/client/ayon_maya/plugins/create/create_maya_usd.py b/client/ayon_maya/plugins/create/create_maya_usd.py index 19b55384..ccde063a 100644 --- a/client/ayon_maya/plugins/create/create_maya_usd.py +++ b/client/ayon_maya/plugins/create/create_maya_usd.py @@ -2,21 +2,22 @@ from ayon_core.lib import ( BoolDef, EnumDef, - TextDef + TextDef, + UILabelDef, + UISeparatorDef, ) from maya import cmds class CreateMayaUsd(plugin.MayaCreator): - """Create Maya USD Export""" + """Create Maya USD Export from maya scene objects""" identifier = "io.openpype.creators.maya.mayausd" label = "Maya USD" product_type = "usd" icon = "cubes" description = "Create Maya USD Export" - cache = {} def get_publish_families(self): @@ -44,7 +45,15 @@ def get_instance_attr_defs(self): self.cache["jobContextItems"] = job_context_items - defs = lib.collect_animation_defs() + defs = [ + BoolDef("exportAnimationData", + label="Export Animation Data", + tooltip="When disabled no frame range is exported and " + "only the start frame is used to define the " + "static export frame.", + default=True) + ] + defs.extend(lib.collect_animation_defs()) defs.extend([ EnumDef("defaultUSDFormat", label="File format", @@ -53,6 +62,17 @@ def get_instance_attr_defs(self): "usda": "ASCII" }, default="usdc"), + # TODO: Remove note from tooltip when issue is resolved, see: + # https://github.com/Autodesk/maya-usd/issues/3389 + BoolDef("exportRoots", + label="Export as roots", + tooltip=( + "Export the members of the object sets without " + "their parents.\n" + "Note: There's an export bug that when this is " + "enabled MayaUsd fails to export instance meshes" + ), + default=True), BoolDef("stripNamespaces", label="Strip Namespaces", tooltip=( @@ -100,3 +120,127 @@ def get_instance_attr_defs(self): ]) return defs + + +class CreateMayaUsdContribution(CreateMayaUsd): + """ + + When writing a USD as 'contribution' it will be added into what it's + contributing to. It will usually contribute to either the main *asset* + or *shot* but can be customized. + + Usually the contribution is done into a Department Layer, like e.g. + model, rig, look for models and layout, animation, fx, lighting for shots. + + Each department contribution will be 'sublayered' into the departments + contribution. + + """ + + identifier = "io.openpype.creators.maya.mayausd.assetcontribution" + label = "Maya USD Asset Contribution" + product_type = "usd" + icon = "cubes" + description = "Create Maya USD Contribution" + + # default_variants = ["main"] + # TODO: Do not include material for model publish + # TODO: Do only include material + assignments for material publish + # + attribute overrides onto existing geo? (`over`?) + # Define all in `geo` as `over`? + + bootstrap = "asset" + + contribution_asset_layer = None + + def create_template_hierarchy(self, folder_name, variant): + """Create the asset root template to hold the geo for the usd asset. + + Args: + folder_name: Asset name to use for the group + variant: Variant name to use as namespace. + This is needed so separate asset contributions can be + correctly created from a single scene. + + Returns: + list: The root node and geometry group. + + """ + + def set_usd_type(node, value): + attr = "USD_typeName" + if not cmds.attributeQuery(attr, node=node, exists=True): + cmds.addAttr(node, ln=attr, dt="string") + cmds.setAttr(f"{node}.{attr}", value, type="string") + + # Ensure simple unique namespace (add trailing number) + namespace = variant + name = f"{namespace}:{folder_name}" + i = 1 + while cmds.objExists(name): + name = f"{namespace}{i}:{folder_name}" + i += 1 + + # Define template hierarchy {folder_name}/geo + root = cmds.createNode("transform", + name=name, + skipSelect=True) + geo = cmds.createNode("transform", + name="geo", + parent=root, + skipSelect=True) + set_usd_type(geo, "Scope") + # Lock + hide transformations since we're exporting as Scope + for attr in ["tx", "ty", "tz", "rx", "ry", "rz", "sx", "sy", "sz"]: + cmds.setAttr(f"{geo}.{attr}", lock=True, keyable=False) + + return [root, geo] + + def create(self, subset_name, instance_data, pre_create_data): + + # Create template hierarchy + if pre_create_data.get("createTemplateHierarchy", True): + members = [] + if pre_create_data.get("use_selection"): + members = cmds.ls(selection=True, + long=True, + type="dagNode") + + folder_path = instance_data["folderPath"] + folder_name = folder_path.rsplit("/", 1)[-1] + + root, geo = self.create_template_hierarchy( + folder_name=folder_name, + variant=instance_data["variant"] + ) + + if members: + cmds.parent(members, geo) + + # Select root and enable selection just so parent class' + # create adds it to the created instance + cmds.select(root, replace=True, noExpand=True) + pre_create_data["use_selection"] = True + + # Create as if we're the other plug-in so that the instance after + # creation thinks it was created by `CreateMayaUsd` and this Creator + # here is solely used to apply different default values + # TODO: Improve this hack + CreateMayaUsd( + project_settings=self.project_settings, + create_context=self.create_context + ).create( + subset_name, + instance_data, + pre_create_data + ) + + def get_pre_create_attr_defs(self): + defs = super(CreateMayaUsdContribution, + self).get_pre_create_attr_defs() + defs.extend([ + BoolDef("createTemplateHierarchy", + label="Create template hierarchy", + default=True) + ]) + return defs \ No newline at end of file diff --git a/client/ayon_maya/plugins/create/create_maya_usd_layer.py b/client/ayon_maya/plugins/create/create_maya_usd_layer.py new file mode 100644 index 00000000..445b2495 --- /dev/null +++ b/client/ayon_maya/plugins/create/create_maya_usd_layer.py @@ -0,0 +1,59 @@ +from ayon_maya.api import plugin +from ayon_core.lib import EnumDef + + +class CreateMayaUsdLayer(plugin.MayaCreator): + """Create Maya USD Export from `mayaUsdProxyShape` layer""" + + identifier = "io.openpype.creators.maya.mayausdlayer" + label = "Maya USD Export Layer" + family = "usd" + icon = "cubes" + description = "Create mayaUsdProxyShape layer export" + + def get_publish_families(self): + return ["usd", "mayaUsdLayer"] + + def get_instance_attr_defs(self): + + from maya import cmds + import mayaUsd + + # Construct the stage + layer EnumDef from the maya proxies in the + # scene and the Sdf.Layer stack of the Usd.Stage per proxy. + items = [] + for proxy in cmds.ls(type="mayaUsdProxyShape", long=True): + stage = mayaUsd.ufe.getStage("|world{}".format(proxy)) + if not stage: + continue + + for layer in stage.GetLayerStack(includeSessionLayers=False): + + proxy_nice_name = proxy.rsplit("|", 2)[-2] + layer_nice_name = layer.GetDisplayName() + label = "{} -> {}".format(proxy_nice_name, layer_nice_name) + value = ">".join([proxy, layer.identifier]) + + items.append({ + "label": label, + "value": value + }) + + if not items: + # EnumDef is not allowed to be empty + items.append("") + + defs = [ + EnumDef("defaultUSDFormat", + label="File format", + items={ + "usdc": "Binary", + "usda": "ASCII" + }, + default="usdc"), + EnumDef("stageLayerIdentifier", + label="Stage and Layer Identifier", + items=items) + ] + + return defs diff --git a/client/ayon_maya/plugins/load/load_maya_usd_add_maya_reference.py b/client/ayon_maya/plugins/load/load_maya_usd_add_maya_reference.py new file mode 100644 index 00000000..54795ed1 --- /dev/null +++ b/client/ayon_maya/plugins/load/load_maya_usd_add_maya_reference.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +import contextlib + +from ayon_core.pipeline import load +from ayon_maya.api.usdlib import ( + containerise_prim, + iter_ufe_usd_selection +) + +from maya import cmds +import mayaUsd + + +@contextlib.contextmanager +def no_edit_mode(prim, restore_after=True): + """Ensure MayaReference prim is not in edit mode during context""" + pulled_node = mayaUsd.lib.PrimUpdaterManager.readPullInformation(prim) + ufe_path = None + try: + # remove edit state if pulled + if pulled_node: + import mayaUsdUtils + assert mayaUsdUtils.isPulledMayaReference(pulled_node) + cmds.mayaUsdDiscardEdits(pulled_node) + + # Discarding the edits directly selects the prim + # so we can get the UFE path from selection + ufe_path = cmds.ls(selection=True, ufeObjects=True, long=True)[0] + + yield prim, ufe_path, pulled_node + finally: + if restore_after and pulled_node and ufe_path: + cmds.mayaUsdEditAsMaya(ufe_path) + + +class MayaUsdProxyAddMayaReferenceLoader(load.LoaderPlugin): + """Read USD data in a Maya USD Proxy + + TODO: It'd be much easier if this loader would be capable of returning the + available containers in the scene based on the AYON URLs inside a USD + stage. That way we could potentially avoid the need for custom metadata + keys, stay closer to USD native data and rely solely on the + AYON:asset=blue,subset=modelMain,version=1 url + + """ + + product_types = {"*"} + representations = ["*"] + extensions = ["ma", "mb"] + + label = "USD Add Maya Reference" + order = 1 + icon = "code-fork" + color = "orange" + + identifier_key = "ayon_identifier" + + def load(self, context, name=None, namespace=None, options=None): + + selection = list(iter_ufe_usd_selection()) + assert len(selection) == 1, "Select only one PRIM please" + ufe_path = selection[0] + path = self.filepath_from_context(context) + # Make sure we can load the plugin + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + import mayaUsdAddMayaReference + + namespace = "test" + prim = mayaUsdAddMayaReference.createMayaReferencePrim( + ufe_path, + path, + namespace, + # todo: add more of the arguments + # mayaReferencePrimName Nameprim_name, + # groupPrim (3-tuple, group name, type and kind) + # variantSet (2-tuple, variant set name and variant name) + ) + if not prim: + # Failed to add a reference + raise RuntimeError(f"Failed to add a reference at {ufe_path}") + + containerise_prim( + prim, + name=name, + namespace=namespace or "", + context=context, + loader=self.__class__.__name__ + ) + + return prim + + def _update_reference_path(self, prim, filepath): + """Update MayaReference prim 'mayaReference' in nearest prim spec""" + + from pxr import Sdf + + # We want to update the authored opinion in the right place, e.g. + # within a VariantSet if it's authored there. We go through the + # PrimStack to find the first prim spec that authors an opinion + # on the 'mayaReference' attribute where we have permission to + # change it. This could technically mean we're altering it in + # layers that we might not want to (e.g. a published USD file?) + stack = prim.GetPrimStack() + for prim_spec in stack: + if "mayaReference" not in prim_spec.attributes: + # prim spec defines no opinion on mayaRefernce attribute? + continue + + attr = prim_spec.attributes["mayaReference"] + if attr.permission != Sdf.PermissionPublic: + print(f"Not allowed to edit: {attr}") + continue + + if filepath != attr.default: + print( + f"Updating {attr.path} - {attr.default} -> {filepath}") + attr.default = filepath + + # Attribute is either updated or already set to + # the value in that layer + return + + # Just define in the current edit layer? + attr = prim.GetAttribute("mayaReference") + attr.Set(filepath) + + def update(self, container, context): + # type: (dict, dict) -> None + """Update container with specified representation.""" + + prim = container["prim"] + representation = context["representation"] + filepath = self.filepath_from_context(context) + + with no_edit_mode(prim): + self._update_reference_path(prim, filepath) + + # Update representation id + # TODO: Do this in prim spec where we update reference path? + prim.SetCustomDataByKey( + "ayon:representation", str(representation["_id"]) + ) + + def switch(self, container, context): + self.update(container, context) + + def remove(self, container): + # type: (dict) -> None + """Remove loaded container.""" + + from ayon_maya.api.usdlib import remove_spec + + prim = container["prim"] + with no_edit_mode(prim, restore_after=False): + for spec in prim.GetPrimStack(): + remove_spec(spec) diff --git a/client/ayon_maya/plugins/load/load_maya_usd_add_reference.py b/client/ayon_maya/plugins/load/load_maya_usd_add_reference.py new file mode 100644 index 00000000..bfa0c076 --- /dev/null +++ b/client/ayon_maya/plugins/load/load_maya_usd_add_reference.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +import uuid + +from ayon_core.pipeline import load +from ayon_core.pipeline.load import get_representation_path_from_context +from ayon_maya.api.usdlib import ( + containerise_prim, + iter_ufe_usd_selection +) + +from maya import cmds +import mayaUsd + + +class MayaUsdProxyReferenceUsd(load.LoaderPlugin): + """Add a USD Reference into mayaUsdProxyShape + + TODO: It'd be much easier if this loader would be capable of returning the + available containers in the scene based on the AYON URLs inside a USD + stage. That way we could potentially avoid the need the custom + identifier, stay closer to USD native data and rely solely on the + AYON:asset=blue,subset=modelMain,version=1 url + + """ + + product_types = {"model", "usd", "pointcache", "animation"} + representations = ["usd", "usda", "usdc", "usdz", "abc"] + + label = "USD Add Reference" + order = 0 + icon = "code-fork" + color = "orange" + + identifier_key = "ayon_identifier" + + def load(self, context, name=None, namespace=None, options=None): + + from pxr import Sdf + + selection = list(iter_ufe_usd_selection()) + if not selection: + # Create a maya USD proxy with /root prim and add the reference + import mayaUsd_createStageWithNewLayer + from pxr import UsdGeom + + # Make sure we can load the plugin + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + shape = mayaUsd_createStageWithNewLayer.createStageWithNewLayer() + stage = mayaUsd.ufe.getStage('|world' + shape) + prim_path = "/root" + UsdGeom.Xform.Define(stage, prim_path) + root_layer = stage.GetRootLayer() + root_layer.defaultPrim = prim_path + prim = stage.GetPrimAtPath(prim_path) + else: + assert len(selection) == 1, "Select only one PRIM please" + ufe_path = selection[0] + prim = mayaUsd.ufe.ufePathToPrim(ufe_path) + + if not prim: + raise RuntimeError("Invalid primitive") + + # Define reference using Sdf.Reference so we can directly set custom + # data for it + path = get_representation_path_from_context(context) + + references = prim.GetReferences() + + # Add unique containerised data to the reference + identifier = str(prim.GetPath()) + ":" + str(uuid.uuid4()) + identifier_data = {self.identifier_key: identifier} + reference = Sdf.Reference(assetPath=path, + customData=identifier_data) + + success = references.AddReference(reference) + if not success: + raise RuntimeError("Failed to add reference") + + # TODO: We should actually just use the data on the `Sdf.Reference` + # instead of on the USDPrim + container = containerise_prim( + prim, + name=name, + namespace=namespace or "", + context=context, + loader=self.__class__.__name__ + ) + + return container + + def update(self, container, context): + # type: (dict, dict) -> None + """Update container with specified representation.""" + + from pxr import Sdf + + prim = container["prim"] + path = self.filepath_from_context(context) + for references, index in self._get_prim_references(prim): + reference = references[index] + new_reference = Sdf.Reference( + assetPath=path, + customData=reference.customData, + layerOffset=reference.layerOffset, + primPath=reference.primPath + ) + references[index] = new_reference + + # Update representation id + # TODO: Do this in prim spec where we update reference path? + # TODO: Store this in the Sdf.Reference CustomData instead? + prim.SetCustomDataByKey( + "ayon:representation", context["representation"]["id"] + ) + + def switch(self, container, context): + self.update(container, context) + + def remove(self, container): + # type: (dict) -> None + """Remove loaded container.""" + prim = container["prim"] + + # Pop the references from the prepended items list + related_references = reversed(list(self._get_prim_references(prim))) + for references, index in related_references: + references.remove(references[index]) + + prim.ClearCustomDataByKey("ayon") + + def _get_prim_references(self, prim): + + # Get a list of all prepended references + for prim_spec in prim.GetPrimStack(): + if not prim_spec: + continue + + if not prim_spec.hasReferences: + continue + + prepended_items = prim_spec.referenceList.prependedItems + for index, _reference in enumerate(prepended_items): + # Override the matching reference identifier + # TODO: Make sure we only return the correct reference + yield prepended_items, index diff --git a/client/ayon_maya/plugins/publish/collect_user_defined_attributes.py b/client/ayon_maya/plugins/publish/collect_user_defined_attributes.py index e468636d..b8ed79e7 100644 --- a/client/ayon_maya/plugins/publish/collect_user_defined_attributes.py +++ b/client/ayon_maya/plugins/publish/collect_user_defined_attributes.py @@ -13,7 +13,7 @@ class CollectUserDefinedAttributes(plugin.MayaInstancePlugin): def process(self, instance): # Collect user defined attributes. - if not instance.data["creator_attributes"].get( + if not instance.data.get("creator_attributes", {}).get( "includeUserDefinedAttributes" ): return diff --git a/client/ayon_maya/plugins/publish/collect_yeti_rig.py b/client/ayon_maya/plugins/publish/collect_yeti_rig.py index dbdc1078..9cf13e6c 100644 --- a/client/ayon_maya/plugins/publish/collect_yeti_rig.py +++ b/client/ayon_maya/plugins/publish/collect_yeti_rig.py @@ -1,5 +1,6 @@ import os import re +import clique import pyblish.api from ayon_core.pipeline.publish import KnownPublishError @@ -271,22 +272,34 @@ def get_sequence(self, filepath, pattern="%04d"): pattern (str): The pattern to swap with the variable frame number. Returns: - list: file sequence. + Optional[list[str]]: file sequence. """ - import clique - - escaped = re.escape(filepath) - re_pattern = escaped.replace(pattern, "-?[0-9]+") - + filename = os.path.basename(filepath) + re_pattern = re.escape(filename) + re_pattern = re_pattern.replace(re.escape(pattern), "-?[0-9]+") source_dir = os.path.dirname(filepath) - files = [f for f in os.listdir(source_dir) - if re.match(re_pattern, f)] - - pattern = [clique.PATTERNS["frames"]] - collection, remainder = clique.assemble(files, patterns=pattern) - - return collection + files = [f for f in os.listdir(source_dir) if re.match(re_pattern, f)] + if not files: + # Files do not exist, this may not be a problem if e.g. the + # textures were relative paths and we're searching across + # multiple image search paths. + return + + collections, _remainder = clique.assemble( + files, + patterns=[clique.PATTERNS["frames"]], + minimum_items=1) + + if len(collections) > 1: + raise ValueError( + f"Multiple collections found for {collections}. " + "This is a bug.") + + return [ + os.path.join(source_dir, filename) + for filename in collections[0] + ] def _replace_tokens(self, strings): env_re = re.compile(r"\$\{(\w+)\}") diff --git a/client/ayon_maya/plugins/publish/extract_maya_usd.py b/client/ayon_maya/plugins/publish/extract_maya_usd.py index d2bf98af..f13ad175 100644 --- a/client/ayon_maya/plugins/publish/extract_maya_usd.py +++ b/client/ayon_maya/plugins/publish/extract_maya_usd.py @@ -4,9 +4,34 @@ import pyblish.api import six -from ayon_maya.api.lib import maintained_selection + +from ayon_core.pipeline import publish +from ayon_core.lib import BoolDef +from ayon_maya.api.lib import maintained_selection, maintained_time from ayon_maya.api import plugin + from maya import cmds +import maya.api.OpenMaya as om + + +def parse_version(version_str): + """Parse string like '0.26.0' to (0, 26, 0)""" + return tuple(int(v) for v in version_str.split(".")) + + +def get_node_hash(node): + """Return integer MObjectHandle hash code. + + Arguments: + node (str): Maya node path. + + Returns: + int: MObjectHandle.hashCode() + + """ + sel = om.MSelectionList() + sel.add(node) + return om.MObjectHandle(sel.getDependNode(0)).hashCode() @contextlib.contextmanager @@ -43,8 +68,6 @@ def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): # todo: this might be better done with a custom export chaser # see `chaser` argument for `mayaUSDExport` - import maya.api.OpenMaya as om - if not attrs and not attr_prefixes: # context manager does nothing yield @@ -60,16 +83,23 @@ def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): usd_json_attr = "USD_UserExportedAttributesJson" strings = attrs + ["{}*".format(prefix) for prefix in attr_prefixes] context_state = {} + + # Keep track of the processed nodes as a node might appear more than once + # e.g. when there are instances. + processed = set() for node in set(nodes): node_attrs = cmds.listAttr(node, st=strings) if not node_attrs: # Nothing to do for this node continue + hash_code = get_node_hash(node) + if hash_code in processed: + continue + node_attr_data = {} for node_attr in set(node_attrs): node_attr_data[node_attr] = mapping.get(node_attr, {}) - if cmds.attributeQuery(usd_json_attr, node=node, exists=True): existing_node_attr_value = cmds.getAttr( "{}.{}".format(node, usd_json_attr) @@ -81,6 +111,7 @@ def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): existing_node_attr_data = json.loads(existing_node_attr_value) node_attr_data.update(existing_node_attr_data) + processed.add(hash_code) context_state[node] = json.dumps(node_attr_data) sel = om.MSelectionList() @@ -111,7 +142,8 @@ def usd_export_attributes(nodes, attrs=None, attr_prefixes=None, mapping=None): dg_mod.undoIt() -class ExtractMayaUsd(plugin.MayaExtractorPlugin): +class ExtractMayaUsd(plugin.MayaExtractorPlugin, + publish.OptionalPyblishPluginMixin): """Extractor for Maya USD Asset data. Upon publish a .usd (or .usdz) asset file will typically be written. @@ -146,8 +178,12 @@ def options(self): "exportRefsAsInstanceable": bool, "eulerFilter": bool, "renderableOnly": bool, - "jobContext": (list, None) # optional list - # "worldspace": bool, + "convertMaterialsTo": str, + "shadingMode": (str, None), # optional str + "jobContext": (list, None), # optional list + "filterTypes": (list, None), # optional list + "staticSingleSample": bool, + "worldspace": bool, } @property @@ -158,18 +194,22 @@ def default_options(self): return { "defaultUSDFormat": "usdc", "stripNamespaces": False, - "mergeTransformAndShape": False, + "mergeTransformAndShape": True, "exportDisplayColor": False, "exportColorSets": True, "exportInstances": True, "exportUVs": True, "exportVisibility": True, - "exportComponentTags": True, + "exportComponentTags": False, "exportRefsAsInstanceable": False, "eulerFilter": True, "renderableOnly": False, - "jobContext": None - # "worldspace": False + "shadingMode": "none", + "convertMaterialsTo": "none", + "jobContext": None, + "filterTypes": None, + "staticSingleSample": True, + "worldspace": False } def parse_overrides(self, instance, options): @@ -202,6 +242,10 @@ def filter_members(self, members): return members def process(self, instance): + if not self.is_active(instance.data): + return + + attr_values = self.get_attr_values_from_data(instance.data) # Load plugin first cmds.loadPlugin("mayaUsdPlugin", quiet=True) @@ -227,10 +271,45 @@ def process(self, instance): self.log.error('No members!') return - start = instance.data["frameStartHandle"] - end = instance.data["frameEndHandle"] + export_anim_data = instance.data.get("exportAnimationData", True) + start = instance.data.get("frameStartHandle", 0) + + if export_anim_data: + end = instance.data["frameEndHandle"] + options["frameRange"] = (start, end) + options["frameStride"] = instance.data.get("step", 1.0) + + if instance.data.get("exportRoots", True): + # Do not include 'objectSets' as roots because the export command + # will fail. We only include the transforms among the members. + options["exportRoots"] = cmds.ls(members, + type="transform", + long=True) + else: + options["selection"] = True + + options["stripNamespaces"] = attr_values.get("stripNamespaces", True) + options["exportComponentTags"] = attr_values.get("exportComponentTags", + False) + options["worldspace"] = attr_values.get("worldspace", True) + + # TODO: Remove hardcoded filterTypes + # We always filter constraint types because they serve no valuable + # data (it doesn't preserve the actual constraint) but it does + # introduce the problem that Shapes do not merge into the Transform + # on export anymore because they are usually parented under transforms + # See: https://github.com/Autodesk/maya-usd/issues/2070 + options["filterTypes"] = ["constraint"] def parse_attr_str(attr_str): + """Return list of strings from `a,b,c,,d` to `[a, b, c, d]`. + + Args: + attr_str (str): Concatenated attributes by comma + + Returns: + List[str]: list of attributes + """ result = list() for attr in attr_str.split(","): attr = attr.strip() @@ -244,16 +323,40 @@ def parse_attr_str(attr_str): attrs += ["cbId"] attr_prefixes = parse_attr_str(instance.data.get("attrPrefix", "")) + # Remove arguments for Maya USD versions not supporting them yet + # Note: Maya 2022.3 ships with Maya USD 0.13.0. + # TODO: Remove this backwards compatibility if Maya 2022 support is + # dropped + maya_usd_version = parse_version( + cmds.pluginInfo("mayaUsdPlugin", query=True, version=True) + ) + for key, required_minimal_version in { + "exportComponentTags": (0, 14, 0), + "jobContext": (0, 15, 0) + }.items(): + if key in options and maya_usd_version < required_minimal_version: + self.log.warning( + "Ignoring export flag '%s' because Maya USD version " + "%s is lower than minimal supported version %s.", + key, + maya_usd_version, + required_minimal_version + ) + del options[key] + self.log.debug('Exporting USD: {} / {}'.format(file_path, members)) - with maintained_selection(): - with usd_export_attributes(instance[:], - attrs=attrs, - attr_prefixes=attr_prefixes): - cmds.mayaUSDExport(file=file_path, - frameRange=(start, end), - frameStride=instance.data.get("step", 1.0), - exportRoots=members, - **options) + with maintained_time(): + with maintained_selection(): + if not export_anim_data: + # Use start frame as current time + cmds.currentTime(start) + + with usd_export_attributes(instance[:], + attrs=attrs, + attr_prefixes=attr_prefixes): + cmds.select(members, replace=True, noExpand=True) + cmds.mayaUSDExport(file=file_path, + **options) representation = { 'name': "usd", @@ -267,6 +370,25 @@ def parse_attr_str(attr_str): "Extracted instance {} to {}".format(instance.name, file_path) ) + @classmethod + def get_attribute_defs(cls): + return super(ExtractMayaUsd, cls).get_attribute_defs() + [ + BoolDef("stripNamespaces", + label="Strip Namespaces (USD)", + tooltip="Strip Namespaces in the USD Export", + default=True), + BoolDef("worldspace", + label="World-Space (USD)", + tooltip="Export all root prim using their full worldspace " + "transform instead of their local transform.", + default=True), + BoolDef("exportComponentTags", + label="Export Component Tags", + tooltip="When enabled, export any geometry component tags " + "as UsdGeomSubset data.", + default=False) + ] + class ExtractMayaUsdAnim(ExtractMayaUsd): """Extractor for Maya USD Animation Sparse Cache data. @@ -276,10 +398,16 @@ class ExtractMayaUsdAnim(ExtractMayaUsd): Upon publish a .usd sparse cache will be written. """ - label = "Extract Maya USD Animation Sparse Cache" - families = ["animation", "mayaUsd"] - match = pyblish.api.Subset + label = "Extract USD Animation" + families = ["animation"] + # Exposed in settings + optional = True + active = False + + # TODO: Support writing out point deformation only, avoid writing UV sets + # component tags and potentially remove `faceVertexCounts`, + # `faceVertexIndices` and `doubleSided` parameters as well. def filter_members(self, members): out_set = next((i for i in members if i.endswith("out_SET")), None) @@ -289,3 +417,33 @@ def filter_members(self, members): members = cmds.ls(cmds.sets(out_set, query=True), long=True) return members + + +class ExtractMayaUsdModel(ExtractMayaUsd): + """Extractor for Maya USD Asset data for model family + + Upon publish a .usd (or .usdz) asset file will typically be written. + """ + + label = "Extract USD" + families = ["model"] + + # Exposed in settings + optional = True + active = False + + def process(self, instance): + # TODO: Fix this without changing instance data + instance.data["exportAnimationData"] = False + super(ExtractMayaUsdModel, self).process(instance) + + +class ExtractMayaUsdPointcache(ExtractMayaUsd): + """Extractor for Maya USD for 'pointcache' family""" + + label = "Extract USD" + families = ["pointcache"] + + # Exposed in settings + optional = True + active = False diff --git a/client/ayon_maya/plugins/publish/extract_maya_usd_layer.py b/client/ayon_maya/plugins/publish/extract_maya_usd_layer.py new file mode 100644 index 00000000..914874ed --- /dev/null +++ b/client/ayon_maya/plugins/publish/extract_maya_usd_layer.py @@ -0,0 +1,63 @@ +import os + +from maya import cmds +from openpype.pipeline import publish + + +class ExtractMayaUsdLayer(publish.Extractor): + """Extractor for Maya USD Layer from `mayaUsdProxyShape` + + Exports a single Sdf.Layer from a mayaUsdPlugin `mayaUsdProxyShape`. + These layers are the same managed via Maya's Windows > USD Layer Editor. + + """ + + label = "Extract Maya USD Layer" + hosts = ["maya"] + families = ["mayaUsdLayer"] + + def process(self, instance): + + import mayaUsd + + # Load plugin first + cmds.loadPlugin("mayaUsdPlugin", quiet=True) + + data = instance.data["stageLayerIdentifier"] + proxy, layer_identifier = data.split(">", 1) + + # TODO: The stage and layer should actually be retrieved during + # Collecting so that they can be validated upon and potentially that + # any 'child layers' can potentially be recursively exported along + stage = mayaUsd.ufe.getStage('|world' + proxy) + layers = stage.GetLayerStack(includeSessionLayers=False) + layer = next( + layer for layer in layers if layer.identifier == layer_identifier + ) + + # Define output file path + staging_dir = self.staging_dir(instance) + file_name = "{0}.usd".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + file_path = file_path.replace('\\', '/') + + self.log.debug("Exporting USD layer to: {}".format(file_path)) + layer.Export(file_path, args={ + "format": instance.data.get("defaultUSDFormat", "usdc") + }) + + # TODO: We might want to remap certain paths - to do so we could take + # the SdfLayer and transfer its contents into a anonymous SdfLayer + # then we can use the copy to alter it in memory to our like before + # writing out + + representation = { + 'name': "usd", + 'ext': "usd", + 'files': file_name, + 'stagingDir': staging_dir + } + instance.data.setdefault("representations", []).append(representation) + self.log.debug( + "Extracted instance {} to {}".format(instance.name, file_path) + ) diff --git a/client/ayon_maya/plugins/publish/validate_instance_has_members.py b/client/ayon_maya/plugins/publish/validate_instance_has_members.py index baca2a90..d061c882 100644 --- a/client/ayon_maya/plugins/publish/validate_instance_has_members.py +++ b/client/ayon_maya/plugins/publish/validate_instance_has_members.py @@ -24,8 +24,15 @@ def get_invalid(cls, instance): def process(self, instance): # Allow renderlayer, rendersetup and workfile to be empty - skip_families = {"workfile", "renderlayer", "rendersetup"} - if instance.data.get("productType") in skip_families: + skip_families = {"workfile", + "renderlayer", + "rendersetup", + "mayaUsdLayer", + "usdLayer", + "usdAsset"} + families = {instance.data.get("family")} + families.update(instance.data.get("families", [])) + if families.intersection(skip_families): return invalid = self.get_invalid(instance) diff --git a/server/settings/publishers.py b/server/settings/publishers.py index 6a127cc9..869dfaa2 100644 --- a/server/settings/publishers.py +++ b/server/settings/publishers.py @@ -538,6 +538,24 @@ class ExtractModelModel(BaseSettingsModel): active: bool = SettingsField(title="Active") +class ExtractMayaUsdModelModel(BaseSettingsModel): + enabled: bool = SettingsField(title="Enabled") + optional: bool = SettingsField(title="Optional") + active: bool = SettingsField(title="Active") + + +class ExtractMayaUsdPointcacheModel(BaseSettingsModel): + enabled: bool = SettingsField(title="Enabled") + optional: bool = SettingsField(title="Optional") + active: bool = SettingsField(title="Active") + + +class ExtractMayaUsdAnimationModel(BaseSettingsModel): + enabled: bool = SettingsField(title="Enabled") + optional: bool = SettingsField(title="Optional") + active: bool = SettingsField(title="Active") + + class ExtractMayaSceneRawModel(BaseSettingsModel): """Add loaded instances to those published families:""" enabled: bool = SettingsField(title="ExtractMayaSceneRaw") @@ -1031,6 +1049,18 @@ class PublishersModel(BaseSettingsModel): default_factory=ExtractAlembicModel, title="Extract Alembic" ) + ExtractMayaUsdModel: ExtractMayaUsdModelModel = SettingsField( + default_factory=ExtractMayaUsdModelModel, + title="Extract Maya USD with Model" + ) + ExtractMayaUsdPointcache: ExtractMayaUsdPointcacheModel = SettingsField( + default_factory=ExtractMayaUsdPointcacheModel, + title="Extract Maya USD with Pointcache" + ) + ExtractMayaUsdAnimation: ExtractMayaUsdAnimationModel = SettingsField( + default_factory=ExtractMayaUsdAnimationModel, + title="Extract Maya USD with Animation" + ) DEFAULT_SUFFIX_NAMING = { @@ -1636,5 +1666,20 @@ class PublishersModel(BaseSettingsModel): "writeNormals": True, "writeUVSets": False, "writeVisibility": False + }, + "ExtractMayaUsdModel": { + "enabled": True, + "optional": True, + "active": False, + }, + "ExtractMayaUsdPointcache": { + "enabled": True, + "optional": True, + "active": False, + }, + "ExtractMayaUsdAnimation": { + "enabled": True, + "optional": True, + "active": False, } }