diff --git a/client/ayon_houdini/api/lib.py b/client/ayon_houdini/api/lib.py index cfaaedc560..eec3995821 100644 --- a/client/ayon_houdini/api/lib.py +++ b/client/ayon_houdini/api/lib.py @@ -367,6 +367,28 @@ def maintained_selection(): node.setSelected(on=True) +@contextmanager +def parm_values(overrides): + """Override Parameter values during the context. + Arguments: + overrides (List[Tuple[hou.Parm, Any]]): The overrides per parm + that should be applied during context. + """ + + originals = [] + try: + for parm, value in overrides: + originals.append((parm, parm.eval())) + parm.set(value) + yield + finally: + for parm, value in originals: + # Parameter might not exist anymore so first + # check whether it's still valid + if hou.parm(parm.path()): + parm.set(value) + + def reset_framerange(fps=True, frame_range=True): """Set frame range and FPS to current folder.""" diff --git a/client/ayon_houdini/api/plugin.py b/client/ayon_houdini/api/plugin.py index 2eb34bc727..8a2344febb 100644 --- a/client/ayon_houdini/api/plugin.py +++ b/client/ayon_houdini/api/plugin.py @@ -134,6 +134,7 @@ def create(self, product_name, instance_data, pre_create_data): instance_data["instance_node"] = instance_node.path() instance_data["instance_id"] = instance_node.path() + instance_data["families"] = self.get_publish_families() instance = CreatedInstance( self.product_type, product_name, @@ -182,6 +183,7 @@ def collect_instances(self): node_path = instance.path() node_data["instance_id"] = node_path node_data["instance_node"] = node_path + node_data["families"] = self.get_publish_families() if "AYON_productName" in node_data: node_data["productName"] = node_data.pop("AYON_productName") @@ -211,6 +213,7 @@ def imprint(self, node, values, update=False): values["AYON_productName"] = values.pop("productName") values.pop("instance_node", None) values.pop("instance_id", None) + values.pop("families", None) imprint(node, values, update=update) def remove_instances(self, instances): @@ -252,6 +255,21 @@ def customize_node_look( node.setUserData('nodeshape', shape) node.setColor(color) + def get_publish_families(self): + """Return families for the instances of this creator. + + Allow a Creator to define multiple families so that a creator can + e.g. specify `usd` and `usdrop`. + + There is no need to override this method if you only have the + primary family defined by the `product_type` property as that will + always be set. + + Returns: + List[str]: families for instances of this creator + """ + return [] + def get_network_categories(self): """Return in which network view type this creator should show. diff --git a/client/ayon_houdini/api/usd.py b/client/ayon_houdini/api/usd.py index f7824dfc5c..a416d581c3 100644 --- a/client/ayon_houdini/api/usd.py +++ b/client/ayon_houdini/api/usd.py @@ -2,9 +2,12 @@ import contextlib import logging +import json +import itertools +from typing import List -from pxr import Sdf - +import hou +from pxr import Usd, Sdf, Tf, Vt, UsdRender log = logging.getLogger(__name__) @@ -119,11 +122,13 @@ def get_usd_rop_loppath(node): return node.parm("loppath").evalAsNode() -def get_layer_save_path(layer): +def get_layer_save_path(layer, expand_string=True): """Get custom HoudiniLayerInfo->HoudiniSavePath from SdfLayer. Args: layer (pxr.Sdf.Layer): The Layer to retrieve the save pah data from. + expand_string (bool): Whether to expand any houdini vars in the save + path before computing the absolute path. Returns: str or None: Path to save to when data exists. @@ -136,6 +141,8 @@ def get_layer_save_path(layer): save_path = hou_layer_info.customData.get("HoudiniSavePath", None) if save_path: # Unfortunately this doesn't actually resolve the full absolute path + if expand_string: + save_path = hou.text.expandString(save_path) return layer.ComputeAbsolutePath(save_path) @@ -181,7 +188,18 @@ def iter_layer_recursive(layer): yield layer -def get_configured_save_layers(usd_rop): +def get_configured_save_layers(usd_rop, strip_above_layer_break=True): + """Retrieve the layer save paths from a USD ROP. + + Arguments: + usdrop (hou.RopNode): USD Rop Node + strip_above_layer_break (Optional[bool]): Whether to exclude any + layers that are above layer breaks. This defaults to True. + + Returns: + List[Sdf.Layer]: The layers with configured save paths. + + """ lop_node = get_usd_rop_loppath(usd_rop) stage = lop_node.stage(apply_viewport_overrides=False) @@ -192,10 +210,170 @@ def get_configured_save_layers(usd_rop): root_layer = stage.GetRootLayer() + if strip_above_layer_break: + layers_above_layer_break = set(lop_node.layersAboveLayerBreak()) + else: + layers_above_layer_break = set() + save_layers = [] for layer in iter_layer_recursive(root_layer): + if ( + strip_above_layer_break and + layer.identifier in layers_above_layer_break + ): + continue + save_path = get_layer_save_path(layer) if save_path is not None: save_layers.append(layer) return save_layers + + +def setup_lop_python_layer(layer, node, savepath=None, + apply_file_format_args=True): + """Set up Sdf.Layer with HoudiniLayerInfo prim for metadata. + + This is the same as `loputils.createPythonLayer` but can be run on top + of `pxr.Sdf.Layer` instances that are already created in a Python LOP node. + That's useful if your layer creation itself is built to be DCC agnostic, + then we just need to run this after per layer to make it explicitly + stored for houdini. + + By default, Houdini doesn't apply the FileFormatArguments supplied to + the created layer; however it does support USD's file save suffix + of `:SDF_FORMAT_ARGS:` to supply them. With `apply_file_format_args` any + file format args set on the layer's creation will be added to the + save path through that. + + Note: The `node.addHeldLayer` call will only work from a LOP python node + whenever `node.editableStage()` or `node.editableLayer()` was called. + + Arguments: + layer (Sdf.Layer): An existing layer (most likely just created + in the current runtime) + node (hou.LopNode): The Python LOP node to attach the layer to so + it does not get garbage collected/mangled after the downstream. + savepath (Optional[str]): When provided the HoudiniSaveControl + will be set to Explicit with HoudiniSavePath to this path. + apply_file_format_args (Optional[bool]): When enabled any + FileFormatArgs defined for the layer on creation will be set + in the HoudiniSavePath so Houdini USD ROP will use them top. + + Returns: + Sdf.PrimSpec: The Created HoudiniLayerInfo prim spec. + + """ + # Add a Houdini Layer Info prim where we can put the save path. + p = Sdf.CreatePrimInLayer(layer, '/HoudiniLayerInfo') + p.specifier = Sdf.SpecifierDef + p.typeName = 'HoudiniLayerInfo' + if savepath: + if apply_file_format_args: + args = layer.GetFileFormatArguments() + savepath = Sdf.Layer.CreateIdentifier(savepath, args) + + p.customData['HoudiniSavePath'] = savepath + p.customData['HoudiniSaveControl'] = 'Explicit' + # Let everyone know what node created this layer. + p.customData['HoudiniCreatorNode'] = node.sessionId() + p.customData['HoudiniEditorNodes'] = Vt.IntArray([node.sessionId()]) + node.addHeldLayer(layer.identifier) + + return p + + +@contextlib.contextmanager +def remap_paths(rop_node, mapping): + """Enable the AyonRemapPaths output processor with provided `mapping`""" + from ayon_houdini.api.lib import parm_values + + if not mapping: + # Do nothing + yield + return + + # Houdini string parms need to escape backslashes due to the support + # of expressions - as such we do so on the json data + value = json.dumps(mapping).replace("\\", "\\\\") + with outputprocessors( + rop_node, + processors=["ayon_remap_paths"], + disable_all_others=True, + ): + with parm_values([ + (rop_node.parm("ayon_remap_paths_remap_json"), value) + ]): + yield + + +def get_usd_render_rop_rendersettings(rop_node, stage=None, logger=None): + """Return the chosen UsdRender.Settings from the stage (if any). + + Args: + rop_node (hou.Node): The Houdini USD Render ROP node. + stage (pxr.Usd.Stage): The USD stage to find the render settings + in. This is usually the stage from the LOP path the USD Render + ROP node refers to. + logger (logging.Logger): Logger to log warnings to if no render + settings were find in stage. + + Returns: + Optional[UsdRender.Settings]: Render Settings. + + """ + if logger is None: + logger = log + + if stage is None: + lop_node = get_usd_rop_loppath(rop_node) + stage = lop_node.stage() + + path = rop_node.evalParm("rendersettings") + if not path: + # Default behavior + path = "/Render/rendersettings" + + prim = stage.GetPrimAtPath(path) + if not prim: + logger.warning("No render settings primitive found at: %s", path) + return + + render_settings = UsdRender.Settings(prim) + if not render_settings: + logger.warning("Prim at %s is not a valid RenderSettings prim.", path) + return + + return render_settings + + +def get_schema_type_names(type_name: str) -> List[str]: + """Return schema type name for type name and its derived types + + This can be useful for checking whether a `Sdf.PrimSpec`'s type name is of + a given type or any of its derived types. + + Args: + type_name (str): The type name, like e.g. 'UsdGeomMesh' + + Returns: + List[str]: List of schema type names and their derived types. + + """ + schema_registry = Usd.SchemaRegistry + type_ = Tf.Type.FindByName(type_name) + + if type_ == Tf.Type.Unknown: + type_ = schema_registry.GetTypeFromSchemaTypeName(type_name) + if type_ == Tf.Type.Unknown: + # Type not found + return [] + + results = [] + derived = type_.GetAllDerivedTypes() + for derived_type in itertools.chain([type_], derived): + schema_type_name = schema_registry.GetSchemaTypeName(derived_type) + if schema_type_name: + results.append(schema_type_name) + + return results diff --git a/client/ayon_houdini/plugins/create/create_usd.py b/client/ayon_houdini/plugins/create/create_usd.py index a731a42f20..b6c0aa8895 100644 --- a/client/ayon_houdini/plugins/create/create_usd.py +++ b/client/ayon_houdini/plugins/create/create_usd.py @@ -8,10 +8,11 @@ class CreateUSD(plugin.HoudiniCreator): """Universal Scene Description""" identifier = "io.openpype.creators.houdini.usd" - label = "USD (experimental)" + label = "USD" product_type = "usd" icon = "cubes" enabled = False + description = "Create USD" def create(self, product_name, instance_data, pre_create_data): @@ -49,3 +50,6 @@ def get_network_categories(self): hou.ropNodeTypeCategory(), hou.lopNodeTypeCategory() ] + + def get_publish_families(self): + return ["usd", "usdrop"] diff --git a/client/ayon_houdini/plugins/publish/collect_usd_layers.py b/client/ayon_houdini/plugins/publish/collect_usd_layers.py index 7ecf5fbb02..5fa787fb39 100644 --- a/client/ayon_houdini/plugins/publish/collect_usd_layers.py +++ b/client/ayon_houdini/plugins/publish/collect_usd_layers.py @@ -1,18 +1,66 @@ +import copy import os -import hou +import re + import pyblish.api + +from ayon_core.pipeline.create import get_product_name from ayon_houdini.api import plugin import ayon_houdini.api.usd as usdlib +import hou + + +def copy_instance_data(instance_src, instance_dest, attr): + """Copy instance data from `src` instance to `dest` instance. + + Examples: + >>> copy_instance_data(instance_src, instance_dest, + >>> attr="publish_attributes.CollectRopFrameRange") + + Arguments: + instance_src (pyblish.api.Instance): Source instance to copy from + instance_dest (pyblish.api.Instance): Target instance to copy to + attr (str): Attribute on the source instance to copy. This can be + a nested key joined by `.` to only copy sub entries of dictionaries + in the source instance's data. + + Raises: + KeyError: If the key does not exist on the source instance. + AssertionError: If a parent key already exists on the destination + instance but is not of the correct type (= is not a dict) + + """ + + src_data = instance_src.data + dest_data = instance_dest.data + keys = attr.split(".") + for i, key in enumerate(keys): + if key not in src_data: + break + + src_value = src_data[key] + if i != len(key): + dest_data = dest_data.setdefault(key, {}) + assert isinstance(dest_data, dict), "Destination must be a dict" + src_data = src_value + else: + # Last iteration - assign the value + dest_data[key] = copy.deepcopy(src_value) + class CollectUsdLayers(plugin.HoudiniInstancePlugin): """Collect the USD Layers that have configured save paths.""" - order = pyblish.api.CollectorOrder + 0.35 + order = pyblish.api.CollectorOrder + 0.25 label = "Collect USD Layers" - families = ["usd"] + families = ["usdrop"] def process(self, instance): + # TODO: Replace this with a Hidden Creator so we collect these BEFORE + # starting the publish so the user sees them before publishing + # - however user should not be able to individually enable/disable + # this from the main ROP its created from? output = instance.data.get("output_node") if not output: @@ -29,13 +77,16 @@ def process(self, instance): creator = info.customData.get("HoudiniCreatorNode") self.log.debug("Found configured save path: " - "%s -> %s" % (layer, save_path)) + "%s -> %s", layer, save_path) # Log node that configured this save path - if creator: - self.log.debug("Created by: %s" % creator) + creator_node = hou.nodeBySessionId(creator) if creator else None + if creator_node: + self.log.debug( + "Created by: %s", creator_node.path() + ) - save_layers.append((layer, save_path)) + save_layers.append((layer, save_path, creator_node)) # Store on the instance instance.data["usdConfiguredSavePaths"] = save_layers @@ -43,23 +94,65 @@ def process(self, instance): # Create configured layer instances so User can disable updating # specific configured layers for publishing. context = instance.context - product_type = "usdlayer" - for layer, save_path in save_layers: + for layer, save_path, creator_node in save_layers: name = os.path.basename(save_path) - label = "{0} -> {1}".format(instance.data["name"], name) layer_inst = context.create_instance(name) - layer_inst.data["productType"] = product_type - layer_inst.data["family"] = product_type - layer_inst.data["families"] = [product_type] - layer_inst.data["productName"] = "__stub__" - layer_inst.data["label"] = label - layer_inst.data["folderPath"] = instance.data["folderPath"] - layer_inst.data["instance_node"] = instance.data["instance_node"] # include same USD ROP layer_inst.append(rop_node) - # include layer data - layer_inst.append((layer, save_path)) - # Allow this product to be grouped into a USD Layer on creation - layer_inst.data["productGroup"] = "USD Layer" + staging_dir, fname = os.path.split(save_path) + fname_no_ext, ext = os.path.splitext(fname) + + variant = fname_no_ext + + # Strip off any trailing version number in the form of _v[0-9]+ + variant = re.sub("_v[0-9]+$", "", variant) + + layer_inst.data["usd_layer"] = layer + layer_inst.data["usd_layer_save_path"] = save_path + + project_name = context.data["projectName"] + variant_base = instance.data["variant"] + subset = get_product_name( + project_name=project_name, + # TODO: This should use task from `instance` + task_name=context.data["anatomyData"]["task"]["name"], + task_type=context.data["anatomyData"]["task"]["type"], + host_name=context.data["hostName"], + product_type="usd", + variant=variant_base + "_" + variant, + project_settings=context.data["project_settings"] + ) + + label = "{0} -> {1}".format(instance.data["name"], subset) + family = "usd" + layer_inst.data["family"] = family + layer_inst.data["families"] = [family] + layer_inst.data["subset"] = subset + layer_inst.data["label"] = label + layer_inst.data["asset"] = instance.data["asset"] + layer_inst.data["task"] = instance.data.get("task") + layer_inst.data["instance_node"] = instance.data["instance_node"] + layer_inst.data["render"] = False + layer_inst.data["output_node"] = creator_node + + # Inherit "use handles" from the source instance + # TODO: Do we want to maybe copy full `publish_attributes` instead? + copy_instance_data( + instance, layer_inst, + attr="publish_attributes.CollectRopFrameRange.use_handles" + ) + + # Allow this subset to be grouped into a USD Layer on creation + layer_inst.data["subsetGroup"] = "USD Layer" + + # For now just assume the representation will get published + representation = { + "name": "usd", + "ext": ext.lstrip("."), + "stagingDir": staging_dir, + "files": fname + } + layer_inst.data.setdefault("representations", []).append( + representation) diff --git a/client/ayon_houdini/plugins/publish/validate_bypass.py b/client/ayon_houdini/plugins/publish/validate_bypass.py index f3856b4147..d984c63756 100644 --- a/client/ayon_houdini/plugins/publish/validate_bypass.py +++ b/client/ayon_houdini/plugins/publish/validate_bypass.py @@ -22,9 +22,12 @@ class ValidateBypassed(plugin.HoudiniInstancePlugin): def process(self, instance): - if len(instance) == 0: - # Ignore instances without any nodes + if not instance.data.get("instance_node"): + # Ignore instances without an instance node # e.g. in memory bootstrap instances + self.log.debug( + "Skipping instance without instance node: {}".format(instance) + ) return invalid = self.get_invalid(instance) diff --git a/client/ayon_houdini/plugins/publish/validate_houdini_license_category.py b/client/ayon_houdini/plugins/publish/validate_houdini_license_category.py index d76f8a0072..1639a28790 100644 --- a/client/ayon_houdini/plugins/publish/validate_houdini_license_category.py +++ b/client/ayon_houdini/plugins/publish/validate_houdini_license_category.py @@ -23,7 +23,7 @@ class ValidateHoudiniNotApprenticeLicense(plugin.HoudiniInstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["usd", "abc", "fbx", "camera"] + families = ["usdrop", "abc", "fbx", "camera"] label = "Houdini Apprentice License" def process(self, instance): diff --git a/client/ayon_houdini/plugins/publish/validate_no_errors.py b/client/ayon_houdini/plugins/publish/validate_no_errors.py index ef66665d7b..2afb6e5d78 100644 --- a/client/ayon_houdini/plugins/publish/validate_no_errors.py +++ b/client/ayon_houdini/plugins/publish/validate_no_errors.py @@ -37,6 +37,13 @@ class ValidateNoErrors(plugin.HoudiniInstancePlugin): def process(self, instance): + if not instance.data.get("instance_node"): + self.log.debug( + "Skipping 'Validate no errors' because instance " + "has no instance node: {}".format(instance) + ) + return + validate_nodes = [] if len(instance) > 0: diff --git a/client/ayon_houdini/plugins/publish/validate_usd_output_node.py b/client/ayon_houdini/plugins/publish/validate_usd_output_node.py index 88d549d46c..7ef9a80394 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_output_node.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_output_node.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- +import inspect + import pyblish.api from ayon_core.pipeline import PublishValidationError - +from ayon_houdini.api.action import SelectROPAction from ayon_houdini.api import plugin @@ -16,18 +18,23 @@ class ValidateUSDOutputNode(plugin.HoudiniInstancePlugin): """ - order = pyblish.api.ValidatorOrder - families = ["usd"] + # Validate early so that this error reports higher than others to the user + # so that if another invalidation is due to the output node being invalid + # the user will likely first focus on this first issue + order = pyblish.api.ValidatorOrder - 0.4 + families = ["usdrop"] label = "Validate Output Node (USD)" + actions = [SelectROPAction] def process(self, instance): invalid = self.get_invalid(instance) if invalid: + path = invalid[0] raise PublishValidationError( - ("Output node(s) `{}` are incorrect. " - "See plug-in log for details.").format(invalid), - title=self.label + "Output node '{}' has no valid LOP path set.".format(path), + title=self.label, + description=self.get_description() ) @classmethod @@ -35,12 +42,12 @@ def get_invalid(cls, instance): import hou - output_node = instance.data["output_node"] + output_node = instance.data.get("output_node") if output_node is None: node = hou.node(instance.data.get("instance_node")) cls.log.error( - "USD node '%s' LOP path does not exist. " + "USD node '%s' configured LOP path does not exist. " "Ensure a valid LOP path is set." % node.path() ) @@ -55,3 +62,13 @@ def get_invalid(cls, instance): % (output_node.path(), output_node.type().category().name()) ) return [output_node.path()] + + def get_description(self): + return inspect.cleandoc( + """### USD ROP has invalid LOP path + + The USD ROP node has no or an invalid LOP path set to be exported. + Make sure to correctly configure what you want to export for the + publish. + """ + ) diff --git a/client/ayon_houdini/plugins/publish/validate_usd_render_product_names.py b/client/ayon_houdini/plugins/publish/validate_usd_render_product_names.py index eb46b266e2..2da9d009ab 100644 --- a/client/ayon_houdini/plugins/publish/validate_usd_render_product_names.py +++ b/client/ayon_houdini/plugins/publish/validate_usd_render_product_names.py @@ -18,7 +18,7 @@ class ValidateUSDRenderProductNames(plugin.HoudiniInstancePlugin): def process(self, instance): invalid = [] - for filepath in instance.data["files"]: + for filepath in instance.data.get("files", []): if not filepath: invalid.append("Detected empty output filepath.")