diff --git a/colorbleed/houdini/__init__.py b/colorbleed/houdini/__init__.py new file mode 100644 index 0000000000..f70b2d6198 --- /dev/null +++ b/colorbleed/houdini/__init__.py @@ -0,0 +1,99 @@ +import os +import logging + +import hou + +from pyblish import api as pyblish + +from avalon import api as avalon +from avalon.houdini import pipeline as houdini + +from colorbleed.houdini import lib + +from colorbleed.lib import ( + any_outdated, + update_task_from_path +) + + +PARENT_DIR = os.path.dirname(__file__) +PACKAGE_DIR = os.path.dirname(PARENT_DIR) +PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") + +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "houdini", "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "houdini", "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "houdini", "create") + +log = logging.getLogger("colorbleed.houdini") + + +def install(): + + # Set + + pyblish.register_plugin_path(PUBLISH_PATH) + avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + + log.info("Installing callbacks ... ") + avalon.on("init", on_init) + avalon.on("save", on_save) + avalon.on("open", on_open) + + log.info("Overriding existing event 'taskChanged'") + + log.info("Setting default family states for loader..") + avalon.data["familiesStateToggled"] = ["colorbleed.imagesequence"] + + +def on_init(_): + houdini.on_houdini_initialize() + + +def on_save(_): + + avalon.logger.info("Running callback on save..") + + update_task_from_path(hou.hipFile.path()) + + 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(): + + update_task_from_path(hou.hipFile.path()) + + if any_outdated(): + from avalon.vendor.Qt import QtWidgets + from ..widgets import popup + + log.warning("Scene has outdated content.") + + # Find maya main window + top_level_widgets = {w.objectName(): w for w in + QtWidgets.QApplication.topLevelWidgets()} + parent = top_level_widgets.get("MayaWindow", None) + + if parent is None: + log.info("Skipping outdated content pop-up " + "because Maya window can't be found.") + else: + + # Show outdated pop-up + def _on_show_inventory(): + import avalon.tools.cbsceneinventory as tool + tool.show(parent=parent) + + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Maya scene has outdated content") + dialog.setMessage("There are outdated containers in " + "your Maya scene.") + dialog.on_show.connect(_on_show_inventory) + dialog.show() + + +def on_task_changed(*args): + """Wrapped function of app initialize and maya's on task changed""" + pass \ No newline at end of file diff --git a/colorbleed/houdini/lib.py b/colorbleed/houdini/lib.py new file mode 100644 index 0000000000..cf526ba2b9 --- /dev/null +++ b/colorbleed/houdini/lib.py @@ -0,0 +1,108 @@ +import uuid + +from contextlib import contextmanager + +import hou + +from avalon import api, io +from avalon.houdini import lib + + +def set_id(node, unique_id, overwrite=False): + + exists = node.parm("id") + if not exists: + lib.imprint(node, {"id": unique_id}) + + if not exists and overwrite: + node.setParm("id", unique_id) + + +def get_id(node): + """ + Get the `cbId` attribute of the given node + Args: + node (hou.Node): the name of the node to retrieve the attribute from + + Returns: + str + + """ + + if node is None: + return + + id = node.parm("id") + if node is None: + return + return id + + +def generate_ids(nodes, asset_id=None): + """Returns new unique ids for the given nodes. + + Note: This does not assign the new ids, it only generates the values. + + To assign new ids using this method: + >>> nodes = ["a", "b", "c"] + >>> for node, id in generate_ids(nodes): + >>> set_id(node, id) + + To also override any existing values (and assign regenerated ids): + >>> nodes = ["a", "b", "c"] + >>> for node, id in generate_ids(nodes): + >>> set_id(node, id, overwrite=True) + + Args: + nodes (list): List of nodes. + asset_id (str or bson.ObjectId): The database id for the *asset* to + generate for. When None provided the current asset in the + active session is used. + + Returns: + list: A list of (node, id) tuples. + + """ + + if asset_id is None: + # Get the asset ID from the database for the asset of current context + asset_data = io.find_one({"type": "asset", + "name": api.Session["AVALON_ASSET"]}, + projection={"_id": True}) + assert asset_data, "No current asset found in Session" + asset_id = asset_data['_id'] + + node_ids = [] + for node in nodes: + _, uid = str(uuid.uuid4()).rsplit("-", 1) + unique_id = "{}:{}".format(asset_id, uid) + node_ids.append((node, unique_id)) + + return node_ids + + +def get_id_required_nodes(): + + valid_types = ["geometry"] + nodes = {n for n in hou.node("/out").children() if + n.type().name() in valid_types} + + return list(nodes) + + +def get_additional_data(container): + """Not implemented yet!""" + return container + + +@contextmanager +def attribute_values(node, data): + + previous_attrs = {key: node.parm(key).eval() for key in data.keys()} + try: + node.setParms(data) + yield + except Exception as exc: + pass + finally: + node.setParms(previous_attrs) diff --git a/colorbleed/lib.py b/colorbleed/lib.py index 1297aba606..119dcd4c25 100644 --- a/colorbleed/lib.py +++ b/colorbleed/lib.py @@ -257,16 +257,52 @@ def get_project_fps(): Returns: int, float + + """ + + data = get_project_data() + fps = data.get("fps", 25.0) + + return fps + + +def get_project_data(): + """Get the data of the current project + + The data of the project can contain things like: + resolution + fps + renderer + + Returns: + dict: + """ project_name = io.active_project() project = io.find_one({"name": project_name, "type": "project"}, - projection={"config": True}) + projection={"data": True}) - config = project.get("config", None) - assert config, "This is a bug" + data = project.get("data", {}) - fps = config.get("fps", 25.0) + return data - return fps + +def get_asset_data(asset=None): + """Get the data from the current asset + + Args: + asset(str, Optional): name of the asset, eg: + + Returns: + dict + """ + + asset_name = asset or avalon.api.Session["AVALON_ASSET"] + document = io.find_one({"name": asset_name, + "type": "asset"}) + + data = document.get("data", {}) + + return data diff --git a/colorbleed/maya/__init__.py b/colorbleed/maya/__init__.py index 74ecebba1f..5463cb0097 100644 --- a/colorbleed/maya/__init__.py +++ b/colorbleed/maya/__init__.py @@ -39,6 +39,8 @@ def install(): avalon.before("save", on_before_save) + avalon.on("new", on_new) + log.info("Overriding existing event 'taskChanged'") override_event("taskChanged", on_task_changed) @@ -158,6 +160,13 @@ def on_open(_): dialog.show() +def on_new(_): + """Set project resolution and fps when create a new file""" + avalon.logger.info("Running callback on new..") + with maya.suspended_refresh(): + lib.set_context_settings() + + def on_task_changed(*args): """Wrapped function of app initialize and maya's on task changed""" diff --git a/colorbleed/maya/lib.py b/colorbleed/maya/lib.py index bb41b4a738..59ea54c20d 100644 --- a/colorbleed/maya/lib.py +++ b/colorbleed/maya/lib.py @@ -90,7 +90,7 @@ _alembic_options = { } INT_FPS = {15, 24, 25, 30, 48, 50, 60, 44100, 48000} -FLOAT_FPS = {23.976, 29.97, 29.97, 47.952, 59.94} +FLOAT_FPS = {23.976, 29.97, 47.952, 59.94} def matrix_equals(a, b, tolerance=1e-10): @@ -1371,6 +1371,7 @@ def get_id_from_history(node): return _id +# Project settings def set_scene_fps(fps, update=True): """Set FPS from project configuration @@ -1384,10 +1385,10 @@ def set_scene_fps(fps, update=True): """ if fps in FLOAT_FPS: - unit = "{:f}fps".format(fps) + unit = "{}fps".format(fps) elif fps in INT_FPS: - unit = "{:d}fps".format(int(fps)) + unit = "{}fps".format(int(fps)) else: raise ValueError("Unsupported FPS value: `%s`" % fps) @@ -1399,6 +1400,69 @@ def set_scene_fps(fps, update=True): cmds.file(modified=True) +def set_scene_resolution(width, height): + """Set the render resolution + + Args: + width(int): value of the width + height(int): value of the height + + Returns: + None + + """ + + control_node = "defaultResolution" + current_renderer = cmds.getAttr("defaultRenderGlobals.currentRenderer") + + # Give VRay a helping hand as it is slightly different from the rest + if current_renderer == "vray": + vray_node = "vraySettings" + if cmds.objExists(vray_node): + control_node = vray_node + else: + log.error("Can't set VRay resolution because there is no node " + "named: `%s`" % vray_node) + + log.info("Setting project resolution to: %s x %s" % (width, height)) + cmds.setAttr("%s.width" % control_node, width) + cmds.setAttr("%s.height" % control_node, height) + + +def set_context_settings(): + """Apply the project settings from the project definition + + Settings can be overwritten by an asset if the asset.data contains + any information regarding those settings. + + Examples of settings: + fps + resolution + renderer + + Returns: + None + """ + + # Todo (Wijnand): apply renderer and resolution of project + + project_data = lib.get_project_data() + asset_data = lib.get_asset_data() + + # Set project fps + fps = asset_data.get("fps", project_data.get("fps", 25)) + set_scene_fps(fps) + + # Set project resolution + width_key = "resolution_width" + height_key = "resolution_height" + + width = asset_data.get(width_key, project_data.get(width_key, 1920)) + height = asset_data.get(height_key, project_data.get(height_key, 1080)) + + set_scene_resolution(width, height) + + # Valid FPS def validate_fps(): """Validate current scene FPS and show pop-up when it is incorrect @@ -1408,7 +1472,8 @@ def validate_fps(): """ - fps = lib.get_project_fps() # can be int or float + asset_data = lib.get_asset_data() + fps = asset_data.get("fps", lib.get_project_fps()) # can be int or float current_fps = mel.eval('currentTimeUnitToFPS()') # returns float if current_fps != fps: diff --git a/colorbleed/plugins/houdini/create/create_pointcache.py b/colorbleed/plugins/houdini/create/create_pointcache.py new file mode 100644 index 0000000000..93cd83a6b3 --- /dev/null +++ b/colorbleed/plugins/houdini/create/create_pointcache.py @@ -0,0 +1,30 @@ +from collections import OrderedDict + +import hou + +from avalon import houdini + + +class CreatePointCache(houdini.Creator): + """Alembic pointcache for animated data""" + + name = "pointcache" + label = "Point Cache" + family = "colorbleed.pointcache" + icon = "gears" + + def __init__(self, *args, **kwargs): + super(CreatePointCache, self).__init__(*args, **kwargs) + + # create an ordered dict with the existing data first + data = OrderedDict(**self.data) + + # Set node type to create for output + data["node_type"] = "alembic" + + # Collect animation data for point cache exporting + start, end = hou.playbar.timelineRange() + data["startFrame"] = start + data["endFrame"] = end + + self.data = data diff --git a/colorbleed/plugins/houdini/load/load_alembic.py b/colorbleed/plugins/houdini/load/load_alembic.py new file mode 100644 index 0000000000..5043ba5a0d --- /dev/null +++ b/colorbleed/plugins/houdini/load/load_alembic.py @@ -0,0 +1,98 @@ +from avalon import api + +from avalon.houdini import pipeline, lib + + +class AbcLoader(api.Loader): + """Specific loader of Alembic for the avalon.animation family""" + + families = ["colorbleed.animation", "colorbleed.pointcache"] + label = "Load Animation" + representations = ["abc"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + + import os + import hou + + # Format file name, Houdini only wants forward slashes + file_path = os.path.normpath(self.fname) + file_path = file_path.replace("\\", "/") + + # Get the root node + obj = hou.node("/obj") + + # Create a unique name + counter = 1 + namespace = namespace if namespace else context["asset"]["name"] + formatted = "{}_{}".format(namespace, name) if namespace else name + node_name = "{0}_{1:03d}".format(formatted, counter) + + children = lib.children_as_string(hou.node("/obj")) + while node_name in children: + counter += 1 + node_name = "{0}_{1:03d}".format(formatted, counter) + + # Create a new geo node + container = obj.createNode("geo", node_name=node_name) + + # Remove the file node, it only loads static meshes + node_path = "/obj/{}/file1".format(node_name) + hou.node(node_path) + + # Create an alembic node (supports animation) + alembic = container.createNode("alembic", node_name=node_name) + alembic.setParms({"fileName": file_path}) + + # Add unpack node + unpack = container.createNode("unpack") + unpack.setInput(0, alembic) + unpack.setParms({"transfer_attributes": "path"}) + + # Set new position for unpack node else it gets cluttered + unpack.setPosition([0, -1]) + + # set unpack as display node + unpack.setDisplayFlag(True) + + null_node = container.createNode("null", + node_name="OUT_{}".format(name)) + null_node.setPosition([0, -2]) + null_node.setInput(0, unpack) + + nodes = [container, alembic, unpack, null_node] + + self[:] = nodes + + return pipeline.containerise(node_name, + namespace, + nodes, + context, + self.__class__.__name__) + + def update(self, container, representation): + + node = container["node"] + try: + alembic_node = next(n for n in node.children() if + n.type().name() == "alembic") + except StopIteration: + self.log.error("Could not find node of type `alembic`") + return + + # Update the file path + file_path = api.get_representation_path(representation) + file_path = file_path.replace("\\", "/") + + alembic_node.setParms({"fileName": file_path}) + + # Update attribute + node.setParms({"representation": str(representation["_id"])}) + + def remove(self, container): + + node = container["node"] + node.destroy() diff --git a/colorbleed/plugins/houdini/publish/collect_alembic_nodes.py b/colorbleed/plugins/houdini/publish/collect_alembic_nodes.py new file mode 100644 index 0000000000..dc0de42126 --- /dev/null +++ b/colorbleed/plugins/houdini/publish/collect_alembic_nodes.py @@ -0,0 +1,9 @@ +import pyblish.api + + +class CollectAlembicNodes(pyblish.api.InstancePlugin): + + label = "Collect Alembic Nodes" + + def process(self, instance): + pass \ No newline at end of file diff --git a/colorbleed/plugins/houdini/publish/collect_current_file.py b/colorbleed/plugins/houdini/publish/collect_current_file.py new file mode 100644 index 0000000000..e8612bdc12 --- /dev/null +++ b/colorbleed/plugins/houdini/publish/collect_current_file.py @@ -0,0 +1,15 @@ +import hou + +import pyblish.api + + +class CollectMayaCurrentFile(pyblish.api.ContextPlugin): + """Inject the current working file into context""" + + order = pyblish.api.CollectorOrder - 0.5 + label = "Houdini Current File" + hosts = ['houdini'] + + def process(self, context): + """Inject the current working file""" + context.data['currentFile'] = hou.hipFile.path() diff --git a/colorbleed/plugins/houdini/publish/collect_instances.py b/colorbleed/plugins/houdini/publish/collect_instances.py new file mode 100644 index 0000000000..5f77b9d805 --- /dev/null +++ b/colorbleed/plugins/houdini/publish/collect_instances.py @@ -0,0 +1,68 @@ +import hou + +import pyblish.api + +from avalon.houdini import lib + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by all node in out graph and pre-defined attributes + + This collector takes into account assets that are associated with + an specific node and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance + + Specific node: + The specific node is important because it dictates in which way the subset + is being exported. + + alembic: will export Alembic file which supports cascading attributes + like 'cbId' and 'path' + geometry: Can export a wide range of file types, default out + + """ + + label = "Collect Instances" + order = pyblish.api.CollectorOrder + hosts = ["houdini"] + + def process(self, context): + + instances = [] + + nodes = hou.node("/out").children() + for node in nodes: + + if not node.parm("id"): + continue + + if node.parm("id").eval() != "pyblish.avalon.instance": + continue + + has_family = node.parm("family").eval() + assert has_family, "'%s' is missing 'family'" % node.name() + + data = lib.read(node) + + # temporarily translation of `active` to `publish` till issue has + # been resolved, https://github.com/pyblish/pyblish-base/issues/307 + if "active" in data: + data["publish"] = data["active"] + + instance = context.create_instance(data.get("name", node.name())) + + instance[:] = [node] + instance.data.update(data) + + instances.append(instance) + + def sort_by_family(instance): + """Sort by family""" + return instance.data.get("families", instance.data.get("family")) + + # Sort/grouped by family (preserving local index) + context[:] = sorted(context, key=sort_by_family) + + return context diff --git a/colorbleed/plugins/houdini/publish/extract_alembic.py b/colorbleed/plugins/houdini/publish/extract_alembic.py new file mode 100644 index 0000000000..098020b905 --- /dev/null +++ b/colorbleed/plugins/houdini/publish/extract_alembic.py @@ -0,0 +1,35 @@ +import os + +import pyblish.api +import colorbleed.api +from colorbleed.houdini import lib + + +class ExtractAlembic(colorbleed.api.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract Pointcache (Alembic)" + hosts = ["houdini"] + families = ["colorbleed.pointcache"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + + file_name = "{}.abc".format(instance.data["subset"]) + tmp_filepath = os.path.join(staging_dir, file_name) + + start_frame = float(instance.data["startFrame"]) + end_frame = float(instance.data["endFrame"]) + + ropnode = instance[0] + attributes = {"filename": tmp_filepath, + "trange": 2} + + with lib.attribute_values(ropnode, attributes): + ropnode.render(frame_range=(start_frame, end_frame, 1)) + + if "files" not in instance.data: + instance.data["files"] = [] + + instance.data["files"].append(file_name) diff --git a/colorbleed/plugins/houdini/publish/validate_mkpaths_toggled.py b/colorbleed/plugins/houdini/publish/validate_mkpaths_toggled.py new file mode 100644 index 0000000000..8dcc6e0509 --- /dev/null +++ b/colorbleed/plugins/houdini/publish/validate_mkpaths_toggled.py @@ -0,0 +1,38 @@ +import pyblish.api +import colorbleed.api + + +class ValidatIntermediateDirectoriesChecked(pyblish.api.InstancePlugin): + """Validate if node attribute Create intermediate Directories is turned on + + Rules: + * The node must have Create intermediate Directories turned on to + ensure the output file will be created + + """ + + order = colorbleed.api.ValidateContentsOrder + families = ['colorbleed.pointcache'] + hosts = ['houdini'] + label = 'Create Intermediate Directories Checked' + + def process(self, instance): + + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError("Found ROP nodes with Create Intermediate " + "Directories turned off") + + @classmethod + def get_invalid(cls, instance): + + result = [] + + for node in instance[:]: + if node.parm("mkpath").eval() != 1: + cls.log.error("Invalid settings found on `%s`" % node.path()) + result.append(node.path()) + + return result + + diff --git a/colorbleed/plugins/houdini/publish/validate_outnode_exists.py b/colorbleed/plugins/houdini/publish/validate_outnode_exists.py new file mode 100644 index 0000000000..479579a8f0 --- /dev/null +++ b/colorbleed/plugins/houdini/publish/validate_outnode_exists.py @@ -0,0 +1,41 @@ +import pyblish.api +import colorbleed.api + + +class ValidatOutputNodeExists(pyblish.api.InstancePlugin): + """Validate if node attribute Create intermediate Directories is turned on + + Rules: + * The node must have Create intermediate Directories turned on to + ensure the output file will be created + + """ + + order = colorbleed.api.ValidateContentsOrder + families = ["*"] + hosts = ['houdini'] + label = "Output Node Exists" + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError("Could not find output node(s)!") + + @classmethod + def get_invalid(cls, instance): + + import hou + + result = set() + + node = instance[0] + sop_path = node.parm("sop_path").eval() + if not sop_path.endswith("OUT"): + cls.log.error("SOP Path does not end path at output node") + result.add(node.path()) + + if hou.node(sop_path) is None: + cls.log.error("Node at '%s' does not exist" % sop_path) + result.add(node.path()) + + return result