diff --git a/client/ayon_houdini/api/lib.py b/client/ayon_houdini/api/lib.py index 6a5f12c875..57a13d934e 100644 --- a/client/ayon_houdini/api/lib.py +++ b/client/ayon_houdini/api/lib.py @@ -105,6 +105,21 @@ def get_output_parameter(node): return node.parm("outputimage") elif node_type == "vray_renderer": return node.parm("SettingsOutput_img_file_path") + elif node_type == "labs::karma::2.0": + return node.parm("picture") + + if isinstance(node, hou.RopNode): + # Use the parm name fallback that SideFX applies for detecting output + # files from PDG/TOPs graphs for ROP nodes. See #ayon-core/692 + parm_names = [ + "vm_picture", "sopoutput", "dopoutput", "lopoutput", "picture", + "copoutput", "filename", "usdfile", "file", "output", + "outputfilepath", "outputimage", "outfile" + ] + for name in parm_names: + parm = node.parm(name) + if parm: + return parm raise TypeError("Node type '%s' not supported" % node_type) @@ -355,6 +370,9 @@ def read(node): except json.JSONDecodeError: # not a json pass + elif parameter.parmTemplate().type() is hou.parmTemplateType.Toggle: + # Toggles should be returned as boolean instead of int of 1 or 0 + value = bool(value) data[parameter.name()] = value return data @@ -1006,10 +1024,20 @@ def self_publish(): def add_self_publish_button(node): """Adds a self publish button to the rop node.""" + parm_name = "ayon_self_publish" + label = os.environ.get("AYON_MENU_LABEL") or "AYON" + template = node.parmTemplateGroup() + existing = template.find(parm_name) + if existing: + log.warning( + f"Self publish parm already found on {node.path()}. " + "Skipping creation..." + ) + return button_parm = hou.ButtonParmTemplate( - "ayon_self_publish", + parm_name, "{} Publish".format(label), script_callback="from ayon_houdini.api.lib import " "self_publish; self_publish()", @@ -1017,7 +1045,6 @@ def add_self_publish_button(node): join_with_next=True ) - template = node.parmTemplateGroup() template.insertBefore((0,), button_parm) node.setParmTemplateGroup(template) @@ -1420,3 +1447,16 @@ def start_workfile_template_builder(): build_workfile_template(workfile_creation_enabled=True) except TemplateProfileNotFound: log.warning("Template profile not found. Skipping...") + + +@contextmanager +def no_auto_create_publishable(): + value = os.environ.get("AYON_HOUDINI_AUTOCREATE") + os.environ["AYON_HOUDINI_AUTOCREATE"] = "0" + try: + yield + finally: + if value is None: + del os.environ["AYON_HOUDINI_AUTOCREATE"] + else: + os.environ["AYON_HOUDINI_AUTOCREATE"] = value diff --git a/client/ayon_houdini/api/node_wrap.py b/client/ayon_houdini/api/node_wrap.py new file mode 100644 index 0000000000..2730dbf191 --- /dev/null +++ b/client/ayon_houdini/api/node_wrap.py @@ -0,0 +1,124 @@ +import dataclasses +from typing import Dict, List, Optional +import hou + +from ayon_core.pipeline import registered_host +from ayon_core.pipeline.create import CreateContext + + +@dataclasses.dataclass +class NodeTypeProductTypes: + """Product type settings for a node type. + + Define the available product types the user can set on a ROP based on + node type. + + When 'strict' an enum attribute is created and the user can not type a + custom product type, otherwise a string attribute is + created with a menu right hand side to help pick a type but allow custom + types. + """ + product_types: List[str] + default: Optional[str] = None + strict: bool = True + + +# Re-usable defaults +GEO_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["pointcache", "model"], + default="pointcache" +) +FBX_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["fbx", "pointcache", "model"], + default="fbx" +) +FBX_ONLY_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["fbx"], + default="fbx" +) +USD_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["usd", "pointcache"], + default="usd" +) +COMP_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["imagesequence", "render"], + default="imagesequence" +) +REVIEW_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["review"], + default="review" +) +RENDER_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["render", "prerender"], + default="render" +) +GLTF_PRODUCT_TYPES = NodeTypeProductTypes( + product_types=["gltf"], + default="gltf" +) + +# TODO: Move this to project settings +NODE_TYPE_PRODUCT_TYPES: Dict[str, NodeTypeProductTypes] = { + "alembic": GEO_PRODUCT_TYPES, + "rop_alembic": GEO_PRODUCT_TYPES, + "geometry": GEO_PRODUCT_TYPES, + "rop_geometry": GEO_PRODUCT_TYPES, + "filmboxfbx": FBX_PRODUCT_TYPES, + "rop_fbx": FBX_PRODUCT_TYPES, + "usd": USD_PRODUCT_TYPES, + "usd_rop": USD_PRODUCT_TYPES, + "usdexport": USD_PRODUCT_TYPES, + "comp": COMP_PRODUCT_TYPES, + "opengl": REVIEW_PRODUCT_TYPES, + "arnold": RENDER_PRODUCT_TYPES, + "karma": RENDER_PRODUCT_TYPES, + "ifd": RENDER_PRODUCT_TYPES, + "usdrender": RENDER_PRODUCT_TYPES, + "usdrender_rop": RENDER_PRODUCT_TYPES, + "vray_renderer": RENDER_PRODUCT_TYPES, + "labs::karma::2.0": RENDER_PRODUCT_TYPES, + "kinefx::rop_fbxanimoutput": FBX_ONLY_PRODUCT_TYPES, + "kinefx::rop_fbxcharacteroutput": FBX_ONLY_PRODUCT_TYPES, + "kinefx::rop_gltfcharacteroutput": GLTF_PRODUCT_TYPES, + "rop_gltf": GLTF_PRODUCT_TYPES +} + +NODE_TYPE_PRODUCT_TYPES_DEFAULT = NodeTypeProductTypes( + product_types=list(sorted( + { + "ass", "pointcache", "model", "render", "camera", + "imagesequence", "review", "vdbcache", "fbx" + })), + default="pointcache", + strict=False +) +AUTO_CREATE_NODE_TYPES = set(NODE_TYPE_PRODUCT_TYPES.keys()) + + +def make_publishable(node): + # TODO: Can we make this imprinting much faster? Unfortunately + # CreateContext initialization is very slow. + host = registered_host() + context = CreateContext(host) + + # Apply the instance creation to the node + context.create( + creator_identifier="io.ayon.creators.houdini.publish", + variant="__use_node_name__", + pre_create_data={ + "node": node + } + ) + + +def autocreate_publishable(node): + # For now only consider RopNode; the LABS Karma node is the odd one out + # here because it's not a RopNode but a LopNode but with an embedded + # RopNode. So we will allow only that for now. + # TODO: Make this more explicit so we don't need the odd edge cases + node_type = node.type().name() + if not isinstance(node, hou.RopNode) and node_type != "labs::karma::2.0": + return + + if node_type in AUTO_CREATE_NODE_TYPES: + make_publishable(node) diff --git a/client/ayon_houdini/api/plugin.py b/client/ayon_houdini/api/plugin.py index d0762eb9a6..62f20eb385 100644 --- a/client/ayon_houdini/api/plugin.py +++ b/client/ayon_houdini/api/plugin.py @@ -20,7 +20,14 @@ ) from ayon_core.lib import BoolDef -from .lib import imprint, read, lsattr, add_self_publish_button, render_rop +from .lib import ( + imprint, + read, + lsattr, + add_self_publish_button, + no_auto_create_publishable, + render_rop +) from .usd import get_ayon_entity_uri_from_representation_context @@ -124,13 +131,14 @@ def create(self, product_name, instance_data, pre_create_data): folder_path = instance_data["folderPath"] - instance_node = self.create_instance_node( - folder_path, - product_name, - "/out", - node_type, - pre_create_data - ) + with no_auto_create_publishable(): + instance_node = self.create_instance_node( + folder_path, + product_name, + "/out", + node_type, + pre_create_data + ) self.customize_node_look(instance_node) diff --git a/client/ayon_houdini/plugins/create/create_generic.py b/client/ayon_houdini/plugins/create/create_generic.py new file mode 100644 index 0000000000..a7c18c335f --- /dev/null +++ b/client/ayon_houdini/plugins/create/create_generic.py @@ -0,0 +1,671 @@ +from typing import TYPE_CHECKING + +from ayon_houdini.api import plugin, node_wrap +from ayon_houdini.api.lib import ( + lsattr, read +) +from ayon_core.pipeline.create import ( + CreatedInstance, + get_product_name +) +from ayon_api import get_folder_by_path, get_task_by_name +from ayon_core.lib import ( + BoolDef, + NumberDef, + EnumDef, + TextDef, + UISeparatorDef, + UILabelDef, + FileDef +) + +import hou +import json + +if TYPE_CHECKING: + from typing import Optional # noqa: F401 + from ayon_core.lib import AbstractAttrDef # noqa: F401 + + +def attribute_def_to_parm_template(attribute_def, key=None): + """AYON Attribute Definition to Houdini Parm Template. + + Arguments: + attribute_def (AbstractAttrDef): Attribute Definition. + key (Optional[str]): Name for the parm template. + If not provided defaults to the Attribute Definition's key. + + Returns: + hou.ParmTemplate: Parm Template matching the Attribute Definition. + """ + + if key is None: + key = attribute_def.key + + if isinstance(attribute_def, BoolDef): + return hou.ToggleParmTemplate(name=key, + label=attribute_def.label, + default_value=attribute_def.default, + help=attribute_def.tooltip) + elif isinstance(attribute_def, NumberDef): + if attribute_def.decimals == 0: + return hou.IntParmTemplate( + name=key, + label=attribute_def.label, + default_value=(attribute_def.default,), + help=attribute_def.tooltip, + min=attribute_def.minimum, + max=attribute_def.maximum, + num_components=1 + ) + else: + return hou.FloatParmTemplate( + name=key, + label=attribute_def.label, + default_value=(attribute_def.default,), + help=attribute_def.tooltip, + min=attribute_def.minimum, + max=attribute_def.maximum, + num_components=1 + ) + elif isinstance(attribute_def, EnumDef): + # TODO: Support multiselection EnumDef + # We only support enums that do not allow multiselection + # as a dedicated houdini parm. + if not attribute_def.multiselection: + labels = [item["label"] for item in attribute_def.items] + values = [item["value"] for item in attribute_def.items] + return hou.StringParmTemplate( + name=key, + label=attribute_def.label, + default_value=(attribute_def.default,), + help=attribute_def.tooltip, + num_components=1, + menu_labels=labels, + menu_items=values, + menu_type=hou.menuType.Normal + ) + elif isinstance(attribute_def, TextDef): + return hou.StringParmTemplate( + name=key, + label=attribute_def.label, + default_value=(attribute_def.default,), + help=attribute_def.tooltip, + num_components=1 + ) + elif isinstance(attribute_def, UISeparatorDef): + return hou.SeparatorParmTemplate( + name=key, + label=attribute_def.label, + ) + elif isinstance(attribute_def, UILabelDef): + return hou.LabelParmTemplate( + name=key, + label=attribute_def.label, + ) + elif isinstance(attribute_def, FileDef): + # TODO: Support FileDef + pass + + # Unsupported attribute definition. We'll store value as JSON so just + # turn it into a string `JSON::` value + json_value = json.dumps(getattr(attribute_def, "default", None), + default=str) + return hou.StringParmTemplate( + name=key, + label=attribute_def.label, + default_value=f"JSON::{json_value}", + help=getattr(attribute_def, "tooltip", None), + num_components=1 + ) + + +def set_values(node: "hou.OpNode", values: dict): + """Set parm values only if both the raw value (e.g. expression) or the + evaluated value differ. This way we preserve expressions if they happen + to evaluate to a matching value. + + Parms must exist on the node already. + + """ + for key, value in values.items(): + + parm = node.parm(key) + + try: + unexpanded_value = parm.unexpandedString() + if unexpanded_value == value: + # Allow matching expressions + continue + except hou.OperationFailed: + pass + + if parm.rawValue() == value: + continue + + if parm.eval() == value: + # Needs no change + continue + + # TODO: Set complex data types as `JSON:` + parm.set(value) + + +class CreateHoudiniGeneric(plugin.HoudiniCreator): + """Generic creator to ingest arbitrary products""" + + USE_DEFAULT_PRODUCT_TYPE = "__use_node_default__" + USE_DEFAULT_NODE_NAME = "__use_node_name__" + + host_name = "houdini" + + identifier = "io.ayon.creators.houdini.publish" + label = "Generic" + product_type = "generic" + icon = "male" + description = "Make any ROP node publishable." + + render_target = "local_no_render" + default_variant = USE_DEFAULT_NODE_NAME + default_variants = ["Main", USE_DEFAULT_NODE_NAME] + + # TODO: Move this to project settings + node_type_product_types = node_wrap.NODE_TYPE_PRODUCT_TYPES + node_type_product_types_default = node_wrap.NODE_TYPE_PRODUCT_TYPES_DEFAULT + + def get_detail_description(self): + return "Publish any ROP node." + + def create(self, product_name, instance_data, pre_create_data): + + product_type = pre_create_data.get("productType", + self.USE_DEFAULT_PRODUCT_TYPE) + instance_data["productType"] = product_type + + # Unfortunately the Create Context will provide the product name + # even before the `create` call without listening to pre create data + # or the instance data - so instead we ignore the product name here + # and redefine it ourselves based on the `variant` in instance data + project_name = self.create_context.project_name + folder_entity = get_folder_by_path(project_name, + instance_data["folderPath"]) + task_entity = get_task_by_name(project_name, + folder_id=folder_entity["id"], + task_name=instance_data["task"]) + + if pre_create_data.get("node"): + nodes = [pre_create_data.get("node")] + else: + nodes = hou.selectedNodes() + + source_variant = instance_data["variant"] + + for node in nodes: + if node.parm("AYON_creator_identifier"): + # Continue if already existing attributes + continue + + # Enforce new style instance id otherwise first save may adjust + # this to the `AVALON_INSTANCE_ID` instead + instance_data["id"] = plugin.AYON_INSTANCE_ID + + # When using default product type base it on node type settings + node_product_type = product_type + if node_product_type == self.USE_DEFAULT_PRODUCT_TYPE: + node_type = node.type().name() + node_product_type = self.node_type_product_types.get( + node_type, self.node_type_product_types_default + ).default + + # Allow variant to be based off of the created node name + variant = source_variant + if variant == self.USE_DEFAULT_NODE_NAME: + variant = node.name() + + product_name = self._get_product_name_dynamic( + self.create_context.project_name, + folder_entity=folder_entity, + task_entity=task_entity, + variant=variant, + product_type=node_product_type + ) + + instance_data["variant"] = variant + instance_data["instance_node"] = node.path() + instance_data["instance_id"] = node.path() + created_instance = CreatedInstance( + node_product_type, product_name, instance_data.copy(), self + ) + + # Add instance + self._add_instance_to_context(created_instance) + + # Imprint on the selected node + # NOTE: We imprint after `_add_instance_to_context` to ensure + # the imprinted data directly contains also the instance + # attributes for the product type. Otherwise, they will appear + # after first save. + self.imprint(created_instance, + values=created_instance.data_to_store(), + update=False) + + def collect_instances(self): + for node in lsattr("AYON_id", plugin.AYON_INSTANCE_ID): + + creator_identifier_parm = node.parm("AYON_creator_identifier") + if not creator_identifier_parm: + continue + + # creator instance + creator_id = creator_identifier_parm.eval() + if creator_id != self.identifier: + continue + + # Read all attributes starting with `ayon_` + node_data = { + key.removeprefix("AYON_"): value + for key, value in read(node).items() + if key.startswith("AYON_") + } + + # 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 = node.path() + node_data["instance_id"] = node_path + node_data["instance_node"] = node_path + node_data["families"] = self.get_publish_families() + + # Read creator and publish attributes + publish_attributes = {} + creator_attributes = {} + for key, value in dict(node_data).items(): + if key.startswith("publish_attributes_"): + # TODO: Technically this isn't entirely safe. We are + # splitting after the first `_` after + # `publish_attributes_` with the assumption that the + # plug-in class name never contains an underscore. + plugin_name, plugin_key = key[len("publish_attributes_"):].split("_", 1) # noqa: E501 + publish_attributes.setdefault(plugin_name, {})[plugin_key] = value # noqa: E501 + del node_data[key] # remove from original + elif key.startswith("creator_attributes_"): + creator_key = key[len("creator_attributes_"):] + creator_attributes[creator_key] = value + del node_data[key] # remove from original + + node_data["creator_attributes"] = creator_attributes + node_data["publish_attributes"] = publish_attributes + + created_instance = CreatedInstance.from_existing( + node_data, self + ) + self._add_instance_to_context(created_instance) + + def update_instances(self, update_list): + # Overridden to pass `created_instance` to `self.imprint` + for created_inst, changes in update_list: + new_values = { + key: changes[key].new_value + for key in changes.changed_keys + } + # Update parm templates and values + self.imprint( + created_inst, + new_values, + update=True + ) + + def get_product_name( + self, + project_name, + folder_entity, + task_entity, + variant, + host_name=None, + instance=None + ): + if instance is not None: + self.product_type = instance.data["productType"] + product_name = super(CreateHoudiniGeneric, self).get_product_name( + project_name, + folder_entity, + task_entity, + variant, + host_name, + instance) + self.product_type = "generic" + return product_name + + else: + return "<-- defined on create -->" + + def create_attribute_def_parms(self, + node: "hou.OpNode", + created_instance: CreatedInstance): + # We imprint all the attributes into an AYON tab on the node in which + # we have a list folder called `attributes` in which we have + # - Instance Attributes + # - Creator Attributes + # - Publish Attributes + # With also a separate `advanced` section for specific attributes + parm_group = node.parmTemplateGroup() + + # Create default folder parm structure + ayon_folder = parm_group.findFolder("AYON") + if not ayon_folder: + ayon_folder = hou.FolderParmTemplate("folder", "AYON") + parm_group.addParmTemplate(ayon_folder) + + # Add publish button + publish_button_parm = hou.ButtonParmTemplate( + "AYON_self_publish", + "Publish", + help="Directly publishes only this node and any inputs with a " + "comment entered in a popup prompt. All other instances will " + "be disabled in the publish.", + script_callback="from ayon_houdini.api.lib import " + "self_publish; self_publish()", + script_callback_language=hou.scriptLanguage.Python, + ) + ayon_folder.addParmTemplate(publish_button_parm) + + attributes_folder = parm_group.find("AYON_attributes") + if not attributes_folder: + attributes_folder = hou.FolderParmTemplate( + "AYON_attributes", + "Attributes", + folder_type=hou.folderType.Collapsible + ) + ayon_folder.addParmTemplate(attributes_folder) + + # Create Instance, Creator and Publish attributes folders + instance_attributes_folder = parm_group.find("AYON_instance_attributes") + if not instance_attributes_folder: + instance_attributes_folder = hou.FolderParmTemplate( + "AYON_instance_attributes", + "Instance Attributes", + folder_type=hou.folderType.Simple + ) + attributes_folder.addParmTemplate(instance_attributes_folder) + + creator_attributes_folder = parm_group.find("AYON_creator_attributes") + if not creator_attributes_folder: + creator_attributes_folder = hou.FolderParmTemplate( + "AYON_creator_attributes", + "Creator Attributes", + folder_type=hou.folderType.Simple + ) + attributes_folder.addParmTemplate(creator_attributes_folder) + + publish_attributes_folder = parm_group.find("AYON_publish_attributes") + if not publish_attributes_folder: + publish_attributes_folder = hou.FolderParmTemplate( + "AYON_publish_attributes", + "Publish Attributes", + folder_type=hou.folderType.Simple + ) + attributes_folder.addParmTemplate(publish_attributes_folder) + + # Create Advanced Folder + advanced_folder = parm_group.find("AYON_advanced") + if not advanced_folder: + advanced_folder = hou.FolderParmTemplate( + "AYON_advanced", + "Advanced", + folder_type=hou.folderType.Collapsible + ) + ayon_folder.addParmTemplate(advanced_folder) + + # Get the creator and publish attribute definitions so that we can + # generate matching Houdini parm types, including label, tooltips, etc. + creator_attribute_defs = created_instance.creator_attributes.attr_defs + for attr_def in creator_attribute_defs: + parm_template = attribute_def_to_parm_template( + attr_def, + key=f"AYON_creator_attributes_{attr_def.key}") + + name = parm_template.name() + existing = parm_group.find(name) + if existing: + # Remove from Parm Group - and also from the folder itself + # because that reference is not live anymore to the parm + # group itself so will still have the parm template + parm_group.remove(name) + creator_attributes_folder.setParmTemplates([ + t for t in creator_attributes_folder.parmTemplates() + if t.name() != name + ]) + creator_attributes_folder.addParmTemplate(parm_template) + + for plugin_name, plugin_attr_values in created_instance.publish_attributes.items(): + prefix = f"AYON_publish_attributes_{plugin_name}_" + for attr_def in plugin_attr_values.attr_defs: + parm_template = attribute_def_to_parm_template( + attr_def, + key=f"{prefix}{attr_def.key}" + ) + + name = parm_template.name() + existing = parm_group.find(name) + if existing: + # Remove from Parm Group - and also from the folder itself + # because that reference is not live anymore to the parm + # group itself so will still have the parm template + parm_group.remove(name) + publish_attributes_folder.setParmTemplates([ + t for t in publish_attributes_folder.parmTemplates() + if t.name() != name + ]) + publish_attributes_folder.addParmTemplate(parm_template) + + # Define the product types picker options + node_type_product_types: node_wrap.NodeTypeProductTypes = ( + self.node_type_product_types.get( + node.type().name(), self.node_type_product_types_default + )) + product_type_kwargs = { + "menu_items": node_type_product_types.product_types, + "default_value": (node_type_product_types.default,) + } + if node_type_product_types.strict: + product_type_kwargs["menu_type"] = hou.menuType.Normal + else: + product_type_kwargs["menu_type"] = hou.menuType.StringReplace + + # Add the Folder Path, Task Name, Product Type, Variant, Product Name + # and Active state in Instance attributes + for attribute in [ + hou.StringParmTemplate( + "AYON_folderPath", "Folder Path", + num_components=1, + default_value=("$AYON_FOLDER_PATH",) + ), + hou.StringParmTemplate( + "AYON_task", "Task Name", + num_components=1, + default_value=("$AYON_TASK_NAME",) + ), + hou.StringParmTemplate( + "AYON_productType", "Product Type", + num_components=1, + **product_type_kwargs + ), + hou.StringParmTemplate( + "AYON_variant", "Variant", + num_components=1, + default_value=("$OS",) + ), + hou.StringParmTemplate( + "AYON_productName", "Product Name", + num_components=1, + # TODO: This default value should adhere more to AYON's + # product name templates. We might need to avoid making + # this field editable and set up callbacks so it evaluates + # dynamically using the `get_product_name` logic based off + # of other attributes on the node + default_value=('`chs("AYON_productType")``chs("AYON_variant")`',) + ), + hou.ToggleParmTemplate( + "AYON_active", "Active", + default_value=True, + help="Whether the instance is enabled for publishing." + ) + ]: + if not parm_group.find(attribute.name()): + instance_attributes_folder.addParmTemplate(attribute) + + # Add the Creator Identifier and ID in advanced + for attribute in [ + hou.StringParmTemplate( + "AYON_id", "ID", + num_components=1, + default_value=(plugin.AYON_INSTANCE_ID,) + ), + hou.StringParmTemplate( + "AYON_creator_identifier", "Creator Identifier", + num_components=1, + default_value=(self.identifier,) + ), + ]: + if not parm_group.find(attribute.name()): + advanced_folder.addParmTemplate(attribute) + + # Ensure all folders are up-to-date if they had previously existed + # already + for folder in [ayon_folder, + attributes_folder, + instance_attributes_folder, + publish_attributes_folder, + creator_attributes_folder, + advanced_folder]: + if parm_group.find(folder.name()): + parm_group.replace(folder.name(), folder) # replace + node.setParmTemplateGroup(parm_group) + + def imprint(self, + created_instance: CreatedInstance, + values: dict, + update=False): + + # Do not ever write these into the node. + values.pop("instance_node", None) + values.pop("instance_id", None) + values.pop("families", None) + if not values: + return + + instance_node = hou.node(created_instance.get("instance_node")) + + # Update attribute definition parms + self.create_attribute_def_parms(instance_node, created_instance) + + # Creator attributes to parms + creator_attributes = values.pop("creator_attributes", {}) + parm_values = {} + for attr, value in creator_attributes.items(): + key = f"AYON_creator_attributes_{attr}" + parm_values[key] = value + + # Publish attributes to parms + publish_attributes = values.pop("publish_attributes", {}) + for plugin_name, plugin_attr_values in publish_attributes.items(): + for attr, value in plugin_attr_values.items(): + key = f"AYON_publish_attributes_{plugin_name}_{attr}" + parm_values[key] = value + + # The remainder attributes are stored without any prefixes + # Prefix all values with `AYON_` + parm_values.update( + {f"AYON_{key}": value for key, value in values.items()} + ) + + set_values(instance_node, parm_values) + + # TODO: Update defaults for Product Name + # on the node so Houdini doesn't show them bold after save + + def get_publish_families(self): + return [self.product_type] + + def get_instance_attr_defs(self): + """get instance attribute definitions. + + Attributes defined in this method are exposed in + publish tab in the publisher UI. + """ + + render_target_items = { + "local": "Local machine rendering", + "local_no_render": "Use existing frames (local)", + "farm": "Farm Rendering", + } + + return [ + # TODO: This review toggle may be odd - because a regular + # pointcache creator does not have the review toggle but with + # this it does. Is that confusing? Can we make it so that `review` + # only shows when relevant? + BoolDef("review", + label="Review", + tooltip="Mark as reviewable", + default=True), + # TODO: This render target isn't actually 'accurate' for render + # instances that should also allow a split export+render workflow. + EnumDef("render_target", + items=render_target_items, + label="Render target", + default=self.render_target) + ] + + def get_pre_create_attr_defs(self): + return [ + TextDef("productType", + label="Product Type", + tooltip=( + "Publish product type. When set to " + f"{self.USE_DEFAULT_PRODUCT_TYPE} it will use " + "the project settings to define the default value."), + default=self.USE_DEFAULT_PRODUCT_TYPE) + ] + + def _get_product_name_dynamic( + self, + project_name, + folder_entity, + task_entity, + variant, + product_type, + host_name=None, + instance=None + ): + if host_name is None: + host_name = self.create_context.host_name + + task_name = task_type = None + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + dynamic_data = self.get_dynamic_data( + project_name, + folder_entity, + task_entity, + variant, + host_name, + instance + ) + + return get_product_name( + project_name, + task_name, + task_type, + host_name, + product_type, + variant, + dynamic_data=dynamic_data, + project_settings=self.project_settings + ) + + def get_network_categories(self): + # Do not show anywhere in TAB menus since it applies to existing nodes + return [] diff --git a/client/ayon_houdini/plugins/publish/collect_farm_instances.py b/client/ayon_houdini/plugins/publish/collect_farm_instances.py index f14ff65518..856a4e50aa 100644 --- a/client/ayon_houdini/plugins/publish/collect_farm_instances.py +++ b/client/ayon_houdini/plugins/publish/collect_farm_instances.py @@ -11,7 +11,8 @@ class CollectFarmInstances(plugin.HoudiniInstancePlugin): "redshift_rop", "arnold_rop", "vray_rop", - "usdrender"] + "usdrender", + "generic"] targets = ["local", "remote"] label = "Collect farm instances" diff --git a/client/ayon_houdini/plugins/publish/collect_frames.py b/client/ayon_houdini/plugins/publish/collect_frames.py index a442e74835..02fe1f7c0a 100644 --- a/client/ayon_houdini/plugins/publish/collect_frames.py +++ b/client/ayon_houdini/plugins/publish/collect_frames.py @@ -16,7 +16,7 @@ class CollectFrames(plugin.HoudiniInstancePlugin): label = "Collect Frames" families = ["camera", "vdbcache", "imagesequence", "ass", "redshiftproxy", "review", "pointcache", "fbx", - "model"] + "model", "rop"] def process(self, instance): diff --git a/client/ayon_houdini/plugins/publish/collect_houdini_batch_families.py b/client/ayon_houdini/plugins/publish/collect_houdini_batch_families.py new file mode 100644 index 0000000000..f39452e34b --- /dev/null +++ b/client/ayon_houdini/plugins/publish/collect_houdini_batch_families.py @@ -0,0 +1,22 @@ +import pyblish.api + + +class CollectNoProductTypeFamilyGeneric(pyblish.api.InstancePlugin): + """Collect data for caching to Deadline.""" + + order = pyblish.api.CollectorOrder - 0.49 + families = ["generic"] + hosts = ["houdini"] + targets = ["local", "remote"] + label = "Collect Data for Cache" + + def process(self, instance): + # Do not allow `productType` to creep into the pyblish families + # so that e.g. any regular plug-ins for `pointcache` or alike do + # not trigger. + instance.data["family"] = "generic" + # TODO: Do not add the dynamic 'rop' family in the collector but + # set it in th Creator? Unfortunately that's currently not possible + # due to logic in the ayon-core `CollectFromCreateContext` that + # sets `family` to `productType`. + instance.data["families"] = ["generic", "rop"] \ No newline at end of file diff --git a/client/ayon_houdini/plugins/publish/extract_rop.py b/client/ayon_houdini/plugins/publish/extract_rop.py index 4944060678..97ee1ed52b 100644 --- a/client/ayon_houdini/plugins/publish/extract_rop.py +++ b/client/ayon_houdini/plugins/publish/extract_rop.py @@ -14,7 +14,7 @@ class ExtractROP(plugin.HoudiniExtractorPlugin): order = pyblish.api.ExtractorOrder families = ["abc", "camera", "bgeo", "pointcache", "fbx", - "vdbcache", "ass", "redshiftproxy", "mantraifd"] + "vdbcache", "ass", "redshiftproxy", "mantraifd", "rop"] targets = ["local", "remote"] def process(self, instance: pyblish.api.Instance): @@ -31,7 +31,14 @@ def process(self, instance: pyblish.api.Instance): ) ext = ext.lstrip(".") - self.render_rop(instance) + render_target: str = instance.data.get("creator_attributes", {}).get( + "render_target", "local") + if render_target == "local": + self.render_rop(instance) + else: + self.log.debug(f"Skipping render because render target is not " + f"'local' but '{render_target}") + self.validate_expected_frames(instance) # In some cases representation name is not the the extension diff --git a/client/ayon_houdini/startup/OPmenu.xml b/client/ayon_houdini/startup/OPmenu.xml index 5637d2cf6a..56bf9a7f80 100644 --- a/client/ayon_houdini/startup/OPmenu.xml +++ b/client/ayon_houdini/startup/OPmenu.xml @@ -5,6 +5,24 @@ + + + + + hasattr(kwargs["node"], "render") + + + + + + + opmenu.unsynchronize diff --git a/client/ayon_houdini/startup/scripts/OnCreated.py b/client/ayon_houdini/startup/scripts/OnCreated.py new file mode 100644 index 0000000000..829380909d --- /dev/null +++ b/client/ayon_houdini/startup/scripts/OnCreated.py @@ -0,0 +1,18 @@ +# Any code here will run on any node being created in Houdini +# As such, preferably the code here should run fast to avoid slowing down node +# creation. Note: It does not trigger on existing nodes for scene open nor on +# node copy-pasting. +from ayon_core.lib import env_value_to_bool, is_dev_mode_enabled +from ayon_houdini.api import node_wrap + + +# Allow easier development by automatic reloads +# TODO: remove this +if is_dev_mode_enabled(): + import importlib + importlib.reload(node_wrap) + + +if env_value_to_bool("AYON_HOUDINI_AUTOCREATE", default=True): + node = kwargs["node"] # noqa: F821 + node_wrap.autocreate_publishable(node)