Add Dynamic (in-memory) runtime creator for Houdini + add Generic ROP creator for Houdini

This commit is contained in:
Roy Nieterau 2024-05-20 07:55:22 +02:00
parent 3dae818df6
commit 24dfc22c74
5 changed files with 875 additions and 1 deletions

View file

@ -0,0 +1,134 @@
import os
from ayon_core.pipeline.create import (
Creator,
CreatedInstance,
get_product_name
)
from ayon_api import get_folder_by_path, get_task_by_name
def create_representation_data(files):
"""Create representation data needed for `instance.data['representations']"""
first_file = files[0]
folder, filename = os.path.split(first_file)
ext = os.path.splitext(filename)[-1].strip(".")
return {
"name": ext,
"ext": ext,
"files": files if len(files) > 1 else first_file,
"stagingDir": folder,
}
class CreateRuntimeInstance(Creator):
"""Create in-memory instances for dynamic PDG publishing of files.
These instances do not persist and are meant for headless automated
publishing. The created instances are transient and will be gone on
resetting the `CreateContext` since they will not be recollected.
"""
# TODO: This should be a global HIDDEN creator instead!
identifier = "io.openpype.creators.houdini.batch"
label = "Ingest"
product_type = "dynamic" # not actually used
icon = "gears"
def create(self, product_name, instance_data, pre_create_data):
# 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
product_type = pre_create_data.get("product_type") or instance_data["product_type"]
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"])
product_name = self._get_product_name_dynamic(
self.create_context.project_name,
folder_entity=folder_entity,
task_entity=task_entity,
variant=instance_data["variant"],
product_type=product_type
)
custom_instance_data = pre_create_data.get("instance_data")
if custom_instance_data:
instance_data.update(custom_instance_data)
# TODO: Add support for multiple representations
files = pre_create_data["files"]
representations = [create_representation_data(files)]
instance_data["representations"] = representations
# We ingest it as a different product type then the creator's generic
# ingest product type. For example, we specify `pointcache`
instance = CreatedInstance(
product_type=product_type,
product_name=product_name,
data=instance_data,
creator=self
)
self._add_instance_to_context(instance)
return instance
# Instances are all dynamic at run-time and cannot be persisted or
# re-collected
def collect_instances(self):
pass
def update_instances(self, update_list):
pass
def remove_instances(self, instances):
for instance in instances:
self._remove_instance_from_context(instance)
# def get_publish_families(self):
# return [self.product_type]
def _get_product_name_dynamic(
self,
project_name,
folder_entity,
task_entity,
variant,
product_type,
host_name=None,
instance=None
):
"""Implementation similar to `self.get_product_name` but taking
`productType` as argument instead of using the 'generic' product type
on the Creator itself."""
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
)

View file

@ -0,0 +1,584 @@
from ayon_core.hosts.houdini.api import plugin
from ayon_core.hosts.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 (
AbstractAttrDef,
BoolDef,
NumberDef,
EnumDef,
TextDef,
UISeparatorDef,
UILabelDef,
FileDef
)
import hou
import json
def attribute_def_to_parm_template(attribute_def, key=None):
"""AYON Attribute Definition to Houdini Parm Template.
Arguments:
attribute_def (AbstractAttrDef): Attribute Definition.
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]
print(attribute_def.default)
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):
"""
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"""
host_name = "houdini"
identifier = "io.ayon.creators.houdini.publish"
label = "Generic"
product_type = "generic"
icon = "male"
description = "Make any ROP node publishable."
# TODO: Override "create" to create the AYON publish attributes on the
# selected node so it becomes a publishable instance.
render_target = "local_no_render"
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", "pointcache")
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"])
product_name = self._get_product_name_dynamic(
self.create_context.project_name,
folder_entity=folder_entity,
task_entity=task_entity,
variant=instance_data["variant"],
product_type=product_type
)
for node in hou.selectedNodes():
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
instance_data["instance_node"] = node.path()
instance_data["instance_id"] = node.path()
created_instance = CreatedInstance(
product_type, product_name, instance_data.copy(), self
)
# Imprint on the selected node
self.imprint(created_instance, values=instance_data, update=False)
# Add instance
self._add_instance_to_context(created_instance)
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_"):
if value == 0 or value == 1:
value = bool(value)
plugin_name, plugin_key = key[len("publish_attributes_"):].split("_", 1)
publish_attributes.setdefault(plugin_name, {})[plugin_key] = value
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)
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)
# TODO
# 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,
default_value=("pointcache",)
),
hou.StringParmTemplate(
"AYON_variant", "Variant",
num_components=1,
default_value=(self.default_variant,)
),
hou.StringParmTemplate(
"AYON_productName", "Product Name",
num_components=1,
default_value=('`chs("AYON_productType")``chs("AYON_variant")`',)
),
hou.ToggleParmTemplate(
"AYON_active", "Active",
default_value=True
)
]:
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 Variant, Product Type, 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 [
BoolDef("review",
label="Review",
tooltip="Mark as reviewable",
default=True),
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",
default="pointcache")
]
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
)

View file

@ -17,7 +17,7 @@ class CollectFrames(pyblish.api.InstancePlugin):
label = "Collect Frames"
families = ["vdbcache", "imagesequence", "ass",
"mantraifd", "redshiftproxy", "review",
"pointcache"]
"pointcache", "rop"]
def process(self, instance):

View file

@ -0,0 +1,112 @@
import os
import pyblish.api
import hou
from ayon_core.hosts.houdini.api import lib
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?
instance.data["families"] = ["generic", "rop"]
self.log.info("Generic..")
class CollectNoProductTypeFamilyDynamic(pyblish.api.InstancePlugin):
"""Collect data for caching to Deadline."""
order = pyblish.api.CollectorOrder - 0.49
families = ["dynamic"]
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"] = "dynamic"
instance.data["families"] = ["dynamic"]
# TODO: Implement for generic rop class
class CollectDataforCache(pyblish.api.InstancePlugin):
"""Collect data for caching to Deadline."""
# Run after Collect Frames
order = pyblish.api.CollectorOrder + 0.11
families = ["todo"]
hosts = ["houdini"]
targets = ["local", "remote"]
label = "Collect Data for Cache"
def process(self, instance):
creator_attribute = instance.data["creator_attributes"]
farm_enabled = creator_attribute["farm"]
instance.data["farm"] = farm_enabled
if not farm_enabled:
self.log.debug("Caching on farm is disabled. "
"Skipping farm collecting.")
return
# Why do we need this particular collector to collect the expected
# output files from a ROP node. Don't we have a dedicated collector
# for that yet?
# Collect expected files
ropnode = hou.node(instance.data["instance_node"])
output_parm = lib.get_output_parameter(ropnode)
expected_filepath = output_parm.eval()
instance.data.setdefault("files", list())
instance.data.setdefault("expectedFiles", list())
if instance.data.get("frames"):
files = self.get_files(instance, expected_filepath)
# list of files
instance.data["files"].extend(files)
else:
# single file
instance.data["files"].append(output_parm.eval())
cache_files = {"_": instance.data["files"]}
# Convert instance family to pointcache if it is bgeo or abc
# because ???
for family in instance.data["families"]:
if family == "bgeo" or "abc":
instance.data["productType"] = "pointcache"
break
instance.data.update({
"plugin": "Houdini",
"publish": True
})
instance.data["families"].append("publish.hou")
instance.data["expectedFiles"].append(cache_files)
self.log.debug("{}".format(instance.data))
def get_files(self, instance, output_parm):
"""Get the files with the frame range data
Args:
instance (_type_): instance
output_parm (_type_): path of output parameter
Returns:
files: a list of files
"""
directory = os.path.dirname(output_parm)
files = [
os.path.join(directory, frame).replace("\\", "/")
for frame in instance.data["frames"]
]
return files

View file

@ -0,0 +1,44 @@
import pyblish.api
from ayon_core.pipeline import publish
from ayon_core.hosts.houdini.api import lib
import hou
class ExtractROP(publish.Extractor):
"""Render a ROP node and add representation to the instance"""
label = "Extract ROP"
families = ["rop"]
hosts = ["houdini"]
order = pyblish.api.ExtractorOrder + 0.1
def process(self, instance):
if instance.data.get('farm'):
# Will be submitted to farm instead - not rendered locally
return
files = instance.data["frames"]
first_file = files[0] if isinstance(files, (list, tuple)) else files
_, ext = lib.splitext(
first_file, allowed_multidot_extensions=[
".ass.gz", ".bgeo.sc", ".bgeo.gz",
".bgeo.lzma", ".bgeo.bz2"])
ext = ext.lstrip(".") # strip starting dot
# prepare representation
representation = {
"name": ext,
"ext": ext,
"files": files,
"stagingDir": instance.data["stagingDir"]
}
# render rop
ropnode = hou.node(instance.data.get("instance_node"))
lib.render_rop(ropnode)
# add representation
instance.data.setdefault("representations", []).append(representation)