moved houdini into openpype

This commit is contained in:
Jakub Trllo 2022-02-04 18:24:31 +01:00
parent f2a9543712
commit 7c22eee4d9
8 changed files with 707 additions and 187 deletions

View file

@ -1,174 +1,60 @@
import os
import sys
import logging
import contextlib
from .pipeline import (
install,
uninstall,
import hou
from pyblish import api as pyblish
from avalon import api as avalon
import openpype.hosts.houdini
from openpype.hosts.houdini.api import lib
from openpype.lib import (
any_outdated
ls,
containerise,
)
from .lib import get_asset_fps
from .plugin import (
Creator,
)
log = logging.getLogger("openpype.hosts.houdini")
from .workio import (
open_file,
save_file,
current_file,
has_unsaved_changes,
file_extensions,
work_root
)
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.houdini.__file__))
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
from .lib import (
lsattr,
lsattrs,
read,
maintained_selection,
unique_name
)
def install():
__all__ = [
"install",
"uninstall",
pyblish.register_plugin_path(PUBLISH_PATH)
avalon.register_plugin_path(avalon.Loader, LOAD_PATH)
avalon.register_plugin_path(avalon.Creator, CREATE_PATH)
"ls",
"containerise",
log.info("Installing callbacks ... ")
# avalon.on("init", on_init)
avalon.before("save", before_save)
avalon.on("save", on_save)
avalon.on("open", on_open)
avalon.on("new", on_new)
"Creator",
pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled)
# Workfiles API
"open_file",
"save_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
log.info("Setting default family states for loader..")
avalon.data["familiesStateToggled"] = [
"imagesequence",
"review"
]
# Utility functions
"lsattr",
"lsattrs",
"read",
# add houdini vendor packages
hou_pythonpath = os.path.join(os.path.dirname(HOST_DIR), "vendor")
"maintained_selection",
"unique_name"
]
sys.path.append(hou_pythonpath)
# Set asset FPS for the empty scene directly after launch of Houdini
# so it initializes into the correct scene FPS
_set_asset_fps()
def before_save(*args):
return lib.validate_fps()
def on_save(*args):
avalon.logger.info("Running callback on save..")
nodes = lib.get_id_required_nodes()
for node, new_id in lib.generate_ids(nodes):
lib.set_id(node, new_id, overwrite=False)
def on_open(*args):
if not hou.isUIAvailable():
log.debug("Batch mode detected, ignoring `on_open` callbacks..")
return
avalon.logger.info("Running callback on open..")
# Validate FPS after update_task_from_path to
# ensure it is using correct FPS for the asset
lib.validate_fps()
if any_outdated():
from openpype.widgets import popup
log.warning("Scene has outdated content.")
# Get main window
parent = hou.ui.mainQtWindow()
if parent is None:
log.info("Skipping outdated content pop-up "
"because Houdini window can't be found.")
else:
# Show outdated pop-up
def _on_show_inventory():
import avalon.tools.sceneinventory as tool
tool.show(parent=parent)
dialog = popup.Popup(parent=parent)
dialog.setWindowTitle("Houdini scene has outdated content")
dialog.setMessage("There are outdated containers in "
"your Houdini scene.")
dialog.on_clicked.connect(_on_show_inventory)
dialog.show()
def on_new(_):
"""Set project resolution and fps when create a new file"""
avalon.logger.info("Running callback on new..")
_set_asset_fps()
def _set_asset_fps():
"""Set Houdini scene FPS to the default required for current asset"""
# Set new scene fps
fps = get_asset_fps()
print("Setting scene FPS to %i" % fps)
lib.set_scene_fps(fps)
def on_pyblish_instance_toggled(instance, new_value, old_value):
"""Toggle saver tool passthrough states on instance toggles."""
@contextlib.contextmanager
def main_take(no_update=True):
"""Enter root take during context"""
original_take = hou.takes.currentTake()
original_update_mode = hou.updateModeSetting()
root = hou.takes.rootTake()
has_changed = False
try:
if original_take != root:
has_changed = True
if no_update:
hou.setUpdateMode(hou.updateMode.Manual)
hou.takes.setCurrentTake(root)
yield
finally:
if has_changed:
if no_update:
hou.setUpdateMode(original_update_mode)
hou.takes.setCurrentTake(original_take)
if not instance.data.get("_allowToggleBypass", True):
return
nodes = instance[:]
if not nodes:
return
# Assume instance node is first node
instance_node = nodes[0]
if not hasattr(instance_node, "isBypassed"):
# Likely not a node that can actually be bypassed
log.debug("Can't bypass node: %s", instance_node.path())
return
if instance_node.isBypassed() != (not old_value):
print("%s old bypass state didn't match old instance state, "
"updating anyway.." % instance_node.path())
try:
# Go into the main take, because when in another take changing
# the bypass state of a note cannot be done due to it being locked
# by default.
with main_take(no_update=True):
instance_node.bypass(not new_value)
except hou.PermissionError as exc:
log.warning("%s - %s", instance_node.path(), exc)
# Backwards API compatibility
open = open_file
save = save_file

View file

@ -2,9 +2,11 @@ import uuid
import logging
from contextlib import contextmanager
from openpype.api import get_asset
import six
from avalon import api, io
from avalon.houdini import lib as houdini
from openpype.api import get_asset
import hou
@ -15,11 +17,11 @@ def get_asset_fps():
"""Return current asset fps."""
return get_asset()["data"].get("fps")
def set_id(node, unique_id, overwrite=False):
def set_id(node, unique_id, overwrite=False):
exists = node.parm("id")
if not exists:
houdini.imprint(node, {"id": unique_id})
imprint(node, {"id": unique_id})
if not exists and overwrite:
node.setParm("id", unique_id)
@ -342,3 +344,183 @@ def render_rop(ropnode):
import traceback
traceback.print_exc()
raise RuntimeError("Render failed: {0}".format(exc))
def children_as_string(node):
return [c.name() for c in node.children()]
def imprint(node, data):
"""Store attributes with value on a node
Depending on the type of attribute it creates the correct parameter
template. Houdini uses a template per type, see the docs for more
information.
http://www.sidefx.com/docs/houdini/hom/hou/ParmTemplate.html
Args:
node(hou.Node): node object from Houdini
data(dict): collection of attributes and their value
Returns:
None
"""
parm_group = node.parmTemplateGroup()
parm_folder = hou.FolderParmTemplate("folder", "Extra")
for key, value in data.items():
if value is None:
continue
if isinstance(value, float):
parm = hou.FloatParmTemplate(name=key,
label=key,
num_components=1,
default_value=(value,))
elif isinstance(value, bool):
parm = hou.ToggleParmTemplate(name=key,
label=key,
default_value=value)
elif isinstance(value, int):
parm = hou.IntParmTemplate(name=key,
label=key,
num_components=1,
default_value=(value,))
elif isinstance(value, six.string_types):
parm = hou.StringParmTemplate(name=key,
label=key,
num_components=1,
default_value=(value,))
else:
raise TypeError("Unsupported type: %r" % type(value))
parm_folder.addParmTemplate(parm)
parm_group.append(parm_folder)
node.setParmTemplateGroup(parm_group)
def lsattr(attr, value=None):
if value is None:
nodes = list(hou.node("/obj").allNodes())
return [n for n in nodes if n.parm(attr)]
return lsattrs({attr: value})
def lsattrs(attrs):
"""Return nodes matching `key` and `value`
Arguments:
attrs (dict): collection of attribute: value
Example:
>> lsattrs({"id": "myId"})
["myNode"]
>> lsattr("id")
["myNode", "myOtherNode"]
Returns:
list
"""
matches = set()
nodes = list(hou.node("/obj").allNodes()) # returns generator object
for node in nodes:
for attr in attrs:
if not node.parm(attr):
continue
elif node.evalParm(attr) != attrs[attr]:
continue
else:
matches.add(node)
return list(matches)
def read(node):
"""Read the container data in to a dict
Args:
node(hou.Node): Houdini node
Returns:
dict
"""
# `spareParms` returns a tuple of hou.Parm objects
return {parameter.name(): parameter.eval() for
parameter in node.spareParms()}
def unique_name(name, format="%03d", namespace="", prefix="", suffix="",
separator="_"):
"""Return unique `name`
The function takes into consideration an optional `namespace`
and `suffix`. The suffix is included in evaluating whether a
name exists - such as `name` + "_GRP" - but isn't included
in the returned value.
If a namespace is provided, only names within that namespace
are considered when evaluating whether the name is unique.
Arguments:
format (str, optional): The `name` is given a number, this determines
how this number is formatted. Defaults to a padding of 2.
E.g. my_name01, my_name02.
namespace (str, optional): Only consider names within this namespace.
suffix (str, optional): Only consider names with this suffix.
Example:
>>> name = hou.node("/obj").createNode("geo", name="MyName")
>>> assert hou.node("/obj/MyName")
True
>>> unique = unique_name(name)
>>> assert hou.node("/obj/{}".format(unique))
False
"""
iteration = 1
parts = [prefix, name, format % iteration, suffix]
if namespace:
parts.insert(0, namespace)
unique = separator.join(parts)
children = children_as_string(hou.node("/obj"))
while unique in children:
iteration += 1
unique = separator.join(parts)
if suffix:
return unique[:-len(suffix)]
return unique
@contextmanager
def maintained_selection():
"""Maintain selection during context
Example:
>>> with maintained_selection():
... # Modify selection
... node.setSelected(on=False, clear_all_selected=True)
>>> # Selection restored
"""
previous_selection = hou.selectedNodes()
try:
yield
finally:
# Clear the selection
# todo: does hou.clearAllSelected() do the same?
for node in hou.selectedNodes():
node.setSelected(on=False)
if previous_selection:
for node in previous_selection:
node.setSelected(on=True)

View file

@ -0,0 +1,347 @@
import os
import sys
import logging
import contextlib
import hou
import pyblish.api
import avalon.api
from avalon.pipeline import AVALON_CONTAINER_ID
from avalon.lib import find_submodule
import openpype.hosts.houdini
from openpype.hosts.houdini.api import lib
from openpype.lib import (
any_outdated
)
from .lib import get_asset_fps
log = logging.getLogger("openpype.hosts.houdini")
AVALON_CONTAINERS = "/obj/AVALON_CONTAINERS"
IS_HEADLESS = not hasattr(hou, "ui")
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.houdini.__file__))
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
self = sys.modules[__name__]
self._has_been_setup = False
self._parent = None
self._events = dict()
def install():
_register_callbacks()
pyblish.api.register_host("houdini")
pyblish.api.register_host("hython")
pyblish.api.register_host("hpython")
pyblish.api.register_plugin_path(PUBLISH_PATH)
avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH)
avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH)
log.info("Installing callbacks ... ")
# avalon.on("init", on_init)
avalon.api.before("save", before_save)
avalon.api.on("save", on_save)
avalon.api.on("open", on_open)
avalon.api.on("new", on_new)
pyblish.api.register_callback("instanceToggled", on_pyblish_instance_toggled)
log.info("Setting default family states for loader..")
avalon.api.data["familiesStateToggled"] = [
"imagesequence",
"review"
]
self._has_been_setup = True
# add houdini vendor packages
hou_pythonpath = os.path.join(os.path.dirname(HOST_DIR), "vendor")
sys.path.append(hou_pythonpath)
# Set asset FPS for the empty scene directly after launch of Houdini
# so it initializes into the correct scene FPS
_set_asset_fps()
def uninstall():
"""Uninstall Houdini-specific functionality of avalon-core.
This function is called automatically on calling `api.uninstall()`.
"""
pyblish.api.deregister_host("hython")
pyblish.api.deregister_host("hpython")
pyblish.api.deregister_host("houdini")
def _register_callbacks():
for handler, event in self._events.copy().items():
if event is None:
continue
try:
hou.hipFile.removeEventCallback(event)
except RuntimeError as e:
log.info(e)
self._events[on_file_event_callback] = hou.hipFile.addEventCallback(
on_file_event_callback
)
def on_file_event_callback(event):
if event == hou.hipFileEventType.AfterLoad:
avalon.api.emit("open", [event])
elif event == hou.hipFileEventType.AfterSave:
avalon.api.emit("save", [event])
elif event == hou.hipFileEventType.BeforeSave:
avalon.api.emit("before_save", [event])
elif event == hou.hipFileEventType.AfterClear:
avalon.api.emit("new", [event])
def get_main_window():
"""Acquire Houdini's main window"""
if self._parent is None:
self._parent = hou.ui.mainQtWindow()
return self._parent
def teardown():
"""Remove integration"""
if not self._has_been_setup:
return
self._has_been_setup = False
print("pyblish: Integration torn down successfully")
def containerise(name,
namespace,
nodes,
context,
loader=None,
suffix=""):
"""Bundle `nodes` into a subnet and imprint it with metadata
Containerisation enables a tracking of version, author and origin
for loaded assets.
Arguments:
name (str): Name of resulting assembly
namespace (str): Namespace under which to host container
nodes (list): Long names of nodes to containerise
context (dict): Asset information
loader (str, optional): Name of loader used to produce this container.
suffix (str, optional): Suffix of container, defaults to `_CON`.
Returns:
container (str): Name of container assembly
"""
# Ensure AVALON_CONTAINERS subnet exists
subnet = hou.node(AVALON_CONTAINERS)
if subnet is None:
obj_network = hou.node("/obj")
subnet = obj_network.createNode("subnet",
node_name="AVALON_CONTAINERS")
# Create proper container name
container_name = "{}_{}".format(name, suffix or "CON")
container = hou.node("/obj/{}".format(name))
container.setName(container_name, unique_name=True)
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace,
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
}
lib.imprint(container, data)
# "Parent" the container under the container network
hou.moveNodesTo([container], subnet)
subnet.node(container_name).moveToGoodPosition()
return container
def parse_container(container):
"""Return the container node's full container data.
Args:
container (hou.Node): A container node name.
Returns:
dict: The container schema data for this container node.
"""
data = lib.read(container)
# Backwards compatibility pre-schemas for containers
data["schema"] = data.get("schema", "openpype:container-1.0")
# Append transient data
data["objectName"] = container.path()
data["node"] = container
return data
def ls():
containers = []
for identifier in (AVALON_CONTAINER_ID,
"pyblish.mindbender.container"):
containers += lib.lsattr("id", identifier)
has_metadata_collector = False
config_host = find_submodule(avalon.api.registered_config(), "houdini")
if hasattr(config_host, "collect_container_metadata"):
has_metadata_collector = True
for container in sorted(containers,
# Hou 19+ Python 3 hou.ObjNode are not
# sortable due to not supporting greater
# than comparisons
key=lambda node: node.path()):
data = parse_container(container)
# Collect custom data if attribute is present
if has_metadata_collector:
metadata = config_host.collect_container_metadata(container)
data.update(metadata)
yield data
def before_save(*args):
return lib.validate_fps()
def on_save(*args):
log.info("Running callback on save..")
nodes = lib.get_id_required_nodes()
for node, new_id in lib.generate_ids(nodes):
lib.set_id(node, new_id, overwrite=False)
def on_open(*args):
if not hou.isUIAvailable():
log.debug("Batch mode detected, ignoring `on_open` callbacks..")
return
log.info("Running callback on open..")
# Validate FPS after update_task_from_path to
# ensure it is using correct FPS for the asset
lib.validate_fps()
if any_outdated():
from openpype.widgets import popup
log.warning("Scene has outdated content.")
# Get main window
parent = get_main_window()
if parent is None:
log.info("Skipping outdated content pop-up "
"because Houdini window can't be found.")
else:
# Show outdated pop-up
def _on_show_inventory():
from openpype.tools.utils import host_tools
host_tools.show_scene_inventory(parent=parent)
dialog = popup.Popup(parent=parent)
dialog.setWindowTitle("Houdini scene has outdated content")
dialog.setMessage("There are outdated containers in "
"your Houdini scene.")
dialog.on_clicked.connect(_on_show_inventory)
dialog.show()
def on_new(_):
"""Set project resolution and fps when create a new file"""
log.info("Running callback on new..")
_set_asset_fps()
def _set_asset_fps():
"""Set Houdini scene FPS to the default required for current asset"""
# Set new scene fps
fps = get_asset_fps()
print("Setting scene FPS to %i" % fps)
lib.set_scene_fps(fps)
def on_pyblish_instance_toggled(instance, new_value, old_value):
"""Toggle saver tool passthrough states on instance toggles."""
@contextlib.contextmanager
def main_take(no_update=True):
"""Enter root take during context"""
original_take = hou.takes.currentTake()
original_update_mode = hou.updateModeSetting()
root = hou.takes.rootTake()
has_changed = False
try:
if original_take != root:
has_changed = True
if no_update:
hou.setUpdateMode(hou.updateMode.Manual)
hou.takes.setCurrentTake(root)
yield
finally:
if has_changed:
if no_update:
hou.setUpdateMode(original_update_mode)
hou.takes.setCurrentTake(original_take)
if not instance.data.get("_allowToggleBypass", True):
return
nodes = instance[:]
if not nodes:
return
# Assume instance node is first node
instance_node = nodes[0]
if not hasattr(instance_node, "isBypassed"):
# Likely not a node that can actually be bypassed
log.debug("Can't bypass node: %s", instance_node.path())
return
if instance_node.isBypassed() != (not old_value):
print("%s old bypass state didn't match old instance state, "
"updating anyway.." % instance_node.path())
try:
# Go into the main take, because when in another take changing
# the bypass state of a note cannot be done due to it being locked
# by default.
with main_take(no_update=True):
instance_node.bypass(not new_value)
except hou.PermissionError as exc:
log.warning("%s - %s", instance_node.path(), exc)

View file

@ -1,25 +1,82 @@
# -*- coding: utf-8 -*-
"""Houdini specific Avalon/Pyblish plugin definitions."""
import sys
from avalon.api import CreatorError
from avalon import houdini
import six
import avalon.api
from avalon.api import CreatorError
import hou
from openpype.api import PypeCreatorMixin
from .lib import imprint
class OpenPypeCreatorError(CreatorError):
pass
class Creator(PypeCreatorMixin, houdini.Creator):
class Creator(PypeCreatorMixin, avalon.api.Creator):
"""Creator plugin to create instances in Houdini
To support the wide range of node types for render output (Alembic, VDB,
Mantra) the Creator needs a node type to create the correct instance
By default, if none is given, is `geometry`. An example of accepted node
types: geometry, alembic, ifd (mantra)
Please check the Houdini documentation for more node types.
Tip: to find the exact node type to create press the `i` left of the node
when hovering over a node. The information is visible under the name of
the node.
"""
def __init__(self, *args, **kwargs):
super(Creator, self).__init__(*args, **kwargs)
self.nodes = list()
def process(self):
"""This is the base functionality to create instances in Houdini
The selected nodes are stored in self to be used in an override method.
This is currently necessary in order to support the multiple output
types in Houdini which can only be rendered through their own node.
Default node type if none is given is `geometry`
It also makes it easier to apply custom settings per instance type
Example of override method for Alembic:
def process(self):
instance = super(CreateEpicNode, self, process()
# Set paramaters for Alembic node
instance.setParms(
{"sop_path": "$HIP/%s.abc" % self.nodes[0]}
)
Returns:
hou.Node
"""
try:
# re-raise as standard Python exception so
# Avalon can catch it
instance = super(Creator, self).process()
if (self.options or {}).get("useSelection"):
self.nodes = hou.selectedNodes()
# Get the node type and remove it from the data, not needed
node_type = self.data.pop("node_type", None)
if node_type is None:
node_type = "geometry"
# Get out node
out = hou.node("/out")
instance = out.createNode(node_type, node_name=self.name)
instance.moveToGoodPosition()
imprint(instance, self.data)
self._process(instance)
except hou.Error as er:
six.reraise(
OpenPypeCreatorError,

View file

@ -0,0 +1,58 @@
"""Host API required Work Files tool"""
import os
import hou
from avalon import api
def file_extensions():
return api.HOST_WORKFILE_EXTENSIONS["houdini"]
def has_unsaved_changes():
return hou.hipFile.hasUnsavedChanges()
def save_file(filepath):
# Force forwards slashes to avoid segfault
filepath = filepath.replace("\\", "/")
hou.hipFile.save(file_name=filepath,
save_to_recent_files=True)
return filepath
def open_file(filepath):
# Force forwards slashes to avoid segfault
filepath = filepath.replace("\\", "/")
hou.hipFile.load(filepath,
suppress_save_prompt=True,
ignore_load_warnings=False)
return filepath
def current_file():
current_filepath = hou.hipFile.path()
if (os.path.basename(current_filepath) == "untitled.hip" and
not os.path.exists(current_filepath)):
# By default a new scene in houdini is saved in the current
# working directory as "untitled.hip" so we need to capture
# that and consider it 'not saved' when it's in that state.
return None
return current_filepath
def work_root(session):
work_dir = session["AVALON_WORKDIR"]
scene_dir = session.get("AVALON_SCENEDIR")
if scene_dir:
return os.path.join(work_dir, scene_dir)
else:
return work_dir

View file

@ -56,18 +56,6 @@ host_tools.show_workfiles(parent)
]]></scriptCode>
</scriptItem>
<separatorItem/>
<subMenu id="avalon_reload_pipeline">
<label>System</label>
<scriptItem>
<label>Reload Pipeline (unstable)</label>
<scriptCode><![CDATA[
from avalon.houdini import pipeline
pipeline.reload_pipeline()]]></scriptCode>
</scriptItem>
</subMenu>
<separatorItem/>
<scriptItem id="experimental_tools">
<label>Experimental tools...</label>

View file

@ -1,9 +1,10 @@
from avalon import api, houdini
import avalon.api
from openpype.hosts.houdini import api
def main():
print("Installing OpenPype ...")
api.install(houdini)
avalon.api.install(api)
main()

View file

@ -1,9 +1,10 @@
from avalon import api, houdini
import avalon.api
from openpype.hosts.houdini import api
def main():
print("Installing OpenPype ...")
api.install(houdini)
avalon.api.install(api)
main()