Merge branch 'develop' into enhancement/houdini_extract_active_view

This commit is contained in:
Roy Nieterau 2024-06-25 15:06:07 +02:00 committed by GitHub
commit bc5caf974c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 426 additions and 380 deletions

View file

@ -2,6 +2,8 @@
from .cache import CacheItem, NestedCacheItem
from .projects import (
StatusItem,
StatusStates,
ProjectItem,
ProjectsModel,
PROJECTS_MODEL_SENDER,
@ -21,6 +23,8 @@ __all__ = (
"CacheItem",
"NestedCacheItem",
"StatusItem",
"StatusStates",
"ProjectItem",
"ProjectsModel",
"PROJECTS_MODEL_SENDER",

View file

@ -1,8 +1,8 @@
import contextlib
from abc import ABCMeta, abstractmethod
from abc import ABC, abstractmethod
from typing import Dict, Any
import ayon_api
import six
from ayon_core.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem, NestedCacheItem
@ -10,8 +10,14 @@ from ayon_core.lib import CacheItem, NestedCacheItem
PROJECTS_MODEL_SENDER = "projects.model"
@six.add_metaclass(ABCMeta)
class AbstractHierarchyController:
class StatusStates:
not_started = "not_started"
in_progress = "in_progress"
done = "done"
blocked = "blocked"
class AbstractHierarchyController(ABC):
@abstractmethod
def emit_event(self, topic, data, source):
pass
@ -25,18 +31,24 @@ class StatusItem:
color (str): Status color in hex ("#434a56").
short (str): Short status name ("NRD").
icon (str): Icon name in MaterialIcons ("fiber_new").
state (Literal["not_started", "in_progress", "done", "blocked"]):
Status state.
state (str): Status state.
"""
def __init__(self, name, color, short, icon, state):
self.name = name
self.color = color
self.short = short
self.icon = icon
self.state = state
def __init__(
self,
name: str,
color: str,
short: str,
icon: str,
state: str
):
self.name: str = name
self.color: str = color
self.short: str = short
self.icon: str = icon
self.state: str = state
def to_data(self):
def to_data(self) -> Dict[str, Any]:
return {
"name": self.name,
"color": self.color,

View file

@ -217,7 +217,9 @@ class InventoryModel(QtGui.QStandardItemModel):
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
is_latest = version_item.is_latest
if not is_latest:
# TODO maybe use different colors for last approved and last
# version? Or don't care about color at all?
if not is_latest and not version_item.is_last_approved:
version_color = self.OUTDATED_COLOR
status_name = version_item.status

View file

@ -3,7 +3,9 @@ import collections
import ayon_api
from ayon_api.graphql import GraphQlQuery
from ayon_core.host import ILoadHost
from ayon_core.tools.common_models.projects import StatusStates
# --- Implementation that should be in ayon-python-api ---
@ -149,26 +151,35 @@ class RepresentationInfo:
class VersionItem:
def __init__(self, version_id, product_id, version, status, is_latest):
self.version = version
self.version_id = version_id
self.product_id = product_id
self.version = version
self.status = status
self.is_latest = is_latest
def __init__(
self,
version_id: str,
product_id: str,
version: int,
status: str,
is_latest: bool,
is_last_approved: bool,
):
self.version_id: str = version_id
self.product_id: str = product_id
self.version: int = version
self.status: str = status
self.is_latest: bool = is_latest
self.is_last_approved: bool = is_last_approved
@property
def is_hero(self):
return self.version < 0
@classmethod
def from_entity(cls, version_entity, is_latest):
def from_entity(cls, version_entity, is_latest, is_last_approved):
return cls(
version_id=version_entity["id"],
product_id=version_entity["productId"],
version=version_entity["version"],
status=version_entity["status"],
is_latest=is_latest,
is_last_approved=is_last_approved,
)
@ -275,6 +286,11 @@ class ContainersModel:
if product_id not in self._version_items_by_product_id
}
if missing_ids:
status_items_by_name = {
status_item.name: status_item
for status_item in self._controller.get_project_status_items()
}
def version_sorted(entity):
return entity["version"]
@ -300,9 +316,21 @@ class ContainersModel:
version_entities_by_product_id.items()
):
last_version = abs(version_entities[-1]["version"])
last_approved_id = None
for version_entity in version_entities:
status_item = status_items_by_name.get(
version_entity["status"]
)
if status_item is None:
continue
if status_item.state == StatusStates.done:
last_approved_id = version_entity["id"]
version_items_by_id = {
entity["id"]: VersionItem.from_entity(
entity, abs(entity["version"]) == last_version
entity,
abs(entity["version"]) == last_version,
entity["id"] == last_approved_id
)
for entity in version_entities
}

View file

@ -233,19 +233,38 @@ class SceneInventoryView(QtWidgets.QTreeView):
has_outdated = False
has_loaded_hero_versions = False
has_available_hero_version = False
for version_items_by_id in version_items_by_product_id.values():
has_outdated_approved = False
last_version_by_product_id = {}
for product_id, version_items_by_id in (
version_items_by_product_id.items()
):
_has_outdated_approved = False
_last_approved_version_item = None
for version_item in version_items_by_id.values():
if version_item.is_hero:
has_available_hero_version = True
elif version_item.is_last_approved:
_last_approved_version_item = version_item
_has_outdated_approved = True
if version_item.version_id not in version_ids:
continue
if version_item.is_hero:
has_loaded_hero_versions = True
elif not version_item.is_latest:
has_outdated = True
if (
_has_outdated_approved
and _last_approved_version_item is not None
):
last_version_by_product_id[product_id] = (
_last_approved_version_item
)
has_outdated_approved = True
switch_to_versioned = None
if has_loaded_hero_versions:
update_icon = qtawesome.icon(
@ -261,6 +280,42 @@ class SceneInventoryView(QtWidgets.QTreeView):
lambda: self._on_switch_to_versioned(item_ids)
)
update_to_last_approved_action = None
approved_version_by_item_id = {}
if has_outdated_approved:
for container_item in container_items_by_id.values():
repre_id = container_item.representation_id
repre_info = repre_info_by_id.get(repre_id)
if not repre_info or not repre_info.is_valid:
continue
version_item = last_version_by_product_id.get(
repre_info.product_id
)
if (
version_item is None
or version_item.version_id == repre_info.version_id
):
continue
approved_version_by_item_id[container_item.item_id] = (
version_item.version
)
if approved_version_by_item_id:
update_icon = qtawesome.icon(
"fa.angle-double-up",
color="#00f0b4"
)
update_to_last_approved_action = QtWidgets.QAction(
update_icon,
"Update to last approved",
menu
)
update_to_last_approved_action.triggered.connect(
lambda: self._update_containers_to_approved_versions(
approved_version_by_item_id
)
)
update_to_latest_action = None
if has_outdated or has_loaded_hero_versions:
update_icon = qtawesome.icon(
@ -299,7 +354,9 @@ class SceneInventoryView(QtWidgets.QTreeView):
# set version
set_version_action = None
if active_repre_id is not None:
set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR)
set_version_icon = qtawesome.icon(
"fa.hashtag", color=DEFAULT_COLOR
)
set_version_action = QtWidgets.QAction(
set_version_icon,
"Set version",
@ -323,6 +380,9 @@ class SceneInventoryView(QtWidgets.QTreeView):
if switch_to_versioned:
menu.addAction(switch_to_versioned)
if update_to_last_approved_action:
menu.addAction(update_to_last_approved_action)
if update_to_latest_action:
menu.addAction(update_to_latest_action)
@ -970,3 +1030,24 @@ class SceneInventoryView(QtWidgets.QTreeView):
"""
versions = [version for _ in range(len(item_ids))]
self._update_containers(item_ids, versions)
def _update_containers_to_approved_versions(
self, approved_version_by_item_id
):
"""Helper to update items to given version (or version per item)
If at least one item is specified this will always try to refresh
the inventory even if errors occurred on any of the items.
Arguments:
approved_version_by_item_id (Dict[str, int]): Version to set by
item id.
"""
versions = []
item_ids = []
for item_id, version in approved_version_by_item_id.items():
item_ids.append(item_id)
versions.append(version)
self._update_containers(item_ids, versions)

View file

@ -448,6 +448,17 @@ DEFAULT_TOOLS_VALUES = {
"task_types": [],
"tasks": [],
"template": "SK_{folder[name]}{variant}"
},
{
"product_types": [
"hda"
],
"hosts": [
"houdini"
],
"task_types": [],
"tasks": [],
"template": "{folder[name]}_{variant}"
}
],
"filter_creator_profiles": []

View file

@ -92,7 +92,7 @@ class AEPlaceholderPlugin(PlaceholderPlugin):
return None, None
def _collect_scene_placeholders(self):
"""" Cache placeholder data to shared data.
"""Cache placeholder data to shared data.
Returns:
(list) of dicts
"""

View file

@ -83,7 +83,7 @@ class ExtractThumbnail(plugin.BlenderExtractor):
instance.data["representations"].append(representation)
def _fix_output_path(self, filepath):
""""Workaround to return correct filepath.
"""Workaround to return correct filepath.
To workaround this we just glob.glob() for any file extensions and
assume the latest modified file is the correct file and return it.

View file

@ -4,10 +4,6 @@ from .pipeline import (
containerise
)
from .plugin import (
Creator,
)
from .lib import (
lsattr,
lsattrs,
@ -23,8 +19,6 @@ __all__ = [
"ls",
"containerise",
"Creator",
# Utility functions
"lsattr",
"lsattrs",

View file

@ -148,89 +148,6 @@ def validate_fps():
return True
def create_remote_publish_node(force=True):
"""Function to create a remote publish node in /out
This is a hacked "Shell" node that does *nothing* except for triggering
`colorbleed.lib.publish_remote()` as pre-render script.
All default attributes of the Shell node are hidden to the Artist to
avoid confusion.
Additionally some custom attributes are added that can be collected
by a Collector to set specific settings for the publish, e.g. whether
to separate the jobs per instance or process in one single job.
"""
cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()"
existing = hou.node("/out/REMOTE_PUBLISH")
if existing:
if force:
log.warning("Removing existing '/out/REMOTE_PUBLISH' node..")
existing.destroy()
else:
raise RuntimeError("Node already exists /out/REMOTE_PUBLISH. "
"Please remove manually or set `force` to "
"True.")
# Create the shell node
out = hou.node("/out")
node = out.createNode("shell", node_name="REMOTE_PUBLISH")
node.moveToGoodPosition()
# Set color make it stand out (avalon/pyblish color)
node.setColor(hou.Color(0.439, 0.709, 0.933))
# Set the pre-render script
node.setParms({
"prerender": cmd,
"lprerender": "python" # command language
})
# Lock the attributes to ensure artists won't easily mess things up.
node.parm("prerender").lock(True)
node.parm("lprerender").lock(True)
# Lock up the actual shell command
command_parm = node.parm("command")
command_parm.set("")
command_parm.lock(True)
shellexec_parm = node.parm("shellexec")
shellexec_parm.set(False)
shellexec_parm.lock(True)
# Get the node's parm template group so we can customize it
template = node.parmTemplateGroup()
# Hide default tabs
template.hideFolder("Shell", True)
template.hideFolder("Scripts", True)
# Hide default settings
template.hide("execute", True)
template.hide("renderdialog", True)
template.hide("trange", True)
template.hide("f", True)
template.hide("take", True)
# Add custom settings to this node.
parm_folder = hou.FolderParmTemplate("folder", "Submission Settings")
# Separate Jobs per Instance
parm = hou.ToggleParmTemplate(name="separateJobPerInstance",
label="Separate Job per Instance",
default_value=False)
parm_folder.addParmTemplate(parm)
# Add our custom Submission Settings folder
template.append(parm_folder)
# Apply template back to the node
node.setParmTemplateGroup(template)
def render_rop(ropnode):
"""Render ROP node utility for Publishing.

View file

@ -221,12 +221,8 @@ def containerise(name,
"""
# 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")
# Get AVALON_CONTAINERS subnet
subnet = get_or_create_avalon_container()
# Create proper container name
container_name = "{}_{}".format(name, suffix or "CON")
@ -401,6 +397,18 @@ def on_new():
_enforce_start_frame()
def get_or_create_avalon_container() -> "hou.OpNode":
avalon_container = hou.node(AVALON_CONTAINERS)
if avalon_container:
return avalon_container
parent_path, name = AVALON_CONTAINERS.rsplit("/", 1)
parent = hou.node(parent_path)
return parent.createNode(
"subnet", node_name=name
)
def _set_context_settings():
"""Apply the project settings from the project definition

View file

@ -10,8 +10,7 @@ import hou
import pyblish.api
from ayon_core.pipeline import (
CreatorError,
LegacyCreator,
Creator as NewCreator,
Creator,
CreatedInstance,
AYON_INSTANCE_ID,
AVALON_INSTANCE_ID,
@ -26,80 +25,6 @@ from .lib import imprint, read, lsattr, add_self_publish_button
SETTINGS_CATEGORY = "houdini"
class Creator(LegacyCreator):
"""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.
Deprecated:
This creator is deprecated and will be removed in future version.
"""
defaults = ['Main']
def __init__(self, *args, **kwargs):
super(Creator, self).__init__(*args, **kwargs)
self.nodes = []
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 parameters for Alembic node
instance.setParms(
{"sop_path": "$HIP/%s.abc" % self.nodes[0]}
)
Returns:
hou.Node
"""
try:
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(
CreatorError,
CreatorError("Creator error: {}".format(er)),
sys.exc_info()[2])
class HoudiniCreatorBase(object):
@staticmethod
def cache_instance_data(shared_data):
@ -148,7 +73,11 @@ class HoudiniCreatorBase(object):
@staticmethod
def create_instance_node(
folder_path, node_name, parent, node_type="geometry"
folder_path,
node_name,
parent,
node_type="geometry",
pre_create_data=None
):
"""Create node representing instance.
@ -157,6 +86,7 @@ class HoudiniCreatorBase(object):
node_name (str): Name of the new node.
parent (str): Name of the parent node.
node_type (str, optional): Type of the node.
pre_create_data (Optional[Dict]): Pre create data.
Returns:
hou.Node: Newly created instance node.
@ -170,7 +100,7 @@ class HoudiniCreatorBase(object):
@six.add_metaclass(ABCMeta)
class HoudiniCreator(NewCreator, HoudiniCreatorBase):
class HoudiniCreator(Creator, HoudiniCreatorBase):
"""Base class for most of the Houdini creator plugins."""
selected_nodes = []
settings_name = None
@ -193,7 +123,12 @@ class HoudiniCreator(NewCreator, HoudiniCreatorBase):
folder_path = instance_data["folderPath"]
instance_node = self.create_instance_node(
folder_path, product_name, "/out", node_type)
folder_path,
product_name,
"/out",
node_type,
pre_create_data
)
self.customize_node_look(instance_node)

View file

@ -1,13 +1,19 @@
# -*- coding: utf-8 -*-
"""Creator plugin for creating publishable Houdini Digital Assets."""
import ayon_api
import hou
from assettools import setToolSubmenu
import ayon_api
from ayon_core.pipeline import (
CreatorError,
get_current_project_name
)
from ayon_core.lib import (
get_ayon_username,
BoolDef
)
from ayon_houdini.api import plugin
import hou
class CreateHDA(plugin.HoudiniCreator):
@ -37,19 +43,38 @@ class CreateHDA(plugin.HoudiniCreator):
return product_name.lower() in existing_product_names_low
def create_instance_node(
self, folder_path, node_name, parent, node_type="geometry"
self,
folder_path,
node_name,
parent,
node_type="geometry",
pre_create_data=None
):
if pre_create_data is None:
pre_create_data = {}
parent_node = hou.node("/obj")
if self.selected_nodes:
# if we have `use selection` enabled, and we have some
# selected nodes ...
subnet = parent_node.collapseIntoSubnet(
self.selected_nodes,
subnet_name="{}_subnet".format(node_name))
subnet.moveToGoodPosition()
to_hda = subnet
if self.selected_nodes[0].type().name() == "subnet":
to_hda = self.selected_nodes[0]
to_hda.setName("{}_subnet".format(node_name), unique_name=True)
else:
parent_node = self.selected_nodes[0].parent()
subnet = parent_node.collapseIntoSubnet(
self.selected_nodes,
subnet_name="{}_subnet".format(node_name))
subnet.moveToGoodPosition()
to_hda = subnet
else:
# Use Obj as the default path
parent_node = hou.node("/obj")
# Find and return the NetworkEditor pane tab with the minimum index
pane = hou.ui.paneTabOfType(hou.paneTabType.NetworkEditor)
if isinstance(pane, hou.NetworkEditor):
# Use the NetworkEditor pane path as the parent path.
parent_node = pane.pwd()
to_hda = parent_node.createNode(
"subnet", node_name="{}_subnet".format(node_name))
if not to_hda.type().definition():
@ -71,7 +96,8 @@ class CreateHDA(plugin.HoudiniCreator):
hda_node = to_hda.createDigitalAsset(
name=type_name,
description=node_name,
hda_file_name="$HIP/{}.hda".format(node_name)
hda_file_name="$HIP/{}.hda".format(node_name),
ignore_external_references=True
)
hda_node.layoutChildren()
elif self._check_existing(folder_path, node_name):
@ -81,21 +107,92 @@ class CreateHDA(plugin.HoudiniCreator):
else:
hda_node = to_hda
hda_node.setName(node_name)
# If user tries to create the same HDA instance more than
# once, then all of them will have the same product name and
# point to the same hda_file_name. But, their node names will
# be incremented.
hda_node.setName(node_name, unique_name=True)
self.customize_node_look(hda_node)
# Set Custom settings.
hda_def = hda_node.type().definition()
if pre_create_data.get("set_user"):
hda_def.setUserInfo(get_ayon_username())
if pre_create_data.get("use_project"):
setToolSubmenu(hda_def, "AYON/{}".format(self.project_name))
return hda_node
def create(self, product_name, instance_data, pre_create_data):
instance_data.pop("active", None)
instance = super(CreateHDA, self).create(
return super(CreateHDA, self).create(
product_name,
instance_data,
pre_create_data)
return instance
def get_network_categories(self):
# Houdini allows creating sub-network nodes inside
# these categories.
# Therefore this plugin can work in these categories.
return [
hou.objNodeTypeCategory()
hou.chopNodeTypeCategory(),
hou.cop2NodeTypeCategory(),
hou.dopNodeTypeCategory(),
hou.ropNodeTypeCategory(),
hou.lopNodeTypeCategory(),
hou.objNodeTypeCategory(),
hou.sopNodeTypeCategory(),
hou.topNodeTypeCategory(),
hou.vopNodeTypeCategory()
]
def get_pre_create_attr_defs(self):
attrs = super(CreateHDA, self).get_pre_create_attr_defs()
return attrs + [
BoolDef("set_user",
tooltip="Set current user as the author of the HDA",
default=False,
label="Set Current User"),
BoolDef("use_project",
tooltip="Use project name as tab submenu path.\n"
"The location in TAB Menu will be\n"
"'AYON/project_name/your_HDA_name'",
default=True,
label="Use Project as menu entry"),
]
def get_dynamic_data(
self,
project_name,
folder_entity,
task_entity,
variant,
host_name,
instance
):
"""
Pass product name from product name templates as dynamic data.
"""
dynamic_data = super(CreateHDA, self).get_dynamic_data(
project_name,
folder_entity,
task_entity,
variant,
host_name,
instance
)
dynamic_data.update(
{
"asset": folder_entity["name"],
"folder": {
"label": folder_entity["label"],
"name": folder_entity["name"]
}
}
)
return dynamic_data

View file

@ -1,8 +1,13 @@
# -*- coding: utf-8 -*-
import os
from ayon_core.pipeline import get_representation_path
import hou
from ayon_core.pipeline import (
get_representation_path,
AVALON_CONTAINER_ID
)
from ayon_core.pipeline.load import LoadError
from ayon_houdini.api import (
lib,
pipeline,
plugin
)
@ -19,42 +24,43 @@ class HdaLoader(plugin.HoudiniLoader):
color = "orange"
def load(self, context, name=None, namespace=None, data=None):
import hou
# Format file name, Houdini only wants forward slashes
file_path = self.filepath_from_context(context)
file_path = os.path.normpath(file_path)
file_path = file_path.replace("\\", "/")
# Get the root node
obj = hou.node("/obj")
namespace = namespace or context["folder"]["name"]
node_name = "{}_{}".format(namespace, name) if namespace else name
hou.hda.installFile(file_path)
# Get the type name from the HDA definition.
hda_defs = hou.hda.definitionsInFile(file_path)
if not hda_defs:
raise LoadError(f"No HDA definitions found in file: {file_path}")
type_name = hda_defs[0].nodeTypeName()
hda_node = obj.createNode(type_name, node_name)
parent_node = self._create_dedicated_parent_node(hda_defs[-1])
self[:] = [hda_node]
# Get the type name from the HDA definition.
type_name = hda_defs[-1].nodeTypeName()
hda_node = parent_node.createNode(type_name, node_name)
hda_node.moveToGoodPosition()
return pipeline.containerise(
node_name,
namespace,
[hda_node],
context,
self.__class__.__name__,
suffix="",
)
# Imprint it manually
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": node_name,
"namespace": namespace,
"loader": self.__class__.__name__,
"representation": context["representation"]["id"],
}
lib.imprint(hda_node, data)
return hda_node
def update(self, container, context):
import hou
repre_entity = context["representation"]
hda_node = container["node"]
@ -71,4 +77,45 @@ class HdaLoader(plugin.HoudiniLoader):
def remove(self, container):
node = container["node"]
parent = node.parent()
node.destroy()
if parent.path() == pipeline.AVALON_CONTAINERS:
return
# Remove parent if empty.
if not parent.children():
parent.destroy()
def _create_dedicated_parent_node(self, hda_def):
# Get the root node
parent_node = pipeline.get_or_create_avalon_container()
node = None
node_type = None
if hda_def.nodeTypeCategory() == hou.objNodeTypeCategory():
return parent_node
elif hda_def.nodeTypeCategory() == hou.chopNodeTypeCategory():
node_type, node_name = "chopnet", "MOTION"
elif hda_def.nodeTypeCategory() == hou.cop2NodeTypeCategory():
node_type, node_name = "cop2net", "IMAGES"
elif hda_def.nodeTypeCategory() == hou.dopNodeTypeCategory():
node_type, node_name = "dopnet", "DOPS"
elif hda_def.nodeTypeCategory() == hou.ropNodeTypeCategory():
node_type, node_name = "ropnet", "ROPS"
elif hda_def.nodeTypeCategory() == hou.lopNodeTypeCategory():
node_type, node_name = "lopnet", "LOPS"
elif hda_def.nodeTypeCategory() == hou.sopNodeTypeCategory():
node_type, node_name = "geo", "SOPS"
elif hda_def.nodeTypeCategory() == hou.topNodeTypeCategory():
node_type, node_name = "topnet", "TOPS"
# TODO: Create a dedicated parent node based on Vop Node vex context.
elif hda_def.nodeTypeCategory() == hou.vopNodeTypeCategory():
node_type, node_name = "matnet", "MATSandVOPS"
node = parent_node.node(node_name)
if not node:
node = parent_node.createNode(node_type, node_name)
node.moveToGoodPosition()
return node

View file

@ -1,29 +0,0 @@
import hou
import pyblish.api
from ayon_core.pipeline.publish import RepairAction
from ayon_houdini.api import lib, plugin
class CollectRemotePublishSettings(plugin.HoudiniContextPlugin):
"""Collect custom settings of the Remote Publish node."""
order = pyblish.api.CollectorOrder
families = ["*"]
targets = ["deadline"]
label = "Remote Publish Submission Settings"
actions = [RepairAction]
def process(self, context):
node = hou.node("/out/REMOTE_PUBLISH")
if not node:
return
attributes = lib.read(node)
# Debug the settings we have collected
for key, value in sorted(attributes.items()):
self.log.debug("Collected %s: %s" % (key, value))
context.data.update(attributes)

View file

@ -1,50 +0,0 @@
# -*-coding: utf-8 -*-
import hou
import pyblish.api
from ayon_core.pipeline.publish import RepairContextAction
from ayon_core.pipeline import PublishValidationError
from ayon_houdini.api import lib, plugin
class ValidateRemotePublishOutNode(plugin.HoudiniContextPlugin):
"""Validate the remote publish out node exists for Deadline to trigger."""
order = pyblish.api.ValidatorOrder - 0.4
families = ["*"]
targets = ["deadline"]
label = "Remote Publish ROP node"
actions = [RepairContextAction]
def process(self, context):
cmd = "import colorbleed.lib; colorbleed.lib.publish_remote()"
node = hou.node("/out/REMOTE_PUBLISH")
if not node:
raise RuntimeError("Missing REMOTE_PUBLISH node.")
# We ensure it's a shell node and that it has the pre-render script
# set correctly. Plus the shell script it will trigger should be
# completely empty (doing nothing)
if node.type().name() != "shell":
self.raise_error("Must be shell ROP node")
if node.parm("command").eval() != "":
self.raise_error("Must have no command")
if node.parm("shellexec").eval():
self.raise_error("Must not execute in shell")
if node.parm("prerender").eval() != cmd:
self.raise_error("REMOTE_PUBLISH node does not have "
"correct prerender script.")
if node.parm("lprerender").eval() != "python":
self.raise_error("REMOTE_PUBLISH node prerender script "
"type not set to 'python'")
@classmethod
def repair(cls, context):
"""(Re)create the node if it fails to pass validation."""
lib.create_remote_publish_node(force=True)
def raise_error(self, message):
raise PublishValidationError(message)

View file

@ -1,41 +0,0 @@
# -*- coding: utf-8 -*-
import hou
import pyblish.api
from ayon_core.pipeline.publish import RepairContextAction
from ayon_core.pipeline import PublishValidationError
from ayon_houdini.api import plugin
class ValidateRemotePublishEnabled(plugin.HoudiniContextPlugin):
"""Validate the remote publish node is *not* bypassed."""
order = pyblish.api.ValidatorOrder - 0.39
families = ["*"]
targets = ["deadline"]
label = "Remote Publish ROP enabled"
actions = [RepairContextAction]
def process(self, context):
node = hou.node("/out/REMOTE_PUBLISH")
if not node:
raise PublishValidationError(
"Missing REMOTE_PUBLISH node.", title=self.label)
if node.isBypassed():
raise PublishValidationError(
"REMOTE_PUBLISH must not be bypassed.", title=self.label)
@classmethod
def repair(cls, context):
"""(Re)create the node if it fails to pass validation."""
node = hou.node("/out/REMOTE_PUBLISH")
if not node:
raise PublishValidationError(
"Missing REMOTE_PUBLISH node.", title=cls.label)
cls.log.info("Disabling bypass on /out/REMOTE_PUBLISH")
node.bypass(False)

View file

@ -10,10 +10,9 @@ from ayon_core.pipeline.publish import (
ValidateContentsOrder,
RepairAction,
)
from ayon_core.pipeline.create import get_product_name
from ayon_houdini.api import plugin
from ayon_houdini.api.action import SelectInvalidAction
from ayon_core.pipeline.create import get_product_name
class FixProductNameAction(RepairAction):
@ -26,7 +25,7 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin,
"""
families = ["staticMesh"]
families = ["staticMesh", "hda"]
label = "Validate Product Name"
order = ValidateContentsOrder + 0.1
actions = [FixProductNameAction, SelectInvalidAction]
@ -67,7 +66,13 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin,
instance.context.data["hostName"],
instance.data["productType"],
variant=instance.data["variant"],
dynamic_data={"asset": folder_entity["name"]}
dynamic_data={
"asset": folder_entity["name"],
"folder": {
"label": folder_entity["label"],
"name": folder_entity["name"]
}
}
)
if instance.data.get("productName") != product_name:
@ -97,7 +102,13 @@ class ValidateSubsetName(plugin.HoudiniInstancePlugin,
instance.context.data["hostName"],
instance.data["productType"],
variant=instance.data["variant"],
dynamic_data={"asset": folder_entity["name"]}
dynamic_data={
"asset": folder_entity["name"],
"folder": {
"label": folder_entity["label"],
"name": folder_entity["name"]
}
}
)
instance.data["productName"] = product_name

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'houdini' version."""
__version__ = "0.3.4"
__version__ = "0.3.6"

View file

@ -1,6 +1,6 @@
name = "houdini"
title = "Houdini"
version = "0.3.4"
version = "0.3.6"
client_dir = "ayon_houdini"

View file

@ -9,11 +9,16 @@ class CreateSetDress(plugin.MayaCreator):
label = "Set Dress"
product_type = "setdress"
icon = "cubes"
exactSetMembersOnly = True
shader = True
default_variants = ["Main", "Anim"]
def get_instance_attr_defs(self):
return [
BoolDef("exactSetMembersOnly",
label="Exact Set Members Only",
default=True)
default=self.exactSetMembersOnly),
BoolDef("shader",
label="Include shader",
default=self.shader)
]

View file

@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""Extract data as Maya scene (raw)."""
import os
import contextlib
from ayon_core.lib import BoolDef
from ayon_core.pipeline import AVALON_CONTAINER_ID, AYON_CONTAINER_ID
from ayon_core.pipeline.publish import AYONPyblishPluginMixin
from ayon_maya.api.lib import maintained_selection
from ayon_maya.api.lib import maintained_selection, shader
from ayon_maya.api import plugin
from maya import cmds
@ -88,17 +88,21 @@ class ExtractMayaSceneRaw(plugin.MayaExtractorPlugin, AYONPyblishPluginMixin):
)
with maintained_selection():
cmds.select(selection, noExpand=True)
cmds.file(path,
force=True,
typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501
exportSelected=True,
preserveReferences=attribute_values[
"preserve_references"
],
constructionHistory=True,
shader=True,
constraints=True,
expressions=True)
with contextlib.ExitStack() as stack:
if not instance.data.get("shader", True):
# Fix bug where export without shader may import the geometry 'green'
# due to the lack of any shader on import.
stack.enter_context(shader(selection, shadingEngine="initialShadingGroup"))
cmds.file(path,
force=True,
typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary",
exportSelected=True,
preserveReferences=attribute_values["preserve_references"],
constructionHistory=True,
shader=instance.data.get("shader", True),
constraints=True,
expressions=True)
if "representations" not in instance.data:
instance.data["representations"] = []

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'maya' version."""
__version__ = "0.2.6"
__version__ = "0.2.7"

View file

@ -1,6 +1,6 @@
name = "maya"
title = "Maya"
version = "0.2.6"
version = "0.2.7"
client_dir = "ayon_maya"
ayon_required_addons = {

View file

@ -124,6 +124,14 @@ class CreateVrayProxyModel(BaseSettingsModel):
default_factory=list, title="Default Products")
class CreateSetDressModel(BaseSettingsModel):
enabled: bool = SettingsField(True)
exactSetMembersOnly: bool = SettingsField(title="Exact Set Members Only")
shader: bool = SettingsField(title="Include shader")
default_variants: list[str] = SettingsField(
default_factory=list, title="Default Products")
class CreateMultishotLayout(BasicCreatorModel):
shotParent: str = SettingsField(title="Shot Parent Folder")
groupLoadedAssets: bool = SettingsField(title="Group Loaded Assets")
@ -217,8 +225,8 @@ class CreatorsModel(BaseSettingsModel):
default_factory=BasicCreatorModel,
title="Create Rig"
)
CreateSetDress: BasicCreatorModel = SettingsField(
default_factory=BasicCreatorModel,
CreateSetDress: CreateSetDressModel = SettingsField(
default_factory=CreateSetDressModel,
title="Create Set Dress"
)
CreateVrayProxy: CreateVrayProxyModel = SettingsField(
@ -396,6 +404,8 @@ DEFAULT_CREATORS_SETTINGS = {
},
"CreateSetDress": {
"enabled": True,
"exactSetMembersOnly": True,
"shader": True,
"default_variants": [
"Main",
"Anim"