diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index efdaa60084..63d9bba470 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Houdini specific Avalon/Pyblish plugin definitions.""" import sys +from avalon.api import CreatorError from avalon import houdini import six @@ -8,7 +9,7 @@ import hou from openpype.api import PypeCreatorMixin -class OpenPypeCreatorError(Exception): +class OpenPypeCreatorError(CreatorError): pass diff --git a/openpype/hosts/houdini/api/usd.py b/openpype/hosts/houdini/api/usd.py index 850ffb60e5..6f808779ea 100644 --- a/openpype/hosts/houdini/api/usd.py +++ b/openpype/hosts/houdini/api/usd.py @@ -4,8 +4,8 @@ import contextlib import logging from Qt import QtCore, QtGui -from avalon.tools.widgets import AssetWidget -from avalon import style +from openpype.tools.utils.widgets import AssetWidget +from avalon import style, io from pxr import Sdf @@ -31,7 +31,7 @@ def pick_asset(node): # Construct the AssetWidget as a frameless popup so it automatically # closes when clicked outside of it. global tool - tool = AssetWidget(silo_creatable=False) + tool = AssetWidget(io) tool.setContentsMargins(5, 5, 5, 5) tool.setWindowTitle("Pick Asset") tool.setStyleSheet(style.load_stylesheet()) @@ -41,8 +41,6 @@ def pick_asset(node): # Select the current asset if there is any name = parm.eval() if name: - from avalon import io - db_asset = io.find_one({"name": name, "type": "asset"}) if db_asset: silo = db_asset.get("silo") diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py new file mode 100644 index 0000000000..2af1e4a257 --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from openpype.hosts.houdini.api import plugin +from avalon.houdini import lib +from avalon import io +import hou + + +class CreateHDA(plugin.Creator): + """Publish Houdini Digital Asset file.""" + + name = "hda" + label = "Houdini Digital Asset (Hda)" + family = "hda" + icon = "gears" + maintain_selection = False + + def __init__(self, *args, **kwargs): + super(CreateHDA, self).__init__(*args, **kwargs) + self.data.pop("active", None) + + def _check_existing(self, subset_name): + # type: (str) -> bool + """Check if existing subset name versions already exists.""" + # Get all subsets of the current asset + asset_id = io.find_one({"name": self.data["asset"], "type": "asset"}, + projection={"_id": True})['_id'] + subset_docs = io.find( + { + "type": "subset", + "parent": asset_id + }, {"name": 1} + ) + existing_subset_names = set(subset_docs.distinct("name")) + existing_subset_names_low = { + _name.lower() for _name in existing_subset_names + } + return subset_name.lower() in existing_subset_names_low + + def _process(self, instance): + subset_name = self.data["subset"] + # get selected nodes + out = hou.node("/obj") + self.nodes = hou.selectedNodes() + + if (self.options or {}).get("useSelection") and self.nodes: + # if we have `use selection` enabled and we have some + # selected nodes ... + to_hda = self.nodes[0] + if len(self.nodes) > 1: + # if there is more then one node, create subnet first + subnet = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) + to_hda = subnet + else: + # in case of no selection, just create subnet node + subnet = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) + subnet.moveToGoodPosition() + to_hda = subnet + + if not to_hda.type().definition(): + # if node type has not its definition, it is not user + # created hda. We test if hda can be created from the node. + if not to_hda.canCreateDigitalAsset(): + raise Exception( + "cannot create hda from node {}".format(to_hda)) + + hda_node = to_hda.createDigitalAsset( + name=subset_name, + hda_file_name="$HIP/{}.hda".format(subset_name) + ) + hou.moveNodesTo(self.nodes, hda_node) + hda_node.layoutChildren() + else: + if self._check_existing(subset_name): + raise plugin.OpenPypeCreatorError( + ("subset {} is already published with different HDA" + "definition.").format(subset_name)) + hda_node = to_hda + + hda_node.setName(subset_name) + + # delete node created by Avalon in /out + # this needs to be addressed in future Houdini workflow refactor. + + hou.node("/out/{}".format(subset_name)).destroy() + + try: + lib.imprint(hda_node, self.data) + except hou.OperationFailed: + raise plugin.OpenPypeCreatorError( + ("Cannot set metadata on asset. Might be that it already is " + "OpenPype asset.") + ) + + return hda_node diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py new file mode 100644 index 0000000000..6610d5e513 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from avalon import api + +from avalon.houdini import pipeline + + +class HdaLoader(api.Loader): + """Load Houdini Digital Asset file.""" + + families = ["hda"] + label = "Load Hda" + representations = ["hda"] + 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 or context["asset"]["name"] + formatted = "{}_{}".format(namespace, name) if namespace else name + node_name = "{0}_{1:03d}".format(formatted, counter) + + hou.hda.installFile(file_path) + hda_node = obj.createNode(name, node_name) + + self[:] = [hda_node] + + return pipeline.containerise( + node_name, + namespace, + [hda_node], + context, + self.__class__.__name__, + suffix="", + ) + + def update(self, container, representation): + import hou + + hda_node = container["node"] + file_path = api.get_representation_path(representation) + file_path = file_path.replace("\\", "/") + hou.hda.installFile(file_path) + defs = hda_node.type().allInstalledDefinitions() + def_paths = [d.libraryFilePath() for d in defs] + new = def_paths.index(file_path) + defs[new].setIsPreferred(True) + + def remove(self, container): + node = container["node"] + node.destroy() diff --git a/openpype/hosts/houdini/plugins/publish/collect_active_state.py b/openpype/hosts/houdini/plugins/publish/collect_active_state.py index 1193f0cd19..862d5720e1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_active_state.py +++ b/openpype/hosts/houdini/plugins/publish/collect_active_state.py @@ -23,8 +23,10 @@ class CollectInstanceActiveState(pyblish.api.InstancePlugin): return # Check bypass state and reverse + active = True node = instance[0] - active = not node.isBypassed() + if hasattr(node, "isBypassed"): + active = not node.isBypassed() # Set instance active state instance.data.update( diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 1b36526783..ac081ac297 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -31,6 +31,7 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): nodes = hou.node("/out").children() + nodes += hou.node("/obj").children() # Include instances in USD stage only when it exists so it # remains backwards compatible with version before houdini 18 @@ -49,9 +50,12 @@ class CollectInstances(pyblish.api.ContextPlugin): has_family = node.evalParm("family") assert has_family, "'%s' is missing 'family'" % node.name() + self.log.info("processing {}".format(node)) + data = lib.read(node) # Check bypass state and reverse - data.update({"active": not node.isBypassed()}) + if hasattr(node, "isBypassed"): + data.update({"active": not node.isBypassed()}) # temporarily translation of `active` to `publish` till issue has # been resolved, https://github.com/pyblish/pyblish-base/issues/307 diff --git a/openpype/hosts/houdini/plugins/publish/extract_hda.py b/openpype/hosts/houdini/plugins/publish/extract_hda.py new file mode 100644 index 0000000000..301dd4e297 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_hda.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import os + +from pprint import pformat + +import pyblish.api +import openpype.api + + +class ExtractHDA(openpype.api.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract HDA" + hosts = ["houdini"] + families = ["hda"] + + def process(self, instance): + self.log.info(pformat(instance.data)) + hda_node = instance[0] + hda_def = hda_node.type().definition() + hda_options = hda_def.options() + hda_options.setSaveInitialParmsAndContents(True) + + next_version = instance.data["anatomyData"]["version"] + self.log.info("setting version: {}".format(next_version)) + hda_def.setVersion(str(next_version)) + hda_def.setOptions(hda_options) + hda_def.save(hda_def.libraryFilePath(), hda_node, hda_options) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + file = os.path.basename(hda_def.libraryFilePath()) + staging_dir = os.path.dirname(hda_def.libraryFilePath()) + self.log.info("Using HDA from {}".format(hda_def.libraryFilePath())) + + representation = { + 'name': 'hda', + 'ext': 'hda', + 'files': file, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_bypass.py b/openpype/hosts/houdini/plugins/publish/validate_bypass.py index 79c67c3008..fc4e18f701 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_bypass.py +++ b/openpype/hosts/houdini/plugins/publish/validate_bypass.py @@ -35,5 +35,5 @@ class ValidateBypassed(pyblish.api.InstancePlugin): def get_invalid(cls, instance): rop = instance[0] - if rop.isBypassed(): + if hasattr(rop, "isBypassed") and rop.isBypassed(): return [rop] diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index d1c13b04d5..0a8370eafc 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -275,8 +275,7 @@ def on_open(_): # Show outdated pop-up def _on_show_inventory(): - import avalon.tools.sceneinventory as tool - tool.show(parent=parent) + host_tools.show_scene_inventory(parent=parent) dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Maya scene has outdated content") diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py index a5177335b3..9f075d66cf 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py @@ -3,7 +3,7 @@ import json import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectBulkMovInstances(pyblish.api.InstancePlugin): @@ -26,16 +26,10 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): context = instance.context asset_name = instance.data["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - { - "_id": 1, - "data.tasks": 1 - } - ) + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) if not asset_doc: raise AssertionError(( "Couldn't find Asset document with name \"{}\"" @@ -53,11 +47,11 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): task_name = available_task_names[_task_name_low] break - subset_name = get_subset_name( + subset_name = get_subset_name_with_asset_doc( self.new_instance_family, self.subset_name_variant, task_name, - asset_doc["_id"], + asset_doc, io.Session["AVALON_PROJECT"] ) instance_name = f"{asset_name}_{subset_name}" diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index dfa8f17ee9..1d7a48e389 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -4,7 +4,7 @@ import copy import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectInstances(pyblish.api.ContextPlugin): @@ -70,16 +70,10 @@ class CollectInstances(pyblish.api.ContextPlugin): # - not sure if it's good idea to require asset id in # get_subset_name? asset_name = context.data["workfile_context"]["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - {"_id": 1} - ) - asset_id = None - if asset_doc: - asset_id = asset_doc["_id"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) # Project name from workfile context project_name = context.data["workfile_context"]["project"] @@ -88,11 +82,11 @@ class CollectInstances(pyblish.api.ContextPlugin): # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] - new_subset_name = get_subset_name( + new_subset_name = get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name, host_name ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index 65e38ea258..68ba350a85 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -3,7 +3,7 @@ import json import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectWorkfile(pyblish.api.ContextPlugin): @@ -28,16 +28,10 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # get_subset_name? family = "workfile" asset_name = context.data["workfile_context"]["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - {"_id": 1} - ) - asset_id = None - if asset_doc: - asset_id = asset_doc["_id"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) # Project name from workfile context project_name = context.data["workfile_context"]["project"] @@ -46,11 +40,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] - subset_name = get_subset_name( + subset_name = get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name, host_name ) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 74004a1239..ee4821b80d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -130,6 +130,7 @@ from .applications import ( from .plugin_tools import ( TaskNotSetError, get_subset_name, + get_subset_name_with_asset_doc, prepare_template_data, filter_pyblish_plugins, set_plugin_attributes_from_settings, @@ -249,6 +250,7 @@ __all__ = [ "TaskNotSetError", "get_subset_name", + "get_subset_name_with_asset_doc", "filter_pyblish_plugins", "set_plugin_attributes_from_settings", "source_hash", diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index 5735cbc99d..c89e2e7ae0 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -245,6 +245,27 @@ def process_sequence( report_items["Source file was not found"].append(msg) return report_items, 0 + delivery_templates = anatomy.templates.get("delivery") or {} + delivery_template = delivery_templates.get(template_name) + if delivery_template is None: + msg = ( + "Delivery template \"{}\" in anatomy of project \"{}\"" + " was not found" + ).format(template_name, anatomy.project_name) + report_items[""].append(msg) + return report_items, 0 + + # Check if 'frame' key is available in template which is required + # for sequence delivery + if "{frame" not in delivery_template: + msg = ( + "Delivery template \"{}\" in anatomy of project \"{}\"" + "does not contain '{{frame}}' key to fill. Delivery of sequence" + " can't be processed." + ).format(template_name, anatomy.project_name) + report_items[""].append(msg) + return report_items, 0 + dir_path, file_name = os.path.split(str(src_path)) context = repre["context"] diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 47e6641731..aa9e0c9b57 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -28,17 +28,44 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) -def get_subset_name( +def get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name=None, host_name=None, default_template=None, - dynamic_data=None, - dbcon=None + dynamic_data=None ): + """Calculate subset name based on passed context and OpenPype settings. + + Subst name templates are defined in `project_settings/global/tools/creator + /subset_name_profiles` where are profiles with host name, family, task name + and task type filters. If context does not match any profile then + `DEFAULT_SUBSET_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate subset + name. + + Args: + family (str): Instance family. + variant (str): In most of cases it is user input during creation. + task_name (str): Task name on which context is instance created. + asset_doc (dict): Queried asset document with it's tasks in data. + Used to get task type. + project_name (str): Name of project on which is instance created. + Important for project settings that are loaded. + host_name (str): One of filtering criteria for template profile + filters. + default_template (str): Default template if any profile does not match + passed context. Constant 'DEFAULT_SUBSET_TEMPLATE' is used if + is not passed. + dynamic_data (dict): Dynamic data specific for a creator which creates + instance. + dbcon (AvalonMongoDB): Mongo connection to be able query asset document + if 'asset_doc' is not passed. + """ if not family: return "" @@ -53,25 +80,6 @@ def get_subset_name( project_name = avalon.api.Session["AVALON_PROJECT"] - # Function should expect asset document instead of asset id - # - that way `dbcon` is not needed - if dbcon is None: - from avalon.api import AvalonMongoDB - - dbcon = AvalonMongoDB() - dbcon.Session["AVALON_PROJECT"] = project_name - - dbcon.install() - - asset_doc = dbcon.find_one( - { - "type": "asset", - "_id": asset_id - }, - { - "data.tasks": True - } - ) asset_tasks = asset_doc.get("data", {}).get("tasks") or {} task_info = asset_tasks.get(task_name) or {} task_type = task_info.get("type") @@ -113,6 +121,49 @@ def get_subset_name( return template.format(**prepare_template_data(fill_pairs)) +def get_subset_name( + family, + variant, + task_name, + asset_id, + project_name=None, + host_name=None, + default_template=None, + dynamic_data=None, + dbcon=None +): + """Calculate subset name using OpenPype settings. + + This variant of function expects asset id as argument. + + This is legacy function should be replaced with + `get_subset_name_with_asset_doc` where asset document is expected. + """ + if dbcon is None: + from avalon.api import AvalonMongoDB + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + dbcon.install() + + asset_doc = dbcon.find_one( + {"_id": asset_id}, + {"data.tasks": True} + ) or {} + + return get_subset_name_with_asset_doc( + family, + variant, + task_name, + asset_doc, + project_name, + host_name, + default_template, + dynamic_data + ) + + def prepare_template_data(fill_pairs): """ Prepares formatted data for filling template. diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 451ea1d80d..753ed78083 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -99,7 +99,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "camerarig", "redshiftproxy", "effect", - "xgen" + "xgen", + "hda" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/openpype/plugins/publish/validate_containers.py b/openpype/plugins/publish/validate_containers.py index 784221c3b6..ce91bd3396 100644 --- a/openpype/plugins/publish/validate_containers.py +++ b/openpype/plugins/publish/validate_containers.py @@ -9,9 +9,9 @@ class ShowInventory(pyblish.api.Action): on = "failed" def process(self, context, plugin): - from avalon.tools import sceneinventory + from openpype.tools.utils import host_tools - sceneinventory.show() + host_tools.show_scene_inventory() class ValidateContainers(pyblish.api.ContextPlugin): diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index aae2d1fa89..775bf40ac4 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -110,7 +110,10 @@ from .enum_entity import ( ) from .list_entity import ListEntity -from .dict_immutable_keys_entity import DictImmutableKeysEntity +from .dict_immutable_keys_entity import ( + DictImmutableKeysEntity, + RootsDictEntity +) from .dict_mutable_keys_entity import DictMutableKeysEntity from .dict_conditional import ( DictConditionalEntity, @@ -169,6 +172,7 @@ __all__ = ( "ListEntity", "DictImmutableKeysEntity", + "RootsDictEntity", "DictMutableKeysEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 0e8274d374..341968bd75 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -510,7 +510,7 @@ class BaseItemEntity(BaseEntity): pass @abstractmethod - def _item_initalization(self): + def _item_initialization(self): """Entity specific initialization process.""" pass @@ -920,7 +920,7 @@ class ItemEntity(BaseItemEntity): _default_label_wrap["collapsed"] ) - self._item_initalization() + self._item_initialization() def save(self): """Call save on root item.""" diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index dfaa75e761..3becf2d865 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -9,7 +9,7 @@ from .exceptions import ( class ColorEntity(InputEntity): schema_types = ["color"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.value_on_not_set = [0, 0, 0, 255] self.use_alpha = self.schema_data.get("use_alpha", True) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 6f27760570..0cb8827991 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -107,7 +107,7 @@ class DictConditionalEntity(ItemEntity): for _key, _value in new_value.items(): self.non_gui_children[self.current_enum][_key].set(_value) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = NOT_SET self._studio_override_metadata = NOT_SET self._project_override_metadata = NOT_SET diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 57e21ff5f3..d0cd41d11c 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -4,7 +4,8 @@ import collections from .lib import ( WRAPPER_TYPES, OverrideState, - NOT_SET + NOT_SET, + STRING_TYPE ) from openpype.settings.constants import ( METADATA_KEYS, @@ -18,6 +19,7 @@ from . import ( GUIEntity ) from .exceptions import ( + DefaultsNotDefined, SchemaDuplicatedKeys, EntitySchemaError, InvalidKeySymbols @@ -172,7 +174,7 @@ class DictImmutableKeysEntity(ItemEntity): for child_obj in added_children: self.gui_layout.append(child_obj) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = NOT_SET self._studio_override_metadata = NOT_SET self._project_override_metadata = NOT_SET @@ -547,3 +549,178 @@ class DictImmutableKeysEntity(ItemEntity): super(DictImmutableKeysEntity, self).reset_callbacks() for child_entity in self.children: child_entity.reset_callbacks() + + +class RootsDictEntity(DictImmutableKeysEntity): + """Entity that adds ability to fill value for roots of current project. + + Value schema is defined by `object_type`. + + It is not possible to change override state (Studio values will always + contain studio overrides and same for project). That is because roots can + be totally different for each project. + """ + _origin_schema_data = None + schema_types = ["dict-roots"] + + def _item_initialization(self): + origin_schema_data = self.schema_data + + self.separate_items = origin_schema_data.get("separate_items", True) + object_type = origin_schema_data.get("object_type") + if isinstance(object_type, STRING_TYPE): + object_type = {"type": object_type} + self.object_type = object_type + + if not self.is_group: + self.is_group = True + + schema_data = copy.deepcopy(self.schema_data) + schema_data["children"] = [] + + self.schema_data = schema_data + self._origin_schema_data = origin_schema_data + + self._default_value = NOT_SET + self._studio_value = NOT_SET + self._project_value = NOT_SET + + super(RootsDictEntity, self)._item_initialization() + + def schema_validations(self): + if self.object_type is None: + reason = ( + "Missing children definitions for root values" + " ('object_type' not filled)." + ) + raise EntitySchemaError(self, reason) + + if not isinstance(self.object_type, dict): + reason = ( + "Children definitions for root values must be dictionary" + " ('object_type' is \"{}\")." + ).format(str(type(self.object_type))) + raise EntitySchemaError(self, reason) + + super(RootsDictEntity, self).schema_validations() + + def set_override_state(self, state, ignore_missing_defaults): + self.children = [] + self.non_gui_children = {} + self.gui_layout = [] + + roots_entity = self.get_entity_from_path( + "project_anatomy/roots" + ) + children = [] + first = True + for key in roots_entity.keys(): + if first: + first = False + elif self.separate_items: + children.append({"type": "separator"}) + child = copy.deepcopy(self.object_type) + child["key"] = key + child["label"] = key + children.append(child) + + schema_data = copy.deepcopy(self.schema_data) + schema_data["children"] = children + + self._add_children(schema_data) + + self._set_children_values(state) + + super(RootsDictEntity, self).set_override_state( + state, True + ) + + if state == OverrideState.STUDIO: + self.add_to_studio_default() + + elif state == OverrideState.PROJECT: + self.add_to_project_override() + + def on_child_change(self, child_obj): + if self._override_state is OverrideState.STUDIO: + if not child_obj.has_studio_override: + self.add_to_studio_default() + + elif self._override_state is OverrideState.PROJECT: + if not child_obj.has_project_override: + self.add_to_project_override() + + return super(RootsDictEntity, self).on_child_change(child_obj) + + def _set_children_values(self, state): + if state >= OverrideState.DEFAULTS: + default_value = self._default_value + if default_value is NOT_SET: + if state > OverrideState.DEFAULTS: + raise DefaultsNotDefined(self) + else: + default_value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = default_value.get(key, NOT_SET) + child_obj.update_default_value(child_value) + + if state >= OverrideState.STUDIO: + value = self._studio_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_studio_value(child_value) + + if state >= OverrideState.PROJECT: + value = self._project_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_project_value(child_value) + + def _update_current_metadata(self): + """Override this method as this entity should not have metadata.""" + self._metadata_are_modified = False + self._current_metadata = {} + + def update_default_value(self, value): + """Update default values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "default") + value, _ = self._prepare_value(value) + + self._default_value = value + self._default_metadata = {} + self.has_default_value = value is not NOT_SET + + def update_studio_value(self, value): + """Update studio override values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "studio override") + value, _ = self._prepare_value(value) + + self._studio_value = value + self._studio_override_metadata = {} + self.had_studio_override = value is not NOT_SET + + def update_project_value(self, value): + """Update project override values. + + Not an api method, should be called by parent. + """ + + value = self._check_update_value(value, "project override") + value, _metadata = self._prepare_value(value) + + self._project_value = value + self._project_override_metadata = {} + self.had_project_override = value is not NOT_SET diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index f75fb23d82..cff346e9ea 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -191,7 +191,7 @@ class DictMutableKeysEntity(EndpointEntity): child_entity = self.children_by_key[key] self.set_child_label(child_entity, label) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = {} self._studio_override_metadata = {} self._project_override_metadata = {} diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index f5d832f918..ab3cebbd42 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -8,7 +8,7 @@ from .lib import ( class BaseEnumEntity(InputEntity): - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = None self.enum_items = None @@ -70,7 +70,7 @@ class BaseEnumEntity(InputEntity): class EnumEntity(BaseEnumEntity): schema_types = ["enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", False) self.enum_items = self.schema_data.get("enum_items") # Default is optional and non breaking attribute @@ -157,7 +157,7 @@ class HostsEnumEntity(BaseEnumEntity): "standalonepublisher" ] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) use_empty_value = False if not self.multiselection: @@ -250,7 +250,7 @@ class HostsEnumEntity(BaseEnumEntity): class AppsEnumEntity(BaseEnumEntity): schema_types = ["apps-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = [] self.enum_items = [] @@ -317,7 +317,7 @@ class AppsEnumEntity(BaseEnumEntity): class ToolsEnumEntity(BaseEnumEntity): schema_types = ["tools-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = [] self.enum_items = [] @@ -376,7 +376,7 @@ class ToolsEnumEntity(BaseEnumEntity): class TaskTypeEnumEntity(BaseEnumEntity): schema_types = ["task-types-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) if self.multiselection: self.valid_value_types = (list, ) @@ -452,7 +452,7 @@ class TaskTypeEnumEntity(BaseEnumEntity): class DeadlineUrlEnumEntity(BaseEnumEntity): schema_types = ["deadline_url-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) self.enum_items = [] @@ -503,7 +503,7 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = False self.enum_items = [] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 0ded3ab7e5..a0598d405e 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -362,7 +362,7 @@ class NumberEntity(InputEntity): float_number_regex = re.compile(r"^\d+\.\d+$") int_number_regex = re.compile(r"^\d+$") - def _item_initalization(self): + def _item_initialization(self): self.minimum = self.schema_data.get("minimum", -99999) self.maximum = self.schema_data.get("maximum", 99999) self.decimal = self.schema_data.get("decimal", 0) @@ -420,7 +420,7 @@ class NumberEntity(InputEntity): class BoolEntity(InputEntity): schema_types = ["boolean"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (bool, ) value_on_not_set = self.convert_to_valid_type( self.schema_data.get("default", True) @@ -431,7 +431,7 @@ class BoolEntity(InputEntity): class TextEntity(InputEntity): schema_types = ["text"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" @@ -449,7 +449,7 @@ class TextEntity(InputEntity): class PathInput(InputEntity): schema_types = ["path-input"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" @@ -460,7 +460,7 @@ class PathInput(InputEntity): class RawJsonEntity(InputEntity): schema_types = ["raw-json"] - def _item_initalization(self): + def _item_initialization(self): # Schema must define if valid value is dict or list store_as_string = self.schema_data.get("store_as_string", False) is_list = self.schema_data.get("is_list", False) diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index c7c9c3097e..ff0a982900 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -48,7 +48,7 @@ class PathEntity(ItemEntity): raise AttributeError(self.attribute_error_msg.format("items")) return self.child_obj.items() - def _item_initalization(self): + def _item_initialization(self): if self.group_item is None and not self.is_group: self.is_group = True @@ -216,7 +216,7 @@ class ListStrictEntity(ItemEntity): return self.children[idx] return default - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.require_key = True diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index b06f4d7a2e..5d89a81351 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -149,7 +149,7 @@ class ListEntity(EndpointEntity): return list(value) return NOT_SET - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.children = [] self.value_on_not_set = [] diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 05d20ee60b..b8baed8a93 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -65,7 +65,7 @@ class RootEntity(BaseItemEntity): super(RootEntity, self).__init__(schema_data) self._require_restart_callbacks = [] self._item_ids_require_restart = set() - self._item_initalization() + self._item_initialization() if reset: self.reset() @@ -176,7 +176,7 @@ class RootEntity(BaseItemEntity): for child_obj in added_children: self.gui_layout.append(child_obj) - def _item_initalization(self): + def _item_initialization(self): # Store `self` to `root_item` for children entities self.root_item = self diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 5258fef9ec..4e8dcc36ce 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -208,6 +208,25 @@ } ``` +## dict-roots +- entity can be used only in Project settings +- keys of dictionary are based on current project roots +- they are not updated "live" it is required to save root changes and then + modify values on this entity + # TODO do live updates +``` +{ + "type": "dict-roots", + "key": "roots", + "label": "Roots", + "object_type": { + "type": "path", + "multiplatform": true, + "multipath": false + } +} +``` + ## dict-conditional - is similar to `dict` but has always available one enum entity - the enum entity has single selection and it's value define other children entities diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index 9b839fb2bc..9e4af1c89b 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -8,8 +8,7 @@ from avalon.api import AvalonMongoDB from openpype import style from openpype.api import resources -from avalon.tools import lib as tools_lib -from avalon.tools.widgets import AssetWidget +from openpype.tools.utils.widgets import AssetWidget from avalon.vendor import qtawesome from .models import ProjectModel from .lib import get_action_label, ProjectHandler diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5b6ed78b50..b7ab9e40d0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1456,7 +1456,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): return raw_data = mime_data.data("application/copy_task") - encoded_data = QtCore.QByteArray.fromRawData(raw_data) + if isinstance(raw_data, QtCore.QByteArray): + # Raw data are already QByteArrat and we don't have to load them + encoded_data = raw_data + else: + encoded_data = QtCore.QByteArray.fromRawData(raw_data) stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly) text = stream.readQString() try: diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index 234135fd9a..1fa3ee657b 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -7,7 +7,6 @@ an active window manager; such as via Travis-CI. """ import os import sys -import traceback import inspect import logging diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index d01dbbd169..aad00f886c 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -35,26 +35,6 @@ def application(): yield app -def defer(delay, func): - """Append artificial delay to `func` - - This aids in keeping the GUI responsive, but complicates logic - when producing tests. To combat this, the environment variable ensures - that every operation is synchonous. - - Arguments: - delay (float): Delay multiplier; default 1, 0 means no delay - func (callable): Any callable - - """ - - delay *= float(os.getenv("PYBLISH_DELAY", 1)) - if delay > 0: - return QtCore.QTimer.singleShot(delay, func) - else: - return func() - - class SharedObjects: jobs = {} @@ -82,18 +62,6 @@ def schedule(func, time, channel="default"): SharedObjects.jobs[channel] = timer -@contextlib.contextmanager -def dummy(): - """Dummy context manager - - Usage: - >> with some_context() if False else dummy(): - .. pass - - """ - yield - - def iter_model_rows(model, column, include_root=False): """Iterate over all row indices in a model""" indices = [QtCore.QModelIndex()] # start iteration at root @@ -111,76 +79,6 @@ def iter_model_rows(model, column, include_root=False): yield index -@contextlib.contextmanager -def preserve_states(tree_view, - column=0, - role=None, - preserve_expanded=True, - preserve_selection=True, - expanded_role=QtCore.Qt.DisplayRole, - selection_role=QtCore.Qt.DisplayRole): - """Preserves row selection in QTreeView by column's data role. - This function is created to maintain the selection status of - the model items. When refresh is triggered the items which are expanded - will stay expanded and vise versa. - tree_view (QWidgets.QTreeView): the tree view nested in the application - column (int): the column to retrieve the data from - role (int): the role which dictates what will be returned - Returns: - None - """ - # When `role` is set then override both expanded and selection roles - if role: - expanded_role = role - selection_role = role - - model = tree_view.model() - selection_model = tree_view.selectionModel() - flags = selection_model.Select | selection_model.Rows - - expanded = set() - - if preserve_expanded: - for index in iter_model_rows( - model, column=column, include_root=False - ): - if tree_view.isExpanded(index): - value = index.data(expanded_role) - expanded.add(value) - - selected = None - - if preserve_selection: - selected_rows = selection_model.selectedRows() - if selected_rows: - selected = set(row.data(selection_role) for row in selected_rows) - - try: - yield - finally: - if expanded: - for index in iter_model_rows( - model, column=0, include_root=False - ): - value = index.data(expanded_role) - is_expanded = value in expanded - # skip if new index was created meanwhile - if is_expanded is None: - continue - tree_view.setExpanded(index, is_expanded) - - if selected: - # Go through all indices, select the ones with similar data - for index in iter_model_rows( - model, column=column, include_root=False - ): - value = index.data(selection_role) - state = value in selected - if state: - tree_view.scrollTo(index) # Ensure item is visible - selection_model.select(index, flags) - - @contextlib.contextmanager def preserve_expanded_rows(tree_view, column=0, role=None): """Preserves expanded row in QTreeView by column's data role. diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index d2aadf05cb..bd422b046e 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -76,3 +76,28 @@ I've selected `vdb1` and went **OpenPype -> Create** and selected **VDB Cache**. geometry ROP in `/out` and sets its paths to output vdb files. During the publishing process whole dops are cooked. +## Publishing Houdini Digital Assets (HDA) + +You can publish most of the nodes in Houdini as hda for easy interchange of data between Houdini instances or even +other DCCs with Houdini Engine. + +## Creating HDA + +Simply select nodes you want to include in hda and go **OpenPype -> Create** and select **Houdini digital asset (hda)**. +You can even use already existing hda as a selected node, and it will be published (see below for limitation). + +:::caution HDA Workflow limitations +As long as the hda is of same type - it is created from different nodes but using the same (subset) name, everything +is ok. But once you've published version of hda subset, you cannot change its type. For example, you create hda **Foo** +from *Cube* and *Sphere* - it will create hda subset named `hdaFoo` with the same type. You publish it as version 1. +Then you create version 2 with added *Torus*. Then you create version 3 from the scratch from completely different nodes, +but still using resulting subset name `hdaFoo`. Everything still works as expected. But then you use already +existing hda as a base, for example from different artist. Its type cannot be changed from what it was and so even if +it is named `hdaFoo` it has different type. It could be published, but you would never load it and retain ability to +switch versions between different hda types. +::: + +## Loading HDA + +When you load hda, it will install its type in your hip file and add published version as its definition file. When +you switch version via Scene Manager, it will add its definition and set it as preferred. \ No newline at end of file