From 0040b10f912d1390f2753b482b55901a161b9443 Mon Sep 17 00:00:00 2001 From: Cyprien CAILLOT Date: Wed, 11 Sep 2024 14:43:07 +0200 Subject: [PATCH 1/2] Enhancement: Add action in the Workfile Template Builder --- openpype/hosts/maya/api/pipeline.py | 5 + openpype/hosts/maya/api/plugin.py | 148 +++++++++++ .../maya/api/workfile_template_builder.py | 3 + .../maya/plugins/builder/builder_test.py | 20 ++ openpype/modules/base.py | 18 +- openpype/modules/interfaces.py | 16 +- openpype/pipeline/__init__.py | 40 ++- openpype/pipeline/action/__init__.py | 30 +++ openpype/pipeline/action/action_plugin.py | 67 +++++ openpype/pipeline/action/utils.py | 235 ++++++++++++++++++ openpype/pipeline/context_tools.py | 10 + openpype/pipeline/workfile/__init__.py | 2 +- .../workfile/workfile_template_builder.py | 78 +++++- .../tools/workfile_template_build/window.py | 46 ++++ 14 files changed, 695 insertions(+), 23 deletions(-) create mode 100644 openpype/hosts/maya/plugins/builder/builder_test.py create mode 100644 openpype/pipeline/action/__init__.py create mode 100644 openpype/pipeline/action/action_plugin.py create mode 100644 openpype/pipeline/action/utils.py diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 1ecfdfaa404..44b3b35ed84 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -31,9 +31,11 @@ register_loader_plugin_path, register_inventory_action_path, register_creator_plugin_path, + register_builder_action_path, deregister_loader_plugin_path, deregister_inventory_action_path, deregister_creator_plugin_path, + deregister_builder_action_path, AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers @@ -64,6 +66,7 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "load") CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +BUILDER_PATH = os.path.join(PLUGINS_DIR, "builder") AVALON_CONTAINERS = ":AVALON_CONTAINERS" @@ -90,6 +93,7 @@ def install(self): register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) + register_builder_action_path(BUILDER_PATH) self.log.info(PUBLISH_PATH) self.log.info("Installing callbacks ... ") @@ -335,6 +339,7 @@ def uninstall(): deregister_loader_plugin_path(LOAD_PATH) deregister_creator_plugin_path(CREATE_PATH) deregister_inventory_action_path(INVENTORY_PATH) + deregister_builder_action_path(BUILDER_PATH) menu.uninstall() diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 38df607665b..2781a995dd3 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -24,6 +24,7 @@ LoaderPlugin, get_representation_path, ) +from openpype.pipeline.action import BuilderAction from openpype.pipeline.load import LoadError from openpype.client import get_asset_by_name from openpype.pipeline.create import get_subset_name @@ -965,3 +966,150 @@ def _organize_containers(nodes, container): continue if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: cmds.sets(node, forceElement=container) + + +class ActionBase(BuilderAction): + """Base class for all actions.""" + label = "Action Base" + options = [ + qargparse.Integer( + "count", + label="Count", + default=1, + min=1, + help="How many times to load?" + ), + qargparse.Double3( + "offset", + label="Position Offset", + help="Offset loaded models for easier selection." + ), + qargparse.Boolean( + "attach_to_root", + label="Group imported asset", + default=True, + help="Should a group be created to encapsulate" + " imported representation ?" + ) + ] + + def load( + self, + context, + name=None, + namespace=None, + options=None + ): + assert os.path.exists(self.fname), "%s does not exist." % self.fname + + asset = context['asset'] + subset = context['subset'] + settings = get_project_settings(context['project']['name']) + custom_naming = settings['maya']['load']['reference_loader'] + loaded_containers = [] + + if not custom_naming['namespace']: + raise LoadError("No namespace specified in " + "Maya ReferenceLoader settings") + elif not custom_naming['group_name']: + raise LoadError("No group name specified in " + "Maya ReferenceLoader settings") + + formatting_data = { + "asset_name": asset['name'], + "asset_type": asset['type'], + "subset": subset['name'], + "family": ( + subset['data'].get('family') or + subset['data']['families'][0] + ) + } + + custom_namespace = custom_naming['namespace'].format( + **formatting_data + ) + + custom_group_name = custom_naming['group_name'].format( + **formatting_data + ) + + count = options.get("count") or 1 + + for c in range(0, count): + namespace = lib.get_custom_namespace(custom_namespace) + group_name = "{}:{}".format( + namespace, + custom_group_name + ) + + options['group_name'] = group_name + + # Offset loaded subset + if "offset" in options: + offset = [i * c for i in options["offset"]] + options["translate"] = offset + + self.log.info(options) + + self.process( + context=context, + name=name, + namespace=namespace, + options=options + ) + + # Only containerize if any nodes were loaded by the Loader + nodes = self[:] + if not nodes: + return + + ref_node = get_reference_node(nodes, self.log) + container = containerise( + name=name, + namespace=namespace, + nodes=[ref_node], + context=context, + loader=self.__class__.__name__ + ) + loaded_containers.append(container) + self._organize_containers(nodes, container) + c += 1 + namespace = None + + return loaded_containers + + def process(self, context, name, namespace, options): + """To be implemented by subclass""" + raise NotImplementedError("Must be implemented by subclass") + + def prepare_root_value(self, file_url, project_name): + """Replace root value with env var placeholder. + Use ${OPENPYPE_ROOT_WORK} (or any other root) instead of proper root + value when storing referenced url into a workfile. + Useful for remote workflows with SiteSync. + Args: + file_url (str) + project_name (dict) + Returns: + (str) + """ + settings = get_project_settings(project_name) + use_env_var_as_root = (settings["maya"] + ["maya-dirmap"] + ["use_env_var_as_root"]) + if use_env_var_as_root: + anatomy = Anatomy(project_name) + file_url = anatomy.replace_root_with_env_key(file_url, '${{{}}}') + + return file_url + + @staticmethod + def _organize_containers(nodes, container): + # type: (list, str) -> None + """Put containers in loaded data to correct hierarchy.""" + for node in nodes: + id_attr = "{}.id".format(node) + if not cmds.attributeQuery("id", node=node, exists=True): + continue + if cmds.getAttr(id_attr) == AVALON_CONTAINER_ID: + cmds.sets(node, forceElement=container) diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index d1ba3cc306d..0fe6f000ee1 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -186,6 +186,9 @@ def create_placeholder(self, placeholder_data): if parent: placeholder = cmds.parent(placeholder, selection[0])[0] + if placeholder_data['action'] is None: + placeholder_data.pop('action') + imprint(placeholder, placeholder_data) # Add helper attributes to keep placeholder info diff --git a/openpype/hosts/maya/plugins/builder/builder_test.py b/openpype/hosts/maya/plugins/builder/builder_test.py new file mode 100644 index 00000000000..935c779940e --- /dev/null +++ b/openpype/hosts/maya/plugins/builder/builder_test.py @@ -0,0 +1,20 @@ +from maya import cmds + +from openpype.pipeline.action import BuilderAction + + +class ConnectShape(BuilderAction): + """Connect Shape within containers. + Source container will connect to the target containers, by searching for + matching geometry IDs (cbid). + Source containers are of family; "animation" and "pointcache". + The connection with be done with a live world space blendshape. + """ + + label = "Connect Shape" + icon = "link" + color = "white" + + def process(self): + self.log.info("Connect Shape") + print(cmds.ls()) \ No newline at end of file diff --git a/openpype/modules/base.py b/openpype/modules/base.py index cb64816cc99..7aebfc06554 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -1037,7 +1037,7 @@ def collect_plugin_paths(self): Returns: dict: Output is dictionary with keys "publish", "create", "load", - "actions" and "inventory" each containing list of paths. + "actions", "inventory", "builder" each containing a list of paths. """ # Output structure output = { @@ -1045,7 +1045,8 @@ def collect_plugin_paths(self): "create": [], "load": [], "actions": [], - "inventory": [] + "inventory": [], + "builder": [] } unknown_keys_by_module = {} for module in self.get_enabled_modules(): @@ -1176,6 +1177,19 @@ def collect_inventory_action_paths(self, host_name): host_name ) + def collect_builder_action_paths(self, host_name): + """Helper to collect builder action paths from modules. + Args: + host_name (str): For which host are load action meant. + Returns: + list: List of builder action paths. + """ + + return self._collect_plugin_paths( + "get_builder_action_paths", + host_name + ) + def get_host_module(self, host_name): """Find host module by host name. diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 0d73bc35a32..578707937e8 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -34,7 +34,7 @@ class IPluginPaths(OpenPypeInterface): """Module has plugin paths to return. Expected result is dictionary with keys "publish", "create", "load", - "actions" or "inventory" and values as list or string. + "actions", "inventory", "builder" and values as a list or string. { "publish": ["path/to/publish_plugins"] } @@ -116,7 +116,7 @@ def get_inventory_action_paths(self, host_name): Notes: Default implementation uses 'get_plugin_paths' and always return - all publish plugin paths. + all inventory plugin paths. Args: host_name (str): For which host are the plugins meant. @@ -124,6 +124,18 @@ def get_inventory_action_paths(self, host_name): return self._get_plugin_paths_by_type("inventory") + def get_builder_action_paths(self, host_name): + """Receive builder action paths. + Give addons ability to add builder action plugin paths. + Notes: + Default implementation uses 'get_plugin_paths' and always return + all builder plugin paths. + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("builder") + class ILaunchHookPaths(OpenPypeInterface): """Module has launch hook paths to return. diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 8f370d389bf..ee0fd87460f 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -1,11 +1,11 @@ from .constants import ( AVALON_CONTAINER_ID, AYON_CONTAINER_ID, - HOST_WORKFILE_EXTENSIONS, + HOST_WORKFILE_EXTENSIONS ) from .mongodb import ( - AvalonMongoDB, + AvalonMongoDB ) from .anatomy import Anatomy @@ -25,7 +25,7 @@ register_creator_plugin, deregister_creator_plugin, register_creator_plugin_path, - deregister_creator_plugin_path, + deregister_creator_plugin_path ) from .load import ( @@ -48,7 +48,7 @@ loaders_from_representation, get_representation_path, get_representation_context, - get_repres_contexts, + get_repres_contexts ) from .publish import ( @@ -56,7 +56,7 @@ PublishXmlValidationError, KnownPublishError, OpenPypePyblishPluginMixin, - OptionalPyblishPluginMixin, + OptionalPyblishPluginMixin ) from .actions import ( @@ -72,7 +72,7 @@ register_inventory_action, register_inventory_action_path, deregister_inventory_action, - deregister_inventory_action_path, + deregister_inventory_action_path ) from .context_tools import ( @@ -96,6 +96,20 @@ get_current_asset_name, get_current_task_name ) + +from .action import ( + BuilderAction, + + discover_builder_plugins, + register_builder_action, + register_builder_action_path, + deregister_builder_action, + deregister_builder_action_path, + + get_actions_by_name, + action_with_repre_context +) + install = install_host uninstall = uninstall_host @@ -196,7 +210,19 @@ "get_current_asset_name", "get_current_task_name", + # --- Action --- + "BuilderAction", + + "discover_builder_plugins", + "register_builder_action", + "register_builder_action_path", + "deregister_builder_action", + "deregister_builder_action_path", + + "get_actions_by_name", + "action_with_repre_context", + # Backwards compatible function names "install", - "uninstall", + "uninstall" ) diff --git a/openpype/pipeline/action/__init__.py b/openpype/pipeline/action/__init__.py new file mode 100644 index 00000000000..612c796a0c9 --- /dev/null +++ b/openpype/pipeline/action/__init__.py @@ -0,0 +1,30 @@ +from .utils import ( + get_actions_by_name, + action_with_repre_context +) + +from .action_plugin import ( + BuilderAction, + + discover_builder_plugins, + register_builder_action, + deregister_builder_action, + register_builder_action_path, + deregister_builder_action_path, +) + + +__all__ = ( + # utils.py + "get_actions_by_name", + "action_with_repre_context", + + # action_plugin.py + "BuilderAction", + + "discover_builder_plugins", + "register_builder_action", + "deregister_builder_action", + "register_builder_action_path", + "deregister_builder_action_path", +) diff --git a/openpype/pipeline/action/action_plugin.py b/openpype/pipeline/action/action_plugin.py new file mode 100644 index 00000000000..cca78e3d86c --- /dev/null +++ b/openpype/pipeline/action/action_plugin.py @@ -0,0 +1,67 @@ +import os +import logging + +from openpype.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) +from .utils import get_representation_path_from_context + + +class BuilderAction(list): + families = [] + representations = [] + extensions = {"*"} + order = 0 + is_multiple_contexts_compatible = False + enabled = True + + options = [] + + log = logging.getLogger("BuilderAction") + log.propagate = True + + def __init__(self, context, name=None, namespace=None, options=None): + self.fname = self.filepath_from_context(context) + + def __repr__(self): + return "".format(self.name) + + @classmethod + def filepath_from_context(cls, context): + return get_representation_path_from_context(context) + + def load(self, context, name=None, namespace=None, options=None): + """Load asset via database + Arguments: + context (dict): Full parenthood of representation to load + name (str, optional): Use pre-defined name + namespace (str, optional): Use pre-defined namespace + options (dict, optional): Additional settings dictionary + """ + raise NotImplementedError("Loader.load() must be " + "implemented by subclass") + + +def discover_builder_plugins(): + plugins = discover(BuilderAction) + return plugins + + +def register_builder_action(plugin): + register_plugin(BuilderAction, plugin) + + +def deregister_builder_action(plugin): + deregister_plugin(BuilderAction, plugin) + + +def register_builder_action_path(path): + register_plugin_path(BuilderAction, path) + + +def deregister_builder_action_path(path): + deregister_plugin_path(BuilderAction, path) diff --git a/openpype/pipeline/action/utils.py b/openpype/pipeline/action/utils.py new file mode 100644 index 00000000000..83f045dcc45 --- /dev/null +++ b/openpype/pipeline/action/utils.py @@ -0,0 +1,235 @@ +import os +import logging +import platform +import getpass + +from openpype.pipeline import ( + legacy_io, + Anatomy +) +from openpype.lib import ( + StringTemplate, + TemplateUnsolved, +) +from openpype.client import get_representation_parents + +log = logging.getLogger(__name__) + + +def get_actions_by_name(): + from .action_plugin import discover_builder_plugins + actions_by_name = {} + for action in discover_builder_plugins(): + action_name = action.__name__ + if action_name in actions_by_name: + raise KeyError( + "Duplicated loader name {} !".format(action_name) + ) + actions_by_name[action_name] = action + return actions_by_name + + +def get_actions_by_family(family): + """Return all actions by family""" + from .action_plugin import discover_builder_plugins + + actions_by_family = {} + for action in discover_builder_plugins(): + action_name = action.__name__ + if action_name in actions_by_family: + raise KeyError( + "Duplicated loader family {} !".format(action_name) + ) + + action_families_list = action.families + if family in action_families_list: + actions_by_family[action_name] = action + return actions_by_family + + +def action_with_repre_context( + Action, repre_context, name=None, namespace=None, options=None, **kwargs +): + # Ensure options is a dictionary when no explicit options provided + if options is None: + options = kwargs.get("data", dict()) # "data" for backward compat + + assert isinstance(options, dict), "Options must be a dictionary" + + # Fallback to subset when name is None + if name is None: + name = repre_context["subset"]["name"] + + log.info( + "Running '%s' on '%s'" % ( + Action.__name__, repre_context["asset"]["name"] + ) + ) + + loader = Action(repre_context) + return loader.load(repre_context, name, namespace, options) + + +def get_representation_path_from_context(context): + """Preparation wrapper using only context as a argument""" + representation = context['representation'] + project_doc = context.get("project") + root = None + session_project = legacy_io.Session.get("AVALON_PROJECT") + if project_doc and project_doc["name"] != session_project: + anatomy = Anatomy(project_doc["name"]) + root = anatomy.roots + + return get_representation_path(representation, root) + + +def get_representation_path(representation, root=None, dbcon=None): + """Get filename from representation document + There are three ways of getting the path from representation which are + tried in following sequence until successful. + 1. Get template from representation['data']['template'] and data from + representation['context']. Then format template with the data. + 2. Get template from project['config'] and format it with default data set + 3. Get representation['data']['path'] and use it directly + Args: + representation(dict): representation document from the database + Returns: + str: fullpath of the representation + """ + + if dbcon is None: + dbcon = legacy_io + + if root is None: + from openpype.pipeline import registered_root + + root = registered_root() + + def path_from_represenation(): + try: + template = representation["data"]["template"] + except KeyError: + return None + + try: + context = representation["context"] + context["root"] = root + path = StringTemplate.format_strict_template( + template, context + ) + # Force replacing backslashes with forward slashed if not on + # windows + if platform.system().lower() != "windows": + path = path.replace("\\", "/") + except (TemplateUnsolved, KeyError): + # Template references unavailable data + return None + + if not path: + return path + + normalized_path = os.path.normpath(path) + if os.path.exists(normalized_path): + return normalized_path + return path + + def path_from_config(): + try: + project_name = dbcon.active_project() + version_, subset, asset, project = get_representation_parents( + project_name, representation + ) + except ValueError: + log.debug( + "Representation %s wasn't found in database, " + "like a bug" % representation["name"] + ) + return None + + try: + template = project["config"]["template"]["publish"] + except KeyError: + log.debug( + "No template in project %s, " + "likely a bug" % project["name"] + ) + return None + + # default list() in get would not discover missing parents on asset + parents = asset.get("data", {}).get("parents") + if parents is not None: + hierarchy = "/".join(parents) + + # Cannot fail, required members only + data = { + "root": root, + "project": { + "name": project["name"], + "code": project.get("data", {}).get("code") + }, + "asset": asset["name"], + "hierarchy": hierarchy, + "subset": subset["name"], + "version": version_["name"], + "representation": representation["name"], + "family": representation.get("context", {}).get("family"), + "user": dbcon.Session.get("AVALON_USER", getpass.getuser()), + "app": dbcon.Session.get("AVALON_APP", ""), + "task": dbcon.Session.get("AVALON_TASK", "") + } + + try: + template_obj = StringTemplate(template) + path = str(template_obj.format(data)) + # Force replacing backslashes with forward slashed if not on + # windows + if platform.system().lower() != "windows": + path = path.replace("\\", "/") + + except KeyError as e: + log.debug("Template references unavailable data: %s" % e) + return None + + normalized_path = os.path.normpath(path) + if os.path.exists(normalized_path): + return normalized_path + return path + + def path_from_data(): + if "path" not in representation["data"]: + return None + + path = representation["data"]["path"] + # Force replacing backslashes with forward slashed if not on + # windows + if platform.system().lower() != "windows": + path = path.replace("\\", "/") + + if os.path.exists(path): + return os.path.normpath(path) + + dir_path, file_name = os.path.split(path) + if not os.path.exists(dir_path): + return + + base_name, ext = os.path.splitext(file_name) + file_name_items = None + if "#" in base_name: + file_name_items = [part for part in base_name.split("#") if part] + elif "%" in base_name: + file_name_items = base_name.split("%") + + if not file_name_items: + return + + filename_start = file_name_items[0] + + for _file in os.listdir(dir_path): + if _file.startswith(filename_start) and _file.endswith(ext): + return os.path.normpath(path) + + return ( + path_from_represenation() or + path_from_config() or + path_from_data() + ) diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index a607c909124..74e33ba0fa9 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -42,6 +42,10 @@ deregister_inventory_action_path ) +from .action import ( + register_builder_action_path, +) + _is_installed = False _process_id = None @@ -205,6 +209,11 @@ def install_openpype_plugins(project_name=None, host_name=None): for path in inventory_action_paths: register_inventory_action_path(path) + builder_action_paths = modules_manager.collect_builder_action_paths( + host_name) + for path in builder_action_paths: + register_builder_action_path(path) + if project_name is None: project_name = os.environ.get("AVALON_PROJECT") @@ -235,6 +244,7 @@ def install_openpype_plugins(project_name=None, host_name=None): register_loader_plugin_path(path) register_creator_plugin_path(path) register_inventory_action_path(path) + register_builder_action_path(path) def uninstall_host(): diff --git a/openpype/pipeline/workfile/__init__.py b/openpype/pipeline/workfile/__init__.py index 94ecc81bd62..296ea8dcf57 100644 --- a/openpype/pipeline/workfile/__init__.py +++ b/openpype/pipeline/workfile/__init__.py @@ -30,5 +30,5 @@ "create_workdir_extra_folders", - "BuildWorkfile", + "BuildWorkfile" ) diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py index 3096d22518b..3eb49b9ad1d 100644 --- a/openpype/pipeline/workfile/workfile_template_builder.py +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -44,6 +44,11 @@ load_with_repre_context, ) +from openpype.pipeline.action import ( + get_actions_by_name, + action_with_repre_context +) + from openpype.pipeline.create import ( discover_legacy_creator_plugins, CreateContext, @@ -114,6 +119,7 @@ def __init__(self, host): self._placeholder_plugins = None self._loaders_by_name = None self._creators_by_name = None + self._actions_by_name = None self._create_context = None self._system_settings = None @@ -249,6 +255,7 @@ def refresh(self): self._placeholder_plugins = None self._loaders_by_name = None self._creators_by_name = None + self._actions_by_name = None self._current_asset_doc = None self._linked_asset_docs = None @@ -290,6 +297,11 @@ def get_creators_by_name(self): return self._creators_by_name + def get_actions_by_name(self): + if self._actions_by_name is None: + self._actions_by_name = get_actions_by_name() + return self._actions_by_name + def get_shared_data(self, key): """Receive shared data across plugins and placeholders. @@ -1273,6 +1285,13 @@ def get_load_plugin_options(self, options=None): # Sort for readability families = list(sorted(families)) + actions_by_name = get_actions_by_name() + actions_items = [{"value": "", "label": ""}] + actions_items.extend( + {"value": action_name, "label": action.label or action_name} + for action_name, action in actions_by_name.items() + ) + if AYON_SERVER_ENABLED: builder_type_enum_items = [ {"label": "Current folder", "value": "context_folder"}, @@ -1293,21 +1312,23 @@ def get_load_plugin_options(self, options=None): ) else: builder_type_enum_items = [ - {"label": "Current asset", "value": "context_asset"}, - {"label": "Linked assets", "value": "linked_asset"}, - {"label": "All assets", "value": "all_assets"}, + {"label": "From Current Asset", "value": "context_asset"}, + {"label": "From Linked Assets", "value": "linked_asset"}, + {"label": "From All Assets", "value": "all_assets"}, ] - build_type_label = "Asset Builder Type" + build_type_label = "Asset Builder Source" build_type_help = ( - "Asset Builder Type\n" + "Asset Builder Source\n" "\nBuilder type describe what template loader will look" " for." - "\ncontext_asset : Template loader will look for subsets" + "\nFrom Current Asset : Template loader will look for subsets" " of current context asset (Asset bob will find asset)" - "\nlinked_asset : Template loader will look for assets" + "\nFrom Linked Asset : Template loader will look for assets" " linked to current context asset." - "\nLinked asset are looked in database under" + "\nLinked assets are looked in database under" " field \"inputLinks\"" + "\nFrom All assets : Template loader will look for all assets" + " in database." ) attr_defs = [ @@ -1347,6 +1368,17 @@ def get_load_plugin_options(self, options=None): "\nField is case sensitive." ) ), + attribute_definitions.EnumDef( + "action", + label="Builder Action", + default=options.get("action"), + items=actions_items, + tooltip=( + "Builder Action" + "\nUsed to do actions before or after processing" + " the placeholders." + ), + ), attribute_definitions.TextDef( "loader_args", label="Loader Arguments", @@ -1740,8 +1772,8 @@ def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): placeholder, representation ) self.log.info( - "Loading {} from {} with loader {}\n" - "Loader arguments used : {}".format( + "Loading {} from {} with loader {} with " + "loader arguments used : {}".format( repre_context["subset"], repre_context["asset"], loader_name, @@ -1754,13 +1786,17 @@ def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): repre_load_context, options=loader_args ) - except Exception: self.load_failed(placeholder, representation) failed = True else: self.load_succeed(placeholder, container) + self.populate_action_placeholder( + placeholder, + repre_load_contexts + ) + # Run post placeholder process after load of all representations self.post_placeholder_process(placeholder, failed) @@ -1773,6 +1809,26 @@ def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): if not placeholder.data.get("keep_placeholder", True): self.delete_placeholder(placeholder) + def populate_action_placeholder(self, placeholder, repre_load_contexts): + if "action" not in placeholder.data: + return + + action_name = placeholder.data["action"] + + if not action_name: + return + + actions_by_name = self.builder.get_actions_by_name() + + for context in repre_load_contexts.values(): + try: + action_with_repre_context( + actions_by_name[action_name], + context + ) + except Exception as e: + self.log.warning(f"Action {action_name} failed: {e}") + def load_failed(self, placeholder, representation): if hasattr(placeholder, "load_failed"): placeholder.load_failed(representation) diff --git a/openpype/tools/workfile_template_build/window.py b/openpype/tools/workfile_template_build/window.py index df7aedf5666..d4ef372de4a 100644 --- a/openpype/tools/workfile_template_build/window.py +++ b/openpype/tools/workfile_template_build/window.py @@ -3,6 +3,7 @@ from openpype import style from openpype.lib import Logger from openpype.pipeline import legacy_io +from openpype.pipeline.action.utils import get_actions_by_family from openpype.tools.attribute_defs import AttributeDefinitionsWidget @@ -171,6 +172,7 @@ def _create_option_widgets(self, plugin, options=None): self._content_layout.addStretch(1) self._attr_defs_widget = widget self._last_selected_plugin = plugin.identifier + self.filter_actions_by_families_widget() def _update_ui_visibility(self): create_mode = self._mode == 0 @@ -239,3 +241,47 @@ def showEvent(self, event): self._first_show = False self.setStyleSheet(style.load_stylesheet()) self.resize(390, 450) + + @staticmethod + def update_builder_action(family_widget, builder_widget): + """Update builder action widget by family widget value""" + builder_widget._input_widget.clear() + actions_by_family = get_actions_by_family(family_widget.current_value()) + action_items = [{"value": "", "label": ""}] + + if actions_by_family: + action_items.extend([ + {"value": action_name, "label": action.label or action_name} + for action_name, action in actions_by_family.items() + ]) + + for item in action_items: + builder_widget._input_widget.addItem(item["label"], item["value"]) + + def filter_actions_by_families_widget(self): + """Filter builder actions by families""" + family_widget = None + builder_widget = None + + for widget in self._attr_defs_widget.children(): + if not hasattr(widget, 'attr_def') or \ + not hasattr(widget.attr_def, 'label') or \ + not widget.attr_def.label: + continue + + widget_label = widget.attr_def.label.lower() + if 'family' in widget_label: + family_widget = widget + elif 'builder action' in widget_label: + builder_widget = widget + + if family_widget and builder_widget: + break + + if not family_widget or not builder_widget: + return + + self.update_builder_action(family_widget, builder_widget) + family_widget._input_widget.currentIndexChanged.connect( + lambda index, family=family_widget, builder=builder_widget: self.update_builder_action(family, builder) + ) From 64845ebc2444b84c91c1a3e3ccce7f1387bab8d0 Mon Sep 17 00:00:00 2001 From: Cyprien CAILLOT Date: Wed, 11 Sep 2024 14:45:16 +0200 Subject: [PATCH 2/2] Enhancement: Add action in the Workfile Template Builder --- openpype/hosts/maya/plugins/builder/builder_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/builder/builder_test.py b/openpype/hosts/maya/plugins/builder/builder_test.py index 935c779940e..89c5bdaeeec 100644 --- a/openpype/hosts/maya/plugins/builder/builder_test.py +++ b/openpype/hosts/maya/plugins/builder/builder_test.py @@ -17,4 +17,4 @@ class ConnectShape(BuilderAction): def process(self): self.log.info("Connect Shape") - print(cmds.ls()) \ No newline at end of file + print(cmds.ls())