diff --git a/openpype/hosts/maya/api/lib_template_builder.py b/openpype/hosts/maya/api/lib_template_builder.py deleted file mode 100644 index 34a8450a26..0000000000 --- a/openpype/hosts/maya/api/lib_template_builder.py +++ /dev/null @@ -1,253 +0,0 @@ -import json -from collections import OrderedDict -import maya.cmds as cmds - -import qargparse -from openpype.tools.utils.widgets import OptionDialog -from .lib import get_main_window, imprint - -# To change as enum -build_types = ["context_asset", "linked_asset", "all_assets"] - - -def get_placeholder_attributes(node): - return { - attr: cmds.getAttr("{}.{}".format(node, attr)) - for attr in cmds.listAttr(node, userDefined=True)} - - -def delete_placeholder_attributes(node): - ''' - function to delete all extra placeholder attributes - ''' - extra_attributes = get_placeholder_attributes(node) - for attribute in extra_attributes: - cmds.deleteAttr(node + '.' + attribute) - - -def create_placeholder(): - args = placeholder_window() - - if not args: - return # operation canceled, no locator created - - # custom arg parse to force empty data query - # and still imprint them on placeholder - # and getting items when arg is of type Enumerator - options = create_options(args) - - # create placeholder name dynamically from args and options - placeholder_name = create_placeholder_name(args, options) - - selection = cmds.ls(selection=True) - if not selection: - raise ValueError("Nothing is selected") - - placeholder = cmds.spaceLocator(name=placeholder_name)[0] - - # get the long name of the placeholder (with the groups) - placeholder_full_name = cmds.ls(selection[0], long=True)[ - 0] + '|' + placeholder.replace('|', '') - - if selection: - cmds.parent(placeholder, selection[0]) - - imprint(placeholder_full_name, options) - - # Some tweaks because imprint force enums to to default value so we get - # back arg read and force them to attributes - imprint_enum(placeholder_full_name, args) - - # Add helper attributes to keep placeholder info - cmds.addAttr( - placeholder_full_name, - longName="parent", - hidden=True, - dataType="string" - ) - cmds.addAttr( - placeholder_full_name, - longName="index", - hidden=True, - attributeType="short", - defaultValue=-1 - ) - - cmds.setAttr(placeholder_full_name + '.parent', "", type="string") - - -def create_placeholder_name(args, options): - placeholder_builder_type = [ - arg.read() for arg in args if 'builder_type' in str(arg) - ][0] - placeholder_family = options['family'] - placeholder_name = placeholder_builder_type.split('_') - - # add famlily in any - if placeholder_family: - placeholder_name.insert(1, placeholder_family) - - # add loader arguments if any - if options['loader_args']: - pos = 2 - loader_args = options['loader_args'].replace('\'', '\"') - loader_args = json.loads(loader_args) - values = [v for v in loader_args.values()] - for i in range(len(values)): - placeholder_name.insert(i + pos, values[i]) - - placeholder_name = '_'.join(placeholder_name) - - return placeholder_name.capitalize() - - -def update_placeholder(): - placeholder = cmds.ls(selection=True) - if len(placeholder) == 0: - raise ValueError("No node selected") - if len(placeholder) > 1: - raise ValueError("Too many selected nodes") - placeholder = placeholder[0] - - args = placeholder_window(get_placeholder_attributes(placeholder)) - - if not args: - return # operation canceled - - # delete placeholder attributes - delete_placeholder_attributes(placeholder) - - options = create_options(args) - - imprint(placeholder, options) - imprint_enum(placeholder, args) - - cmds.addAttr( - placeholder, - longName="parent", - hidden=True, - dataType="string" - ) - cmds.addAttr( - placeholder, - longName="index", - hidden=True, - attributeType="short", - defaultValue=-1 - ) - - cmds.setAttr(placeholder + '.parent', '', type="string") - - -def create_options(args): - options = OrderedDict() - for arg in args: - if not type(arg) == qargparse.Separator: - options[str(arg)] = arg._data.get("items") or arg.read() - return options - - -def imprint_enum(placeholder, args): - """ - Imprint method doesn't act properly with enums. - Replacing the functionnality with this for now - """ - enum_values = {str(arg): arg.read() - for arg in args if arg._data.get("items")} - string_to_value_enum_table = { - build: i for i, build - in enumerate(build_types)} - for key, value in enum_values.items(): - cmds.setAttr( - placeholder + "." + key, - string_to_value_enum_table[value]) - - -def placeholder_window(options=None): - options = options or dict() - dialog = OptionDialog(parent=get_main_window()) - dialog.setWindowTitle("Create Placeholder") - - args = [ - qargparse.Separator("Main attributes"), - qargparse.Enum( - "builder_type", - label="Asset Builder Type", - default=options.get("builder_type", 0), - items=build_types, - help="""Asset Builder Type -Builder type describe what template loader will look for. -context_asset : Template loader will look for subsets of -current context asset (Asset bob will find asset) -linked_asset : Template loader will look for assets linked -to current context asset. -Linked asset are looked in avalon database under field "inputLinks" -""" - ), - qargparse.String( - "family", - default=options.get("family", ""), - label="OpenPype Family", - placeholder="ex: model, look ..."), - qargparse.String( - "representation", - default=options.get("representation", ""), - label="OpenPype Representation", - placeholder="ex: ma, abc ..."), - qargparse.String( - "loader", - default=options.get("loader", ""), - label="Loader", - placeholder="ex: ReferenceLoader, LightLoader ...", - help="""Loader -Defines what openpype loader will be used to load assets. -Useable loader depends on current host's loader list. -Field is case sensitive. -"""), - qargparse.String( - "loader_args", - default=options.get("loader_args", ""), - label="Loader Arguments", - placeholder='ex: {"camera":"persp", "lights":True}', - help="""Loader -Defines a dictionnary of arguments used to load assets. -Useable arguments depend on current placeholder Loader. -Field should be a valid python dict. Anything else will be ignored. -"""), - qargparse.Integer( - "order", - default=options.get("order", 0), - min=0, - max=999, - label="Order", - placeholder="ex: 0, 100 ... (smallest order loaded first)", - help="""Order -Order defines asset loading priority (0 to 999) -Priority rule is : "lowest is first to load"."""), - qargparse.Separator( - "Optional attributes"), - qargparse.String( - "asset", - default=options.get("asset", ""), - label="Asset filter", - placeholder="regex filtering by asset name", - help="Filtering assets by matching field regex to asset's name"), - qargparse.String( - "subset", - default=options.get("subset", ""), - label="Subset filter", - placeholder="regex filtering by subset name", - help="Filtering assets by matching field regex to subset's name"), - qargparse.String( - "hierarchy", - default=options.get("hierarchy", ""), - label="Hierarchy filter", - placeholder="regex filtering by asset's hierarchy", - help="Filtering assets by matching field asset's hierarchy") - ] - dialog.create(args) - - if not dialog.exec_(): - return None - - return args diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 666f555660..e20f29049b 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -9,16 +9,17 @@ import maya.cmds as cmds from openpype.settings import get_project_settings from openpype.pipeline import legacy_io from openpype.pipeline.workfile import BuildWorkfile -from openpype.pipeline.workfile.build_template import ( - build_workfile_template, - update_workfile_template -) from openpype.tools.utils import host_tools from openpype.hosts.maya.api import lib, lib_rendersettings from .lib import get_main_window, IS_HEADLESS from .commands import reset_frame_range -from .lib_template_builder import create_placeholder, update_placeholder +from .workfile_template_builder import ( + create_placeholder, + update_placeholder, + build_workfile_template, + update_workfile_template, +) log = logging.getLogger(__name__) @@ -161,12 +162,12 @@ def install(): cmds.menuItem( "Create Placeholder", parent=builder_menu, - command=lambda *args: create_placeholder() + command=create_placeholder ) cmds.menuItem( "Update Placeholder", parent=builder_menu, - command=lambda *args: update_placeholder() + command=update_placeholder ) cmds.menuItem( "Build Workfile from template", diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index c13b47ef4a..b3bf738a2b 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -42,6 +42,7 @@ from openpype.hosts.maya import MAYA_ROOT_DIR from openpype.hosts.maya.lib import create_workspace_mel from . import menu, lib +from .workfile_template_builder import MayaPlaceholderLoadPlugin from .workio import ( open_file, save_file, @@ -135,6 +136,11 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost): def get_containers(self): return ls() + def get_workfile_build_placeholder_plugins(self): + return [ + MayaPlaceholderLoadPlugin + ] + @contextlib.contextmanager def maintained_selection(self): with lib.maintained_selection(): diff --git a/openpype/hosts/maya/api/template_loader.py b/openpype/hosts/maya/api/template_loader.py deleted file mode 100644 index ecffafc93d..0000000000 --- a/openpype/hosts/maya/api/template_loader.py +++ /dev/null @@ -1,252 +0,0 @@ -import re -from maya import cmds - -from openpype.client import get_representations -from openpype.pipeline import legacy_io -from openpype.pipeline.workfile.abstract_template_loader import ( - AbstractPlaceholder, - AbstractTemplateLoader -) -from openpype.pipeline.workfile.build_template_exceptions import ( - TemplateAlreadyImported -) - -PLACEHOLDER_SET = 'PLACEHOLDERS_SET' - - -class MayaTemplateLoader(AbstractTemplateLoader): - """Concrete implementation of AbstractTemplateLoader for maya - """ - - def import_template(self, path): - """Import template into current scene. - Block if a template is already loaded. - Args: - path (str): A path to current template (usually given by - get_template_path implementation) - Returns: - bool: Wether the template was succesfully imported or not - """ - if cmds.objExists(PLACEHOLDER_SET): - raise TemplateAlreadyImported( - "Build template already loaded\n" - "Clean scene if needed (File > New Scene)") - - cmds.sets(name=PLACEHOLDER_SET, empty=True) - self.new_nodes = cmds.file(path, i=True, returnNewNodes=True) - cmds.setAttr(PLACEHOLDER_SET + '.hiddenInOutliner', True) - - for set in cmds.listSets(allSets=True): - if (cmds.objExists(set) and - cmds.attributeQuery('id', node=set, exists=True) and - cmds.getAttr(set + '.id') == 'pyblish.avalon.instance'): - if cmds.attributeQuery('asset', node=set, exists=True): - cmds.setAttr( - set + '.asset', - legacy_io.Session['AVALON_ASSET'], type='string' - ) - - return True - - def template_already_imported(self, err_msg): - clearButton = "Clear scene and build" - updateButton = "Update template" - abortButton = "Abort" - - title = "Scene already builded" - message = ( - "It's seems a template was already build for this scene.\n" - "Error message reveived :\n\n\"{}\"".format(err_msg)) - buttons = [clearButton, updateButton, abortButton] - defaultButton = clearButton - cancelButton = abortButton - dismissString = abortButton - answer = cmds.confirmDialog( - t=title, - m=message, - b=buttons, - db=defaultButton, - cb=cancelButton, - ds=dismissString) - - if answer == clearButton: - cmds.file(newFile=True, force=True) - self.import_template(self.template_path) - self.populate_template() - elif answer == updateButton: - self.update_missing_containers() - elif answer == abortButton: - return - - @staticmethod - def get_template_nodes(): - attributes = cmds.ls('*.builder_type', long=True) - return [attribute.rpartition('.')[0] for attribute in attributes] - - def get_loaded_containers_by_id(self): - try: - containers = cmds.sets("AVALON_CONTAINERS", q=True) - except ValueError: - return None - - return [ - cmds.getAttr(container + '.representation') - for container in containers] - - -class MayaPlaceholder(AbstractPlaceholder): - """Concrete implementation of AbstractPlaceholder for maya - """ - - optional_keys = {'asset', 'subset', 'hierarchy'} - - def get_data(self, node): - user_data = dict() - for attr in self.required_keys.union(self.optional_keys): - attribute_name = '{}.{}'.format(node, attr) - if not cmds.attributeQuery(attr, node=node, exists=True): - print("{} not found".format(attribute_name)) - continue - user_data[attr] = cmds.getAttr( - attribute_name, - asString=True) - user_data['parent'] = ( - cmds.getAttr(node + '.parent', asString=True) - or node.rpartition('|')[0] - or "" - ) - user_data['node'] = node - if user_data['parent']: - siblings = cmds.listRelatives(user_data['parent'], children=True) - else: - siblings = cmds.ls(assemblies=True) - node_shortname = user_data['node'].rpartition('|')[2] - current_index = cmds.getAttr(node + '.index', asString=True) - user_data['index'] = ( - current_index if current_index >= 0 - else siblings.index(node_shortname)) - - self.data = user_data - - def parent_in_hierarchy(self, containers): - """Parent loaded container to placeholder's parent - ie : Set loaded content as placeholder's sibling - Args: - containers (String): Placeholder loaded containers - """ - if not containers: - return - - roots = cmds.sets(containers, q=True) - nodes_to_parent = [] - for root in roots: - if root.endswith("_RN"): - refRoot = cmds.referenceQuery(root, n=True)[0] - refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot] - nodes_to_parent.extend(refRoot) - elif root in cmds.listSets(allSets=True): - if not cmds.sets(root, q=True): - return - else: - continue - else: - nodes_to_parent.append(root) - - if self.data['parent']: - cmds.parent(nodes_to_parent, self.data['parent']) - # Move loaded nodes to correct index in outliner hierarchy - placeholder_node = self.data['node'] - placeholder_form = cmds.xform( - placeholder_node, - q=True, - matrix=True, - worldSpace=True - ) - for node in set(nodes_to_parent): - cmds.reorder(node, front=True) - cmds.reorder(node, relative=self.data['index']) - cmds.xform(node, matrix=placeholder_form, ws=True) - - holding_sets = cmds.listSets(object=placeholder_node) - if not holding_sets: - return - for holding_set in holding_sets: - cmds.sets(roots, forceElement=holding_set) - - def clean(self): - """Hide placeholder, parent them to root - add them to placeholder set and register placeholder's parent - to keep placeholder info available for future use - """ - node = self.data['node'] - if self.data['parent']: - cmds.setAttr(node + '.parent', self.data['parent'], type='string') - if cmds.getAttr(node + '.index') < 0: - cmds.setAttr(node + '.index', self.data['index']) - - holding_sets = cmds.listSets(object=node) - if holding_sets: - for set in holding_sets: - cmds.sets(node, remove=set) - - if cmds.listRelatives(node, p=True): - node = cmds.parent(node, world=True)[0] - cmds.sets(node, addElement=PLACEHOLDER_SET) - cmds.hide(node) - cmds.setAttr(node + '.hiddenInOutliner', True) - - def get_representations(self, current_asset_doc, linked_asset_docs): - project_name = legacy_io.active_project() - - builder_type = self.data["builder_type"] - if builder_type == "context_asset": - context_filters = { - "asset": [current_asset_doc["name"]], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representations": [self.data["representation"]], - "family": [self.data["family"]] - } - - elif builder_type != "linked_asset": - context_filters = { - "asset": [re.compile(self.data["asset"])], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]] - } - - else: - asset_regex = re.compile(self.data["asset"]) - linked_asset_names = [] - for asset_doc in linked_asset_docs: - asset_name = asset_doc["name"] - if asset_regex.match(asset_name): - linked_asset_names.append(asset_name) - - context_filters = { - "asset": linked_asset_names, - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]], - } - - return list(get_representations( - project_name, - context_filters=context_filters - )) - - def err_message(self): - return ( - "Error while trying to load a representation.\n" - "Either the subset wasn't published or the template is malformed." - "\n\n" - "Builder was looking for :\n{attributes}".format( - attributes="\n".join([ - "{}: {}".format(key.title(), value) - for key, value in self.data.items()] - ) - ) - ) diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py new file mode 100644 index 0000000000..ef043ed0f4 --- /dev/null +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -0,0 +1,330 @@ +import json + +from maya import cmds + +from openpype.pipeline import registered_host +from openpype.pipeline.workfile.workfile_template_builder import ( + TemplateAlreadyImported, + AbstractTemplateBuilder, + PlaceholderPlugin, + LoadPlaceholderItem, + PlaceholderLoadMixin, +) +from openpype.tools.workfile_template_build import ( + WorkfileBuildPlaceholderDialog, +) + +from .lib import read, imprint + +PLACEHOLDER_SET = "PLACEHOLDERS_SET" + + +class MayaTemplateBuilder(AbstractTemplateBuilder): + """Concrete implementation of AbstractTemplateBuilder for maya""" + + def import_template(self, path): + """Import template into current scene. + Block if a template is already loaded. + + Args: + path (str): A path to current template (usually given by + get_template_path implementation) + + Returns: + bool: Wether the template was succesfully imported or not + """ + + if cmds.objExists(PLACEHOLDER_SET): + raise TemplateAlreadyImported(( + "Build template already loaded\n" + "Clean scene if needed (File > New Scene)" + )) + + cmds.sets(name=PLACEHOLDER_SET, empty=True) + cmds.file(path, i=True, returnNewNodes=True) + + cmds.setAttr(PLACEHOLDER_SET + ".hiddenInOutliner", True) + + return True + + +class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): + identifier = "maya.load" + label = "Maya load" + + def _collect_scene_placeholders(self): + # Cache placeholder data to shared data + placeholder_nodes = self.builder.get_shared_populate_data( + "placeholder_nodes" + ) + if placeholder_nodes is None: + attributes = cmds.ls("*.plugin_identifier", long=True) + placeholder_nodes = {} + for attribute in attributes: + node_name = attribute.rpartition(".")[0] + placeholder_nodes[node_name] = ( + self._parse_placeholder_node_data(node_name) + ) + + self.builder.set_shared_populate_data( + "placeholder_nodes", placeholder_nodes + ) + return placeholder_nodes + + def _parse_placeholder_node_data(self, node_name): + placeholder_data = read(node_name) + parent_name = ( + cmds.getAttr(node_name + ".parent", asString=True) + or node_name.rpartition("|")[0] + or "" + ) + if parent_name: + siblings = cmds.listRelatives(parent_name, children=True) + else: + siblings = cmds.ls(assemblies=True) + node_shortname = node_name.rpartition("|")[2] + current_index = cmds.getAttr(node_name + ".index", asString=True) + if current_index < 0: + current_index = siblings.index(node_shortname) + + placeholder_data.update({ + "parent": parent_name, + "index": current_index + }) + return placeholder_data + + def _create_placeholder_name(self, placeholder_data): + placeholder_name_parts = placeholder_data["builder_type"].split("_") + + pos = 1 + # add famlily in any + placeholder_family = placeholder_data["family"] + if placeholder_family: + placeholder_name_parts.insert(pos, placeholder_family) + pos += 1 + + # add loader arguments if any + loader_args = placeholder_data["loader_args"] + if loader_args: + loader_args = json.loads(loader_args.replace('\'', '\"')) + values = [v for v in loader_args.values()] + for value in values: + placeholder_name_parts.insert(pos, value) + pos += 1 + + placeholder_name = "_".join(placeholder_name_parts) + + return placeholder_name.capitalize() + + def _get_loaded_repre_ids(self): + loaded_representation_ids = self.builder.get_shared_populate_data( + "loaded_representation_ids" + ) + if loaded_representation_ids is None: + try: + containers = cmds.sets("AVALON_CONTAINERS", q=True) + except ValueError: + containers = [] + + loaded_representation_ids = { + cmds.getAttr(container + ".representation") + for container in containers + } + self.builder.set_shared_populate_data( + "loaded_representation_ids", loaded_representation_ids + ) + return loaded_representation_ids + + def create_placeholder(self, placeholder_data): + selection = cmds.ls(selection=True) + if not selection: + raise ValueError("Nothing is selected") + if len(selection) > 1: + raise ValueError("More then one item are selected") + + placeholder_data["plugin_identifier"] = self.identifier + + placeholder_name = self._create_placeholder_name(placeholder_data) + + placeholder = cmds.spaceLocator(name=placeholder_name)[0] + # TODO: this can crash if selection can't be used + cmds.parent(placeholder, selection[0]) + + # get the long name of the placeholder (with the groups) + placeholder_full_name = ( + cmds.ls(selection[0], long=True)[0] + + "|" + + placeholder.replace("|", "") + ) + + imprint(placeholder_full_name, placeholder_data) + + # Add helper attributes to keep placeholder info + cmds.addAttr( + placeholder_full_name, + longName="parent", + hidden=True, + dataType="string" + ) + cmds.addAttr( + placeholder_full_name, + longName="index", + hidden=True, + attributeType="short", + defaultValue=-1 + ) + + cmds.setAttr(placeholder_full_name + ".parent", "", type="string") + + def update_placeholder(self, placeholder_item, placeholder_data): + node_name = placeholder_item.scene_identifier + new_values = {} + for key, value in placeholder_data.items(): + placeholder_value = placeholder_item.data.get(key) + if value != placeholder_value: + new_values[key] = value + placeholder_item.data[key] = value + + for key in new_values.keys(): + cmds.deleteAttr(node_name + "." + key) + + imprint(node_name, new_values) + + def collect_placeholders(self): + output = [] + scene_placeholders = self._collect_scene_placeholders() + for node_name, placeholder_data in scene_placeholders.items(): + if placeholder_data.get("plugin_identifier") != self.identifier: + continue + + # TODO do data validations and maybe updgrades if are invalid + output.append( + LoadPlaceholderItem(node_name, placeholder_data, self) + ) + + return output + + def populate_placeholder(self, placeholder): + self.populate_load_placeholder(placeholder) + + def repopulate_placeholder(self, placeholder): + repre_ids = self._get_loaded_repre_ids() + self.populate_load_placeholder(placeholder, repre_ids) + + def get_placeholder_options(self, options=None): + return self.get_load_plugin_options(options) + + def cleanup_placeholder(self, placeholder, failed): + """Hide placeholder, parent them to root + add them to placeholder set and register placeholder's parent + to keep placeholder info available for future use + """ + + node = placeholder._scene_identifier + node_parent = placeholder.data["parent"] + if node_parent: + cmds.setAttr(node + ".parent", node_parent, type="string") + + if cmds.getAttr(node + ".index") < 0: + cmds.setAttr(node + ".index", placeholder.data["index"]) + + holding_sets = cmds.listSets(object=node) + if holding_sets: + for set in holding_sets: + cmds.sets(node, remove=set) + + if cmds.listRelatives(node, p=True): + node = cmds.parent(node, world=True)[0] + cmds.sets(node, addElement=PLACEHOLDER_SET) + cmds.hide(node) + cmds.setAttr(node + ".hiddenInOutliner", True) + + def load_succeed(self, placeholder, container): + self._parent_in_hierarhchy(placeholder, container) + + def _parent_in_hierarchy(self, placeholder, container): + """Parent loaded container to placeholder's parent. + + ie : Set loaded content as placeholder's sibling + + Args: + container (str): Placeholder loaded containers + """ + + if not container: + return + + roots = cmds.sets(container, q=True) + nodes_to_parent = [] + for root in roots: + if root.endswith("_RN"): + refRoot = cmds.referenceQuery(root, n=True)[0] + refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot] + nodes_to_parent.extend(refRoot) + elif root not in cmds.listSets(allSets=True): + nodes_to_parent.append(root) + + elif not cmds.sets(root, q=True): + return + + if placeholder.data["parent"]: + cmds.parent(nodes_to_parent, placeholder.data["parent"]) + # Move loaded nodes to correct index in outliner hierarchy + placeholder_form = cmds.xform( + placeholder.scene_identifier, + q=True, + matrix=True, + worldSpace=True + ) + for node in set(nodes_to_parent): + cmds.reorder(node, front=True) + cmds.reorder(node, relative=placeholder.data["index"]) + cmds.xform(node, matrix=placeholder_form, ws=True) + + holding_sets = cmds.listSets(object=placeholder.scene_identifier) + if not holding_sets: + return + for holding_set in holding_sets: + cmds.sets(roots, forceElement=holding_set) + + +def build_workfile_template(*args): + builder = MayaTemplateBuilder(registered_host()) + builder.build_template() + + +def update_workfile_template(*args): + builder = MayaTemplateBuilder(registered_host()) + builder.rebuild_template() + + +def create_placeholder(*args): + host = registered_host() + builder = MayaTemplateBuilder(host) + window = WorkfileBuildPlaceholderDialog(host, builder) + window.exec_() + + +def update_placeholder(*args): + host = registered_host() + builder = MayaTemplateBuilder(host) + placeholder_items_by_id = { + placeholder_item.scene_identifier: placeholder_item + for placeholder_item in builder.get_placeholders() + } + placeholder_items = [] + for node_name in cmds.ls(selection=True, long=True): + if node_name in placeholder_items_by_id: + placeholder_items.append(placeholder_items_by_id[node_name]) + + # TODO show UI at least + if len(placeholder_items) == 0: + raise ValueError("No node selected") + + if len(placeholder_items) > 1: + raise ValueError("Too many selected nodes") + + placeholder_item = placeholder_items[0] + window = WorkfileBuildPlaceholderDialog(host, builder) + window.set_update_mode(placeholder_item) + window.exec_() diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 962f31c177..c65058874b 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -21,6 +21,8 @@ from .pipeline import ( containerise, parse_container, update_container, + + get_workfile_build_placeholder_plugins, ) from .lib import ( maintained_selection, @@ -55,6 +57,8 @@ __all__ = ( "parse_container", "update_container", + "get_workfile_build_placeholder_plugins", + "maintained_selection", "reset_selection", "get_view_process_node", diff --git a/openpype/hosts/nuke/api/lib_template_builder.py b/openpype/hosts/nuke/api/lib_template_builder.py deleted file mode 100644 index 61baa23928..0000000000 --- a/openpype/hosts/nuke/api/lib_template_builder.py +++ /dev/null @@ -1,220 +0,0 @@ -from collections import OrderedDict - -import qargparse - -import nuke - -from openpype.tools.utils.widgets import OptionDialog - -from .lib import imprint, get_main_window - - -# To change as enum -build_types = ["context_asset", "linked_asset", "all_assets"] - - -def get_placeholder_attributes(node, enumerate=False): - list_atts = { - "builder_type", - "family", - "representation", - "loader", - "loader_args", - "order", - "asset", - "subset", - "hierarchy", - "siblings", - "last_loaded" - } - attributes = {} - for attr in node.knobs().keys(): - if attr in list_atts: - if enumerate: - try: - attributes[attr] = node.knob(attr).values() - except AttributeError: - attributes[attr] = node.knob(attr).getValue() - else: - attributes[attr] = node.knob(attr).getValue() - - return attributes - - -def delete_placeholder_attributes(node): - """Delete all extra placeholder attributes.""" - - extra_attributes = get_placeholder_attributes(node) - for attribute in extra_attributes.keys(): - try: - node.removeKnob(node.knob(attribute)) - except ValueError: - continue - - -def hide_placeholder_attributes(node): - """Hide all extra placeholder attributes.""" - - extra_attributes = get_placeholder_attributes(node) - for attribute in extra_attributes.keys(): - try: - node.knob(attribute).setVisible(False) - except ValueError: - continue - - -def create_placeholder(): - args = placeholder_window() - if not args: - # operation canceled, no locator created - return - - placeholder = nuke.nodes.NoOp() - placeholder.setName("PLACEHOLDER") - placeholder.knob("tile_color").setValue(4278190335) - - # custom arg parse to force empty data query - # and still imprint them on placeholder - # and getting items when arg is of type Enumerator - options = OrderedDict() - for arg in args: - if not type(arg) == qargparse.Separator: - options[str(arg)] = arg._data.get("items") or arg.read() - imprint(placeholder, options) - imprint(placeholder, {"is_placeholder": True}) - placeholder.knob("is_placeholder").setVisible(False) - - -def update_placeholder(): - placeholder = nuke.selectedNodes() - if not placeholder: - raise ValueError("No node selected") - if len(placeholder) > 1: - raise ValueError("Too many selected nodes") - placeholder = placeholder[0] - - args = placeholder_window(get_placeholder_attributes(placeholder)) - if not args: - return # operation canceled - # delete placeholder attributes - delete_placeholder_attributes(placeholder) - - options = OrderedDict() - for arg in args: - if not type(arg) == qargparse.Separator: - options[str(arg)] = arg._data.get("items") or arg.read() - imprint(placeholder, options) - - -def imprint_enum(placeholder, args): - """ - Imprint method doesn't act properly with enums. - Replacing the functionnality with this for now - """ - - enum_values = { - str(arg): arg.read() - for arg in args - if arg._data.get("items") - } - string_to_value_enum_table = { - build: idx - for idx, build in enumerate(build_types) - } - attrs = {} - for key, value in enum_values.items(): - attrs[key] = string_to_value_enum_table[value] - - -def placeholder_window(options=None): - options = options or dict() - dialog = OptionDialog(parent=get_main_window()) - dialog.setWindowTitle("Create Placeholder") - - args = [ - qargparse.Separator("Main attributes"), - qargparse.Enum( - "builder_type", - label="Asset Builder Type", - default=options.get("builder_type", 0), - items=build_types, - help="""Asset Builder Type -Builder type describe what template loader will look for. - -context_asset : Template loader will look for subsets of -current context asset (Asset bob will find asset) - -linked_asset : Template loader will look for assets linked -to current context asset. -Linked asset are looked in OpenPype database under field "inputLinks" -""" - ), - qargparse.String( - "family", - default=options.get("family", ""), - label="OpenPype Family", - placeholder="ex: image, plate ..."), - qargparse.String( - "representation", - default=options.get("representation", ""), - label="OpenPype Representation", - placeholder="ex: mov, png ..."), - qargparse.String( - "loader", - default=options.get("loader", ""), - label="Loader", - placeholder="ex: LoadClip, LoadImage ...", - help="""Loader - -Defines what openpype loader will be used to load assets. -Useable loader depends on current host's loader list. -Field is case sensitive. -"""), - qargparse.String( - "loader_args", - default=options.get("loader_args", ""), - label="Loader Arguments", - placeholder='ex: {"camera":"persp", "lights":True}', - help="""Loader - -Defines a dictionnary of arguments used to load assets. -Useable arguments depend on current placeholder Loader. -Field should be a valid python dict. Anything else will be ignored. -"""), - qargparse.Integer( - "order", - default=options.get("order", 0), - min=0, - max=999, - label="Order", - placeholder="ex: 0, 100 ... (smallest order loaded first)", - help="""Order - -Order defines asset loading priority (0 to 999) -Priority rule is : "lowest is first to load"."""), - qargparse.Separator( - "Optional attributes "), - qargparse.String( - "asset", - default=options.get("asset", ""), - label="Asset filter", - placeholder="regex filtering by asset name", - help="Filtering assets by matching field regex to asset's name"), - qargparse.String( - "subset", - default=options.get("subset", ""), - label="Subset filter", - placeholder="regex filtering by subset name", - help="Filtering assets by matching field regex to subset's name"), - qargparse.String( - "hierarchy", - default=options.get("hierarchy", ""), - label="Hierarchy filter", - placeholder="regex filtering by asset's hierarchy", - help="Filtering assets by matching field asset's hierarchy") - ] - dialog.create(args) - if not dialog.exec_(): - return None - - return args diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index bac42128cc..c6ccfaeb3a 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -22,10 +22,6 @@ from openpype.pipeline import ( AVALON_CONTAINER_ID, ) from openpype.pipeline.workfile import BuildWorkfile -from openpype.pipeline.workfile.build_template import ( - build_workfile_template, - update_workfile_template -) from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop @@ -40,8 +36,12 @@ from .lib import ( set_avalon_knob_data, read_avalon_data, ) -from .lib_template_builder import ( - create_placeholder, update_placeholder +from .workfile_template_builder import ( + NukePlaceholderLoadPlugin, + build_workfile_template, + update_workfile_template, + create_placeholder, + update_placeholder, ) log = Logger.get_logger(__name__) @@ -141,6 +141,12 @@ def _show_workfiles(): host_tools.show_workfiles(parent=None, on_top=False) +def get_workfile_build_placeholder_plugins(): + return [ + NukePlaceholderLoadPlugin + ] + + def _install_menu(): # uninstall original avalon menu main_window = get_main_window() diff --git a/openpype/hosts/nuke/api/template_loader.py b/openpype/hosts/nuke/api/workfile_template_builder.py similarity index 51% rename from openpype/hosts/nuke/api/template_loader.py rename to openpype/hosts/nuke/api/workfile_template_builder.py index 5ff4b8fc41..7a2e442e32 100644 --- a/openpype/hosts/nuke/api/template_loader.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -1,13 +1,16 @@ -import re import collections import nuke -from openpype.client import get_representations -from openpype.pipeline import legacy_io -from openpype.pipeline.workfile.abstract_template_loader import ( - AbstractPlaceholder, - AbstractTemplateLoader, +from openpype.pipeline import registered_host +from openpype.pipeline.workfile.workfile_template_builder import ( + AbstractTemplateBuilder, + PlaceholderPlugin, + LoadPlaceholderItem, + PlaceholderLoadMixin, +) +from openpype.tools.workfile_template_build import ( + WorkfileBuildPlaceholderDialog, ) from .lib import ( @@ -25,19 +28,11 @@ from .lib import ( node_tempfile, ) -from .lib_template_builder import ( - delete_placeholder_attributes, - get_placeholder_attributes, - hide_placeholder_attributes -) - PLACEHOLDER_SET = "PLACEHOLDERS_SET" -class NukeTemplateLoader(AbstractTemplateLoader): - """Concrete implementation of AbstractTemplateLoader for Nuke - - """ +class NukeTemplateBuilder(AbstractTemplateBuilder): + """Concrete implementation of AbstractTemplateBuilder for maya""" def import_template(self, path): """Import template into current scene. @@ -58,224 +53,255 @@ class NukeTemplateLoader(AbstractTemplateLoader): return True - def preload(self, placeholder, loaders_by_name, last_representation): - placeholder.data["nodes_init"] = nuke.allNodes() - placeholder.data["last_repre_id"] = str(last_representation["_id"]) - def populate_template(self, ignored_ids=None): - processed_key = "_node_processed" +class NukePlaceholderPlugin(PlaceholderPlugin): + node_color = 4278190335 - processed_nodes = [] - nodes = self.get_template_nodes() - while nodes: - # Mark nodes as processed so they're not re-executed - # - that can happen if processing of placeholder node fails - for node in nodes: - imprint(node, {processed_key: True}) - processed_nodes.append(node) + def _collect_scene_placeholders(self): + # Cache placeholder data to shared data + placeholder_nodes = self.builder.get_shared_populate_data( + "placeholder_nodes" + ) + if placeholder_nodes is None: + placeholder_nodes = {} + all_groups = collections.deque() + all_groups.append(nuke.thisGroup()) + while all_groups: + group = all_groups.popleft() + for node in group.nodes(): + if isinstance(node, nuke.Group): + all_groups.append(node) - super(NukeTemplateLoader, self).populate_template(ignored_ids) + node_knobs = node.knobs() + if ( + "builder_type" not in node_knobs + or "is_placeholder" not in node_knobs + or not node.knob("is_placeholder").value() + ): + continue - # Recollect nodes to repopulate - nodes = [] - for node in self.get_template_nodes(): - # Skip already processed nodes - if ( - processed_key in node.knobs() - and node.knob(processed_key).value() - ): - continue - nodes.append(node) + if "empty" in node_knobs and node.knob("empty").value(): + continue - for node in processed_nodes: - knob = node.knob(processed_key) + placeholder_nodes[node.fullName()] = node + + self.builder.set_shared_populate_data( + "placeholder_nodes", placeholder_nodes + ) + return placeholder_nodes + + def create_placeholder(self, placeholder_data): + placeholder_data["plugin_identifier"] = self.identifier + + placeholder = nuke.nodes.NoOp() + placeholder.setName("PLACEHOLDER") + placeholder.knob("tile_color").setValue(self.node_color) + + imprint(placeholder, placeholder_data) + imprint(placeholder, {"is_placeholder": True}) + placeholder.knob("is_placeholder").setVisible(False) + + def update_placeholder(self, placeholder_item, placeholder_data): + node = nuke.toNode(placeholder_item.scene_identifier) + imprint(node, placeholder_data) + + def _parse_placeholder_node_data(self, node): + placeholder_data = {} + for key in self.get_placeholder_keys(): + knob = node.knob(key) + value = None if knob is not None: - node.removeKnob(knob) - - @staticmethod - def get_template_nodes(): - placeholders = [] - all_groups = collections.deque() - all_groups.append(nuke.thisGroup()) - while all_groups: - group = all_groups.popleft() - for node in group.nodes(): - if isinstance(node, nuke.Group): - all_groups.append(node) - - node_knobs = node.knobs() - if ( - "builder_type" not in node_knobs - or "is_placeholder" not in node_knobs - or not node.knob("is_placeholder").value() - ): - continue - - if "empty" in node_knobs and node.knob("empty").value(): - continue - - placeholders.append(node) - - return placeholders - - def update_missing_containers(self): - nodes_by_id = collections.defaultdict(list) - - for node in nuke.allNodes(): - node_knobs = node.knobs().keys() - if "repre_id" in node_knobs: - repre_id = node.knob("repre_id").getValue() - nodes_by_id[repre_id].append(node.name()) - - if "empty" in node_knobs: - node.removeKnob(node.knob("empty")) - imprint(node, {"empty": False}) - - for node_names in nodes_by_id.values(): - node = None - for node_name in node_names: - node_by_name = nuke.toNode(node_name) - if "builder_type" in node_by_name.knobs().keys(): - node = node_by_name - break - - if node is None: - continue - - placeholder = nuke.nodes.NoOp() - placeholder.setName("PLACEHOLDER") - placeholder.knob("tile_color").setValue(4278190335) - attributes = get_placeholder_attributes(node, enumerate=True) - imprint(placeholder, attributes) - pos_x = int(node.knob("x").getValue()) - pos_y = int(node.knob("y").getValue()) - placeholder.setXYpos(pos_x, pos_y) - imprint(placeholder, {"nb_children": 1}) - refresh_node(placeholder) - - self.populate_template(self.get_loaded_containers_by_id()) - - def get_loaded_containers_by_id(self): - repre_ids = set() - for node in nuke.allNodes(): - if "repre_id" in node.knobs(): - repre_ids.add(node.knob("repre_id").getValue()) - - # Removes duplicates in the list - return list(repre_ids) - - def delete_placeholder(self, placeholder): - placeholder_node = placeholder.data["node"] - last_loaded = placeholder.data["last_loaded"] - if not placeholder.data["delete"]: - if "empty" in placeholder_node.knobs().keys(): - placeholder_node.removeKnob(placeholder_node.knob("empty")) - imprint(placeholder_node, {"empty": True}) - return - - if not last_loaded: - nuke.delete(placeholder_node) - return - - if "last_loaded" in placeholder_node.knobs().keys(): - for node_name in placeholder_node.knob("last_loaded").values(): - node = nuke.toNode(node_name) - try: - delete_placeholder_attributes(node) - except Exception: - pass - - last_loaded_names = [ - loaded_node.name() - for loaded_node in last_loaded - ] - imprint(placeholder_node, {"last_loaded": last_loaded_names}) - - for node in last_loaded: - refresh_node(node) - refresh_node(placeholder_node) - if "builder_type" not in node.knobs().keys(): - attributes = get_placeholder_attributes(placeholder_node, True) - imprint(node, attributes) - imprint(node, {"is_placeholder": False}) - hide_placeholder_attributes(node) - node.knob("is_placeholder").setVisible(False) - imprint( - node, - { - "x": placeholder_node.xpos(), - "y": placeholder_node.ypos() - } - ) - node.knob("x").setVisible(False) - node.knob("y").setVisible(False) - nuke.delete(placeholder_node) + value = knob.getValue() + placeholder_data[key] = value + return placeholder_data -class NukePlaceholder(AbstractPlaceholder): - """Concrete implementation of AbstractPlaceholder for Nuke""" +class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): + identifier = "nuke.load" + label = "Nuke load" - optional_keys = {"asset", "subset", "hierarchy"} + def _parse_placeholder_node_data(self, node): + placeholder_data = super( + NukePlaceholderLoadPlugin, self + )._parse_placeholder_node_data(node) - def get_data(self, node): - user_data = dict() node_knobs = node.knobs() - for attr in self.required_keys.union(self.optional_keys): - if attr in node_knobs: - user_data[attr] = node_knobs[attr].getValue() - user_data["node"] = node - nb_children = 0 if "nb_children" in node_knobs: nb_children = int(node_knobs["nb_children"].getValue()) - user_data["nb_children"] = nb_children + placeholder_data["nb_children"] = nb_children siblings = [] if "siblings" in node_knobs: siblings = node_knobs["siblings"].values() - user_data["siblings"] = siblings + placeholder_data["siblings"] = siblings node_full_name = node.fullName() - user_data["group_name"] = node_full_name.rpartition(".")[0] - user_data["last_loaded"] = [] - user_data["delete"] = False - self.data = user_data + placeholder_data["group_name"] = node_full_name.rpartition(".")[0] + placeholder_data["last_loaded"] = [] + placeholder_data["delete"] = False + return placeholder_data - def parent_in_hierarchy(self, containers): - return + def _get_loaded_repre_ids(self): + loaded_representation_ids = self.builder.get_shared_populate_data( + "loaded_representation_ids" + ) + if loaded_representation_ids is None: + loaded_representation_ids = set() + for node in nuke.allNodes(): + if "repre_id" in node.knobs(): + loaded_representation_ids.add( + node.knob("repre_id").getValue() + ) - def create_sib_copies(self): - """ creating copies of the palce_holder siblings (the ones who were - loaded with it) for the new nodes added + self.builder.set_shared_populate_data( + "loaded_representation_ids", loaded_representation_ids + ) + return loaded_representation_ids + + def _before_repre_load(self, placeholder, representation): + placeholder.data["nodes_init"] = nuke.allNodes() + placeholder.data["last_repre_id"] = str(representation["_id"]) + + def collect_placeholders(self): + output = [] + scene_placeholders = self._collect_scene_placeholders() + for node_name, node in scene_placeholders.items(): + plugin_identifier_knob = node.knob("plugin_identifier") + if ( + plugin_identifier_knob is None + or plugin_identifier_knob.getValue() != self.identifier + ): + continue + + placeholder_data = self._parse_placeholder_node_data(node) + # TODO do data validations and maybe updgrades if are invalid + output.append( + LoadPlaceholderItem(node_name, placeholder_data, self) + ) + + return output + + def populate_placeholder(self, placeholder): + self.populate_load_placeholder(placeholder) + + def repopulate_placeholder(self, placeholder): + repre_ids = self._get_loaded_repre_ids() + self.populate_load_placeholder(placeholder, repre_ids) + + def get_placeholder_options(self, options=None): + return self.get_load_plugin_options(options) + + def cleanup_placeholder(self, placeholder, failed): + # deselect all selected nodes + placeholder_node = nuke.toNode(placeholder.scene_identifier) + + # getting the latest nodes added + # TODO get from shared populate data! + nodes_init = placeholder.data["nodes_init"] + nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init)) + self.log.debug("Loaded nodes: {}".format(nodes_loaded)) + if not nodes_loaded: + return + + placeholder.data["delete"] = True + + nodes_loaded = self._move_to_placeholder_group( + placeholder, nodes_loaded + ) + placeholder.data["last_loaded"] = nodes_loaded + refresh_nodes(nodes_loaded) + + # positioning of the loaded nodes + min_x, min_y, _, _ = get_extreme_positions(nodes_loaded) + for node in nodes_loaded: + xpos = (node.xpos() - min_x) + placeholder_node.xpos() + ypos = (node.ypos() - min_y) + placeholder_node.ypos() + node.setXYpos(xpos, ypos) + refresh_nodes(nodes_loaded) + + # fix the problem of z_order for backdrops + self._fix_z_order(placeholder) + self._imprint_siblings(placeholder) + + if placeholder.data["nb_children"] == 0: + # save initial nodes postions and dimensions, update them + # and set inputs and outputs of loaded nodes + + self._imprint_inits() + self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded) + self._set_loaded_connections(placeholder) + + elif placeholder.data["siblings"]: + # create copies of placeholder siblings for the new loaded nodes, + # set their inputs and outpus and update all nodes positions and + # dimensions and siblings names + + siblings = get_nodes_by_names(placeholder.data["siblings"]) + refresh_nodes(siblings) + copies = self._create_sib_copies(placeholder) + new_nodes = list(copies.values()) # copies nodes + self._update_nodes(new_nodes, nodes_loaded) + placeholder_node.removeKnob(placeholder_node.knob("siblings")) + new_nodes_name = get_names_from_nodes(new_nodes) + imprint(placeholder_node, {"siblings": new_nodes_name}) + self._set_copies_connections(placeholder, copies) + + self._update_nodes( + nuke.allNodes(), + new_nodes + nodes_loaded, + 20 + ) + + new_siblings = get_names_from_nodes(new_nodes) + placeholder.data["siblings"] = new_siblings + + else: + # if the placeholder doesn't have siblings, the loaded + # nodes will be placed in a free space + + xpointer, ypointer = find_free_space_to_paste_nodes( + nodes_loaded, direction="bottom", offset=200 + ) + node = nuke.createNode("NoOp") + reset_selection() + nuke.delete(node) + for node in nodes_loaded: + xpos = (node.xpos() - min_x) + xpointer + ypos = (node.ypos() - min_y) + ypointer + node.setXYpos(xpos, ypos) + + placeholder.data["nb_children"] += 1 + reset_selection() + # go back to root group + nuke.root().begin() + + def _move_to_placeholder_group(self, placeholder, nodes_loaded): + """ + opening the placeholder's group and copying loaded nodes in it. Returns : - copies (dict) : with copied nodes names and their copies + nodes_loaded (list): the new list of pasted nodes """ - copies = {} - siblings = get_nodes_by_names(self.data["siblings"]) - for node in siblings: - new_node = duplicate_node(node) + groups_name = placeholder.data["group_name"] + reset_selection() + select_nodes(nodes_loaded) + if groups_name: + with node_tempfile() as filepath: + nuke.nodeCopy(filepath) + for node in nuke.selectedNodes(): + nuke.delete(node) + group = nuke.toNode(groups_name) + group.begin() + nuke.nodePaste(filepath) + nodes_loaded = nuke.selectedNodes() + return nodes_loaded - x_init = int(new_node.knob("x_init").getValue()) - y_init = int(new_node.knob("y_init").getValue()) - new_node.setXYpos(x_init, y_init) - if isinstance(new_node, nuke.BackdropNode): - w_init = new_node.knob("w_init").getValue() - h_init = new_node.knob("h_init").getValue() - new_node.knob("bdwidth").setValue(w_init) - new_node.knob("bdheight").setValue(h_init) - refresh_node(node) - - if "repre_id" in node.knobs().keys(): - node.removeKnob(node.knob("repre_id")) - copies[node.name()] = new_node - return copies - - def fix_z_order(self): + def _fix_z_order(self, placeholder): """Fix the problem of z_order when a backdrop is loaded.""" - nodes_loaded = self.data["last_loaded"] + nodes_loaded = placeholder.data["last_loaded"] loaded_backdrops = [] bd_orders = set() for node in nodes_loaded: @@ -287,7 +313,7 @@ class NukePlaceholder(AbstractPlaceholder): return sib_orders = set() - for node_name in self.data["siblings"]: + for node_name in placeholder.data["siblings"]: node = nuke.toNode(node_name) if isinstance(node, nuke.BackdropNode): sib_orders.add(node.knob("z_order").getValue()) @@ -302,7 +328,56 @@ class NukePlaceholder(AbstractPlaceholder): backdrop_node.knob("z_order").setValue( z_order + max_order - min_order + 1) - def update_nodes(self, nodes, considered_nodes, offset_y=None): + def _imprint_siblings(self, placeholder): + """ + - add siblings names to placeholder attributes (nodes loaded with it) + - add Id to the attributes of all the other nodes + """ + + loaded_nodes = placeholder.data["last_loaded"] + loaded_nodes_set = set(loaded_nodes) + data = {"repre_id": str(placeholder.data["last_repre_id"])} + + for node in loaded_nodes: + node_knobs = node.knobs() + if "builder_type" not in node_knobs: + # save the id of representation for all imported nodes + imprint(node, data) + node.knob("repre_id").setVisible(False) + refresh_node(node) + continue + + if ( + "is_placeholder" not in node_knobs + or ( + "is_placeholder" in node_knobs + and node.knob("is_placeholder").value() + ) + ): + siblings = list(loaded_nodes_set - {node}) + siblings_name = get_names_from_nodes(siblings) + siblings = {"siblings": siblings_name} + imprint(node, siblings) + + def _imprint_inits(self): + """Add initial positions and dimensions to the attributes""" + + for node in nuke.allNodes(): + refresh_node(node) + imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()}) + node.knob("x_init").setVisible(False) + node.knob("y_init").setVisible(False) + width = node.screenWidth() + height = node.screenHeight() + if "bdwidth" in node.knobs(): + imprint(node, {"w_init": width, "h_init": height}) + node.knob("w_init").setVisible(False) + node.knob("h_init").setVisible(False) + refresh_node(node) + + def _update_nodes( + self, placeholder, nodes, considered_nodes, offset_y=None + ): """Adjust backdrop nodes dimensions and positions. Considering some nodes sizes. @@ -314,7 +389,7 @@ class NukePlaceholder(AbstractPlaceholder): offset (int): distance between copies """ - placeholder_node = self.data["node"] + placeholder_node = nuke.toNode(placeholder.scene_identifier) min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes) @@ -330,7 +405,7 @@ class NukePlaceholder(AbstractPlaceholder): min_x = placeholder_node.xpos() min_y = placeholder_node.ypos() else: - siblings = get_nodes_by_names(self.data["siblings"]) + siblings = get_nodes_by_names(placeholder.data["siblings"]) minX, _, maxX, _ = get_extreme_positions(siblings) diff_y = max_y - min_y + 20 diff_x = abs(max_x - min_x - maxX + minX) @@ -369,59 +444,14 @@ class NukePlaceholder(AbstractPlaceholder): refresh_node(node) - def imprint_inits(self): - """Add initial positions and dimensions to the attributes""" - - for node in nuke.allNodes(): - refresh_node(node) - imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()}) - node.knob("x_init").setVisible(False) - node.knob("y_init").setVisible(False) - width = node.screenWidth() - height = node.screenHeight() - if "bdwidth" in node.knobs(): - imprint(node, {"w_init": width, "h_init": height}) - node.knob("w_init").setVisible(False) - node.knob("h_init").setVisible(False) - refresh_node(node) - - def imprint_siblings(self): - """ - - add siblings names to placeholder attributes (nodes loaded with it) - - add Id to the attributes of all the other nodes - """ - - loaded_nodes = self.data["last_loaded"] - loaded_nodes_set = set(loaded_nodes) - data = {"repre_id": str(self.data["last_repre_id"])} - - for node in loaded_nodes: - node_knobs = node.knobs() - if "builder_type" not in node_knobs: - # save the id of representation for all imported nodes - imprint(node, data) - node.knob("repre_id").setVisible(False) - refresh_node(node) - continue - - if ( - "is_placeholder" not in node_knobs - or ( - "is_placeholder" in node_knobs - and node.knob("is_placeholder").value() - ) - ): - siblings = list(loaded_nodes_set - {node}) - siblings_name = get_names_from_nodes(siblings) - siblings = {"siblings": siblings_name} - imprint(node, siblings) - - def set_loaded_connections(self): + def _set_loaded_connections(self, placeholder): """ set inputs and outputs of loaded nodes""" - placeholder_node = self.data["node"] - input_node, output_node = get_group_io_nodes(self.data["last_loaded"]) + placeholder_node = nuke.toNode(placeholder.scene_identifier) + input_node, output_node = get_group_io_nodes( + placeholder.data["last_loaded"] + ) for node in placeholder_node.dependent(): for idx in range(node.inputs()): if node.input(idx) == placeholder_node: @@ -432,15 +462,45 @@ class NukePlaceholder(AbstractPlaceholder): if placeholder_node.input(idx) == node: input_node.setInput(0, node) - def set_copies_connections(self, copies): + def _create_sib_copies(self, placeholder): + """ creating copies of the palce_holder siblings (the ones who were + loaded with it) for the new nodes added + + Returns : + copies (dict) : with copied nodes names and their copies + """ + + copies = {} + siblings = get_nodes_by_names(placeholder.data["siblings"]) + for node in siblings: + new_node = duplicate_node(node) + + x_init = int(new_node.knob("x_init").getValue()) + y_init = int(new_node.knob("y_init").getValue()) + new_node.setXYpos(x_init, y_init) + if isinstance(new_node, nuke.BackdropNode): + w_init = new_node.knob("w_init").getValue() + h_init = new_node.knob("h_init").getValue() + new_node.knob("bdwidth").setValue(w_init) + new_node.knob("bdheight").setValue(h_init) + refresh_node(node) + + if "repre_id" in node.knobs().keys(): + node.removeKnob(node.knob("repre_id")) + copies[node.name()] = new_node + return copies + + def _set_copies_connections(self, placeholder, copies): """Set inputs and outputs of the copies. Args: copies (dict): Copied nodes by their names. """ - last_input, last_output = get_group_io_nodes(self.data["last_loaded"]) - siblings = get_nodes_by_names(self.data["siblings"]) + last_input, last_output = get_group_io_nodes( + placeholder.data["last_loaded"] + ) + siblings = get_nodes_by_names(placeholder.data["siblings"]) siblings_input, siblings_output = get_group_io_nodes(siblings) copy_input = copies[siblings_input.name()] copy_output = copies[siblings_output.name()] @@ -474,166 +534,45 @@ class NukePlaceholder(AbstractPlaceholder): siblings_input.setInput(0, copy_output) - def move_to_placeholder_group(self, nodes_loaded): - """ - opening the placeholder's group and copying loaded nodes in it. - Returns : - nodes_loaded (list): the new list of pasted nodes - """ +def build_workfile_template(*args): + builder = NukeTemplateBuilder(registered_host()) + builder.build_template() - groups_name = self.data["group_name"] - reset_selection() - select_nodes(nodes_loaded) - if groups_name: - with node_tempfile() as filepath: - nuke.nodeCopy(filepath) - for node in nuke.selectedNodes(): - nuke.delete(node) - group = nuke.toNode(groups_name) - group.begin() - nuke.nodePaste(filepath) - nodes_loaded = nuke.selectedNodes() - return nodes_loaded - def clean(self): - # deselect all selected nodes - placeholder_node = self.data["node"] +def update_workfile_template(*args): + builder = NukeTemplateBuilder(registered_host()) + builder.rebuild_template() - # getting the latest nodes added - nodes_init = self.data["nodes_init"] - nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init)) - self.log.debug("Loaded nodes: {}".format(nodes_loaded)) - if not nodes_loaded: - return - self.data["delete"] = True +def create_placeholder(*args): + host = registered_host() + builder = NukeTemplateBuilder(host) + window = WorkfileBuildPlaceholderDialog(host, builder) + window.exec_() - nodes_loaded = self.move_to_placeholder_group(nodes_loaded) - self.data["last_loaded"] = nodes_loaded - refresh_nodes(nodes_loaded) - # positioning of the loaded nodes - min_x, min_y, _, _ = get_extreme_positions(nodes_loaded) - for node in nodes_loaded: - xpos = (node.xpos() - min_x) + placeholder_node.xpos() - ypos = (node.ypos() - min_y) + placeholder_node.ypos() - node.setXYpos(xpos, ypos) - refresh_nodes(nodes_loaded) +def update_placeholder(*args): + host = registered_host() + builder = NukeTemplateBuilder(host) + placeholder_items_by_id = { + placeholder_item.scene_identifier: placeholder_item + for placeholder_item in builder.get_placeholders() + } + placeholder_items = [] + for node in nuke.selectedNodes(): + node_name = node.fullName() + if node_name in placeholder_items_by_id: + placeholder_items.append(placeholder_items_by_id[node_name]) - self.fix_z_order() # fix the problem of z_order for backdrops - self.imprint_siblings() + # TODO show UI at least + if len(placeholder_items) == 0: + raise ValueError("No node selected") - if self.data["nb_children"] == 0: - # save initial nodes postions and dimensions, update them - # and set inputs and outputs of loaded nodes + if len(placeholder_items) > 1: + raise ValueError("Too many selected nodes") - self.imprint_inits() - self.update_nodes(nuke.allNodes(), nodes_loaded) - self.set_loaded_connections() - - elif self.data["siblings"]: - # create copies of placeholder siblings for the new loaded nodes, - # set their inputs and outpus and update all nodes positions and - # dimensions and siblings names - - siblings = get_nodes_by_names(self.data["siblings"]) - refresh_nodes(siblings) - copies = self.create_sib_copies() - new_nodes = list(copies.values()) # copies nodes - self.update_nodes(new_nodes, nodes_loaded) - placeholder_node.removeKnob(placeholder_node.knob("siblings")) - new_nodes_name = get_names_from_nodes(new_nodes) - imprint(placeholder_node, {"siblings": new_nodes_name}) - self.set_copies_connections(copies) - - self.update_nodes( - nuke.allNodes(), - new_nodes + nodes_loaded, - 20 - ) - - new_siblings = get_names_from_nodes(new_nodes) - self.data["siblings"] = new_siblings - - else: - # if the placeholder doesn't have siblings, the loaded - # nodes will be placed in a free space - - xpointer, ypointer = find_free_space_to_paste_nodes( - nodes_loaded, direction="bottom", offset=200 - ) - node = nuke.createNode("NoOp") - reset_selection() - nuke.delete(node) - for node in nodes_loaded: - xpos = (node.xpos() - min_x) + xpointer - ypos = (node.ypos() - min_y) + ypointer - node.setXYpos(xpos, ypos) - - self.data["nb_children"] += 1 - reset_selection() - # go back to root group - nuke.root().begin() - - def get_representations(self, current_asset_doc, linked_asset_docs): - project_name = legacy_io.active_project() - - builder_type = self.data["builder_type"] - if builder_type == "context_asset": - context_filters = { - "asset": [re.compile(self.data["asset"])], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representations": [self.data["representation"]], - "family": [self.data["family"]] - } - - elif builder_type != "linked_asset": - context_filters = { - "asset": [ - current_asset_doc["name"], - re.compile(self.data["asset"]) - ], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]] - } - - else: - asset_regex = re.compile(self.data["asset"]) - linked_asset_names = [] - for asset_doc in linked_asset_docs: - asset_name = asset_doc["name"] - if asset_regex.match(asset_name): - linked_asset_names.append(asset_name) - - if not linked_asset_names: - return [] - - context_filters = { - "asset": linked_asset_names, - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]], - } - - return list(get_representations( - project_name, - context_filters=context_filters - )) - - def err_message(self): - return ( - "Error while trying to load a representation.\n" - "Either the subset wasn't published or the template is malformed." - "\n\n" - "Builder was looking for:\n{attributes}".format( - attributes="\n".join([ - "{}: {}".format(key.title(), value) - for key, value in self.data.items()] - ) - ) - ) + placeholder_item = placeholder_items[0] + window = WorkfileBuildPlaceholderDialog(host, builder) + window.set_update_mode(placeholder_item) + window.exec_() diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 17658eef93..37446f01f8 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -9,6 +9,49 @@ import six import clique +def get_attributes_keys(attribute_definitions): + """Collect keys from list of attribute definitions. + + Args: + attribute_definitions (List[AbtractAttrDef]): Objects of attribute + definitions. + + Returns: + Set[str]: Keys that will be created using passed attribute definitions. + """ + + keys = set() + if not attribute_definitions: + return keys + + for attribute_def in attribute_definitions: + if not isinstance(attribute_def, UIDef): + keys.add(attribute_def.key) + return keys + + +def get_default_values(attribute_definitions): + """Receive default values for attribute definitions. + + Args: + attribute_definitions (List[AbtractAttrDef]): Attribute definitions for + which default values should be collected. + + Returns: + Dict[str, Any]: Default values for passet attribute definitions. + """ + + output = {} + if not attribute_definitions: + return output + + for attr_def in attribute_definitions: + # Skip UI definitions + if not isinstance(attr_def, UIDef): + output[attr_def.key] = attr_def.default + return output + + class AbstractAttrDefMeta(ABCMeta): """Meta class to validate existence of 'key' attribute. diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index bf38a0b3c8..e96f64f2a4 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -5,6 +5,7 @@ from .utils import ( InvalidRepresentationContext, get_repres_contexts, + get_contexts_for_repre_docs, get_subset_contexts, get_representation_context, @@ -54,6 +55,7 @@ __all__ = ( "InvalidRepresentationContext", "get_repres_contexts", + "get_contexts_for_repre_docs", "get_subset_contexts", "get_representation_context", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 363120600c..784d4628f3 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -87,13 +87,20 @@ def get_repres_contexts(representation_ids, dbcon=None): if not dbcon: dbcon = legacy_io - contexts = {} if not representation_ids: - return contexts + return {} project_name = dbcon.active_project() repre_docs = get_representations(project_name, representation_ids) + return get_contexts_for_repre_docs(project_name, repre_docs) + + +def get_contexts_for_repre_docs(project_name, repre_docs): + contexts = {} + if not repre_docs: + return contexts + repre_docs_by_id = {} version_ids = set() for repre_doc in repre_docs: diff --git a/openpype/pipeline/workfile/abstract_template_loader.py b/openpype/pipeline/workfile/abstract_template_loader.py deleted file mode 100644 index e2fbea98ca..0000000000 --- a/openpype/pipeline/workfile/abstract_template_loader.py +++ /dev/null @@ -1,528 +0,0 @@ -import os -from abc import ABCMeta, abstractmethod - -import six -import logging -from functools import reduce - -from openpype.client import ( - get_asset_by_name, - get_linked_assets, -) -from openpype.settings import get_project_settings -from openpype.lib import ( - StringTemplate, - Logger, - filter_profiles, -) -from openpype.pipeline import legacy_io, Anatomy -from openpype.pipeline.load import ( - get_loaders_by_name, - get_representation_context, - load_with_repre_context, -) - -from .build_template_exceptions import ( - TemplateAlreadyImported, - TemplateLoadingFailed, - TemplateProfileNotFound, - TemplateNotFound -) - -log = logging.getLogger(__name__) - - -def update_representations(entities, entity): - if entity['context']['subset'] not in entities: - entities[entity['context']['subset']] = entity - else: - current = entities[entity['context']['subset']] - incomming = entity - entities[entity['context']['subset']] = max( - current, incomming, - key=lambda entity: entity["context"].get("version", -1)) - - return entities - - -def parse_loader_args(loader_args): - if not loader_args: - return dict() - try: - parsed_args = eval(loader_args) - if not isinstance(parsed_args, dict): - return dict() - else: - return parsed_args - except Exception as err: - print( - "Error while parsing loader arguments '{}'.\n{}: {}\n\n" - "Continuing with default arguments. . .".format( - loader_args, - err.__class__.__name__, - err)) - return dict() - - -@six.add_metaclass(ABCMeta) -class AbstractTemplateLoader: - """ - Abstraction of Template Loader. - Properties: - template_path : property to get current template path - Methods: - import_template : Abstract Method. Used to load template, - depending on current host - get_template_nodes : Abstract Method. Used to query nodes acting - as placeholders. Depending on current host - """ - - _log = None - - def __init__(self, placeholder_class): - # TODO template loader should expect host as and argument - # - host have all responsibility for most of code (also provide - # placeholder class) - # - also have responsibility for current context - # - this won't work in DCCs where multiple workfiles with - # different contexts can be opened at single time - # - template loader should have ability to change context - project_name = legacy_io.active_project() - asset_name = legacy_io.Session["AVALON_ASSET"] - - self.loaders_by_name = get_loaders_by_name() - self.current_asset = asset_name - self.project_name = project_name - self.host_name = legacy_io.Session["AVALON_APP"] - self.task_name = legacy_io.Session["AVALON_TASK"] - self.placeholder_class = placeholder_class - self.current_asset_doc = get_asset_by_name(project_name, asset_name) - self.task_type = ( - self.current_asset_doc - .get("data", {}) - .get("tasks", {}) - .get(self.task_name, {}) - .get("type") - ) - - self.log.info( - "BUILDING ASSET FROM TEMPLATE :\n" - "Starting templated build for {asset} in {project}\n\n" - "Asset : {asset}\n" - "Task : {task_name} ({task_type})\n" - "Host : {host}\n" - "Project : {project}\n".format( - asset=self.current_asset, - host=self.host_name, - project=self.project_name, - task_name=self.task_name, - task_type=self.task_type - )) - # Skip if there is no loader - if not self.loaders_by_name: - self.log.warning( - "There is no registered loaders. No assets will be loaded") - return - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(self.__class__.__name__) - return self._log - - def template_already_imported(self, err_msg): - """In case template was already loaded. - Raise the error as a default action. - Override this method in your template loader implementation - to manage this case.""" - self.log.error("{}: {}".format( - err_msg.__class__.__name__, - err_msg)) - raise TemplateAlreadyImported(err_msg) - - def template_loading_failed(self, err_msg): - """In case template loading failed - Raise the error as a default action. - Override this method in your template loader implementation - to manage this case. - """ - self.log.error("{}: {}".format( - err_msg.__class__.__name__, - err_msg)) - raise TemplateLoadingFailed(err_msg) - - @property - def template_path(self): - """ - Property returning template path. Avoiding setter. - Getting template path from open pype settings based on current avalon - session and solving the path variables if needed. - Returns: - str: Solved template path - Raises: - TemplateProfileNotFound: No profile found from settings for - current avalon session - KeyError: Could not solve path because a key does not exists - in avalon context - TemplateNotFound: Solved path does not exists on current filesystem - """ - project_name = self.project_name - host_name = self.host_name - task_name = self.task_name - task_type = self.task_type - - anatomy = Anatomy(project_name) - project_settings = get_project_settings(project_name) - - build_info = project_settings[host_name]["templated_workfile_build"] - profile = filter_profiles( - build_info["profiles"], - { - "task_types": task_type, - "task_names": task_name - } - ) - - if not profile: - raise TemplateProfileNotFound( - "No matching profile found for task '{}' of type '{}' " - "with host '{}'".format(task_name, task_type, host_name) - ) - - path = profile["path"] - if not path: - raise TemplateLoadingFailed( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles".format(host_name.title())) - - # Try fill path with environments and anatomy roots - fill_data = { - key: value - for key, value in os.environ.items() - } - fill_data["root"] = anatomy.roots - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - - if path and os.path.exists(path): - self.log.info("Found template at: '{}'".format(path)) - return path - - solved_path = None - while True: - try: - solved_path = anatomy.path_remapper(path) - except KeyError as missing_key: - raise KeyError( - "Could not solve key '{}' in template path '{}'".format( - missing_key, path)) - - if solved_path is None: - solved_path = path - if solved_path == path: - break - path = solved_path - - solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): - raise TemplateNotFound( - "Template found in openPype settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) - - self.log.info("Found template at: '{}'".format(solved_path)) - - return solved_path - - def populate_template(self, ignored_ids=None): - """ - Use template placeholders to load assets and parent them in hierarchy - Arguments : - ignored_ids : - Returns: - None - """ - - loaders_by_name = self.loaders_by_name - current_asset_doc = self.current_asset_doc - linked_assets = get_linked_assets(current_asset_doc) - - ignored_ids = ignored_ids or [] - placeholders = self.get_placeholders() - self.log.debug("Placeholders found in template: {}".format( - [placeholder.name for placeholder in placeholders] - )) - for placeholder in placeholders: - self.log.debug("Start to processing placeholder {}".format( - placeholder.name - )) - placeholder_representations = self.get_placeholder_representations( - placeholder, - current_asset_doc, - linked_assets - ) - - if not placeholder_representations: - self.log.info( - "There's no representation for this placeholder: " - "{}".format(placeholder.name) - ) - continue - - for representation in placeholder_representations: - self.preload(placeholder, loaders_by_name, representation) - - if self.load_data_is_incorrect( - placeholder, - representation, - ignored_ids): - continue - - self.log.info( - "Loading {}_{} with loader {}\n" - "Loader arguments used : {}".format( - representation['context']['asset'], - representation['context']['subset'], - placeholder.loader_name, - placeholder.loader_args)) - - try: - container = self.load( - placeholder, loaders_by_name, representation) - except Exception: - self.load_failed(placeholder, representation) - else: - self.load_succeed(placeholder, container) - finally: - self.postload(placeholder) - - def get_placeholder_representations( - self, placeholder, current_asset_doc, linked_asset_docs - ): - placeholder_representations = placeholder.get_representations( - current_asset_doc, - linked_asset_docs - ) - for repre_doc in reduce( - update_representations, - placeholder_representations, - dict() - ).values(): - yield repre_doc - - def load_data_is_incorrect( - self, placeholder, last_representation, ignored_ids): - if not last_representation: - self.log.warning(placeholder.err_message()) - return True - if (str(last_representation['_id']) in ignored_ids): - print("Ignoring : ", last_representation['_id']) - return True - return False - - def preload(self, placeholder, loaders_by_name, last_representation): - pass - - def load(self, placeholder, loaders_by_name, last_representation): - repre = get_representation_context(last_representation) - return load_with_repre_context( - loaders_by_name[placeholder.loader_name], - repre, - options=parse_loader_args(placeholder.loader_args)) - - def load_succeed(self, placeholder, container): - placeholder.parent_in_hierarchy(container) - - def load_failed(self, placeholder, last_representation): - self.log.warning( - "Got error trying to load {}:{} with {}".format( - last_representation['context']['asset'], - last_representation['context']['subset'], - placeholder.loader_name - ), - exc_info=True - ) - - def postload(self, placeholder): - placeholder.clean() - - def update_missing_containers(self): - loaded_containers_ids = self.get_loaded_containers_by_id() - self.populate_template(ignored_ids=loaded_containers_ids) - - def get_placeholders(self): - placeholders = map(self.placeholder_class, self.get_template_nodes()) - valid_placeholders = filter( - lambda i: i.is_valid, - placeholders - ) - sorted_placeholders = list(sorted( - valid_placeholders, - key=lambda i: i.order - )) - return sorted_placeholders - - @abstractmethod - def get_loaded_containers_by_id(self): - """ - Collect already loaded containers for updating scene - Return: - dict (string, node): A dictionnary id as key - and containers as value - """ - pass - - @abstractmethod - def import_template(self, template_path): - """ - Import template in current host - Args: - template_path (str): fullpath to current task and - host's template file - Return: - None - """ - pass - - @abstractmethod - def get_template_nodes(self): - """ - Returning a list of nodes acting as host placeholders for - templating. The data representation is by user. - AbstractLoadTemplate (and LoadTemplate) won't directly manipulate nodes - Args : - None - Returns: - list(AnyNode): Solved template path - """ - pass - - -@six.add_metaclass(ABCMeta) -class AbstractPlaceholder: - """Abstraction of placeholders logic. - - Properties: - required_keys: A list of mandatory keys to decribe placeholder - and assets to load. - optional_keys: A list of optional keys to decribe - placeholder and assets to load - loader_name: Name of linked loader to use while loading assets - - Args: - identifier (str): Placeholder identifier. Should be possible to be - used as identifier in "a scene" (e.g. unique node name). - """ - - required_keys = { - "builder_type", - "family", - "representation", - "order", - "loader", - "loader_args" - } - optional_keys = {} - - def __init__(self, identifier): - self._log = None - self._name = identifier - self.get_data(identifier) - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(repr(self)) - return self._log - - def __repr__(self): - return "< {} {} >".format(self.__class__.__name__, self.name) - - @property - def name(self): - return self._name - - @property - def loader_args(self): - return self.data["loader_args"] - - @property - def builder_type(self): - return self.data["builder_type"] - - @property - def order(self): - return self.data["order"] - - @property - def loader_name(self): - """Return placeholder loader name. - - Returns: - str: Loader name that will be used to load placeholder - representations. - """ - - return self.data["loader"] - - @property - def is_valid(self): - """Test validity of placeholder. - - i.e.: every required key exists in placeholder data - - Returns: - bool: True if every key is in data - """ - - if set(self.required_keys).issubset(self.data.keys()): - self.log.debug("Valid placeholder : {}".format(self.name)) - return True - self.log.info("Placeholder is not valid : {}".format(self.name)) - return False - - @abstractmethod - def parent_in_hierarchy(self, container): - """Place loaded container in correct hierarchy given by placeholder - - Args: - container (Dict[str, Any]): Loaded container created by loader. - """ - - pass - - @abstractmethod - def clean(self): - """Clean placeholder from hierarchy after loading assets.""" - - pass - - @abstractmethod - def get_representations(self, current_asset_doc, linked_asset_docs): - """Query representations based on placeholder data. - - Args: - current_asset_doc (Dict[str, Any]): Document of current - context asset. - linked_asset_docs (List[Dict[str, Any]]): Documents of assets - linked to current context asset. - - Returns: - Iterable[Dict[str, Any]]: Representations that are matching - placeholder filters. - """ - - pass - - @abstractmethod - def get_data(self, identifier): - """Collect information about placeholder by identifier. - - Args: - identifier (str): A unique placeholder identifier defined by - implementation. - """ - - pass diff --git a/openpype/pipeline/workfile/build_template.py b/openpype/pipeline/workfile/build_template.py deleted file mode 100644 index 3328dfbc9e..0000000000 --- a/openpype/pipeline/workfile/build_template.py +++ /dev/null @@ -1,72 +0,0 @@ -import os -from importlib import import_module -from openpype.lib import classes_from_module -from openpype.host import HostBase -from openpype.pipeline import registered_host - -from .abstract_template_loader import ( - AbstractPlaceholder, - AbstractTemplateLoader) - -from .build_template_exceptions import ( - TemplateLoadingFailed, - TemplateAlreadyImported, - MissingHostTemplateModule, - MissingTemplatePlaceholderClass, - MissingTemplateLoaderClass -) - -_module_path_format = 'openpype.hosts.{host}.api.template_loader' - - -def build_workfile_template(*args): - template_loader = build_template_loader() - try: - template_loader.import_template(template_loader.template_path) - except TemplateAlreadyImported as err: - template_loader.template_already_imported(err) - except TemplateLoadingFailed as err: - template_loader.template_loading_failed(err) - else: - template_loader.populate_template() - - -def update_workfile_template(*args): - template_loader = build_template_loader() - template_loader.update_missing_containers() - - -def build_template_loader(): - # TODO refactor to use advantage of 'HostBase' and don't import dynamically - # - hosts should have methods that gives option to return builders - host = registered_host() - if isinstance(host, HostBase): - host_name = host.name - else: - host_name = os.environ.get("AVALON_APP") - if not host_name: - host_name = host.__name__.split(".")[-2] - - module_path = _module_path_format.format(host=host_name) - module = import_module(module_path) - if not module: - raise MissingHostTemplateModule( - "No template loader found for host {}".format(host_name)) - - template_loader_class = classes_from_module( - AbstractTemplateLoader, - module - ) - template_placeholder_class = classes_from_module( - AbstractPlaceholder, - module - ) - - if not template_loader_class: - raise MissingTemplateLoaderClass() - template_loader_class = template_loader_class[0] - - if not template_placeholder_class: - raise MissingTemplatePlaceholderClass() - template_placeholder_class = template_placeholder_class[0] - return template_loader_class(template_placeholder_class) diff --git a/openpype/pipeline/workfile/build_template_exceptions.py b/openpype/pipeline/workfile/build_template_exceptions.py deleted file mode 100644 index 7a5075e3dc..0000000000 --- a/openpype/pipeline/workfile/build_template_exceptions.py +++ /dev/null @@ -1,35 +0,0 @@ -class MissingHostTemplateModule(Exception): - """Error raised when expected module does not exists""" - pass - - -class MissingTemplatePlaceholderClass(Exception): - """Error raised when module doesn't implement a placeholder class""" - pass - - -class MissingTemplateLoaderClass(Exception): - """Error raised when module doesn't implement a template loader class""" - pass - - -class TemplateNotFound(Exception): - """Exception raised when template does not exist.""" - pass - - -class TemplateProfileNotFound(Exception): - """Exception raised when current profile - doesn't match any template profile""" - pass - - -class TemplateAlreadyImported(Exception): - """Error raised when Template was already imported by host for - this session""" - pass - - -class TemplateLoadingFailed(Exception): - """Error raised whend Template loader was unable to load the template""" - pass diff --git a/openpype/pipeline/workfile/build_workfile.py b/openpype/pipeline/workfile/build_workfile.py index 0b8a444436..87b9df158f 100644 --- a/openpype/pipeline/workfile/build_workfile.py +++ b/openpype/pipeline/workfile/build_workfile.py @@ -1,3 +1,14 @@ +"""Workfile build based on settings. + +Workfile builder will do stuff based on project settings. Advantage is that +it need only access to settings. Disadvantage is that it is hard to focus +build per context and being explicit about loaded content. + +For more explicit workfile build is recommended 'AbstractTemplateBuilder' +from '~/openpype/pipeline/workfile/workfile_template_builder'. Which gives +more abilities to define how build happens but require more code to achive it. +""" + import os import re import collections diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py new file mode 100644 index 0000000000..582657c735 --- /dev/null +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -0,0 +1,1451 @@ +"""Workfile build mechanism using workfile templates. + +Build templates are manually prepared using plugin definitions which create +placeholders inside the template which are populated on import. + +This approach is very explicit to achive very specific build logic that can be +targeted by task types and names. + +Placeholders are created using placeholder plugins which should care about +logic and data of placeholder items. 'PlaceholderItem' is used to keep track +about it's progress. +""" + +import os +import re +import collections +import copy +from abc import ABCMeta, abstractmethod + +import six + +from openpype.client import ( + get_asset_by_name, + get_linked_assets, + get_representations, +) +from openpype.settings import ( + get_project_settings, + get_system_settings, +) +from openpype.host import HostBase +from openpype.lib import ( + Logger, + StringTemplate, + filter_profiles, + attribute_definitions, +) +from openpype.lib.attribute_definitions import get_attributes_keys +from openpype.pipeline import legacy_io, Anatomy +from openpype.pipeline.load import ( + get_loaders_by_name, + get_contexts_for_repre_docs, + load_with_repre_context, +) +from openpype.pipeline.create import get_legacy_creator_by_name + + +class TemplateNotFound(Exception): + """Exception raised when template does not exist.""" + pass + + +class TemplateProfileNotFound(Exception): + """Exception raised when current profile + doesn't match any template profile""" + pass + + +class TemplateAlreadyImported(Exception): + """Error raised when Template was already imported by host for + this session""" + pass + + +class TemplateLoadFailed(Exception): + """Error raised whend Template loader was unable to load the template""" + pass + + +@six.add_metaclass(ABCMeta) +class AbstractTemplateBuilder(object): + """Abstraction of Template Builder. + + Builder cares about context, shared data, cache, discovery of plugins + and trigger logic. Provides public api for host workfile build systen. + + Rest of logic is based on plugins that care about collection and creation + of placeholder items. + + Population of placeholders happens in loops. Each loop will collect all + available placeholders, skip already populated, and populate the rest. + + Builder item has 2 types of shared data. Refresh lifetime which are cleared + on refresh and populate lifetime which are cleared after loop of + placeholder population. + + Args: + host (Union[HostBase, ModuleType]): Implementation of host. + """ + + _log = None + + def __init__(self, host): + # Get host name + if isinstance(host, HostBase): + host_name = host.name + else: + host_name = os.environ.get("AVALON_APP") + + self._host = host + self._host_name = host_name + + # Shared data across placeholder plugins + self._shared_data = {} + self._shared_populate_data = {} + + # Where created objects of placeholder plugins will be stored + self._placeholder_plugins = None + self._loaders_by_name = None + self._creators_by_name = None + + self._system_settings = None + self._project_settings = None + + self._current_asset_doc = None + self._linked_asset_docs = None + self._task_type = None + + @property + def project_name(self): + return legacy_io.active_project() + + @property + def current_asset_name(self): + return legacy_io.Session["AVALON_ASSET"] + + @property + def current_task_name(self): + return legacy_io.Session["AVALON_TASK"] + + @property + def system_settings(self): + if self._system_settings is None: + self._system_settings = get_system_settings() + return self._system_settings + + @property + def project_settings(self): + if self._project_settings is None: + self._project_settings = get_project_settings(self.project_name) + return self._project_settings + + @property + def current_asset_doc(self): + if self._current_asset_doc is None: + self._current_asset_doc = get_asset_by_name( + self.project_name, self.current_asset_name + ) + return self._current_asset_doc + + @property + def linked_asset_docs(self): + if self._linked_asset_docs is None: + self._linked_asset_docs = get_linked_assets( + self.current_asset_doc + ) + return self._linked_asset_docs + + @property + def current_task_type(self): + asset_doc = self.current_asset_doc + if not asset_doc: + return None + return ( + asset_doc + .get("data", {}) + .get("tasks", {}) + .get(self.current_task_name, {}) + .get("type") + ) + + def get_placeholder_plugin_classes(self): + """Get placeholder plugin classes that can be used to build template. + + Default implementation looks for method + 'get_workfile_build_placeholder_plugins' on host. + + Returns: + List[PlaceholderPlugin]: Plugin classes available for host. + """ + + if hasattr(self._host, "get_workfile_build_placeholder_plugins"): + return self._host.get_workfile_build_placeholder_plugins() + return [] + + @property + def host(self): + """Access to host implementation. + + Returns: + Union[HostBase, ModuleType]: Implementation of host. + """ + + return self._host + + @property + def host_name(self): + """Name of 'host' implementation. + + Returns: + str: Host's name. + """ + + return self._host_name + + @property + def log(self): + """Dynamically created logger for the plugin.""" + + if self._log is None: + self._log = Logger.get_logger(repr(self)) + return self._log + + def refresh(self): + """Reset cached data.""" + + self._placeholder_plugins = None + self._loaders_by_name = None + self._creators_by_name = None + + self._current_asset_doc = None + self._linked_asset_docs = None + self._task_type = None + + self._system_settings = None + self._project_settings = None + + self.clear_shared_data() + self.clear_shared_populate_data() + + def get_loaders_by_name(self): + if self._loaders_by_name is None: + self._loaders_by_name = get_loaders_by_name() + return self._loaders_by_name + + def get_creators_by_name(self): + if self._creators_by_name is None: + self._creators_by_name = get_legacy_creator_by_name() + return self._creators_by_name + + def get_shared_data(self, key): + """Receive shared data across plugins and placeholders. + + This can be used to scroll scene only once to look for placeholder + items if the storing is unified but each placeholder plugin would have + to call it again. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + return self._shared_data.get(key) + + def set_shared_data(self, key, value): + """Store share data across plugins and placeholders. + + Store data that can be afterwards accessed from any future call. It + is good practice to check if the same value is not already stored under + different key or if the key is not already used for something else. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + self._shared_data[key] = value + + def clear_shared_data(self): + """Clear shared data. + + Method only clear shared data to default state. + """ + + self._shared_data = {} + + def clear_shared_populate_data(self): + """Receive shared data across plugins and placeholders. + + These data are cleared after each loop of populating of template. + + This can be used to scroll scene only once to look for placeholder + items if the storing is unified but each placeholder plugin would have + to call it again. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + self._shared_populate_data = {} + + def get_shared_populate_data(self, key): + """Store share populate data across plugins and placeholders. + + These data are cleared after each loop of populating of template. + + Store data that can be afterwards accessed from any future call. It + is good practice to check if the same value is not already stored under + different key or if the key is not already used for something else. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + return self._shared_populate_data.get(key) + + def set_shared_populate_data(self, key, value): + """Store share populate data across plugins and placeholders. + + These data are cleared after each loop of populating of template. + + Store data that can be afterwards accessed from any future call. It + is good practice to check if the same value is not already stored under + different key or if the key is not already used for something else. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + self._shared_populate_data[key] = value + + @property + def placeholder_plugins(self): + """Access to initialized placeholder plugins. + + Returns: + List[PlaceholderPlugin]: Initialized plugins available for host. + """ + + if self._placeholder_plugins is None: + placeholder_plugins = {} + for cls in self.get_placeholder_plugin_classes(): + try: + plugin = cls(self) + placeholder_plugins[plugin.identifier] = plugin + + except Exception: + self.log.warning( + "Failed to initialize placeholder plugin {}".format( + cls.__name__ + ), + exc_info=True + ) + + self._placeholder_plugins = placeholder_plugins + return self._placeholder_plugins + + def create_placeholder(self, plugin_identifier, placeholder_data): + """Create new placeholder using plugin identifier and data. + + Args: + plugin_identifier (str): Identifier of plugin. That's how builder + know which plugin should be used. + placeholder_data (Dict[str, Any]): Placeholder item data. They + should match options required by the plugin. + + Returns: + PlaceholderItem: Created placeholder item. + """ + + plugin = self.placeholder_plugins[plugin_identifier] + return plugin.create_placeholder(placeholder_data) + + def get_placeholders(self): + """Collect placeholder items from scene. + + Each placeholder plugin can collect it's placeholders and return them. + This method does not use cached values but always go through the scene. + + Returns: + List[PlaceholderItem]: Sorted placeholder items. + """ + + placeholders = [] + for placeholder_plugin in self.placeholder_plugins.values(): + result = placeholder_plugin.collect_placeholders() + if result: + placeholders.extend(result) + + return list(sorted( + placeholders, + key=lambda i: i.order + )) + + def build_template(self, template_path=None, level_limit=None): + """Main callback for building workfile from template path. + + Todo: + Handle report of populated placeholders from + 'populate_scene_placeholders' to be shown to a user. + + Args: + template_path (str): Path to a template file with placeholders. + Template from settings 'get_template_path' used when not + passed. + level_limit (int): Limit of populate loops. Related to + 'populate_scene_placeholders' method. + """ + + if template_path is None: + template_path = self.get_template_path() + self.import_template(template_path) + self.populate_scene_placeholders(level_limit) + + def rebuild_template(self): + """Go through existing placeholders in scene and update them. + + This could not make sense for all plugin types so this is optional + logic for plugins. + + Note: + Logic is not importing the template again but using placeholders + that were already available. We should maybe change the method + name. + + Question: + Should this also handle subloops as it is possible that another + template is loaded during processing? + """ + + if not self.placeholder_plugins: + self.log.info("There are no placeholder plugins available.") + return + + placeholders = self.get_placeholders() + if not placeholders: + self.log.info("No placeholders were found.") + return + + for placeholder in placeholders: + plugin = placeholder.plugin + plugin.repopulate_placeholder(placeholder) + + self.clear_shared_populate_data() + + @abstractmethod + def import_template(self, template_path): + """ + Import template in current host. + + Should load the content of template into scene so + 'populate_scene_placeholders' can be started. + + Args: + template_path (str): Fullpath for current task and + host's template file. + """ + + pass + + def _prepare_placeholders(self, placeholders): + """Run preparation part for placeholders on plugins. + + Args: + placeholders (List[PlaceholderItem]): Placeholder items that will + be processed. + """ + + # Prepare placeholder items by plugin + plugins_by_identifier = {} + placeholders_by_plugin_id = collections.defaultdict(list) + for placeholder in placeholders: + plugin = placeholder.plugin + identifier = plugin.identifier + plugins_by_identifier[identifier] = plugin + placeholders_by_plugin_id[identifier].append(placeholder) + + # Plugin should prepare data for passed placeholders + for identifier, placeholders in placeholders_by_plugin_id.items(): + plugin = plugins_by_identifier[identifier] + plugin.prepare_placeholders(placeholders) + + def populate_scene_placeholders(self, level_limit=None): + """Find placeholders in scene using plugins and process them. + + This should happen after 'import_template'. + + Collect available placeholders from scene. All of them are processed + after that shared data are cleared. Placeholder items are collected + again and if there are any new the loop happens again. This is possible + to change with defying 'level_limit'. + + Placeholders are marked as processed so they're not re-processed. To + identify which placeholders were already processed is used + placeholder's 'scene_identifier'. + + Args: + level_limit (int): Level of loops that can happen. Default is 1000. + """ + + if not self.placeholder_plugins: + self.log.warning("There are no placeholder plugins available.") + return + + placeholders = self.get_placeholders() + if not placeholders: + self.log.warning("No placeholders were found.") + return + + # Avoid infinite loop + # - 1000 iterations of placeholders processing must be enough + if not level_limit: + level_limit = 1000 + + placeholder_by_scene_id = { + placeholder.scene_identifier: placeholder + for placeholder in placeholders + } + all_processed = len(placeholders) == 0 + # Counter is checked at the ned of a loop so the loop happens at least + # once. + iter_counter = 0 + while not all_processed: + filtered_placeholders = [] + for placeholder in placeholders: + if placeholder.finished: + continue + + if placeholder.in_progress: + self.log.warning(( + "Placeholder that should be processed" + " is already in progress." + )) + continue + filtered_placeholders.append(placeholder) + + self._prepare_placeholders(filtered_placeholders) + + for placeholder in filtered_placeholders: + placeholder.set_in_progress() + placeholder_plugin = placeholder.plugin + try: + placeholder_plugin.populate_placeholder(placeholder) + + except Exception as exc: + self.log.warning( + ( + "Failed to process placeholder {} with plugin {}" + ).format( + placeholder.scene_identifier, + placeholder_plugin.__class__.__name__ + ), + exc_info=True + ) + placeholder.set_failed(exc) + + placeholder.set_finished() + + # Clear shared data before getting new placeholders + self.clear_shared_populate_data() + + iter_counter += 1 + if iter_counter >= level_limit: + break + + all_processed = True + collected_placeholders = self.get_placeholders() + for placeholder in collected_placeholders: + identifier = placeholder.scene_identifier + if identifier in placeholder_by_scene_id: + continue + + all_processed = False + placeholder_by_scene_id[identifier] = placeholder + placeholders.append(placeholder) + + self.refresh() + + def _get_build_profiles(self): + """Get build profiles for workfile build template path. + + Returns: + List[Dict[str, Any]]: Profiles for template path resolving. + """ + + return ( + self.project_settings + [self.host_name] + ["templated_workfile_build"] + ["profiles"] + ) + + def get_template_path(self): + """Unified way how template path is received usign settings. + + Method is dependent on '_get_build_profiles' which should return filter + profiles to resolve path to a template. Default implementation looks + into host settings: + - 'project_settings/{host name}/templated_workfile_build/profiles' + + Returns: + str: Path to a template file with placeholders. + + Raises: + TemplateProfileNotFound: When profiles are not filled. + TemplateLoadFailed: Profile was found but path is not set. + TemplateNotFound: Path was set but file does not exists. + """ + + host_name = self.host_name + project_name = self.project_name + task_name = self.current_task_name + task_type = self.current_task_type + + build_profiles = self._get_build_profiles() + profile = filter_profiles( + build_profiles, + { + "task_types": task_type, + "task_names": task_name + } + ) + + if not profile: + raise TemplateProfileNotFound(( + "No matching profile found for task '{}' of type '{}' " + "with host '{}'" + ).format(task_name, task_type, host_name)) + + path = profile["path"] + if not path: + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) + + # Try fill path with environments and anatomy roots + anatomy = Anatomy(project_name) + fill_data = { + key: value + for key, value in os.environ.items() + } + fill_data["root"] = anatomy.roots + result = StringTemplate.format_template(path, fill_data) + if result.solved: + path = result.normalized() + + if path and os.path.exists(path): + self.log.info("Found template at: '{}'".format(path)) + return path + + solved_path = None + while True: + try: + solved_path = anatomy.path_remapper(path) + except KeyError as missing_key: + raise KeyError( + "Could not solve key '{}' in template path '{}'".format( + missing_key, path)) + + if solved_path is None: + solved_path = path + if solved_path == path: + break + path = solved_path + + solved_path = os.path.normpath(solved_path) + if not os.path.exists(solved_path): + raise TemplateNotFound( + "Template found in openPype settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, solved_path)) + + self.log.info("Found template at: '{}'".format(solved_path)) + + return solved_path + + +@six.add_metaclass(ABCMeta) +class PlaceholderPlugin(object): + """Plugin which care about handling of placeholder items logic. + + Plugin create and update placeholders in scene and populate them on + template import. Populating means that based on placeholder data happens + a logic in the scene. Most common logic is to load representation using + loaders or to create instances in scene. + """ + + label = None + _log = None + + def __init__(self, builder): + self._builder = builder + + @property + def builder(self): + """Access to builder which initialized the plugin. + + Returns: + AbstractTemplateBuilder: Loader of template build. + """ + + return self._builder + + @property + def project_name(self): + return self._builder.project_name + + @property + def log(self): + """Dynamically created logger for the plugin.""" + + if self._log is None: + self._log = Logger.get_logger(repr(self)) + return self._log + + @property + def identifier(self): + """Identifier which will be stored to placeholder. + + Default implementation uses class name. + + Returns: + str: Unique identifier of placeholder plugin. + """ + + return self.__class__.__name__ + + @abstractmethod + def create_placeholder(self, placeholder_data): + """Create new placeholder in scene and get it's item. + + It matters on the plugin implementation if placeholder will use + selection in scene or create new node. + + Args: + placeholder_data (Dict[str, Any]): Data that were created + based on attribute definitions from 'get_placeholder_options'. + + Returns: + PlaceholderItem: Created placeholder item. + """ + + pass + + @abstractmethod + def update_placeholder(self, placeholder_item, placeholder_data): + """Update placeholder item with new data. + + New data should be propagated to object of placeholder item itself + and also into the scene. + + Reason: + Some placeholder plugins may require some special way how the + updates should be propagated to object. + + Args: + placeholder_item (PlaceholderItem): Object of placeholder that + should be updated. + placeholder_data (Dict[str, Any]): Data related to placeholder. + Should match plugin options. + """ + + pass + + @abstractmethod + def collect_placeholders(self): + """Collect placeholders from scene. + + Returns: + List[PlaceholderItem]: Placeholder objects. + """ + + pass + + def get_placeholder_options(self, options=None): + """Placeholder options for data showed. + + Returns: + List[AbtractAttrDef]: Attribute definitions of placeholder options. + """ + + return [] + + def get_placeholder_keys(self): + """Get placeholder keys that are stored in scene. + + Returns: + Set[str]: Key of placeholder keys that are stored in scene. + """ + + option_keys = get_attributes_keys(self.get_placeholder_options()) + option_keys.add("plugin_identifier") + return option_keys + + def prepare_placeholders(self, placeholders): + """Preparation part of placeholders. + + Args: + placeholders (List[PlaceholderItem]): List of placeholders that + will be processed. + """ + + pass + + @abstractmethod + def populate_placeholder(self, placeholder): + """Process single placeholder item. + + Processing of placeholders is defined by their order thus can't be + processed in batch. + + Args: + placeholder (PlaceholderItem): Placeholder that should be + processed. + """ + + pass + + def repopulate_placeholder(self, placeholder): + """Update scene with current context for passed placeholder. + + Can be used to re-run placeholder logic (if it make sense). + """ + + pass + + def get_plugin_shared_data(self, key): + """Receive shared data across plugin and placeholders. + + Using shared data from builder but stored under plugin identifier. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + plugin_data = self.builder.get_shared_data(self.identifier) + if plugin_data is None: + return None + return plugin_data.get(key) + + def set_plugin_shared_data(self, key, value): + """Store share data across plugin and placeholders. + + Using shared data from builder but stored under plugin identifier. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + plugin_data = self.builder.get_shared_data(self.identifier) + if plugin_data is None: + plugin_data = {} + plugin_data[key] = value + self.builder.set_shared_data(self.identifier, plugin_data) + + def get_plugin_shared_populate_data(self, key): + """Receive shared data across plugin and placeholders. + + Using shared populate data from builder but stored under plugin + identifier. + + Shared populate data are cleaned up during populate while loop. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + plugin_data = self.builder.get_shared_populate_data(self.identifier) + if plugin_data is None: + return None + return plugin_data.get(key) + + def set_plugin_shared_populate_data(self, key, value): + """Store share data across plugin and placeholders. + + Using shared data from builder but stored under plugin identifier. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Shared populate data are cleaned up during populate while loop. + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + plugin_data = self.builder.get_shared_populate_data(self.identifier) + if plugin_data is None: + plugin_data = {} + plugin_data[key] = value + self.builder.set_shared_populate_data(self.identifier, plugin_data) + + +class PlaceholderItem(object): + """Item representing single item in scene that is a placeholder to process. + + Items are always created and updated by their plugins. Each plugin can use + modified class of 'PlacehoderItem' but only to add more options instead of + new other. + + Scene identifier is used to avoid processing of the palceholder item + multiple times so must be unique across whole workfile builder. + + Args: + scene_identifier (str): Unique scene identifier. If placeholder is + created from the same "node" it must have same identifier. + data (Dict[str, Any]): Data related to placeholder. They're defined + by plugin. + plugin (PlaceholderPlugin): Plugin which created the placeholder item. + """ + + default_order = 100 + + def __init__(self, scene_identifier, data, plugin): + self._log = None + self._scene_identifier = scene_identifier + self._data = data + self._plugin = plugin + + # Keep track about state of Placeholder process + self._state = 0 + + # Error messages to be shown in UI + # - all other messages should be logged + self._errors = [] # -> List[str] + + @property + def plugin(self): + """Access to plugin which created placeholder. + + Returns: + PlaceholderPlugin: Plugin object. + """ + + return self._plugin + + @property + def builder(self): + """Access to builder. + + Returns: + AbstractTemplateBuilder: Builder which is the top part of + placeholder. + """ + + return self.plugin.builder + + @property + def data(self): + """Placeholder data which can modify how placeholder is processed. + + Possible general keys + - order: Can define the order in which is palceholder processed. + Lower == earlier. + + Other keys are defined by placeholder and should validate them on item + creation. + + Returns: + Dict[str, Any]: Placeholder item data. + """ + + return self._data + + def to_dict(self): + """Create copy of item's data. + + Returns: + Dict[str, Any]: Placeholder data. + """ + + return copy.deepcopy(self.data) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(repr(self)) + return self._log + + def __repr__(self): + return "< {} {} >".format(self.__class__.__name__, self.name) + + @property + def order(self): + """Order of item processing.""" + + order = self._data.get("order") + if order is None: + return self.default_order + return order + + @property + def scene_identifier(self): + return self._scene_identifier + + @property + def finished(self): + """Item was already processed.""" + + return self._state == 2 + + @property + def in_progress(self): + """Processing is in progress.""" + + return self._state == 1 + + def set_in_progress(self): + """Change to in progress state.""" + + self._state = 1 + + def set_finished(self): + """Change to finished state.""" + + self._state = 2 + + def set_failed(self, exception): + self.add_error(str(exception)) + + def add_error(self, error): + """Set placeholder item as failed and mark it as finished.""" + + self._errors.append(error) + + def get_errors(self): + """Exception with which the placeholder process failed. + + Gives ability to access the exception. + """ + + return self._errors + + +class PlaceholderLoadMixin(object): + """Mixin prepared for loading placeholder plugins. + + Implementation prepares options for placeholders with + 'get_load_plugin_options'. + + For placeholder population is implemented 'populate_load_placeholder'. + + PlaceholderItem can have implemented methods: + - 'load_failed' - called when loading of one representation failed + - 'load_succeed' - called when loading of one representation succeeded + """ + + def get_load_plugin_options(self, options=None): + """Unified attribute definitions for load placeholder. + + Common function for placeholder plugins used for loading of + repsentations. Use it in 'get_placeholder_options'. + + Args: + plugin (PlaceholderPlugin): Plugin used for loading of + representations. + options (Dict[str, Any]): Already available options which are used + as defaults for attributes. + + Returns: + List[AbtractAttrDef]: Attribute definitions common for load + plugins. + """ + + loaders_by_name = self.builder.get_loaders_by_name() + loader_items = [ + (loader_name, loader.label or loader_name) + for loader_name, loader in loaders_by_name.items() + ] + + loader_items = list(sorted(loader_items, key=lambda i: i[1])) + options = options or {} + return [ + attribute_definitions.UISeparatorDef(), + attribute_definitions.UILabelDef("Main attributes"), + attribute_definitions.UISeparatorDef(), + + attribute_definitions.EnumDef( + "builder_type", + label="Asset Builder Type", + default=options.get("builder_type"), + items=[ + ("context_asset", "Current asset"), + ("linked_asset", "Linked assets"), + ("all_assets", "All assets") + ], + tooltip=( + "Asset Builder Type\n" + "\nBuilder type describe what template loader will look" + " for." + "\ncontext_asset : Template loader will look for subsets" + " of current context asset (Asset bob will find asset)" + "\nlinked_asset : Template loader will look for assets" + " linked to current context asset." + "\nLinked asset are looked in database under" + " field \"inputLinks\"" + ) + ), + attribute_definitions.TextDef( + "family", + label="Family", + default=options.get("family"), + placeholder="model, look, ..." + ), + attribute_definitions.TextDef( + "representation", + label="Representation name", + default=options.get("representation"), + placeholder="ma, abc, ..." + ), + attribute_definitions.EnumDef( + "loader", + label="Loader", + default=options.get("loader"), + items=loader_items, + tooltip=( + "Loader" + "\nDefines what OpenPype loader will be used to" + " load assets." + "\nUseable loader depends on current host's loader list." + "\nField is case sensitive." + ) + ), + attribute_definitions.TextDef( + "loader_args", + label="Loader Arguments", + default=options.get("loader_args"), + placeholder='{"camera":"persp", "lights":True}', + tooltip=( + "Loader" + "\nDefines a dictionnary of arguments used to load assets." + "\nUseable arguments depend on current placeholder Loader." + "\nField should be a valid python dict." + " Anything else will be ignored." + ) + ), + attribute_definitions.NumberDef( + "order", + label="Order", + default=options.get("order") or 0, + decimals=0, + minimum=0, + maximum=999, + tooltip=( + "Order" + "\nOrder defines asset loading priority (0 to 999)" + "\nPriority rule is : \"lowest is first to load\"." + ) + ), + attribute_definitions.UISeparatorDef(), + attribute_definitions.UILabelDef("Optional attributes"), + attribute_definitions.UISeparatorDef(), + attribute_definitions.TextDef( + "asset", + label="Asset filter", + default=options.get("asset"), + placeholder="regex filtering by asset name", + tooltip=( + "Filtering assets by matching field regex to asset's name" + ) + ), + attribute_definitions.TextDef( + "subset", + label="Subset filter", + default=options.get("subset"), + placeholder="regex filtering by subset name", + tooltip=( + "Filtering assets by matching field regex to subset's name" + ) + ), + attribute_definitions.TextDef( + "hierarchy", + label="Hierarchy filter", + default=options.get("hierarchy"), + placeholder="regex filtering by asset's hierarchy", + tooltip=( + "Filtering assets by matching field asset's hierarchy" + ) + ) + ] + + def parse_loader_args(self, loader_args): + """Helper function to parse string of loader arugments. + + Empty dictionary is returned if conversion fails. + + Args: + loader_args (str): Loader args filled by user. + + Returns: + Dict[str, Any]: Parsed arguments used as dictionary. + """ + + if not loader_args: + return {} + + try: + parsed_args = eval(loader_args) + if isinstance(parsed_args, dict): + return parsed_args + + except Exception as err: + print( + "Error while parsing loader arguments '{}'.\n{}: {}\n\n" + "Continuing with default arguments. . .".format( + loader_args, err.__class__.__name__, err)) + + return {} + + def _get_representations(self, placeholder): + """Prepared query of representations based on load options. + + This function is directly connected to options defined in + 'get_load_plugin_options'. + + Note: + This returns all representation documents from all versions of + matching subset. To filter for last version use + '_reduce_last_version_repre_docs'. + + Args: + placeholder (PlaceholderItem): Item which should be populated. + + Returns: + List[Dict[str, Any]]: Representation documents matching filters + from placeholder data. + """ + + project_name = self.builder.project_name + current_asset_doc = self.builder.current_asset_doc + linked_asset_docs = self.builder.linked_asset_docs + + builder_type = placeholder.data["builder_type"] + if builder_type == "context_asset": + context_filters = { + "asset": [current_asset_doc["name"]], + "subset": [re.compile(placeholder.data["subset"])], + "hierarchy": [re.compile(placeholder.data["hierarchy"])], + "representation": [placeholder.data["representation"]], + "family": [placeholder.data["family"]] + } + + elif builder_type != "linked_asset": + context_filters = { + "asset": [re.compile(placeholder.data["asset"])], + "subset": [re.compile(placeholder.data["subset"])], + "hierarchy": [re.compile(placeholder.data["hierarchy"])], + "representation": [placeholder.data["representation"]], + "family": [placeholder.data["family"]] + } + + else: + asset_regex = re.compile(placeholder.data["asset"]) + linked_asset_names = [] + for asset_doc in linked_asset_docs: + asset_name = asset_doc["name"] + if asset_regex.match(asset_name): + linked_asset_names.append(asset_name) + + context_filters = { + "asset": linked_asset_names, + "subset": [re.compile(placeholder.data["subset"])], + "hierarchy": [re.compile(placeholder.data["hierarchy"])], + "representation": [placeholder.data["representation"]], + "family": [placeholder.data["family"]], + } + + return list(get_representations( + project_name, + context_filters=context_filters + )) + + def _before_repre_load(self, placeholder, representation): + """Can be overriden. Is called before representation is loaded.""" + + pass + + def _reduce_last_version_repre_docs(self, representations): + """Reduce representations to last verison.""" + + mapping = {} + for repre_doc in representations: + repre_context = repre_doc["context"] + + asset_name = repre_context["asset"] + subset_name = repre_context["subset"] + version = repre_context.get("version", -1) + + if asset_name not in mapping: + mapping[asset_name] = {} + + subset_mapping = mapping[asset_name] + if subset_name not in subset_mapping: + subset_mapping[subset_name] = collections.defaultdict(list) + + version_mapping = subset_mapping[subset_name] + version_mapping[version].append(repre_doc) + + output = [] + for subset_mapping in mapping.values(): + for version_mapping in subset_mapping.values(): + last_version = tuple(sorted(version_mapping.keys()))[-1] + output.extend(version_mapping[last_version]) + return output + + def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): + """Load placeholder is goind to load matching representations. + + Note: + Ignore repre ids is to avoid loading the same representation again + on load. But the representation can be loaded with different loader + and there could be published new version of matching subset for the + representation. We should maybe expect containers. + + Also import loaders don't have containers at all... + + Args: + placeholder (PlaceholderItem): Placeholder item with information + about requested representations. + ignore_repre_ids (Iterable[Union[str, ObjectId]]): Representation + ids that should be skipped. + """ + + if ignore_repre_ids is None: + ignore_repre_ids = set() + + # TODO check loader existence + loader_name = placeholder.data["loader"] + loader_args = placeholder.data["loader_args"] + + placeholder_representations = self._get_representations(placeholder) + + filtered_representations = [] + for representation in self._reduce_last_version_repre_docs( + placeholder_representations + ): + repre_id = str(representation["_id"]) + if repre_id not in ignore_repre_ids: + filtered_representations.append(representation) + + if not filtered_representations: + self.log.info(( + "There's no representation for this placeholder: {}" + ).format(placeholder.scene_identifier)) + return + + repre_load_contexts = get_contexts_for_repre_docs( + self.project_name, filtered_representations + ) + loaders_by_name = self.builder.get_loaders_by_name() + for repre_load_context in repre_load_contexts.values(): + representation = repre_load_context["representation"] + repre_context = representation["context"] + self._before_repre_load( + placeholder, representation + ) + self.log.info( + "Loading {} from {} with loader {}\n" + "Loader arguments used : {}".format( + repre_context["subset"], + repre_context["asset"], + loader_name, + loader_args + ) + ) + try: + container = load_with_repre_context( + loaders_by_name[loader_name], + repre_load_context, + options=self.parse_loader_args(loader_args) + ) + + except Exception: + failed = True + self.load_failed(placeholder, representation) + + else: + failed = False + self.load_succeed(placeholder, container) + self.cleanup_placeholder(placeholder, failed) + + def load_failed(self, placeholder, representation): + if hasattr(placeholder, "load_failed"): + placeholder.load_failed(representation) + + def load_succeed(self, placeholder, container): + if hasattr(placeholder, "load_succeed"): + placeholder.load_succeed(container) + + def cleanup_placeholder(self, placeholder, failed): + """Cleanup placeholder after load of single representation. + + Can be called multiple times during placeholder item populating and is + called even if loading failed. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. + """ + + pass + + +class LoadPlaceholderItem(PlaceholderItem): + """PlaceholderItem for plugin which is loading representations. + + Connected to 'PlaceholderLoadMixin'. + """ + + def __init__(self, *args, **kwargs): + super(LoadPlaceholderItem, self).__init__(*args, **kwargs) + self._failed_representations = [] + + def get_errors(self): + if not self._failed_representations: + return [] + message = ( + "Failed to load {} representations using Loader {}" + ).format( + len(self._failed_representations), + self.data["loader"] + ) + return [message] + + def load_failed(self, representation): + self._failed_representations.append(representation) diff --git a/openpype/tools/workfile_template_build/__init__.py b/openpype/tools/workfile_template_build/__init__.py new file mode 100644 index 0000000000..82a22aea50 --- /dev/null +++ b/openpype/tools/workfile_template_build/__init__.py @@ -0,0 +1,5 @@ +from .window import WorkfileBuildPlaceholderDialog + +__all__ = ( + "WorkfileBuildPlaceholderDialog", +) diff --git a/openpype/tools/workfile_template_build/window.py b/openpype/tools/workfile_template_build/window.py new file mode 100644 index 0000000000..ea4e2fec5a --- /dev/null +++ b/openpype/tools/workfile_template_build/window.py @@ -0,0 +1,242 @@ +from Qt import QtWidgets + +from openpype import style +from openpype.lib import Logger +from openpype.pipeline import legacy_io +from openpype.widgets.attribute_defs import AttributeDefinitionsWidget + + +class WorkfileBuildPlaceholderDialog(QtWidgets.QDialog): + def __init__(self, host, builder, parent=None): + super(WorkfileBuildPlaceholderDialog, self).__init__(parent) + self.setWindowTitle("Workfile Placeholder Manager") + + self._log = None + + self._first_show = True + self._first_refreshed = False + + self._builder = builder + self._host = host + # Mode can be 0 (create) or 1 (update) + # TODO write it a little bit better + self._mode = 0 + self._update_item = None + self._last_selected_plugin = None + + host_name = getattr(self._host, "name", None) + if not host_name: + host_name = legacy_io.Session.get("AVALON_APP") or "NA" + self._host_name = host_name + + plugins_combo = QtWidgets.QComboBox(self) + + content_widget = QtWidgets.QWidget(self) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + btns_widget = QtWidgets.QWidget(self) + create_btn = QtWidgets.QPushButton("Create", btns_widget) + save_btn = QtWidgets.QPushButton("Save", btns_widget) + close_btn = QtWidgets.QPushButton("Close", btns_widget) + + create_btn.setVisible(False) + save_btn.setVisible(False) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addStretch(1) + btns_layout.addWidget(create_btn, 0) + btns_layout.addWidget(save_btn, 0) + btns_layout.addWidget(close_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(plugins_combo, 0) + main_layout.addWidget(content_widget, 1) + main_layout.addWidget(btns_widget, 0) + + create_btn.clicked.connect(self._on_create_click) + save_btn.clicked.connect(self._on_save_click) + close_btn.clicked.connect(self._on_close_click) + plugins_combo.currentIndexChanged.connect(self._on_plugin_change) + + self._attr_defs_widget = None + self._plugins_combo = plugins_combo + + self._content_widget = content_widget + self._content_layout = content_layout + + self._create_btn = create_btn + self._save_btn = save_btn + self._close_btn = close_btn + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def _clear_content_widget(self): + while self._content_layout.count() > 0: + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + + def _add_message_to_content(self, message): + msg_label = QtWidgets.QLabel(message, self._content_widget) + self._content_layout.addWidget(msg_label, 0) + self._content_layout.addStretch(1) + + def refresh(self): + self._first_refreshed = True + + self._clear_content_widget() + + if not self._builder: + self._add_message_to_content(( + "Host \"{}\" does not have implemented logic" + " for template workfile build." + ).format(self._host_name)) + self._update_ui_visibility() + return + + placeholder_plugins = self._builder.placeholder_plugins + + if self._mode == 1: + self._last_selected_plugin + plugin = self._builder.placeholder_plugins.get( + self._last_selected_plugin + ) + self._create_option_widgets( + plugin, self._update_item.to_dict() + ) + self._update_ui_visibility() + return + + if not placeholder_plugins: + self._add_message_to_content(( + "Host \"{}\" does not have implemented plugins" + " for template workfile build." + ).format(self._host_name)) + self._update_ui_visibility() + return + + last_selected_plugin = self._last_selected_plugin + self._last_selected_plugin = None + self._plugins_combo.clear() + for identifier, plugin in placeholder_plugins.items(): + label = plugin.label or identifier + self._plugins_combo.addItem(label, identifier) + + index = self._plugins_combo.findData(last_selected_plugin) + if index < 0: + index = 0 + self._plugins_combo.setCurrentIndex(index) + self._on_plugin_change() + + self._update_ui_visibility() + + def set_create_mode(self): + if self._mode == 0: + return + + self._mode = 0 + self._update_item = None + self.refresh() + + def set_update_mode(self, update_item): + if self._mode == 1: + return + + self._mode = 1 + self._update_item = update_item + if update_item: + self._last_selected_plugin = update_item.plugin.identifier + self.refresh() + return + + self._clear_content_widget() + self._add_message_to_content(( + "Nothing to update." + " (You maybe don't have selected placeholder.)" + )) + self._update_ui_visibility() + + def _create_option_widgets(self, plugin, options=None): + self._clear_content_widget() + attr_defs = plugin.get_placeholder_options(options) + widget = AttributeDefinitionsWidget(attr_defs, self._content_widget) + self._content_layout.addWidget(widget, 0) + self._content_layout.addStretch(1) + self._attr_defs_widget = widget + self._last_selected_plugin = plugin.identifier + + def _update_ui_visibility(self): + create_mode = self._mode == 0 + self._plugins_combo.setVisible(create_mode) + + if not self._builder: + self._save_btn.setVisible(False) + self._create_btn.setVisible(False) + return + + save_enabled = not create_mode + if save_enabled: + save_enabled = self._update_item is not None + self._save_btn.setVisible(save_enabled) + self._create_btn.setVisible(create_mode) + + def _on_plugin_change(self): + index = self._plugins_combo.currentIndex() + plugin_identifier = self._plugins_combo.itemData(index) + if plugin_identifier == self._last_selected_plugin: + return + + plugin = self._builder.placeholder_plugins.get(plugin_identifier) + self._create_option_widgets(plugin) + + def _on_save_click(self): + options = self._attr_defs_widget.current_value() + plugin = self._builder.placeholder_plugins.get( + self._last_selected_plugin + ) + # TODO much better error handling + try: + plugin.update_placeholder(self._update_item, options) + self.accept() + except Exception: + self.log.warning("Something went wrong", exc_info=True) + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Something went wrong") + dialog.setText("Something went wrong") + dialog.exec_() + + def _on_create_click(self): + options = self._attr_defs_widget.current_value() + plugin = self._builder.placeholder_plugins.get( + self._last_selected_plugin + ) + # TODO much better error handling + try: + plugin.create_placeholder(options) + self.accept() + except Exception: + self.log.warning("Something went wrong", exc_info=True) + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Something went wrong") + dialog.setText("Something went wrong") + dialog.exec_() + + def _on_close_click(self): + self.reject() + + def showEvent(self, event): + super(WorkfileBuildPlaceholderDialog, self).showEvent(event) + if not self._first_refreshed: + self.refresh() + + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + self.resize(390, 450) diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 60ae952553..dc697b08a6 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -108,10 +108,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): row = 0 for attr_def in attr_defs: - if attr_def.key in self._current_keys: - raise KeyError("Duplicated key \"{}\"".format(attr_def.key)) + if not isinstance(attr_def, UIDef): + if attr_def.key in self._current_keys: + raise KeyError( + "Duplicated key \"{}\"".format(attr_def.key)) - self._current_keys.add(attr_def.key) + self._current_keys.add(attr_def.key) widget = create_widget_for_attr_def(attr_def, self) expand_cols = 2