From 68d3f9ec3e53ba2f7c4c1efbfa2609f0903fe9ec Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 2 Feb 2022 18:25:38 +0100 Subject: [PATCH 01/38] fix imports --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 465dab2a76..61be634a42 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,6 +1,6 @@ from avalon.maya import lib from avalon import api -from openpype.api import config +from openpype.api import get_project_settings import os import maya.cmds as cmds @@ -19,7 +19,7 @@ class VRaySceneLoader(api.Loader): def load(self, context, name, namespace, data): from avalon.maya.pipeline import containerise - from openpype.hosts.maya.lib import namespaced + from openpype.hosts.maya.api.lib import namespaced try: family = context["representation"]["context"]["family"] @@ -47,8 +47,8 @@ class VRaySceneLoader(api.Loader): return # colour the group node - presets = config.get_presets(project=os.environ['AVALON_PROJECT']) - colors = presets['plugins']['maya']['load']['colors'] + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) From 6fd4e7cf381e2e3601c256d1fd62787ed23a0ae7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:12:56 +0100 Subject: [PATCH 02/38] Lock shape nodes --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 61be634a42..6cad4f3e1e 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -131,6 +131,10 @@ class VRaySceneLoader(api.Loader): cmds.setAttr("{}.FilePath".format(vray_scene), filename, type="string") + # Lock the shape nodes so the user cannot delete these + cmds.lockNode(mesh, lock=True) + cmds.lockNode(vray_scene, lock=True) + # Create important connections cmds.connectAttr("time1.outTime", "{0}.inputTime".format(trans)) From ed2908353d3a1b1bdbf242167f48abcc88bc745f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:21:03 +0100 Subject: [PATCH 03/38] Don't create redundant extra group --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 6cad4f3e1e..40d7bd6403 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -39,8 +39,8 @@ class VRaySceneLoader(api.Loader): with lib.maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): - nodes, group_node = self.create_vray_scene(name, - filename=self.fname) + nodes, root_node = self.create_vray_scene(name, + filename=self.fname) self[:] = nodes if not nodes: @@ -51,8 +51,8 @@ class VRaySceneLoader(api.Loader): colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: - cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) - cmds.setAttr("{0}.outlinerColor".format(group_node), + cmds.setAttr("{0}.useOutlinerColor".format(root_node), 1) + cmds.setAttr("{0}.outlinerColor".format(root_node), (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) @@ -142,11 +142,9 @@ class VRaySceneLoader(api.Loader): # Connect mesh to initialShadingGroup cmds.sets([mesh], forceElement="initialShadingGroup") - group_node = cmds.group(empty=True, name="{}_GRP".format(name)) - cmds.parent(trans, group_node) - nodes = [trans, vray_scene, mesh, group_node] + nodes = [trans, vray_scene, mesh] # Fix: Force refresh so the mesh shows correctly after creation cmds.refresh() - return nodes, group_node + return nodes, trans From b0aa53b52c31f3552f16680c4177b539414c8ee7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:21:42 +0100 Subject: [PATCH 04/38] Remove redundant string format --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 40d7bd6403..3c0edac9a8 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -120,7 +120,7 @@ class VRaySceneLoader(api.Loader): mesh_node_name = "VRayScene_{}".format(name) trans = cmds.createNode( - "transform", name="{}".format(mesh_node_name)) + "transform", name=mesh_node_name) mesh = cmds.createNode( "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) vray_scene = cmds.createNode( From e7e8235be1dab0aecdc0295903e9025fab0acb6b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:28:29 +0100 Subject: [PATCH 05/38] Add V-Ray Scene to Maya Loaded Subsets Outliner Colors settings --- openpype/settings/defaults/project_settings/maya.json | 6 ++++++ .../schemas/projects_schema/schemas/schema_maya_load.json | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 52b8db058c..2712aeb1b2 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -575,6 +575,12 @@ 12, 255 ], + "vrayscene_layer": [ + 255, + 150, + 12, + 255 + ], "yeticache": [ 99, 206, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 7c87644817..6b2315abc0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -75,6 +75,11 @@ "label": "Vray Proxy:", "key": "vrayproxy" }, + { + "type": "color", + "label": "Vray Scene:", + "key": "vrayscene_layer" + }, { "type": "color", "label": "Yeti Cache:", From 5269510ed9707d40b7b740770e5e61babd3fe116 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:38:09 +0100 Subject: [PATCH 06/38] Create and parent the V-Ray Scene first to transform so that outliner shows VRayScene icon on transform --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 3c0edac9a8..5a67ab859d 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -121,10 +121,10 @@ class VRaySceneLoader(api.Loader): trans = cmds.createNode( "transform", name=mesh_node_name) - mesh = cmds.createNode( - "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) vray_scene = cmds.createNode( "VRayScene", name="{}_VRSCN".format(mesh_node_name), parent=trans) + mesh = cmds.createNode( + "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) cmds.connectAttr( "{}.outMesh".format(vray_scene), "{}.inMesh".format(mesh)) From a9506a14806fe92b1d86bb68a3cd84a5749b4141 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:31:31 +0100 Subject: [PATCH 07/38] extracted template formatting logic from anatomy --- openpype/lib/path_templates.py | 745 +++++++++++++++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 openpype/lib/path_templates.py diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py new file mode 100644 index 0000000000..6f68cc4ce9 --- /dev/null +++ b/openpype/lib/path_templates.py @@ -0,0 +1,745 @@ +import os +import re +import copy +import numbers +import collections + +import six + +from .log import PypeLogger + +log = PypeLogger.get_logger(__name__) + + +KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})") +KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+") +SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") +OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") + + +def merge_dict(main_dict, enhance_dict): + """Merges dictionaries by keys. + + Function call itself if value on key is again dictionary. + + Args: + main_dict (dict): First dict to merge second one into. + enhance_dict (dict): Second dict to be merged. + + Returns: + dict: Merged result. + + .. note:: does not overrides whole value on first found key + but only values differences from enhance_dict + + """ + for key, value in enhance_dict.items(): + if key not in main_dict: + main_dict[key] = value + elif isinstance(value, dict) and isinstance(main_dict[key], dict): + main_dict[key] = merge_dict(main_dict[key], value) + else: + main_dict[key] = value + return main_dict + + +class TemplateMissingKey(Exception): + """Exception for cases when key does not exist in template.""" + + msg = "Template key does not exist: `{}`." + + def __init__(self, parents): + parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) + super(TemplateMissingKey, self).__init__( + self.msg.format(parent_join) + ) + + +class TemplateUnsolved(Exception): + """Exception for unsolved template when strict is set to True.""" + + msg = "Template \"{0}\" is unsolved.{1}{2}" + invalid_types_msg = " Keys with invalid DataType: `{0}`." + missing_keys_msg = " Missing keys: \"{0}\"." + + def __init__(self, template, missing_keys, invalid_types): + invalid_type_items = [] + for _key, _type in invalid_types.items(): + invalid_type_items.append( + "\"{0}\" {1}".format(_key, str(_type)) + ) + + invalid_types_msg = "" + if invalid_type_items: + invalid_types_msg = self.invalid_types_msg.format( + ", ".join(invalid_type_items) + ) + + missing_keys_msg = "" + if missing_keys: + missing_keys_msg = self.missing_keys_msg.format( + ", ".join(missing_keys) + ) + super(TemplateUnsolved, self).__init__( + self.msg.format(template, missing_keys_msg, invalid_types_msg) + ) + + +class StringTemplate(object): + """String that can be formatted.""" + def __init__(self, template): + if not isinstance(template, six.string_types): + raise TypeError("<{}> argument must be a string, not {}.".format( + self.__class__.__name__, str(type(template)) + )) + + self._template = template + parts = [] + last_end_idx = 0 + for item in KEY_PATTERN.finditer(template): + start, end = item.span() + if start > last_end_idx: + parts.append(template[last_end_idx:start]) + parts.append(FormattingPart(template[start:end])) + last_end_idx = end + + if last_end_idx < len(template): + parts.append(template[last_end_idx:len(template)]) + + new_parts = [] + for part in parts: + if not isinstance(part, six.string_types): + new_parts.append(part) + continue + + substr = "" + for char in part: + if char not in ("<", ">"): + substr += char + else: + if substr: + new_parts.append(substr) + new_parts.append(char) + substr = "" + if substr: + new_parts.append(substr) + + self._parts = self.find_optional_parts(new_parts) + + @property + def template(self): + return self._template + + def format(self, data): + """ Figure out with whole formatting. + + Separate advanced keys (*Like '{project[name]}') from string which must + be formatted separatelly in case of missing or incomplete keys in data. + + Args: + data (dict): Containing keys to be filled into template. + + Returns: + TemplateResult: Filled or partially filled template containing all + data needed or missing for filling template. + """ + result = TemplatePartResult() + for part in self._parts: + if isinstance(part, six.string_types): + result.add_output(part) + else: + part.format(data, result) + + invalid_types = result.invalid_types + invalid_types.update(result.invalid_optional_types) + invalid_types = result.split_keys_to_subdicts(invalid_types) + + missing_keys = result.missing_keys + missing_keys |= result.missing_optional_keys + + solved = result.solved + used_values = result.split_keys_to_subdicts(result.used_values) + + return TemplateResult( + result.output, + self.template, + solved, + used_values, + missing_keys, + invalid_types + ) + + def format_strict(self, *args, **kwargs): + result = self.format(*args, **kwargs) + result.validate() + return result + + @staticmethod + def find_optional_parts(parts): + new_parts = [] + tmp_parts = {} + counted_symb = -1 + for part in parts: + if part == "<": + counted_symb += 1 + tmp_parts[counted_symb] = [] + + elif part == ">": + if counted_symb > -1: + parts = tmp_parts.pop(counted_symb) + counted_symb -= 1 + if parts: + # Remove optional start char + parts.pop(0) + if counted_symb < 0: + out_parts = new_parts + else: + out_parts = tmp_parts[counted_symb] + # Store temp parts + out_parts.append(OptionalPart(parts)) + continue + + if counted_symb < 0: + new_parts.append(part) + else: + tmp_parts[counted_symb].append(part) + + if tmp_parts: + for idx in sorted(tmp_parts.keys()): + new_parts.extend(tmp_parts[idx]) + return new_parts + + +class TemplatesDict(object): + def __init__(self, templates=None): + self._raw_templates = None + self._templates = None + self.set_templates(templates) + + def set_templates(self, templates): + if templates is None: + self._raw_templates = None + self._templates = None + elif isinstance(templates, dict): + self._raw_templates = copy.deepcopy(templates) + self._templates = self.create_ojected_templates(templates) + else: + raise TypeError("<{}> argument must be a dict, not {}.".format( + self.__class__.__name__, str(type(templates)) + )) + + def __getitem__(self, key): + return self.templates[key] + + def get(self, key, *args, **kwargs): + return self.templates.get(key, *args, **kwargs) + + @property + def raw_templates(self): + return self._raw_templates + + @property + def templates(self): + return self._templates + + @classmethod + def create_ojected_templates(cls, templates): + if not isinstance(templates, dict): + raise TypeError("Expected dict object, got {}".format( + str(type(templates)) + )) + + objected_templates = copy.deepcopy(templates) + inner_queue = collections.deque() + inner_queue.append(objected_templates) + while inner_queue: + item = inner_queue.popleft() + if not isinstance(item, dict): + continue + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, six.string_types): + item[key] = StringTemplate(value) + elif isinstance(value, dict): + inner_queue.append(value) + return objected_templates + + def _format_value(self, value, data): + if isinstance(value, StringTemplate): + return value.format(data) + + if isinstance(value, dict): + return self._solve_dict(value, data) + return value + + def _solve_dict(self, templates, data): + """ Solves templates with entered data. + + Args: + templates (dict): All templates which will be formatted. + data (dict): Containing keys to be filled into template. + + Returns: + dict: With `TemplateResult` in values containing filled or + partially filled templates. + """ + output = collections.defaultdict(dict) + for key, value in templates.items(): + output[key] = self._format_value(value, data) + + return output + + def format(self, in_data, only_keys=True, strict=True): + """ Solves templates based on entered data. + + Args: + data (dict): Containing keys to be filled into template. + only_keys (bool, optional): Decides if environ will be used to + fill templates or only keys in data. + + Returns: + TemplatesResultDict: Output `TemplateResult` have `strict` + attribute set to True so accessing unfilled keys in templates + will raise exceptions with explaned error. + """ + # Create a copy of inserted data + data = copy.deepcopy(in_data) + + # Add environment variable to data + if only_keys is False: + for key, val in os.environ.items(): + env_key = "$" + key + if env_key not in data: + data[env_key] = val + + solved = self._solve_dict(self.templates, data) + + output = TemplatesResultDict(solved) + output.strict = strict + return output + + +class TemplateResult(str): + """Result of template format with most of information in. + + Args: + used_values (dict): Dictionary of template filling data with + only used keys. + solved (bool): For check if all required keys were filled. + template (str): Original template. + missing_keys (list): Missing keys that were not in the data. Include + missing optional keys. + invalid_types (dict): When key was found in data, but value had not + allowed DataType. Allowed data types are `numbers`, + `str`(`basestring`) and `dict`. Dictionary may cause invalid type + when value of key in data is dictionary but template expect string + of number. + """ + used_values = None + solved = None + template = None + missing_keys = None + invalid_types = None + + def __new__( + cls, filled_template, template, solved, + used_values, missing_keys, invalid_types + ): + new_obj = super(TemplateResult, cls).__new__(cls, filled_template) + new_obj.used_values = used_values + new_obj.solved = solved + new_obj.template = template + new_obj.missing_keys = list(set(missing_keys)) + new_obj.invalid_types = invalid_types + return new_obj + + def validate(self): + if not self.solved: + raise TemplateUnsolved( + self.template, + self.missing_keys, + self.invalid_types + ) + + +class TemplatesResultDict(dict): + """Holds and wrap TemplateResults for easy bug report.""" + + def __init__(self, in_data, key=None, parent=None, strict=None): + super(TemplatesResultDict, self).__init__() + for _key, _value in in_data.items(): + if isinstance(_value, dict): + _value = self.__class__(_value, _key, self) + self[_key] = _value + + self.key = key + self.parent = parent + self.strict = strict + if self.parent is None and strict is None: + self.strict = True + + def __getitem__(self, key): + if key not in self.keys(): + hier = self.hierarchy() + hier.append(key) + raise TemplateMissingKey(hier) + + value = super(TemplatesResultDict, self).__getitem__(key) + if isinstance(value, self.__class__): + return value + + # Raise exception when expected solved templates and it is not. + if self.raise_on_unsolved and hasattr(value, "validate"): + value.validate() + return value + + @property + def raise_on_unsolved(self): + """To affect this change `strict` attribute.""" + if self.strict is not None: + return self.strict + return self.parent.raise_on_unsolved + + def hierarchy(self): + """Return dictionary keys one by one to root parent.""" + if self.parent is None: + return [] + + hier_keys = [] + par_hier = self.parent.hierarchy() + if par_hier: + hier_keys.extend(par_hier) + hier_keys.append(self.key) + + return hier_keys + + @property + def missing_keys(self): + """Return missing keys of all children templates.""" + missing_keys = set() + for value in self.values(): + missing_keys |= value.missing_keys + return missing_keys + + @property + def invalid_types(self): + """Return invalid types of all children templates.""" + invalid_types = {} + for value in self.values(): + invalid_types = merge_dict(invalid_types, value.invalid_types) + return invalid_types + + @property + def used_values(self): + """Return used values for all children templates.""" + used_values = {} + for value in self.values(): + used_values = merge_dict(used_values, value.used_values) + return used_values + + def get_solved(self): + """Get only solved key from templates.""" + result = {} + for key, value in self.items(): + if isinstance(value, self.__class__): + value = value.get_solved() + if not value: + continue + result[key] = value + + elif ( + not hasattr(value, "solved") or + value.solved + ): + result[key] = value + return self.__class__(result, key=self.key, parent=self.parent) + + +class TemplatePartResult: + """Result to store result of template parts.""" + def __init__(self, optional=False): + # Missing keys or invalid value types of required keys + self._missing_keys = set() + self._invalid_types = {} + # Missing keys or invalid value types of optional keys + self._missing_optional_keys = set() + self._invalid_optional_types = {} + + # Used values stored by key + # - key without any padding or key modifiers + # - value from filling data + # Example: {"version": 1} + self._used_values = {} + # Used values stored by key with all modifirs + # - value is already formatted string + # Example: {"version:0>3": "001"} + self._realy_used_values = {} + # Concatenated string output after formatting + self._output = "" + # Is this result from optional part + self._optional = True + + def add_output(self, other): + if isinstance(other, six.string_types): + self._output += other + + elif isinstance(other, TemplatePartResult): + self._output += other.output + + self._missing_keys |= other.missing_keys + self._missing_optional_keys |= other.missing_optional_keys + + self._invalid_types.update(other.invalid_types) + self._invalid_optional_types.update(other.invalid_optional_types) + + if other.optional and not other.solved: + return + self._used_values.update(other.used_values) + self._realy_used_values.update(other.realy_used_values) + + else: + raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( + str(type(other)), self.__class__.__name__) + ) + + @property + def solved(self): + if self.optional: + if ( + len(self.missing_optional_keys) > 0 + or len(self.invalid_optional_types) > 0 + ): + return False + return ( + len(self.missing_keys) == 0 + and len(self.invalid_types) == 0 + ) + + @property + def optional(self): + return self._optional + + @property + def output(self): + return self._output + + @property + def missing_keys(self): + return self._missing_keys + + @property + def missing_optional_keys(self): + return self._missing_optional_keys + + @property + def invalid_types(self): + return self._invalid_types + + @property + def invalid_optional_types(self): + return self._invalid_optional_types + + @property + def realy_used_values(self): + return self._realy_used_values + + @property + def used_values(self): + return self._used_values + + @staticmethod + def split_keys_to_subdicts(values): + output = {} + for key, value in values.items(): + key_padding = list(KEY_PADDING_PATTERN.findall(key)) + if key_padding: + key = key_padding[0] + key_subdict = list(SUB_DICT_PATTERN.findall(key)) + data = output + last_key = key_subdict.pop(-1) + for subkey in key_subdict: + if subkey not in data: + data[subkey] = {} + data = data[subkey] + data[last_key] = value + return output + + def add_realy_used_value(self, key, value): + self._realy_used_values[key] = value + + def add_used_value(self, key, value): + self._used_values[key] = value + + def add_missing_key(self, key): + if self._optional: + self._missing_optional_keys.add(key) + else: + self._missing_keys.add(key) + + def add_invalid_type(self, key, value): + if self._optional: + self._invalid_optional_types[key] = type(value) + else: + self._invalid_types[key] = type(value) + + +class FormatObject(object): + def __init__(self): + self.value = "" + + def __format__(self, *args, **kwargs): + return self.value.__format__(*args, **kwargs) + + def __str__(self): + return str(self.value) + + def __repr__(self): + return self.__str__() + + +class FormattingPart: + """String with formatting template. + + Containt only single key to format e.g. "{project[name]}". + + Args: + template(str): String containing the formatting key. + """ + def __init__(self, template): + self._template = template + + @property + def template(self): + return self._template + + def __repr__(self): + return "".format(self._template) + + def __str__(self): + return self._template + + @staticmethod + def validate_value_type(value): + """Check if value can be used for formatting of single key.""" + if isinstance(value, (numbers.Number, FormatObject)): + return True + + for inh_class in type(value).mro(): + if inh_class in six.string_types: + return True + return False + + def format(self, data, result): + """Format the formattings string. + + Args: + data(dict): Data that should be used for formatting. + result(TemplatePartResult): Object where result is stored. + """ + key = self.template[1:-1] + if key in result.realy_used_values: + result.add_output(result.realy_used_values[key]) + return result + + # check if key expects subdictionary keys (e.g. project[name]) + existence_check = key + key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) + if key_padding: + existence_check = key_padding[0] + key_subdict = list(SUB_DICT_PATTERN.findall(existence_check)) + + value = data + missing_key = False + invalid_type = False + used_keys = [] + for sub_key in key_subdict: + if ( + value is None + or (hasattr(value, "items") and sub_key not in value) + ): + missing_key = True + used_keys.append(sub_key) + break + + if not hasattr(value, "items"): + invalid_type = True + break + + used_keys.append(sub_key) + value = value.get(sub_key) + + if missing_key or invalid_type: + if len(used_keys) == 0: + invalid_key = key_subdict[0] + else: + invalid_key = used_keys[0] + for idx, sub_key in enumerate(used_keys): + if idx == 0: + continue + invalid_key += "[{0}]".format(sub_key) + + if missing_key: + result.add_missing_key(invalid_key) + + elif invalid_type: + result.add_invalid_type(invalid_key, value) + + result.add_output(self.template) + return result + + if self.validate_value_type(value): + fill_data = {} + first_value = True + for used_key in reversed(used_keys): + if first_value: + first_value = False + fill_data[used_key] = value + else: + _fill_data = {used_key: fill_data} + fill_data = _fill_data + + formatted_value = self.template.format(**fill_data) + result.add_realy_used_value(key, formatted_value) + result.add_used_value(existence_check, value) + result.add_output(formatted_value) + return result + + result.add_invalid_type(key, value) + result.add_output(self.template) + + return result + + +class OptionalPart: + """Template part which contains optional formatting strings. + + If this part can't be filled the result is empty string. + + Args: + parts(list): Parts of template. Can contain 'str', 'OptionalPart' or + 'FormattingPart'. + """ + def __init__(self, parts): + self._parts = parts + + @property + def parts(self): + return self._parts + + def __str__(self): + return "<{}>".format("".join([str(p) for p in self._parts])) + + def __repr__(self): + return "".format("".join([str(p) for p in self._parts])) + + def format(self, data, result): + new_result = TemplatePartResult(True) + for part in self._parts: + if isinstance(part, six.string_types): + new_result.add_output(part) + else: + part.format(data, new_result) + + if new_result.solved: + result.add_output(new_result) + return result From a80b15d7912cb92b419699a16e0442d6dc8909f4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:33:51 +0100 Subject: [PATCH 08/38] use new templates logic in anatomy templates --- openpype/lib/__init__.py | 18 +- openpype/lib/anatomy.py | 635 +++++---------------------------- openpype/lib/path_templates.py | 4 + 3 files changed, 117 insertions(+), 540 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..4d956d9876 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,6 +16,15 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) +from .path_templates import ( + merge_dict, + TemplateMissingKey, + TemplateUnsolved, + StringTemplate, + TemplatesDict, + FormatObject, +) + from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -41,7 +50,6 @@ from .mongo import ( OpenPypeMongoConnection ) from .anatomy import ( - merge_dict, Anatomy ) @@ -183,6 +191,13 @@ from .openpype_version import ( terminal = Terminal __all__ = [ + "merge_dict", + "TemplateMissingKey", + "TemplateUnsolved", + "StringTemplate", + "TemplatesDict", + "FormatObject", + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", @@ -285,7 +300,6 @@ __all__ = [ "terminal", - "merge_dict", "Anatomy", "get_datetime_data", diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index fa81a18ff7..f817646cd7 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -9,6 +9,14 @@ from openpype.settings.lib import ( get_default_anatomy_settings, get_anatomy_settings ) +from .path_templates import ( + merge_dict, + TemplateUnsolved, + TemplateResult, + TemplatesResultDict, + TemplatesDict, + FormatObject, +) from .log import PypeLogger log = PypeLogger().get_logger(__name__) @@ -19,32 +27,6 @@ except NameError: StringType = str -def merge_dict(main_dict, enhance_dict): - """Merges dictionaries by keys. - - Function call itself if value on key is again dictionary. - - Args: - main_dict (dict): First dict to merge second one into. - enhance_dict (dict): Second dict to be merged. - - Returns: - dict: Merged result. - - .. note:: does not overrides whole value on first found key - but only values differences from enhance_dict - - """ - for key, value in enhance_dict.items(): - if key not in main_dict: - main_dict[key] = value - elif isinstance(value, dict) and isinstance(main_dict[key], dict): - main_dict[key] = merge_dict(main_dict[key], value) - else: - main_dict[key] = value - return main_dict - - class ProjectNotSet(Exception): """Exception raised when is created Anatomy without project name.""" @@ -59,7 +41,7 @@ class RootCombinationError(Exception): # TODO better error message msg = ( "Combination of root with and" - " without root name in Templates. {}" + " without root name in AnatomyTemplates. {}" ).format(joined_roots) super(RootCombinationError, self).__init__(msg) @@ -68,7 +50,7 @@ class RootCombinationError(Exception): class Anatomy: """Anatomy module helps to keep project settings. - Wraps key project specifications, Templates and Roots. + Wraps key project specifications, AnatomyTemplates and Roots. Args: project_name (str): Project name to look on overrides. @@ -93,7 +75,7 @@ class Anatomy: get_anatomy_settings(project_name, site_name) ) self._site_name = site_name - self._templates_obj = Templates(self) + self._templates_obj = AnatomyTemplates(self) self._roots_obj = Roots(self) # Anatomy used as dictionary @@ -158,12 +140,12 @@ class Anatomy: @property def templates(self): - """Wrap property `templates` of Anatomy's Templates instance.""" + """Wrap property `templates` of Anatomy's AnatomyTemplates instance.""" return self._templates_obj.templates @property def templates_obj(self): - """Return `Templates` object of current Anatomy instance.""" + """Return `AnatomyTemplates` object of current Anatomy instance.""" return self._templates_obj def format(self, *args, **kwargs): @@ -375,203 +357,45 @@ class Anatomy: return rootless_path.format(**data) -class TemplateMissingKey(Exception): - """Exception for cases when key does not exist in Anatomy.""" - - msg = "Anatomy key does not exist: `anatomy{0}`." - - def __init__(self, parents): - parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) - super(TemplateMissingKey, self).__init__( - self.msg.format(parent_join) - ) - - -class TemplateUnsolved(Exception): +class AnatomyTemplateUnsolved(TemplateUnsolved): """Exception for unsolved template when strict is set to True.""" msg = "Anatomy template \"{0}\" is unsolved.{1}{2}" - invalid_types_msg = " Keys with invalid DataType: `{0}`." - missing_keys_msg = " Missing keys: \"{0}\"." - def __init__(self, template, missing_keys, invalid_types): - invalid_type_items = [] - for _key, _type in invalid_types.items(): - invalid_type_items.append( - "\"{0}\" {1}".format(_key, str(_type)) - ) - invalid_types_msg = "" - if invalid_type_items: - invalid_types_msg = self.invalid_types_msg.format( - ", ".join(invalid_type_items) - ) +class AnatomyTemplateResult(TemplateResult): + rootless = None - missing_keys_msg = "" - if missing_keys: - missing_keys_msg = self.missing_keys_msg.format( - ", ".join(missing_keys) - ) - super(TemplateUnsolved, self).__init__( - self.msg.format(template, missing_keys_msg, invalid_types_msg) + def __new__(cls, result, rootless_path): + new_obj = super(AnatomyTemplateResult, cls).__new__( + cls, + str(result), + result.template, + result.solved, + result.used_values, + result.missing_keys, + result.invalid_types ) - - -class TemplateResult(str): - """Result (formatted template) of anatomy with most of information in. - - Args: - used_values (dict): Dictionary of template filling data with - only used keys. - solved (bool): For check if all required keys were filled. - template (str): Original template. - missing_keys (list): Missing keys that were not in the data. Include - missing optional keys. - invalid_types (dict): When key was found in data, but value had not - allowed DataType. Allowed data types are `numbers`, - `str`(`basestring`) and `dict`. Dictionary may cause invalid type - when value of key in data is dictionary but template expect string - of number. - """ - - def __new__( - cls, filled_template, template, solved, rootless_path, - used_values, missing_keys, invalid_types - ): - new_obj = super(TemplateResult, cls).__new__(cls, filled_template) - new_obj.used_values = used_values - new_obj.solved = solved - new_obj.template = template new_obj.rootless = rootless_path - new_obj.missing_keys = list(set(missing_keys)) - _invalid_types = {} - for invalid_type in invalid_types: - for key, val in invalid_type.items(): - if key in _invalid_types: - continue - _invalid_types[key] = val - new_obj.invalid_types = _invalid_types return new_obj - -class TemplatesDict(dict): - """Holds and wrap TemplateResults for easy bug report.""" - - def __init__(self, in_data, key=None, parent=None, strict=None): - super(TemplatesDict, self).__init__() - for _key, _value in in_data.items(): - if isinstance(_value, dict): - _value = self.__class__(_value, _key, self) - self[_key] = _value - - self.key = key - self.parent = parent - self.strict = strict - if self.parent is None and strict is None: - self.strict = True - - def __getitem__(self, key): - # Raise error about missing key in anatomy.yaml - if key not in self.keys(): - hier = self.hierarchy() - hier.append(key) - raise TemplateMissingKey(hier) - - value = super(TemplatesDict, self).__getitem__(key) - if isinstance(value, self.__class__): - return value - - # Raise exception when expected solved templates and it is not. - if ( - self.raise_on_unsolved - and (hasattr(value, "solved") and not value.solved) - ): - raise TemplateUnsolved( - value.template, value.missing_keys, value.invalid_types + def validate(self): + if not self.solved: + raise AnatomyTemplateUnsolved( + self.template, + self.missing_keys, + self.invalid_types ) - return value - - @property - def raise_on_unsolved(self): - """To affect this change `strict` attribute.""" - if self.strict is not None: - return self.strict - return self.parent.raise_on_unsolved - - def hierarchy(self): - """Return dictionary keys one by one to root parent.""" - if self.parent is None: - return [] - - hier_keys = [] - par_hier = self.parent.hierarchy() - if par_hier: - hier_keys.extend(par_hier) - hier_keys.append(self.key) - - return hier_keys - - @property - def missing_keys(self): - """Return missing keys of all children templates.""" - missing_keys = [] - for value in self.values(): - missing_keys.extend(value.missing_keys) - return list(set(missing_keys)) - - @property - def invalid_types(self): - """Return invalid types of all children templates.""" - invalid_types = {} - for value in self.values(): - for invalid_type in value.invalid_types: - _invalid_types = {} - for key, val in invalid_type.items(): - if key in invalid_types: - continue - _invalid_types[key] = val - invalid_types = merge_dict(invalid_types, _invalid_types) - return invalid_types - - @property - def used_values(self): - """Return used values for all children templates.""" - used_values = {} - for value in self.values(): - used_values = merge_dict(used_values, value.used_values) - return used_values - - def get_solved(self): - """Get only solved key from templates.""" - result = {} - for key, value in self.items(): - if isinstance(value, self.__class__): - value = value.get_solved() - if not value: - continue - result[key] = value - - elif ( - not hasattr(value, "solved") or - value.solved - ): - result[key] = value - return self.__class__(result, key=self.key, parent=self.parent) -class Templates: - key_pattern = re.compile(r"(\{.*?[^{0]*\})") - key_padding_pattern = re.compile(r"([^:]+)\S+[><]\S+") - sub_dict_pattern = re.compile(r"([^\[\]]+)") - optional_pattern = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") - +class AnatomyTemplates(TemplatesDict): inner_key_pattern = re.compile(r"(\{@.*?[^{}0]*\})") inner_key_name_pattern = re.compile(r"\{@(.*?[^{}0]*)\}") def __init__(self, anatomy): + super(AnatomyTemplates, self).__init__() self.anatomy = anatomy self.loaded_project = None - self._templates = None def __getitem__(self, key): return self.templates[key] @@ -596,13 +420,51 @@ class Templates: self._templates = None if self._templates is None: - self._templates = self._discover() + self._discover() self.loaded_project = self.project_name return self._templates + def _format_value(self, value, data): + if isinstance(value, RootItem): + return self._solve_dict(value, data) + + result = super(AnatomyTemplates, self)._format_value(value, data) + if isinstance(result, TemplateResult): + rootless_path = self._rootless_path(result, data) + result = AnatomyTemplateResult(result, rootless_path) + return result + + def set_templates(self, templates): + if not templates: + self._raw_templates = None + self._templates = None + else: + self._raw_templates = copy.deepcopy(templates) + templates = copy.deepcopy(templates) + v_queue = collections.deque() + v_queue.append(templates) + while v_queue: + item = v_queue.popleft() + if not isinstance(item, dict): + continue + + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, dict): + v_queue.append(value) + + elif ( + isinstance(value, StringType) + and "{task}" in value + ): + item[key] = value.replace("{task}", "{task[name]}") + + solved_templates = self.solve_template_inner_links(templates) + self._templates = self.create_ojected_templates(solved_templates) + def default_templates(self): """Return default templates data with solved inner keys.""" - return Templates.solve_template_inner_links( + return self.solve_template_inner_links( self.anatomy["templates"] ) @@ -613,7 +475,7 @@ class Templates: TODO: create templates if not exist. Returns: - TemplatesDict: Contain templates data for current project of + TemplatesResultDict: Contain templates data for current project of default templates. """ @@ -624,7 +486,7 @@ class Templates: " Trying to use default." ).format(self.project_name)) - return Templates.solve_template_inner_links(self.anatomy["templates"]) + self.set_templates(self.anatomy["templates"]) @classmethod def replace_inner_keys(cls, matches, value, key_values, key): @@ -791,149 +653,6 @@ class Templates: return keys_by_subkey - def _filter_optional(self, template, data): - """Filter invalid optional keys. - - Invalid keys may be missing keys of with invalid value DataType. - - Args: - template (str): Anatomy template which will be formatted. - data (dict): Containing keys to be filled into template. - - Result: - tuple: Contain origin template without missing optional keys and - without optional keys identificator ("<" and ">"), information - about missing optional keys and invalid types of optional keys. - - """ - - # Remove optional missing keys - missing_keys = [] - invalid_types = [] - for optional_group in self.optional_pattern.findall(template): - _missing_keys = [] - _invalid_types = [] - for optional_key in self.key_pattern.findall(optional_group): - key = str(optional_key[1:-1]) - key_padding = list( - self.key_padding_pattern.findall(key) - ) - if key_padding: - key = key_padding[0] - - validation_result = self._validate_data_key( - key, data - ) - missing_key = validation_result["missing_key"] - invalid_type = validation_result["invalid_type"] - - valid = True - if missing_key is not None: - _missing_keys.append(missing_key) - valid = False - - if invalid_type is not None: - _invalid_types.append(invalid_type) - valid = False - - if valid: - try: - optional_key.format(**data) - except KeyError: - _missing_keys.append(key) - valid = False - - valid = len(_invalid_types) == 0 and len(_missing_keys) == 0 - missing_keys.extend(_missing_keys) - invalid_types.extend(_invalid_types) - replacement = "" - if valid: - replacement = optional_group[1:-1] - - template = template.replace(optional_group, replacement) - return (template, missing_keys, invalid_types) - - def _validate_data_key(self, key, data): - """Check and prepare missing keys and invalid types of template.""" - result = { - "missing_key": None, - "invalid_type": None - } - - # check if key expects subdictionary keys (e.g. project[name]) - key_subdict = list(self.sub_dict_pattern.findall(key)) - used_keys = [] - if len(key_subdict) <= 1: - if key not in data: - result["missing_key"] = key - return result - - used_keys.append(key) - value = data[key] - - else: - value = data - missing_key = False - invalid_type = False - for sub_key in key_subdict: - if ( - value is None - or (hasattr(value, "items") and sub_key not in value) - ): - missing_key = True - used_keys.append(sub_key) - break - - elif not hasattr(value, "items"): - invalid_type = True - break - - used_keys.append(sub_key) - value = value.get(sub_key) - - if missing_key or invalid_type: - if len(used_keys) == 0: - invalid_key = key_subdict[0] - else: - invalid_key = used_keys[0] - for idx, sub_key in enumerate(used_keys): - if idx == 0: - continue - invalid_key += "[{0}]".format(sub_key) - - if missing_key: - result["missing_key"] = invalid_key - - elif invalid_type: - result["invalid_type"] = {invalid_key: type(value)} - - return result - - if isinstance(value, (numbers.Number, Roots, RootItem)): - return result - - for inh_class in type(value).mro(): - if inh_class == StringType: - return result - - result["missing_key"] = key - result["invalid_type"] = {key: type(value)} - return result - - def _merge_used_values(self, current_used, keys, value): - key = keys[0] - _keys = keys[1:] - if len(_keys) == 0: - current_used[key] = value - else: - next_dict = {} - if key in current_used: - next_dict = current_used[key] - current_used[key] = self._merge_used_values( - next_dict, _keys, value - ) - return current_used - def _dict_to_subkeys_list(self, subdict, pre_keys=None): if pre_keys is None: pre_keys = [] @@ -956,9 +675,11 @@ class Templates: return {key_list[0]: value} return {key_list[0]: self._keys_to_dicts(key_list[1:], value)} - def _rootless_path( - self, template, used_values, final_data, missing_keys, invalid_types - ): + def _rootless_path(self, result, final_data): + used_values = result.used_values + missing_keys = result.missing_keys + template = result.template + invalid_types = result.invalid_types if ( "root" not in used_values or "root" in missing_keys @@ -974,210 +695,48 @@ class Templates: if not root_keys: return - roots_dict = {} + output = str(result) for used_root_keys in root_keys: if not used_root_keys: continue + used_value = used_values root_key = None for key in used_root_keys: + used_value = used_value[key] if root_key is None: root_key = key else: root_key += "[{}]".format(key) root_key = "{" + root_key + "}" - - roots_dict = merge_dict( - roots_dict, - self._keys_to_dicts(used_root_keys, root_key) - ) - - final_data["root"] = roots_dict["root"] - return template.format(**final_data) - - def _format(self, orig_template, data): - """ Figure out with whole formatting. - - Separate advanced keys (*Like '{project[name]}') from string which must - be formatted separatelly in case of missing or incomplete keys in data. - - Args: - template (str): Anatomy template which will be formatted. - data (dict): Containing keys to be filled into template. - - Returns: - TemplateResult: Filled or partially filled template containing all - data needed or missing for filling template. - """ - task_data = data.get("task") - if ( - isinstance(task_data, StringType) - and "{task[name]}" in orig_template - ): - # Change task to dictionary if template expect dictionary - data["task"] = {"name": task_data} - - template, missing_optional, invalid_optional = ( - self._filter_optional(orig_template, data) - ) - # Remove optional missing keys - used_values = {} - invalid_required = [] - missing_required = [] - replace_keys = [] - - for group in self.key_pattern.findall(template): - orig_key = group[1:-1] - key = str(orig_key) - key_padding = list(self.key_padding_pattern.findall(key)) - if key_padding: - key = key_padding[0] - - validation_result = self._validate_data_key(key, data) - missing_key = validation_result["missing_key"] - invalid_type = validation_result["invalid_type"] - - if invalid_type is not None: - invalid_required.append(invalid_type) - replace_keys.append(key) - continue - - if missing_key is not None: - missing_required.append(missing_key) - replace_keys.append(key) - continue - - try: - value = group.format(**data) - key_subdict = list(self.sub_dict_pattern.findall(key)) - if len(key_subdict) <= 1: - used_values[key] = value - - else: - used_values = self._merge_used_values( - used_values, key_subdict, value - ) - - except (TypeError, KeyError): - missing_required.append(key) - replace_keys.append(key) - - final_data = copy.deepcopy(data) - for key in replace_keys: - key_subdict = list(self.sub_dict_pattern.findall(key)) - if len(key_subdict) <= 1: - final_data[key] = "{" + key + "}" - continue - - replace_key_dst = "---".join(key_subdict) - replace_key_dst_curly = "{" + replace_key_dst + "}" - replace_key_src_curly = "{" + key + "}" - template = template.replace( - replace_key_src_curly, replace_key_dst_curly - ) - final_data[replace_key_dst] = replace_key_src_curly - - solved = len(missing_required) == 0 and len(invalid_required) == 0 - - missing_keys = missing_required + missing_optional - invalid_types = invalid_required + invalid_optional - - filled_template = template.format(**final_data) - # WARNING `_rootless_path` change values in `final_data` please keep - # in midn when changing order - rootless_path = self._rootless_path( - template, used_values, final_data, missing_keys, invalid_types - ) - if rootless_path is None: - rootless_path = filled_template - - result = TemplateResult( - filled_template, orig_template, solved, rootless_path, - used_values, missing_keys, invalid_types - ) - return result - - def solve_dict(self, templates, data): - """ Solves templates with entered data. - - Args: - templates (dict): All Anatomy templates which will be formatted. - data (dict): Containing keys to be filled into template. - - Returns: - dict: With `TemplateResult` in values containing filled or - partially filled templates. - """ - output = collections.defaultdict(dict) - for key, orig_value in templates.items(): - if isinstance(orig_value, StringType): - # Replace {task} by '{task[name]}' for backward compatibility - if '{task}' in orig_value: - orig_value = orig_value.replace('{task}', '{task[name]}') - - output[key] = self._format(orig_value, data) - continue - - # Check if orig_value has items attribute (any dict inheritance) - if not hasattr(orig_value, "items"): - # TODO we should handle this case - output[key] = orig_value - continue - - for s_key, s_value in self.solve_dict(orig_value, data).items(): - output[key][s_key] = s_value + output = output.replace(str(used_value), root_key) return output + def format(self, data, strict=True): + roots = self.roots + if roots: + data["root"] = roots + result = super(AnatomyTemplates, self).format(data) + result.strict = strict + return result + def format_all(self, in_data, only_keys=True): """ Solves templates based on entered data. Args: data (dict): Containing keys to be filled into template. - only_keys (bool, optional): Decides if environ will be used to - fill templates or only keys in data. Returns: - TemplatesDict: Output `TemplateResult` have `strict` attribute - set to False so accessing unfilled keys in templates won't - raise any exceptions. + TemplatesResultDict: Output `TemplateResult` have `strict` + attribute set to False so accessing unfilled keys in templates + won't raise any exceptions. """ - output = self.format(in_data, only_keys) - output.strict = False - return output - - def format(self, in_data, only_keys=True): - """ Solves templates based on entered data. - - Args: - data (dict): Containing keys to be filled into template. - only_keys (bool, optional): Decides if environ will be used to - fill templates or only keys in data. - - Returns: - TemplatesDict: Output `TemplateResult` have `strict` attribute - set to True so accessing unfilled keys in templates will - raise exceptions with explaned error. - """ - # Create a copy of inserted data - data = copy.deepcopy(in_data) - - # Add environment variable to data - if only_keys is False: - for key, val in os.environ.items(): - data["$" + key] = val - - # override root value - roots = self.roots - if roots: - data["root"] = roots - solved = self.solve_dict(self.templates, data) - - return TemplatesDict(solved) + return self.format(in_data, strict=False) -class RootItem: +class RootItem(FormatObject): """Represents one item or roots. Holds raw data of root item specification. Raw data contain value diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 6f68cc4ce9..b51951851f 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -584,6 +584,10 @@ class TemplatePartResult: class FormatObject(object): + """Object that can be used for formatting. + + This is base that is valid for to be used in 'StringTemplate' value. + """ def __init__(self): self.value = "" From 1fa2e86d1840a8a8801fdb8455930442550f792d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:36:13 +0100 Subject: [PATCH 09/38] reorganized init --- openpype/lib/__init__.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 4d956d9876..6ec10a2209 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,15 +16,6 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) -from .path_templates import ( - merge_dict, - TemplateMissingKey, - TemplateUnsolved, - StringTemplate, - TemplatesDict, - FormatObject, -) - from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -44,6 +35,16 @@ from .execute import ( CREATE_NO_WINDOW ) from .log import PypeLogger, timeit + +from .path_templates import ( + merge_dict, + TemplateMissingKey, + TemplateUnsolved, + StringTemplate, + TemplatesDict, + FormatObject, +) + from .mongo import ( get_default_components, validate_mongo_connection, @@ -191,13 +192,6 @@ from .openpype_version import ( terminal = Terminal __all__ = [ - "merge_dict", - "TemplateMissingKey", - "TemplateUnsolved", - "StringTemplate", - "TemplatesDict", - "FormatObject", - "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", @@ -298,6 +292,13 @@ __all__ = [ "get_version_from_path", "get_last_version_from_path", + "merge_dict", + "TemplateMissingKey", + "TemplateUnsolved", + "StringTemplate", + "TemplatesDict", + "FormatObject", + "terminal", "Anatomy", From 26549b34650a45037084107356f94a12b3146441 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:51:28 +0100 Subject: [PATCH 10/38] hound fixes --- openpype/lib/anatomy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index f817646cd7..8f2f09a803 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -10,10 +10,8 @@ from openpype.settings.lib import ( get_anatomy_settings ) from .path_templates import ( - merge_dict, TemplateUnsolved, TemplateResult, - TemplatesResultDict, TemplatesDict, FormatObject, ) @@ -69,7 +67,10 @@ class Anatomy: " to load data for specific project." )) + from .avalon_context import get_project_code + self.project_name = project_name + self.project_code = get_project_code(project_name) self._data = self._prepare_anatomy_data( get_anatomy_settings(project_name, site_name) From aa9df7edd5fe2d0e693495a79dd7498b2fadc08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 21 Feb 2022 18:42:48 +0100 Subject: [PATCH 11/38] wip on integrating avalon functionality --- openpype/hosts/unreal/__init__.py | 7 +- openpype/hosts/unreal/api/helpers.py | 44 ++ openpype/hosts/unreal/api/lib.py | 18 +- openpype/hosts/unreal/api/pipeline.py | 388 ++++++++++++++++++ openpype/hosts/unreal/api/plugin.py | 7 +- openpype/hosts/unreal/integration/.gitignore | 35 ++ .../integration/Content/Python/init_unreal.py | 27 ++ .../hosts/unreal/integration/OpenPype.uplugin | 24 ++ openpype/hosts/unreal/integration/README.md | 11 + .../integration/Resources/openpype128.png | Bin 0 -> 14594 bytes .../integration/Resources/openpype40.png | Bin 0 -> 4884 bytes .../integration/Resources/openpype512.png | Bin 0 -> 85856 bytes .../integration/Source/Avalon/Avalon.Build.cs | 57 +++ .../Source/Avalon/Private/AssetContainer.cpp | 115 ++++++ .../Avalon/Private/AssetContainerFactory.cpp | 20 + .../Source/Avalon/Private/Avalon.cpp | 103 +++++ .../Source/Avalon/Private/AvalonLib.cpp | 48 +++ .../Avalon/Private/AvalonPublishInstance.cpp | 108 +++++ .../Private/AvalonPublishInstanceFactory.cpp | 20 + .../Avalon/Private/AvalonPythonBridge.cpp | 13 + .../Source/Avalon/Private/AvalonStyle.cpp | 69 ++++ .../Source/Avalon/Public/AssetContainer.h | 39 ++ .../Avalon/Public/AssetContainerFactory.h | 21 + .../integration/Source/Avalon/Public/Avalon.h | 21 + .../Source/Avalon/Public/AvalonLib.h | 19 + .../Avalon/Public/AvalonPublishInstance.h | 21 + .../Public/AvalonPublishInstanceFactory.h | 19 + .../Source/Avalon/Public/AvalonPythonBridge.h | 20 + .../Source/Avalon/Public/AvalonStyle.h | 22 + 29 files changed, 1282 insertions(+), 14 deletions(-) create mode 100644 openpype/hosts/unreal/api/helpers.py create mode 100644 openpype/hosts/unreal/api/pipeline.py create mode 100644 openpype/hosts/unreal/integration/.gitignore create mode 100644 openpype/hosts/unreal/integration/Content/Python/init_unreal.py create mode 100644 openpype/hosts/unreal/integration/OpenPype.uplugin create mode 100644 openpype/hosts/unreal/integration/README.md create mode 100644 openpype/hosts/unreal/integration/Resources/openpype128.png create mode 100644 openpype/hosts/unreal/integration/Resources/openpype40.png create mode 100644 openpype/hosts/unreal/integration/Resources/openpype512.png create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index 1280442916..e6ca1e833d 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -3,11 +3,12 @@ import os def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" - # Set AVALON_UNREAL_PLUGIN required for Unreal implementation + # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( - os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration" + os.environ["OPENPYPE_ROOT"], "openpype", "hosts", + "unreal", "integration" ) - env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path + env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings defaults = { diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py new file mode 100644 index 0000000000..6fc89cf176 --- /dev/null +++ b/openpype/hosts/unreal/api/helpers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +import unreal # noqa + + +class OpenPypeUnrealException(Exception): + pass + + +@unreal.uclass() +class OpenPypeHelpers(unreal.OpenPypeLib): + """Class wrapping some useful functions for OpenPype. + + This class is extending native BP class in OpenPype Integration Plugin. + + """ + + @unreal.ufunction(params=[str, unreal.LinearColor, bool]) + def set_folder_color(self, path: str, color: unreal.LinearColor) -> Bool: + """Set color on folder in Content Browser. + + This method sets color on folder in Content Browser. Unfortunately + there is no way to refresh Content Browser so new color isn't applied + immediately. They are saved to config file and appears correctly + only after Editor is restarted. + + Args: + path (str): Path to folder + color (:class:`unreal.LinearColor`): Color of the folder + + Example: + + AvalonHelpers().set_folder_color( + "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) + ) + + Note: + This will take effect only after Editor is restarted. I couldn't + find a way to refresh it. Also this saves the color definition + into the project config, binding this path with color. So if you + delete this path and later re-create, it will set this color + again. + + """ + self.c_set_folder_color(path, color, False) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index 61dac46fac..e04606a333 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -169,11 +169,11 @@ def create_unreal_project(project_name: str, env: dict = None) -> None: """This will create `.uproject` file at specified location. - As there is no way I know to create project via command line, this is - easiest option. Unreal project file is basically JSON file. If we find - `AVALON_UNREAL_PLUGIN` environment variable we assume this is location - of Avalon Integration Plugin and we copy its content to project folder - and enable this plugin. + As there is no way I know to create a project via command line, this is + easiest option. Unreal project file is basically a JSON file. If we find + the `OPENPYPE_UNREAL_PLUGIN` environment variable we assume this is the + location of the Integration Plugin and we copy its content to the project + folder and enable this plugin. Args: project_name (str): Name of the project. @@ -254,14 +254,14 @@ def create_unreal_project(project_name: str, {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, {"Name": "SequencerScripting", "Enabled": True}, - {"Name": "Avalon", "Enabled": True} + {"Name": "OpenPype", "Enabled": True} ] } if dev_mode or preset["dev_mode"]: - # this will add project module and necessary source file to make it - # C++ project and to (hopefully) make Unreal Editor to compile all - # sources at start + # this will add the project module and necessary source file to + # make it a C++ project and to (hopefully) make Unreal Editor to + # compile all # sources at start data["Modules"] = [{ "Name": project_name, diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py new file mode 100644 index 0000000000..c255005f31 --- /dev/null +++ b/openpype/hosts/unreal/api/pipeline.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +import sys +import pyblish.api +from avalon.pipeline import AVALON_CONTAINER_ID + +import unreal # noqa +from typing import List + +from openpype.tools.utils import host_tools + +from avalon import api + + +AVALON_CONTAINERS = "OpenPypeContainers" + + +def install(): + + pyblish.api.register_host("unreal") + _register_callbacks() + _register_events() + + +def _register_callbacks(): + """ + TODO: Implement callbacks if supported by UE4 + """ + pass + + +def _register_events(): + """ + TODO: Implement callbacks if supported by UE4 + """ + pass + + +def uninstall(): + pyblish.api.deregister_host("unreal") + + +class Creator(api.Creator): + hosts = ["unreal"] + asset_types = [] + + def process(self): + nodes = list() + + with unreal.ScopedEditorTransaction("Avalon Creating Instance"): + if (self.options or {}).get("useSelection"): + self.log.info("setting ...") + print("settings ...") + nodes = unreal.EditorUtilityLibrary.get_selected_assets() + + asset_paths = [a.get_path_name() for a in nodes] + self.name = move_assets_to_path( + "/Game", self.name, asset_paths + ) + + instance = create_publish_instance("/Game", self.name) + imprint(instance, self.data) + + return instance + + +class Loader(api.Loader): + hosts = ["unreal"] + + +def ls(): + """ + List all containers found in *Content Manager* of Unreal and return + metadata from them. Adding `objectName` to set. + """ + ar = unreal.AssetRegistryHelpers.get_asset_registry() + avalon_containers = ar.get_assets_by_class("AssetContainer", True) + + # get_asset_by_class returns AssetData. To get all metadata we need to + # load asset. get_tag_values() work only on metadata registered in + # Asset Registy Project settings (and there is no way to set it with + # python short of editing ini configuration file). + for asset_data in avalon_containers: + asset = asset_data.get_asset() + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset_data.asset_name + data = cast_map_to_str_dict(data) + + yield data + + +def parse_container(container): + """ + To get data from container, AssetContainer must be loaded. + + Args: + container(str): path to container + + Returns: + dict: metadata stored on container + """ + asset = unreal.EditorAssetLibrary.load_asset(container) + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset.get_name() + data = cast_map_to_str_dict(data) + + return data + + +def publish(): + """Shorthand to publish from within host""" + import pyblish.util + + return pyblish.util.publish() + + +def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): + + """Bundles *nodes* (assets) into a *container* and add metadata to it. + + Unreal doesn't support *groups* of assets that you can add metadata to. + But it does support folders that helps to organize asset. Unfortunately + those folders are just that - you cannot add any additional information + to them. `Avalon Integration Plugin`_ is providing way out - Implementing + `AssetContainer` Blueprint class. This class when added to folder can + handle metadata on it using standard + :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and + :func:`unreal.EditorAssetLibrary.get_metadata_tag_values()`. It also + stores and monitor all changes in assets in path where it resides. List of + those assets is available as `assets` property. + + This is list of strings starting with asset type and ending with its path: + `Material /Game/Avalon/Test/TestMaterial.TestMaterial` + + .. _Avalon Integration Plugin: + https://github.com/pypeclub/avalon-unreal-integration + + """ + # 1 - create directory for container + root = "/Game" + container_name = "{}{}".format(name, suffix) + new_name = move_assets_to_path(root, container_name, nodes) + + # 2 - create Asset Container there + path = "{}/{}".format(root, new_name) + create_container(container=container_name, path=path) + + namespace = path + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": new_name, + "namespace": namespace, + "loader": str(loader), + "representation": context["representation"]["_id"], + } + # 3 - imprint data + imprint("{}/{}".format(path, container_name), data) + return path + + +def instantiate(root, name, data, assets=None, suffix="_INS"): + """ + Bundles *nodes* into *container* marking it with metadata as publishable + instance. If assets are provided, they are moved to new path where + `AvalonPublishInstance` class asset is created and imprinted with metadata. + + This can then be collected for publishing by Pyblish for example. + + Args: + root (str): root path where to create instance container + name (str): name of the container + data (dict): data to imprint on container + assets (list of str): list of asset paths to include in publish + instance + suffix (str): suffix string to append to instance name + """ + container_name = "{}{}".format(name, suffix) + + # if we specify assets, create new folder and move them there. If not, + # just create empty folder + if assets: + new_name = move_assets_to_path(root, container_name, assets) + else: + new_name = create_folder(root, name) + + path = "{}/{}".format(root, new_name) + create_publish_instance(instance=container_name, path=path) + + imprint("{}/{}".format(path, container_name), data) + + +def imprint(node, data): + loaded_asset = unreal.EditorAssetLibrary.load_asset(node) + for key, value in data.items(): + # Support values evaluated at imprint + if callable(value): + value = value() + # Unreal doesn't support NoneType in metadata values + if value is None: + value = "" + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, key, str(value) + ) + + with unreal.ScopedEditorTransaction("Avalon containerising"): + unreal.EditorAssetLibrary.save_asset(node) + + +def show_tools_popup(): + """Show popup with tools. + + Popup will disappear on click or loosing focus. + """ + from openpype.hosts.unreal.api import tools_ui + + tools_ui.show_tools_popup() + + +def show_tools_dialog(): + """Show dialog with tools. + + Dialog will stay visible. + """ + from openpype.hosts.unreal.api import tools_ui + + tools_ui.show_tools_dialog() + + +def show_creator(): + host_tools.show_creator() + + +def show_loader(): + host_tools.show_loader(use_context=True) + + +def show_publisher(): + host_tools.show_publish() + + +def show_manager(): + host_tools.show_scene_inventory() + + +def show_experimental_tools(): + host_tools.show_experimental_tools_dialog() + + +def create_folder(root: str, name: str) -> str: + """Create new folder + + If folder exists, append number at the end and try again, incrementing + if needed. + + Args: + root (str): path root + name (str): folder name + + Returns: + str: folder name + + Example: + >>> create_folder("/Game/Foo") + /Game/Foo + >>> create_folder("/Game/Foo") + /Game/Foo1 + + """ + eal = unreal.EditorAssetLibrary + index = 1 + while True: + if eal.does_directory_exist("{}/{}".format(root, name)): + name = "{}{}".format(name, index) + index += 1 + else: + eal.make_directory("{}/{}".format(root, name)) + break + + return name + + +def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: + """ + Moving (renaming) list of asset paths to new destination. + + Args: + root (str): root of the path (eg. `/Game`) + name (str): name of destination directory (eg. `Foo` ) + assets (list of str): list of asset paths + + Returns: + str: folder name + + Example: + This will get paths of all assets under `/Game/Test` and move them + to `/Game/NewTest`. If `/Game/NewTest` already exists, then resulting + path will be `/Game/NewTest1` + + >>> assets = unreal.EditorAssetLibrary.list_assets("/Game/Test") + >>> move_assets_to_path("/Game", "NewTest", assets) + NewTest + + """ + eal = unreal.EditorAssetLibrary + name = create_folder(root, name) + + unreal.log(assets) + for asset in assets: + loaded = eal.load_asset(asset) + eal.rename_asset( + asset, "{}/{}/{}".format(root, name, loaded.get_name()) + ) + + return name + + +def create_container(container: str, path: str) -> unreal.Object: + """ + Helper function to create Asset Container class on given path. + This Asset Class helps to mark given path as Container + and enable asset version control on it. + + Args: + container (str): Asset Container name + path (str): Path where to create Asset Container. This path should + point into container folder + + Returns: + :class:`unreal.Object`: instance of created asset + + Example: + + create_avalon_container( + "/Game/modelingFooCharacter_CON", + "modelingFooCharacter_CON" + ) + + """ + factory = unreal.AssetContainerFactory() + tools = unreal.AssetToolsHelpers().get_asset_tools() + + asset = tools.create_asset(container, path, None, factory) + return asset + + +def create_publish_instance(instance: str, path: str) -> unreal.Object: + """ + Helper function to create Avalon Publish Instance on given path. + This behaves similary as :func:`create_avalon_container`. + + Args: + path (str): Path where to create Publish Instance. + This path should point into container folder + instance (str): Publish Instance name + + Returns: + :class:`unreal.Object`: instance of created asset + + Example: + + create_publish_instance( + "/Game/modelingFooCharacter_INST", + "modelingFooCharacter_INST" + ) + + """ + factory = unreal.AvalonPublishInstanceFactory() + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset = tools.create_asset(instance, path, None, factory) + return asset + + +def cast_map_to_str_dict(map) -> dict: + """Cast Unreal Map to dict. + + Helper function to cast Unreal Map object to plain old python + dict. This will also cast values and keys to str. Useful for + metadata dicts. + + Args: + map: Unreal Map object + + Returns: + dict + + """ + return {str(key): str(value) for (key, value) in map.items()} diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py index 5a6b236730..2327fc09c8 100644 --- a/openpype/hosts/unreal/api/plugin.py +++ b/openpype/hosts/unreal/api/plugin.py @@ -1,5 +1,8 @@ -from avalon import api +# -*- coding: utf-8 -*- +from abc import ABC + import openpype.api +import avalon.api class Creator(openpype.api.Creator): @@ -7,6 +10,6 @@ class Creator(openpype.api.Creator): defaults = ['Main'] -class Loader(api.Loader): +class Loader(avalon.api.Loader, ABC): """This serves as skeleton for future OpenPype specific functionality""" pass diff --git a/openpype/hosts/unreal/integration/.gitignore b/openpype/hosts/unreal/integration/.gitignore new file mode 100644 index 0000000000..b32a6f55e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/.gitignore @@ -0,0 +1,35 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +/Binaries +/Intermediate diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py new file mode 100644 index 0000000000..48e931bb04 --- /dev/null +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -0,0 +1,27 @@ +import unreal + +avalon_detected = True +try: + from avalon import api + from avalon import unreal as avalon_unreal +except ImportError as exc: + avalon_detected = False + unreal.log_error("Avalon: cannot load avalon [ {} ]".format(exc)) + +if avalon_detected: + api.install(avalon_unreal) + + +@unreal.uclass() +class AvalonIntegration(unreal.AvalonPythonBridge): + @unreal.ufunction(override=True) + def RunInPython_Popup(self): + unreal.log_warning("Avalon: showing tools popup") + if avalon_detected: + avalon_unreal.show_tools_popup() + + @unreal.ufunction(override=True) + def RunInPython_Dialog(self): + unreal.log_warning("Avalon: showing tools dialog") + if avalon_detected: + avalon_unreal.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/OpenPype.uplugin b/openpype/hosts/unreal/integration/OpenPype.uplugin new file mode 100644 index 0000000000..4c7a74403c --- /dev/null +++ b/openpype/hosts/unreal/integration/OpenPype.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "OpenPype", + "Description": "OpenPype Integration", + "Category": "OpenPype.Integration", + "CreatedBy": "Ondrej Samohel", + "CreatedByURL": "https://openpype.io", + "DocsURL": "https://openpype.io/docs/artist_hosts_unreal", + "MarketplaceURL": "", + "SupportURL": "https://pype.club/", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "OpenPype", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/README.md new file mode 100644 index 0000000000..a32d89aab8 --- /dev/null +++ b/openpype/hosts/unreal/integration/README.md @@ -0,0 +1,11 @@ +# OpenPype Unreal Integration plugin + +This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. + +## How does this work + +Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button +on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are +declared in c++ but needs to be implemented during Unreal Editor +startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor +automatically. diff --git a/openpype/hosts/unreal/integration/Resources/openpype128.png b/openpype/hosts/unreal/integration/Resources/openpype128.png new file mode 100644 index 0000000000000000000000000000000000000000..abe8a807ef40f00b75d7446d020a2437732c7583 GIT binary patch literal 14594 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Resources/openpype512.png b/openpype/hosts/unreal/integration/Resources/openpype512.png new file mode 100644 index 0000000000000000000000000000000000000000..97c4d4326bc16ba6dfb45d35c4362d8bc15900ae GIT binary patch literal 85856 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 bCJFu@j}?W0K?Ok;00000NkvXXu0mjf9`4Bp literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs b/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs new file mode 100644 index 0000000000..5068e37d80 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs @@ -0,0 +1,57 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class Avalon : ModuleRules +{ + public Avalon(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Projects", + "InputCore", + "UnrealEd", + "LevelEditor", + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp new file mode 100644 index 0000000000..c766f87a8e --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp @@ -0,0 +1,115 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "AssetContainer.h" +#include "AssetRegistryModule.h" +#include "Misc/PackageName.h" +#include "Engine.h" +#include "Containers/UnrealString.h" + +UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) +: UAssetUserData(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAssetContainer::GetPathName(); + UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); +} + +void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + assets.Add(assetPath); + assetsData.Add(AssetData); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAssetContainer::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + assetsData.Remove(AssetData); + } + } +} + +void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + assetsData.Remove(AssetData); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} + diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp new file mode 100644 index 0000000000..b943150bdd --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp @@ -0,0 +1,20 @@ +#include "AssetContainerFactory.h" +#include "AssetContainer.h" + +UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAssetContainer::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags); + return AssetContainer; +} + +bool UAssetContainerFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp new file mode 100644 index 0000000000..ed782f4870 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp @@ -0,0 +1,103 @@ +#include "Avalon.h" +#include "LevelEditor.h" +#include "AvalonPythonBridge.h" +#include "AvalonStyle.h" + + +static const FName AvalonTabName("Avalon"); + +#define LOCTEXT_NAMESPACE "FAvalonModule" + +// This function is triggered when the plugin is staring up +void FAvalonModule::StartupModule() +{ + + FAvalonStyle::Initialize(); + FAvalonStyle::SetIcon("Logo", "openpype40"); + + // Create the Extender that will add content to the menu + FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); + + TSharedPtr MenuExtender = MakeShareable(new FExtender()); + TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); + + MenuExtender->AddMenuExtension( + "LevelEditor", + EExtensionHook::After, + NULL, + FMenuExtensionDelegate::CreateRaw(this, &FAvalonModule::AddMenuEntry) + ); + ToolbarExtender->AddToolBarExtension( + "Settings", + EExtensionHook::After, + NULL, + FToolBarExtensionDelegate::CreateRaw(this, &FAvalonModule::AddToobarEntry)); + + + LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); + LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); + +} + +void FAvalonModule::ShutdownModule() +{ + FAvalonStyle::Shutdown(); +} + + +void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +{ + // Create Section + MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); + { + // Create a Submenu inside of the Section + MenuBuilder.AddMenuEntry( + FText::FromString("Tools..."), + FText::FromString("Pipeline tools"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup)) + ); + + MenuBuilder.AddMenuEntry( + FText::FromString("Tools dialog..."), + FText::FromString("Pipeline tools dialog"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuDialog)) + ); + + } + MenuBuilder.EndSection(); +} + +void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +{ + ToolbarBuilder.BeginSection(TEXT("OpenPype")); + { + ToolbarBuilder.AddToolBarButton( + FUIAction( + FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup), + NULL, + FIsActionChecked() + + ), + NAME_None, + LOCTEXT("OpenPype_label", "OpenPype"), + LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo") + ); + } + ToolbarBuilder.EndSection(); +} + + +void FAvalonModule::MenuPopup() { + UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); + bridge->RunInPython_Popup(); +} + +void FAvalonModule::MenuDialog() { + UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); + bridge->RunInPython_Dialog(); +} + +IMPLEMENT_MODULE(FAvalonModule, Avalon) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp new file mode 100644 index 0000000000..312656424c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp @@ -0,0 +1,48 @@ +#include "AvalonLib.h" +#include "Misc/Paths.h" +#include "Misc/ConfigCacheIni.h" +#include "UObject/UnrealType.h" + +/** + * Sets color on folder icon on given path + * @param InPath - path to folder + * @param InFolderColor - color of the folder + * @warning This color will appear only after Editor restart. Is there a better way? + */ + +void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +{ + auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) + { + // Saves the color of the folder to the config + if (FPaths::FileExists(GEditorPerProjectIni)) + { + GConfig->SetString(TEXT("PathColor"), *InPath, *InFolderColor.ToString(), GEditorPerProjectIni); + } + + }; + + SaveColorInternal(FolderPath, FolderColor); + +} +/** + * Returns all poperties on given object + * @param cls - class + * @return TArray of properties + */ +TArray UAvalonLib::GetAllProperties(UClass* cls) +{ + TArray Ret; + if (cls != nullptr) + { + for (TFieldIterator It(cls); It; ++It) + { + FProperty* Property = *It; + if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit)) + { + Ret.Add(Property->GetName()); + } + } + } + return Ret; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp new file mode 100644 index 0000000000..2bb31a4853 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp @@ -0,0 +1,108 @@ +#pragma once + +#include "AvalonPublishInstance.h" +#include "AssetRegistryModule.h" + + +UAvalonPublishInstance::UAvalonPublishInstance(const FObjectInitializer& ObjectInitializer) + : UObject(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAvalonPublishInstance::GetPathName(); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAvalonPublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAvalonPublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAvalonPublishInstance::OnAssetRenamed); +} + +void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AvalonPublishInstance") + { + assets.Add(assetPath); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAvalonPublishInstance::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AvalonPublishInstance") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + } + } +} + +void UAvalonPublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp new file mode 100644 index 0000000000..e14a14f1e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "AvalonPublishInstanceFactory.h" +#include "AvalonPublishInstance.h" + +UAvalonPublishInstanceFactory::UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAvalonPublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAvalonPublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAvalonPublishInstance* AvalonPublishInstance = NewObject(InParent, Class, Name, Flags); + return AvalonPublishInstance; +} + +bool UAvalonPublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp new file mode 100644 index 0000000000..8642ab6b63 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp @@ -0,0 +1,13 @@ +#include "AvalonPythonBridge.h" + +UAvalonPythonBridge* UAvalonPythonBridge::Get() +{ + TArray AvalonPythonBridgeClasses; + GetDerivedClasses(UAvalonPythonBridge::StaticClass(), AvalonPythonBridgeClasses); + int32 NumClasses = AvalonPythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp new file mode 100644 index 0000000000..5b3d1269b0 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp @@ -0,0 +1,69 @@ +#include "AvalonStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FAvalonStyle::AvalonStyleInstance = nullptr; + +void FAvalonStyle::Initialize() +{ + if (!AvalonStyleInstance.IsValid()) + { + AvalonStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*AvalonStyleInstance); + } +} + +void FAvalonStyle::Shutdown() +{ + if (AvalonStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*AvalonStyleInstance); + AvalonStyleInstance.Reset(); + } +} + +FName FAvalonStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("AvalonStyle")); + return StyleSetName; +} + +FName FAvalonStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FAvalonStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("Avalon/Resources")); + + return Style; +} + +void FAvalonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = AvalonStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FAvalonStyle::Get() +{ + check(AvalonStyleInstance); + return *AvalonStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h new file mode 100644 index 0000000000..1195f95cba --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h @@ -0,0 +1,39 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "Engine/AssetUserData.h" +#include "AssetData.h" +#include "AssetContainer.generated.h" + +/** + * + */ +UCLASS(Blueprintable) +class AVALON_API UAssetContainer : public UAssetUserData +{ + GENERATED_BODY() + +public: + + UAssetContainer(const FObjectInitializer& ObjectInitalizer); + // ~UAssetContainer(); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; + + // There seems to be no reflection option to expose array of FAssetData + /* + UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data")) + TArray assetsData; + */ +private: + TArray assetsData; + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; + + diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h new file mode 100644 index 0000000000..62b6e73640 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h @@ -0,0 +1,21 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AssetContainerFactory.generated.h" + +/** + * + */ +UCLASS() +class AVALON_API UAssetContainerFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h new file mode 100644 index 0000000000..2dd6a825ab --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h @@ -0,0 +1,21 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Engine.h" + + +class FAvalonModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + + void AddMenuEntry(FMenuBuilder& MenuBuilder); + void AddToobarEntry(FToolBarBuilder& ToolbarBuilder); + void MenuPopup(); + void MenuDialog(); + +}; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h new file mode 100644 index 0000000000..da3369970c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Engine.h" +#include "AvalonLib.generated.h" + + +UCLASS(Blueprintable) +class AVALON_API UAvalonLib : public UObject +{ + + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static void CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd); + + UFUNCTION(BlueprintCallable, Category = Python) + static TArray GetAllProperties(UClass* cls); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h new file mode 100644 index 0000000000..7678f78924 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Engine.h" +#include "AvalonPublishInstance.generated.h" + + +UCLASS(Blueprintable) +class AVALON_API UAvalonPublishInstance : public UObject +{ + GENERATED_BODY() + +public: + UAvalonPublishInstance(const FObjectInitializer& ObjectInitalizer); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; +private: + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h new file mode 100644 index 0000000000..79e781c60c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h @@ -0,0 +1,19 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AvalonPublishInstanceFactory.generated.h" + +/** + * + */ +UCLASS() +class AVALON_API UAvalonPublishInstanceFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h new file mode 100644 index 0000000000..db4b16d53f --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine.h" +#include "AvalonPythonBridge.generated.h" + +UCLASS(Blueprintable) +class UAvalonPythonBridge : public UObject +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static UAvalonPythonBridge* Get(); + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Popup() const; + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Dialog() const; + +}; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h new file mode 100644 index 0000000000..ffb2bc7aa4 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h @@ -0,0 +1,22 @@ +#pragma once +#include "CoreMinimal.h" + +class FSlateStyleSet; +class ISlateStyle; + + +class FAvalonStyle +{ +public: + static void Initialize(); + static void Shutdown(); + static const ISlateStyle& Get(); + static FName GetStyleSetName(); + static FName GetContextName(); + + static void SetIcon(const FString& StyleName, const FString& ResourcePath); + +private: + static TUniquePtr< FSlateStyleSet > Create(); + static TUniquePtr< FSlateStyleSet > AvalonStyleInstance; +}; \ No newline at end of file From f985e58bb9687d19e63c05dce061063fce35e77e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Feb 2022 12:27:13 +0100 Subject: [PATCH 12/38] Removed forgotten lines --- openpype/lib/anatomy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 8f2f09a803..3bcd6169e4 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -67,10 +67,7 @@ class Anatomy: " to load data for specific project." )) - from .avalon_context import get_project_code - self.project_name = project_name - self.project_code = get_project_code(project_name) self._data = self._prepare_anatomy_data( get_anatomy_settings(project_name, site_name) From 8d7f56c9e822cc3b71c9d928cf8da153c68d1804 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:11:40 +0100 Subject: [PATCH 13/38] added more methods to StringTemplate --- openpype/lib/path_templates.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index b51951851f..3b0e9ad3cc 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -126,6 +126,19 @@ class StringTemplate(object): self._parts = self.find_optional_parts(new_parts) + def __str__(self): + return self.template + + def __repr__(self): + return "<{}> {}".format(self.__class__.__name__, self.template) + + def __contains__(self, other): + return other in self.template + + def replace(self, *args, **kwargs): + self._template = self.template.replace(*args, **kwargs) + return self + @property def template(self): return self._template From 899e59b05919a8ebd3830fd381a81aded412d5fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:12:09 +0100 Subject: [PATCH 14/38] fix which template key is used for getting last workfile --- openpype/lib/applications.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 393c83e9be..f6182c1846 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -28,7 +28,8 @@ from .profiles_filtering import filter_profiles from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, - get_workdir_with_workdir_data + get_workdir_with_workdir_data, + get_workfile_template_key ) from .python_module_tools import ( @@ -1587,14 +1588,15 @@ def _prepare_last_workfile(data, workdir): last_workfile_path = data.get("last_workfile_path") or "" if not last_workfile_path: extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(app.host_name) - if extensions: anatomy = data["anatomy"] + project_settings = data["project_settings"] + task_type = workdir_data["task"]["type"] + template_key = get_workfile_template_key( + task_type, app.host_name, project_settings=project_settings + ) # Find last workfile - file_template = anatomy.templates["work"]["file"] - # Replace {task} by '{task[name]}' for backward compatibility - if '{task}' in file_template: - file_template = file_template.replace('{task}', '{task[name]}') + file_template = str(anatomy.templates[template_key]["file"]) workdir_data.update({ "version": 1, From 43839e63f131e0bd3fc32d1d91fdb61b1db1e96d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:36:15 +0100 Subject: [PATCH 15/38] TemplatesDict does not return objected templates with 'templates' attribute --- openpype/lib/anatomy.py | 61 +++++++++++-------- openpype/lib/path_templates.py | 11 +++- .../action_create_folders.py | 1 - 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 3bcd6169e4..3d56c1f1ba 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -402,7 +402,9 @@ class AnatomyTemplates(TemplatesDict): return self.templates.get(key, default) def reset(self): + self._raw_templates = None self._templates = None + self._objected_templates = None @property def project_name(self): @@ -414,13 +416,21 @@ class AnatomyTemplates(TemplatesDict): @property def templates(self): + self._validate_discovery() + return self._templates + + @property + def objected_templates(self): + self._validate_discovery() + return self._objected_templates + + def _validate_discovery(self): if self.project_name != self.loaded_project: - self._templates = None + self.reset() if self._templates is None: self._discover() self.loaded_project = self.project_name - return self._templates def _format_value(self, value, data): if isinstance(value, RootItem): @@ -434,31 +444,34 @@ class AnatomyTemplates(TemplatesDict): def set_templates(self, templates): if not templates: - self._raw_templates = None - self._templates = None - else: - self._raw_templates = copy.deepcopy(templates) - templates = copy.deepcopy(templates) - v_queue = collections.deque() - v_queue.append(templates) - while v_queue: - item = v_queue.popleft() - if not isinstance(item, dict): - continue + self.reset() + return - for key in tuple(item.keys()): - value = item[key] - if isinstance(value, dict): - v_queue.append(value) + self._raw_templates = copy.deepcopy(templates) + templates = copy.deepcopy(templates) + v_queue = collections.deque() + v_queue.append(templates) + while v_queue: + item = v_queue.popleft() + if not isinstance(item, dict): + continue - elif ( - isinstance(value, StringType) - and "{task}" in value - ): - item[key] = value.replace("{task}", "{task[name]}") + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, dict): + v_queue.append(value) - solved_templates = self.solve_template_inner_links(templates) - self._templates = self.create_ojected_templates(solved_templates) + elif ( + isinstance(value, StringType) + and "{task}" in value + ): + item[key] = value.replace("{task}", "{task[name]}") + + solved_templates = self.solve_template_inner_links(templates) + self._templates = solved_templates + self._objected_templates = self.create_ojected_templates( + solved_templates + ) def default_templates(self): """Return default templates data with solved inner keys.""" diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 3b0e9ad3cc..370ffdd27c 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -227,15 +227,18 @@ class TemplatesDict(object): def __init__(self, templates=None): self._raw_templates = None self._templates = None + self._objected_templates = None self.set_templates(templates) def set_templates(self, templates): if templates is None: self._raw_templates = None self._templates = None + self._objected_templates = None elif isinstance(templates, dict): self._raw_templates = copy.deepcopy(templates) - self._templates = self.create_ojected_templates(templates) + self._templates = templates + self._objected_templates = self.create_ojected_templates(templates) else: raise TypeError("<{}> argument must be a dict, not {}.".format( self.__class__.__name__, str(type(templates)) @@ -255,6 +258,10 @@ class TemplatesDict(object): def templates(self): return self._templates + @property + def objected_templates(self): + return self._objected_templates + @classmethod def create_ojected_templates(cls, templates): if not isinstance(templates, dict): @@ -325,7 +332,7 @@ class TemplatesDict(object): if env_key not in data: data[env_key] = val - solved = self._solve_dict(self.templates, data) + solved = self._solve_dict(self.objected_templates, data) output = TemplatesResultDict(solved) output.strict = strict diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py index 8bbef9ad73..d15a865124 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py @@ -97,7 +97,6 @@ class CreateFolders(BaseAction): all_entities = self.get_notask_children(entity) anatomy = Anatomy(project_name) - project_settings = get_project_settings(project_name) work_keys = ["work", "folder"] work_template = anatomy.templates From e9c67e35bc2d294d1aa9a4319f4d67022079d1af Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 17:21:52 +0100 Subject: [PATCH 16/38] fixed used values passed to TemplateResult --- openpype/lib/path_templates.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 370ffdd27c..62bfdf774a 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -171,7 +171,7 @@ class StringTemplate(object): missing_keys |= result.missing_optional_keys solved = result.solved - used_values = result.split_keys_to_subdicts(result.used_values) + used_values = result.get_clean_used_values() return TemplateResult( result.output, @@ -485,7 +485,7 @@ class TemplatePartResult: self._missing_optional_keys = set() self._invalid_optional_types = {} - # Used values stored by key + # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} @@ -584,6 +584,15 @@ class TemplatePartResult: data[last_key] = value return output + def get_clean_used_values(self): + new_used_values = {} + for key, value in self.used_values.items(): + if isinstance(value, FormatObject): + value = str(value) + new_used_values[key] = value + + return self.split_keys_to_subdicts(new_used_values) + def add_realy_used_value(self, key, value): self._realy_used_values[key] = value @@ -724,7 +733,7 @@ class FormattingPart: formatted_value = self.template.format(**fill_data) result.add_realy_used_value(key, formatted_value) - result.add_used_value(existence_check, value) + result.add_used_value(existence_check, formatted_value) result.add_output(formatted_value) return result From a6392f131ee69b4b5c6979f1da577fae66dd656c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 18:56:00 +0100 Subject: [PATCH 17/38] use AVALON_APP to get value for "app" key --- openpype/hosts/nuke/api/lib.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 6faf6cd108..dba7ec1b85 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1,6 +1,5 @@ import os import re -import sys import six import platform import contextlib @@ -679,10 +678,10 @@ def get_render_path(node): } nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + host_name = os.environ.get("AVALON_APP") - application = lib.get_application(os.environ["AVALON_APP_NAME"]) data.update({ - "application": application, + "app": host_name, "nuke_imageio_writes": nuke_imageio_writes }) @@ -805,18 +804,14 @@ def create_write_node(name, data, input=None, prenodes=None, ''' imageio_writes = get_created_node_imageio_setting(**data) - app_manager = ApplicationManager() - app_name = os.environ.get("AVALON_APP_NAME") - if app_name: - app = app_manager.applications.get(app_name) - for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] + host_name = os.environ.get("AVALON_APP") try: data.update({ - "app": app.host_name, + "app": host_name, "imageio_writes": imageio_writes, "representation": representation, }) From 713b82b19c6fe6c4bae7fcc57bdd3662e30eecc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 1 Mar 2022 11:32:03 +0100 Subject: [PATCH 18/38] renaming integration --- .gitmodules | 5 +- openpype/hosts/unreal/api/__init__.py | 2 +- openpype/hosts/unreal/api/helpers.py | 2 +- openpype/hosts/unreal/api/lib.py | 18 ++--- openpype/hosts/unreal/api/pipeline.py | 74 ++++++++----------- .../unreal/hooks/pre_workfile_preparation.py | 4 +- .../integration/Content/Python/init_unreal.py | 31 ++++---- .../Private/AvalonPublishInstanceFactory.cpp | 20 ----- .../Avalon/Private/AvalonPythonBridge.cpp | 13 ---- .../Source/Avalon/Private/AvalonStyle.cpp | 69 ----------------- .../OpenPype.Build.cs} | 2 +- .../Private/AssetContainer.cpp | 0 .../Private/AssetContainerFactory.cpp | 0 .../Private/OpenPype.cpp} | 46 ++++++------ .../Private/OpenPypeLib.cpp} | 6 +- .../Private/OpenPypePublishInstance.cpp} | 30 ++++---- .../OpenPypePublishInstanceFactory.cpp | 20 +++++ .../OpenPype/Private/OpenPypePythonBridge.cpp | 13 ++++ .../Source/OpenPype/Private/OpenPypeStyle.cpp | 70 ++++++++++++++++++ .../Public/AssetContainer.h | 0 .../Public/AssetContainerFactory.h | 2 +- .../Avalon.h => OpenPype/Public/OpenPype.h} | 2 +- .../Public/OpenPypeLib.h} | 2 +- .../Public/OpenPypePublishInstance.h} | 6 +- .../Public/OpenPypePublishInstanceFactory.h} | 6 +- .../Public/OpenPypePythonBridge.h} | 6 +- .../Public/OpenPypeStyle.h} | 2 +- .../unreal/plugins/create/create_camera.py | 2 +- .../unreal/plugins/create/create_layout.py | 3 +- .../unreal/plugins/create/create_look.py | 14 ++-- .../plugins/create/create_staticmeshfbx.py | 8 +- .../load/load_alembic_geometrycache.py | 22 +++--- .../plugins/load/load_alembic_skeletalmesh.py | 19 ++--- .../plugins/load/load_alembic_staticmesh.py | 21 +++--- .../unreal/plugins/load/load_animation.py | 24 +++--- .../hosts/unreal/plugins/load/load_camera.py | 16 ++-- .../hosts/unreal/plugins/load/load_layout.py | 49 ++++++------ .../hosts/unreal/plugins/load/load_rig.py | 25 ++++--- .../unreal/plugins/load/load_staticmeshfbx.py | 26 ++++--- .../plugins/publish/collect_current_file.py | 9 ++- .../plugins/publish/collect_instances.py | 10 ++- .../unreal/plugins/publish/extract_camera.py | 8 +- .../unreal/plugins/publish/extract_layout.py | 7 +- .../unreal/plugins/publish/extract_look.py | 15 ++-- 44 files changed, 376 insertions(+), 353 deletions(-) delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp rename openpype/hosts/unreal/integration/Source/{Avalon/Avalon.Build.cs => OpenPype/OpenPype.Build.cs} (96%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Private/AssetContainer.cpp (100%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Private/AssetContainerFactory.cpp (100%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/Avalon.cpp => OpenPype/Private/OpenPype.cpp} (56%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/AvalonLib.cpp => OpenPype/Private/OpenPypeLib.cpp} (84%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/AvalonPublishInstance.cpp => OpenPype/Private/OpenPypePublishInstance.cpp} (67%) create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Public/AssetContainer.h (100%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Public/AssetContainerFactory.h (89%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/Avalon.h => OpenPype/Public/OpenPype.h} (87%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonLib.h => OpenPype/Public/OpenPypeLib.h} (88%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPublishInstance.h => OpenPype/Public/OpenPypePublishInstance.h} (65%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPublishInstanceFactory.h => OpenPype/Public/OpenPypePublishInstanceFactory.h} (61%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPythonBridge.h => OpenPype/Public/OpenPypePythonBridge.h} (71%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonStyle.h => OpenPype/Public/OpenPypeStyle.h} (95%) diff --git a/.gitmodules b/.gitmodules index 67b820a247..9920ceaad6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "repos/avalon-core"] path = repos/avalon-core - url = https://github.com/pypeclub/avalon-core.git -[submodule "repos/avalon-unreal-integration"] - path = repos/avalon-unreal-integration - url = https://github.com/pypeclub/avalon-unreal-integration.git \ No newline at end of file + url = https://github.com/pypeclub/avalon-core.git \ No newline at end of file diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index 38469e0ddb..df86c09073 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -16,7 +16,7 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") def install(): - """Install Unreal configuration for Avalon.""" + """Install Unreal configuration for OpenPype.""" print("-=" * 40) logo = '''. . diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py index 6fc89cf176..555133eae0 100644 --- a/openpype/hosts/unreal/api/helpers.py +++ b/openpype/hosts/unreal/api/helpers.py @@ -29,7 +29,7 @@ class OpenPypeHelpers(unreal.OpenPypeLib): Example: - AvalonHelpers().set_folder_color( + OpenPypeHelpers().set_folder_color( "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) ) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index e04606a333..d4a776e892 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -230,18 +230,18 @@ def create_unreal_project(project_name: str, ue_id = "{" + loaded_modules.get("BuildId") + "}" plugins_path = None - if os.path.isdir(env.get("AVALON_UNREAL_PLUGIN", "")): + if os.path.isdir(env.get("OPENPYPE_UNREAL_PLUGIN", "")): # copy plugin to correct path under project plugins_path = pr_dir / "Plugins" - avalon_plugin_path = plugins_path / "Avalon" - if not avalon_plugin_path.is_dir(): - avalon_plugin_path.mkdir(parents=True, exist_ok=True) + openpype_plugin_path = plugins_path / "OpenPype" + if not openpype_plugin_path.is_dir(): + openpype_plugin_path.mkdir(parents=True, exist_ok=True) dir_util._path_created = {} - dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), - avalon_plugin_path.as_posix()) + dir_util.copy_tree(os.environ.get("OPENPYPE_UNREAL_PLUGIN"), + openpype_plugin_path.as_posix()) - if not (avalon_plugin_path / "Binaries").is_dir() \ - or not (avalon_plugin_path / "Intermediate").is_dir(): + if not (openpype_plugin_path / "Binaries").is_dir() \ + or not (openpype_plugin_path / "Intermediate").is_dir(): dev_mode = True # data for project file @@ -304,7 +304,7 @@ def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None: """Prepare CPP Unreal Project. This function will add source files needed for project to be - rebuild along with the avalon integration plugin. + rebuild along with the OpenPype integration plugin. There seems not to be automated way to do it from command line. But there might be way to create at least those target and build files diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index c255005f31..02c89abadd 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,21 +1,17 @@ # -*- coding: utf-8 -*- -import sys import pyblish.api from avalon.pipeline import AVALON_CONTAINER_ID import unreal # noqa from typing import List - from openpype.tools.utils import host_tools - from avalon import api -AVALON_CONTAINERS = "OpenPypeContainers" +OPENPYPE_CONTAINERS = "OpenPypeContainers" def install(): - pyblish.api.register_host("unreal") _register_callbacks() _register_events() @@ -46,7 +42,7 @@ class Creator(api.Creator): def process(self): nodes = list() - with unreal.ScopedEditorTransaction("Avalon Creating Instance"): + with unreal.ScopedEditorTransaction("OpenPype Creating Instance"): if (self.options or {}).get("useSelection"): self.log.info("setting ...") print("settings ...") @@ -63,23 +59,21 @@ class Creator(api.Creator): return instance -class Loader(api.Loader): - hosts = ["unreal"] - - def ls(): - """ - List all containers found in *Content Manager* of Unreal and return + """List all containers. + + List all found in *Content Manager* of Unreal and return metadata from them. Adding `objectName` to set. + """ ar = unreal.AssetRegistryHelpers.get_asset_registry() - avalon_containers = ar.get_assets_by_class("AssetContainer", True) + openpype_containers = ar.get_assets_by_class("AssetContainer", True) # get_asset_by_class returns AssetData. To get all metadata we need to # load asset. get_tag_values() work only on metadata registered in - # Asset Registy Project settings (and there is no way to set it with + # Asset Registry Project settings (and there is no way to set it with # python short of editing ini configuration file). - for asset_data in avalon_containers: + for asset_data in openpype_containers: asset = asset_data.get_asset() data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) data["objectName"] = asset_data.asset_name @@ -89,8 +83,7 @@ def ls(): def parse_container(container): - """ - To get data from container, AssetContainer must be loaded. + """To get data from container, AssetContainer must be loaded. Args: container(str): path to container @@ -107,20 +100,19 @@ def parse_container(container): def publish(): - """Shorthand to publish from within host""" + """Shorthand to publish from within host.""" import pyblish.util return pyblish.util.publish() def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): - """Bundles *nodes* (assets) into a *container* and add metadata to it. Unreal doesn't support *groups* of assets that you can add metadata to. But it does support folders that helps to organize asset. Unfortunately those folders are just that - you cannot add any additional information - to them. `Avalon Integration Plugin`_ is providing way out - Implementing + to them. OpenPype Integration Plugin is providing way out - Implementing `AssetContainer` Blueprint class. This class when added to folder can handle metadata on it using standard :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and @@ -129,10 +121,7 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): those assets is available as `assets` property. This is list of strings starting with asset type and ending with its path: - `Material /Game/Avalon/Test/TestMaterial.TestMaterial` - - .. _Avalon Integration Plugin: - https://github.com/pypeclub/avalon-unreal-integration + `Material /Game/OpenPype/Test/TestMaterial.TestMaterial` """ # 1 - create directory for container @@ -160,10 +149,11 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): def instantiate(root, name, data, assets=None, suffix="_INS"): - """ - Bundles *nodes* into *container* marking it with metadata as publishable - instance. If assets are provided, they are moved to new path where - `AvalonPublishInstance` class asset is created and imprinted with metadata. + """Bundles *nodes* into *container*. + + Marking it with metadata as publishable instance. If assets are provided, + they are moved to new path where `OpenPypePublishInstance` class asset is + created and imprinted with metadata. This can then be collected for publishing by Pyblish for example. @@ -174,6 +164,7 @@ def instantiate(root, name, data, assets=None, suffix="_INS"): assets (list of str): list of asset paths to include in publish instance suffix (str): suffix string to append to instance name + """ container_name = "{}{}".format(name, suffix) @@ -203,7 +194,7 @@ def imprint(node, data): loaded_asset, key, str(value) ) - with unreal.ScopedEditorTransaction("Avalon containerising"): + with unreal.ScopedEditorTransaction("OpenPype containerising"): unreal.EditorAssetLibrary.save_asset(node) @@ -248,7 +239,7 @@ def show_experimental_tools(): def create_folder(root: str, name: str) -> str: - """Create new folder + """Create new folder. If folder exists, append number at the end and try again, incrementing if needed. @@ -281,8 +272,7 @@ def create_folder(root: str, name: str) -> str: def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: - """ - Moving (renaming) list of asset paths to new destination. + """Moving (renaming) list of asset paths to new destination. Args: root (str): root of the path (eg. `/Game`) @@ -316,8 +306,8 @@ def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: def create_container(container: str, path: str) -> unreal.Object: - """ - Helper function to create Asset Container class on given path. + """Helper function to create Asset Container class on given path. + This Asset Class helps to mark given path as Container and enable asset version control on it. @@ -331,7 +321,7 @@ def create_container(container: str, path: str) -> unreal.Object: Example: - create_avalon_container( + create_container( "/Game/modelingFooCharacter_CON", "modelingFooCharacter_CON" ) @@ -345,9 +335,9 @@ def create_container(container: str, path: str) -> unreal.Object: def create_publish_instance(instance: str, path: str) -> unreal.Object: - """ - Helper function to create Avalon Publish Instance on given path. - This behaves similary as :func:`create_avalon_container`. + """Helper function to create OpenPype Publish Instance on given path. + + This behaves similarly as :func:`create_openpype_container`. Args: path (str): Path where to create Publish Instance. @@ -365,13 +355,13 @@ def create_publish_instance(instance: str, path: str) -> unreal.Object: ) """ - factory = unreal.AvalonPublishInstanceFactory() + factory = unreal.OpenPypePublishInstanceFactory() tools = unreal.AssetToolsHelpers().get_asset_tools() asset = tools.create_asset(instance, path, None, factory) return asset -def cast_map_to_str_dict(map) -> dict: +def cast_map_to_str_dict(umap) -> dict: """Cast Unreal Map to dict. Helper function to cast Unreal Map object to plain old python @@ -379,10 +369,10 @@ def cast_map_to_str_dict(map) -> dict: metadata dicts. Args: - map: Unreal Map object + umap: Unreal Map object Returns: dict """ - return {str(key): str(value) for (key, value) in map.items()} + return {str(key): str(value) for (key, value) in umap.items()} diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 880dba5cfb..6b787f4da7 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -136,9 +136,9 @@ class UnrealPrelaunchHook(PreLaunchHook): f"{self.signature} creating unreal " f"project [ {unreal_project_name} ]" )) - # Set "AVALON_UNREAL_PLUGIN" to current process environment for + # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` - env_key = "AVALON_UNREAL_PLUGIN" + env_key = "OPENPYPE_UNREAL_PLUGIN" if self.launch_context.env.get(env_key): os.environ[env_key] = self.launch_context.env[env_key] diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 48e931bb04..4445abb1b0 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -1,27 +1,32 @@ import unreal -avalon_detected = True +openpype_detected = True try: from avalon import api - from avalon import unreal as avalon_unreal except ImportError as exc: - avalon_detected = False - unreal.log_error("Avalon: cannot load avalon [ {} ]".format(exc)) + openpype_detected = False + unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc)) -if avalon_detected: - api.install(avalon_unreal) +try: + from openpype.host.unreal import api as openpype_host +except ImportError as exc: + openpype_detected = False + unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) + +if openpype_detected: + api.install(openpype_host) @unreal.uclass() -class AvalonIntegration(unreal.AvalonPythonBridge): +class OpenPypeIntegration(unreal.OpenPypePythonBridge): @unreal.ufunction(override=True) def RunInPython_Popup(self): - unreal.log_warning("Avalon: showing tools popup") - if avalon_detected: - avalon_unreal.show_tools_popup() + unreal.log_warning("OpenPype: showing tools popup") + if openpype_detected: + openpype_host.show_tools_popup() @unreal.ufunction(override=True) def RunInPython_Dialog(self): - unreal.log_warning("Avalon: showing tools dialog") - if avalon_detected: - avalon_unreal.show_tools_dialog() + unreal.log_warning("OpenPype: showing tools dialog") + if openpype_detected: + openpype_host.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp deleted file mode 100644 index e14a14f1e5..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "AvalonPublishInstanceFactory.h" -#include "AvalonPublishInstance.h" - -UAvalonPublishInstanceFactory::UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer) - : UFactory(ObjectInitializer) -{ - SupportedClass = UAvalonPublishInstance::StaticClass(); - bCreateNew = false; - bEditorImport = true; -} - -UObject* UAvalonPublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) -{ - UAvalonPublishInstance* AvalonPublishInstance = NewObject(InParent, Class, Name, Flags); - return AvalonPublishInstance; -} - -bool UAvalonPublishInstanceFactory::ShouldShowInNewMenu() const { - return false; -} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp deleted file mode 100644 index 8642ab6b63..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "AvalonPythonBridge.h" - -UAvalonPythonBridge* UAvalonPythonBridge::Get() -{ - TArray AvalonPythonBridgeClasses; - GetDerivedClasses(UAvalonPythonBridge::StaticClass(), AvalonPythonBridgeClasses); - int32 NumClasses = AvalonPythonBridgeClasses.Num(); - if (NumClasses > 0) - { - return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); - } - return nullptr; -}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp deleted file mode 100644 index 5b3d1269b0..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "AvalonStyle.h" -#include "Framework/Application/SlateApplication.h" -#include "Styling/SlateStyle.h" -#include "Styling/SlateStyleRegistry.h" - - -TUniquePtr< FSlateStyleSet > FAvalonStyle::AvalonStyleInstance = nullptr; - -void FAvalonStyle::Initialize() -{ - if (!AvalonStyleInstance.IsValid()) - { - AvalonStyleInstance = Create(); - FSlateStyleRegistry::RegisterSlateStyle(*AvalonStyleInstance); - } -} - -void FAvalonStyle::Shutdown() -{ - if (AvalonStyleInstance.IsValid()) - { - FSlateStyleRegistry::UnRegisterSlateStyle(*AvalonStyleInstance); - AvalonStyleInstance.Reset(); - } -} - -FName FAvalonStyle::GetStyleSetName() -{ - static FName StyleSetName(TEXT("AvalonStyle")); - return StyleSetName; -} - -FName FAvalonStyle::GetContextName() -{ - static FName ContextName(TEXT("OpenPype")); - return ContextName; -} - -#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) - -const FVector2D Icon40x40(40.0f, 40.0f); - -TUniquePtr< FSlateStyleSet > FAvalonStyle::Create() -{ - TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); - Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("Avalon/Resources")); - - return Style; -} - -void FAvalonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) -{ - FSlateStyleSet* Style = AvalonStyleInstance.Get(); - - FString Name(GetContextName().ToString()); - Name = Name + "." + StyleName; - Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); - - - FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); -} - -#undef IMAGE_BRUSH - -const ISlateStyle& FAvalonStyle::Get() -{ - check(AvalonStyleInstance); - return *AvalonStyleInstance; -} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs similarity index 96% rename from openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs rename to openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index 5068e37d80..cf50041aed 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -2,7 +2,7 @@ using UnrealBuildTool; -public class Avalon : ModuleRules +public class OpenPype : ModuleRules { public Avalon(ReadOnlyTargetRules Target) : base(Target) { diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp similarity index 56% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index ed782f4870..65da780ad6 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,19 +1,19 @@ #include "Avalon.h" #include "LevelEditor.h" -#include "AvalonPythonBridge.h" -#include "AvalonStyle.h" +#include "OpenPypePythonBridge.h" +#include "OpenPypeStyle.h" -static const FName AvalonTabName("Avalon"); +static const FName OpenPypeTabName("OpenPype"); -#define LOCTEXT_NAMESPACE "FAvalonModule" +#define LOCTEXT_NAMESPACE "FOpenPypeModule" // This function is triggered when the plugin is staring up -void FAvalonModule::StartupModule() +void FOpenPypeModule::StartupModule() { - FAvalonStyle::Initialize(); - FAvalonStyle::SetIcon("Logo", "openpype40"); + FOpenPypeStyle::Initialize(); + FOpenPypeStyle::SetIcon("Logo", "openpype40"); // Create the Extender that will add content to the menu FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); @@ -25,13 +25,13 @@ void FAvalonModule::StartupModule() "LevelEditor", EExtensionHook::After, NULL, - FMenuExtensionDelegate::CreateRaw(this, &FAvalonModule::AddMenuEntry) + FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) ); ToolbarExtender->AddToolBarExtension( "Settings", EExtensionHook::After, NULL, - FToolBarExtensionDelegate::CreateRaw(this, &FAvalonModule::AddToobarEntry)); + FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); @@ -39,13 +39,13 @@ void FAvalonModule::StartupModule() } -void FAvalonModule::ShutdownModule() +void FOpenPypeModule::ShutdownModule() { - FAvalonStyle::Shutdown(); + FOpenPypeStyle::Shutdown(); } -void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder) { // Create Section MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); @@ -54,22 +54,22 @@ void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) MenuBuilder.AddMenuEntry( FText::FromString("Tools..."), FText::FromString("Pipeline tools"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup)) + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup)) ); MenuBuilder.AddMenuEntry( FText::FromString("Tools dialog..."), FText::FromString("Pipeline tools dialog"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuDialog)) + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog)) ); } MenuBuilder.EndSection(); } -void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) { ToolbarBuilder.BeginSection(TEXT("OpenPype")); { @@ -83,21 +83,21 @@ void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) NAME_None, LOCTEXT("OpenPype_label", "OpenPype"), LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo") + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo") ); } ToolbarBuilder.EndSection(); } -void FAvalonModule::MenuPopup() { - UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); +void FOpenPypeModule::MenuPopup() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); bridge->RunInPython_Popup(); } -void FAvalonModule::MenuDialog() { - UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); +void FOpenPypeModule::MenuDialog() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); bridge->RunInPython_Dialog(); } -IMPLEMENT_MODULE(FAvalonModule, Avalon) +IMPLEMENT_MODULE(FOpenPypeModule, OpenPype) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 84% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp index 312656424c..5facab7b8b 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp @@ -1,4 +1,4 @@ -#include "AvalonLib.h" +#include "OpenPypeLib.h" #include "Misc/Paths.h" #include "Misc/ConfigCacheIni.h" #include "UObject/UnrealType.h" @@ -10,7 +10,7 @@ * @warning This color will appear only after Editor restart. Is there a better way? */ -void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +void UOpenPypeLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) { auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) { @@ -30,7 +30,7 @@ void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, b * @param cls - class * @return TArray of properties */ -TArray UAvalonLib::GetAllProperties(UClass* cls) +TArray UOpenPypeLib::GetAllProperties(UClass* cls) { TArray Ret; if (cls != nullptr) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 67% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp index 2bb31a4853..4f1e846c0b 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -1,28 +1,28 @@ #pragma once -#include "AvalonPublishInstance.h" +#include "OpenPypePublishInstance.h" #include "AssetRegistryModule.h" -UAvalonPublishInstance::UAvalonPublishInstance(const FObjectInitializer& ObjectInitializer) +UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer) : UObject(ObjectInitializer) { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); - FString path = UAvalonPublishInstance::GetPathName(); + FString path = UOpenPypePublishInstance::GetPathName(); FARFilter Filter; Filter.PackagePaths.Add(FName(*path)); - AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAvalonPublishInstance::OnAssetAdded); - AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAvalonPublishInstance::OnAssetRemoved); - AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAvalonPublishInstance::OnAssetRenamed); + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UOpenPypePublishInstance::OnAssetRenamed); } -void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) +void UOpenPypePublishInstance::OnAssetAdded(const FAssetData& AssetData) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class @@ -38,7 +38,7 @@ void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) if (assetDir.StartsWith(*selfDir)) { // exclude self - if (assetFName != "AvalonPublishInstance") + if (assetFName != "OpenPypePublishInstance") { assets.Add(assetPath); UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); @@ -46,12 +46,12 @@ void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) } } -void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) +void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& AssetData) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class @@ -64,13 +64,13 @@ void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) FString assetDir = FPackageName::GetLongPackagePath(*split[1]); // take interest only in paths starting with path of current container - FString path = UAvalonPublishInstance::GetPathName(); + FString path = UOpenPypePublishInstance::GetPathName(); FString lpp = FPackageName::GetLongPackagePath(*path); if (assetDir.StartsWith(*selfDir)) { // exclude self - if (assetFName != "AvalonPublishInstance") + if (assetFName != "OpenPypePublishInstance") { // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); assets.Remove(assetPath); @@ -78,12 +78,12 @@ void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) } } -void UAvalonPublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +void UOpenPypePublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp new file mode 100644 index 0000000000..e61964c689 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "OpenPypePublishInstanceFactory.h" +#include "OpenPypePublishInstance.h" + +UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UOpenPypePublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UOpenPypePublishInstance* OpenPypePublishInstance = NewObject(InParent, Class, Name, Flags); + return OpenPypePublishInstance; +} + +bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp new file mode 100644 index 0000000000..767f089374 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -0,0 +1,13 @@ +#include "OpenPypePythonBridge.h" + +UOpenPypePythonBridge* UOpenPypePythonBridge::Get() +{ + TArray OpenPypePythonBridgeClasses; + GetDerivedClasses(UAvalonPythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + int32 NumClasses = OpenPypePythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp new file mode 100644 index 0000000000..a51c2d6aa5 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -0,0 +1,70 @@ +#include "OpenPypeStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr; + +void FOpenPypeStyle::Initialize() +{ + if (!OpenPypeStyleInstance.IsValid()) + { + OpenPypeStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance); + } +} + +void FOpenPypeStyle::Shutdown() +{ + if (OpenPypeStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); + OpenPypeStyleInstance.Reset(); + } +} + +FName FOpenPypeStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("OpenPypeStyle")); + return StyleSetName; +} + +FName FOpenPypeStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources")); + + return Style; +} + +void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = OpenPypeStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FOpenPypeStyle::Get() +{ + check(OpenPypeStyleInstance); + return *OpenPypeStyleInstance; + return *OpenPypeStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h similarity index 89% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h index 62b6e73640..331ce6bb50 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h @@ -10,7 +10,7 @@ * */ UCLASS() -class AVALON_API UAssetContainerFactory : public UFactory +class OPENPYPE_API UAssetContainerFactory : public UFactory { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h similarity index 87% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h index 2dd6a825ab..db3f299354 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h @@ -5,7 +5,7 @@ #include "Engine.h" -class FAvalonModule : public IModuleInterface +class FOpenPypeModule : public IModuleInterface { public: virtual void StartupModule() override; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h similarity index 88% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h index da3369970c..3b4afe1408 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h @@ -5,7 +5,7 @@ UCLASS(Blueprintable) -class AVALON_API UAvalonLib : public UObject +class OPENPYPE_API UOpenPypeLib : public UObject { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 65% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h index 7678f78924..0a27a078d7 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -1,16 +1,16 @@ #pragma once #include "Engine.h" -#include "AvalonPublishInstance.generated.h" +#include "OpenPypePublishInstance.generated.h" UCLASS(Blueprintable) -class AVALON_API UAvalonPublishInstance : public UObject +class OPENPYPE_API UOpenPypePublishInstance : public UObject { GENERATED_BODY() public: - UAvalonPublishInstance(const FObjectInitializer& ObjectInitalizer); + UOpenPypePublishInstance(const FObjectInitializer& ObjectInitalizer); UPROPERTY(EditAnywhere, BlueprintReadOnly) TArray assets; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 61% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h index 79e781c60c..a2b3abe13e 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -2,18 +2,18 @@ #include "CoreMinimal.h" #include "Factories/Factory.h" -#include "AvalonPublishInstanceFactory.generated.h" +#include "OpenPypePublishInstanceFactory.generated.h" /** * */ UCLASS() -class AVALON_API UAvalonPublishInstanceFactory : public UFactory +class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory { GENERATED_BODY() public: - UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer); virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; virtual bool ShouldShowInNewMenu() const override; }; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 71% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h index db4b16d53f..692aab2e5e 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -1,15 +1,15 @@ #pragma once #include "Engine.h" -#include "AvalonPythonBridge.generated.h" +#include "OpenPypePythonBridge.generated.h" UCLASS(Blueprintable) -class UAvalonPythonBridge : public UObject +class UOpenPypePythonBridge : public UObject { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = Python) - static UAvalonPythonBridge* Get(); + static UOpenPypePythonBridge* Get(); UFUNCTION(BlueprintImplementableEvent, Category = Python) void RunInPython_Popup() const; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h similarity index 95% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index ffb2bc7aa4..0e9400406a 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -5,7 +5,7 @@ class FSlateStyleSet; class ISlateStyle; -class FAvalonStyle +class FOpenPypeStyle { public: static void Initialize(); diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py index eda2b52be3..c2905fb6dd 100644 --- a/openpype/hosts/unreal/plugins/create/create_camera.py +++ b/openpype/hosts/unreal/plugins/create/create_camera.py @@ -16,7 +16,7 @@ class CreateCamera(Creator): family = "camera" icon = "cubes" - root = "/Game/Avalon/Instances" + root = "/Game/OpenPype/Instances" suffix = "_INS" def __init__(self, *args, **kwargs): diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py index 239b72787b..00e83cf433 100644 --- a/openpype/hosts/unreal/plugins/create/create_layout.py +++ b/openpype/hosts/unreal/plugins/create/create_layout.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from unreal import EditorLevelLibrary as ell from openpype.hosts.unreal.api.plugin import Creator from avalon.unreal import ( @@ -6,7 +7,7 @@ from avalon.unreal import ( class CreateLayout(Creator): - """Layout output for character rigs""" + """Layout output for character rigs.""" name = "layoutMain" label = "Layout" diff --git a/openpype/hosts/unreal/plugins/create/create_look.py b/openpype/hosts/unreal/plugins/create/create_look.py index 7d3913b883..59c40d3e74 100644 --- a/openpype/hosts/unreal/plugins/create/create_look.py +++ b/openpype/hosts/unreal/plugins/create/create_look.py @@ -1,10 +1,12 @@ -import unreal +# -*- coding: utf-8 -*- +"""Create look in Unreal.""" +import unreal # noqa from openpype.hosts.unreal.api.plugin import Creator -from avalon.unreal import pipeline +from openpype.hosts.unreal.api import pipeline class CreateLook(Creator): - """Shader connections defining shape look""" + """Shader connections defining shape look.""" name = "unrealLook" label = "Unreal - Look" @@ -49,14 +51,14 @@ class CreateLook(Creator): for material in materials: name = material.get_editor_property('material_slot_name') object_path = f"{full_path}/{name}.{name}" - object = unreal.EditorAssetLibrary.duplicate_loaded_asset( + unreal_object = unreal.EditorAssetLibrary.duplicate_loaded_asset( cube.get_asset(), object_path ) # Remove the default material of the cube object - object.get_editor_property('static_materials').pop() + unreal_object.get_editor_property('static_materials').pop() - object.add_material( + unreal_object.add_material( material.get_editor_property('material_interface')) self.data["members"].append(object_path) diff --git a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py index 4cc67e0f1f..700eac7366 100644 --- a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py @@ -1,12 +1,14 @@ -import unreal +# -*- coding: utf-8 -*- +"""Create Static Meshes as FBX geometry.""" +import unreal # noqa from openpype.hosts.unreal.api.plugin import Creator -from avalon.unreal import ( +from openpype.hosts.unreal.api.pipeline import ( instantiate, ) class CreateStaticMeshFBX(Creator): - """Static FBX geometry""" + """Static FBX geometry.""" name = "unrealStaticMeshMain" label = "Unreal - Static Mesh" diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index e2023e8b47..a0cd69326f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -1,12 +1,15 @@ +# -*- coding: utf-8 -*- +"""Loader for published alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline + +import unreal # noqa -class PointCacheAlembicLoader(api.Loader): +class PointCacheAlembicLoader(plugin.Loader): """Load Point Cache from Alembic""" families = ["model", "pointcache"] @@ -56,8 +59,7 @@ class PointCacheAlembicLoader(api.Loader): return task def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -76,10 +78,10 @@ class PointCacheAlembicLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -109,7 +111,7 @@ class PointCacheAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py index b652af0b89..0236bab138 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Load Skeletal Mesh alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class SkeletalMeshAlembicLoader(api.Loader): +class SkeletalMeshAlembicLoader(plugin.Loader): """Load Unreal SkeletalMesh from Alembic""" families = ["pointcache"] @@ -16,8 +18,7 @@ class SkeletalMeshAlembicLoader(api.Loader): color = "orange" def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -38,8 +39,8 @@ class SkeletalMeshAlembicLoader(api.Loader): list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and openpype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -74,7 +75,7 @@ class SkeletalMeshAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index ccec31b832..aec8b45041 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Loader for Static Mesh alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class StaticMeshAlembicLoader(api.Loader): +class StaticMeshAlembicLoader(plugin.Loader): """Load Unreal StaticMesh from Alembic""" families = ["model"] @@ -49,8 +51,7 @@ class StaticMeshAlembicLoader(api.Loader): return task def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -69,10 +70,10 @@ class StaticMeshAlembicLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -93,7 +94,7 @@ class StaticMeshAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 20baa30847..63c734b969 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -1,14 +1,16 @@ +# -*- coding: utf-8 -*- +"""Load FBX with animations.""" import os import json from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class AnimationFBXLoader(api.Loader): - """Load Unreal SkeletalMesh from FBX""" +class AnimationFBXLoader(plugin.Loader): + """Load Unreal SkeletalMesh from FBX.""" families = ["animation"] label = "Import FBX Animation" @@ -37,10 +39,10 @@ class AnimationFBXLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -62,9 +64,9 @@ class AnimationFBXLoader(api.Loader): task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() - libpath = self.fname.replace("fbx", "json") + lib_path = self.fname.replace("fbx", "json") - with open(libpath, "r") as fp: + with open(lib_path, "r") as fp: data = json.load(fp) instance_name = data.get("instance_name") @@ -127,7 +129,7 @@ class AnimationFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index b2b25eec73..c6bcfa08a9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Load camera from FBX.""" import os from avalon import api, io, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class CameraLoader(api.Loader): +class CameraLoader(plugin.Loader): """Load Unreal StaticMesh from FBX""" families = ["camera"] @@ -38,8 +40,8 @@ class CameraLoader(api.Loader): list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -109,7 +111,7 @@ class CameraLoader(api.Loader): ) # Create Asset Container - lib.create_avalon_container(container=container_name, path=asset_dir) + unreal_pipeline.create_container(container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 19d0b74e3e..a5e93a009f 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Loader for layouts.""" import os import json from pathlib import Path @@ -10,11 +12,11 @@ from unreal import FBXImportType from unreal import MathLibrary as umath from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline -class LayoutLoader(api.Loader): +class LayoutLoader(plugin.Loader): """Load Layout from a JSON file""" families = ["layout"] @@ -23,6 +25,7 @@ class LayoutLoader(api.Loader): label = "Load Layout" icon = "code-fork" color = "orange" + ASSET_ROOT = "/Game/OpenPype/Assets" def _get_asset_containers(self, path): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -40,7 +43,8 @@ class LayoutLoader(api.Loader): return asset_containers - def _get_fbx_loader(self, loaders, family): + @staticmethod + def _get_fbx_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshFBXLoader" @@ -58,7 +62,8 @@ class LayoutLoader(api.Loader): return None - def _get_abc_loader(self, loaders, family): + @staticmethod + def _get_abc_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshAlembicLoader" @@ -74,14 +79,15 @@ class LayoutLoader(api.Loader): return None - def _process_family(self, assets, classname, transform, inst_name=None): + @staticmethod + def _process_family(assets, class_name, transform, inst_name=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() - if obj.get_class().get_name() == classname: + if obj.get_class().get_name() == class_name: actor = EditorLevelLibrary.spawn_actor_from_object( obj, transform.get('translation') @@ -111,8 +117,9 @@ class LayoutLoader(api.Loader): return actors + @staticmethod def _import_animation( - self, asset_dir, path, instance_name, skeleton, actors_dict, + asset_dir, path, instance_name, skeleton, actors_dict, animation_file): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') @@ -192,10 +199,10 @@ class LayoutLoader(api.Loader): actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) - def _process(self, libpath, asset_dir, loaded=None): + def _process(self, lib_path, asset_dir, loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() - with open(libpath, "r") as fp: + with open(lib_path, "r") as fp: data = json.load(fp) all_loaders = api.discover(api.Loader) @@ -203,7 +210,7 @@ class LayoutLoader(api.Loader): if not loaded: loaded = [] - path = Path(libpath) + path = Path(lib_path) skeleton_dict = {} actors_dict = {} @@ -292,17 +299,18 @@ class LayoutLoader(api.Loader): asset_dir, path, instance_name, skeleton, actors_dict, animation_file) - def _remove_family(self, assets, components, classname, propname): + @staticmethod + def _remove_family(assets, components, class_name, prop_name): ar = unreal.AssetRegistryHelpers.get_asset_registry() objects = [] for a in assets: obj = ar.get_asset_by_object_path(a) - if obj.get_asset().get_class().get_name() == classname: + if obj.get_asset().get_class().get_name() == class_name: objects.append(obj) for obj in objects: for comp in components: - if comp.get_editor_property(propname) == obj.get_asset(): + if comp.get_editor_property(prop_name) == obj.get_asset(): comp.get_owner().destroy_actor() def _remove_actors(self, path): @@ -334,8 +342,7 @@ class LayoutLoader(api.Loader): assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh') def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -349,14 +356,14 @@ class LayoutLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + root = self.ASSET_ROOT asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -375,7 +382,7 @@ class LayoutLoader(api.Loader): self._process(self.fname, asset_dir) # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { @@ -406,7 +413,7 @@ class LayoutLoader(api.Loader): source_path = api.get_representation_path(representation) destination_path = container["namespace"] - libpath = Path(api.get_representation_path(representation)) + lib_path = Path(api.get_representation_path(representation)) self._remove_actors(destination_path) @@ -502,7 +509,7 @@ class LayoutLoader(api.Loader): if animation_file and skeleton: self._import_animation( - destination_path, libpath, + destination_path, lib_path, instance_name, skeleton, actors_dict, animation_file) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index c7d095aa21..1503477ec7 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -1,13 +1,15 @@ +# -*- coding: utf-8 -*- +"""Load Skeletal Meshes form FBX.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class SkeletalMeshFBXLoader(api.Loader): - """Load Unreal SkeletalMesh from FBX""" +class SkeletalMeshFBXLoader(plugin.Loader): + """Load Unreal SkeletalMesh from FBX.""" families = ["rig"] label = "Import FBX Skeletal Mesh" @@ -16,8 +18,7 @@ class SkeletalMeshFBXLoader(api.Loader): color = "orange" def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -31,15 +32,15 @@ class SkeletalMeshFBXLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" if options and options.get("asset_dir"): root = options["asset_dir"] asset = context.get('asset').get('name') @@ -94,7 +95,7 @@ class SkeletalMeshFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 510c4331ad..14ca39c728 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -1,13 +1,15 @@ +# -*- coding: utf-8 -*- +"""Load Static meshes form FBX.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class StaticMeshFBXLoader(api.Loader): - """Load Unreal StaticMesh from FBX""" +class StaticMeshFBXLoader(plugin.Loader): + """Load Unreal StaticMesh from FBX.""" families = ["model", "unrealStaticMesh"] label = "Import FBX Static Mesh" @@ -15,7 +17,8 @@ class StaticMeshFBXLoader(api.Loader): icon = "cube" color = "orange" - def get_task(self, filename, asset_dir, asset_name, replace): + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.FbxImportUI() import_data = unreal.FbxStaticMeshImportData() @@ -41,8 +44,7 @@ class StaticMeshFBXLoader(api.Loader): return task def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -56,15 +58,15 @@ class StaticMeshFBXLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" if options and options.get("asset_dir"): root = options["asset_dir"] asset = context.get('asset').get('name') @@ -87,7 +89,7 @@ class StaticMeshFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/publish/collect_current_file.py b/openpype/hosts/unreal/plugins/publish/collect_current_file.py index 4e828933bb..acd4c5c8d2 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_current_file.py +++ b/openpype/hosts/unreal/plugins/publish/collect_current_file.py @@ -1,17 +1,18 @@ -import unreal - +# -*- coding: utf-8 -*- +"""Collect current project path.""" +import unreal # noqa import pyblish.api class CollectUnrealCurrentFile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" + """Inject the current working file into context.""" order = pyblish.api.CollectorOrder - 0.5 label = "Unreal Current File" hosts = ['unreal'] def process(self, context): - """Inject the current working file""" + """Inject the current working file.""" current_file = unreal.Paths.get_project_file_path() context.data['currentFile'] = current_file diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py index 62676f9938..94e732d728 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_instances.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Collect publishable instances in Unreal.""" import ast -import unreal +import unreal # noqa import pyblish.api class CollectInstances(pyblish.api.ContextPlugin): - """Gather instances by AvalonPublishInstance class + """Gather instances by OpenPypePublishInstance class - This collector finds all paths containing `AvalonPublishInstance` class + This collector finds all paths containing `OpenPypePublishInstance` class asset Identifier: @@ -22,7 +24,7 @@ class CollectInstances(pyblish.api.ContextPlugin): ar = unreal.AssetRegistryHelpers.get_asset_registry() instance_containers = ar.get_assets_by_class( - "AvalonPublishInstance", True) + "OpenPypePublishInstance", True) for container_data in instance_containers: asset = container_data.get_asset() diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py index 10862fc0ef..ce53824563 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_camera.py +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract camera from Unreal.""" import os import unreal @@ -17,7 +19,7 @@ class ExtractCamera(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) fbx_filename = "{}.fbx".format(instance.name) # Perform extraction @@ -38,7 +40,7 @@ class ExtractCamera(openpype.api.Extractor): sequence, sequence.get_bindings(), unreal.FbxExportOption(), - os.path.join(stagingdir, fbx_filename) + os.path.join(staging_dir, fbx_filename) ) break @@ -49,6 +51,6 @@ class ExtractCamera(openpype.api.Extractor): 'name': 'fbx', 'ext': 'fbx', 'files': fbx_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(fbx_representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index a47187cf47..2d09b0e7bd 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import os import json import math @@ -20,7 +21,7 @@ class ExtractLayout(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) # Perform extraction self.log.info("Performing extraction..") @@ -96,7 +97,7 @@ class ExtractLayout(openpype.api.Extractor): json_data.append(json_element) json_filename = "{}.json".format(instance.name) - json_path = os.path.join(stagingdir, json_filename) + json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) @@ -108,6 +109,6 @@ class ExtractLayout(openpype.api.Extractor): 'name': 'json', 'ext': 'json', 'files': json_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_look.py b/openpype/hosts/unreal/plugins/publish/extract_look.py index 0f1539a7d5..ea39949417 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_look.py +++ b/openpype/hosts/unreal/plugins/publish/extract_look.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import json import os @@ -17,7 +18,7 @@ class ExtractLook(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) resources_dir = instance.data["resourcesDir"] ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -57,7 +58,7 @@ class ExtractLook(openpype.api.Extractor): tga_export_task.set_editor_property('automated', True) tga_export_task.set_editor_property('object', texture) tga_export_task.set_editor_property( - 'filename', f"{stagingdir}/{tga_filename}") + 'filename', f"{staging_dir}/{tga_filename}") tga_export_task.set_editor_property('prompt', False) tga_export_task.set_editor_property('selected', False) @@ -66,7 +67,7 @@ class ExtractLook(openpype.api.Extractor): json_element['tga_filename'] = tga_filename transfers.append(( - f"{stagingdir}/{tga_filename}", + f"{staging_dir}/{tga_filename}", f"{resources_dir}/{tga_filename}")) fbx_filename = f"{instance.name}_{name}.fbx" @@ -84,7 +85,7 @@ class ExtractLook(openpype.api.Extractor): task.set_editor_property('automated', True) task.set_editor_property('object', object) task.set_editor_property( - 'filename', f"{stagingdir}/{fbx_filename}") + 'filename', f"{staging_dir}/{fbx_filename}") task.set_editor_property('prompt', False) task.set_editor_property('selected', False) @@ -93,13 +94,13 @@ class ExtractLook(openpype.api.Extractor): json_element['fbx_filename'] = fbx_filename transfers.append(( - f"{stagingdir}/{fbx_filename}", + f"{staging_dir}/{fbx_filename}", f"{resources_dir}/{fbx_filename}")) json_data.append(json_element) json_filename = f"{instance.name}.json" - json_path = os.path.join(stagingdir, json_filename) + json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) @@ -113,7 +114,7 @@ class ExtractLook(openpype.api.Extractor): 'name': 'json', 'ext': 'json', 'files': json_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) From de8e1f821859def926995381403504368f6b3ba9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Mar 2022 11:54:05 +0100 Subject: [PATCH 19/38] globa: fix host name retrieving from running session --- openpype/lib/avalon_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 3ce205c499..1e8d21852b 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -952,7 +952,7 @@ class BuildWorkfile: Returns: (dict): preset per entered task name """ - host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1] + host_name = os.environ["AVALON_APP"] project_settings = get_project_settings( avalon.io.Session["AVALON_PROJECT"] ) From 3c9501ac3ceb471f98f602b52baf2aa73d2b2434 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:32:21 +0100 Subject: [PATCH 20/38] remove submodule, minor fixes --- openpype/hosts/unreal/api/__init__.py | 40 +++++++++++++++++++ .../integration/Content/Python/init_unreal.py | 4 +- .../Source/OpenPype/OpenPype.Build.cs | 2 +- .../Source/OpenPype/Private/OpenPype.cpp | 4 +- .../OpenPype/Private/OpenPypePythonBridge.cpp | 4 +- .../Source/OpenPype/Public/AssetContainer.h | 2 +- .../Source/OpenPype/Public/OpenPypeLib.h | 2 +- .../Source/OpenPype/Public/OpenPypeStyle.h | 2 +- repos/avalon-unreal-integration | 1 - 9 files changed, 51 insertions(+), 10 deletions(-) delete mode 160000 repos/avalon-unreal-integration diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index df86c09073..e70749004b 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -4,6 +4,26 @@ import logging from avalon import api as avalon from pyblish import api as pyblish import openpype.hosts.unreal +from .plugin import( + Loader, + Creator +) +from .pipeline import ( + install, + uninstall, + ls, + publish, + containerise, + show_creator, + show_loader, + show_publisher, + show_manager, + show_experimental_tools, + show_tools_dialog, + show_tools_popup, + instantiate, +) + logger = logging.getLogger("openpype.hosts.unreal") @@ -15,6 +35,26 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +__all__ = [ + "install", + "uninstall", + "Creator", + "Loader", + "ls", + "publish", + "containerise", + "show_creator", + "show_loader", + "show_publisher", + "show_manager", + "show_experimental_tools", + "show_tools_dialog", + "show_tools_popup", + "instantiate" +] + + + def install(): """Install Unreal configuration for OpenPype.""" print("-=" * 40) diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 4445abb1b0..2ecd301c25 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -4,12 +4,14 @@ openpype_detected = True try: from avalon import api except ImportError as exc: + api = None openpype_detected = False unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc)) try: - from openpype.host.unreal import api as openpype_host + from openpype.hosts.unreal import api as openpype_host except ImportError as exc: + openpype_host = None openpype_detected = False unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index cf50041aed..c30835b63d 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -4,7 +4,7 @@ using UnrealBuildTool; public class OpenPype : ModuleRules { - public Avalon(ReadOnlyTargetRules Target) : base(Target) + public OpenPype(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index 65da780ad6..15c46b3862 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,4 +1,4 @@ -#include "Avalon.h" +#include "OpenPype.h" #include "LevelEditor.h" #include "OpenPypePythonBridge.h" #include "OpenPypeStyle.h" @@ -75,7 +75,7 @@ void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) { ToolbarBuilder.AddToolBarButton( FUIAction( - FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup), + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), NULL, FIsActionChecked() diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp index 767f089374..8113231503 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -3,11 +3,11 @@ UOpenPypePythonBridge* UOpenPypePythonBridge::Get() { TArray OpenPypePythonBridgeClasses; - GetDerivedClasses(UAvalonPythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses); int32 NumClasses = OpenPypePythonBridgeClasses.Num(); if (NumClasses > 0) { - return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); } return nullptr; }; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h index 1195f95cba..3c2a360c78 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h @@ -12,7 +12,7 @@ * */ UCLASS(Blueprintable) -class AVALON_API UAssetContainer : public UAssetUserData +class OPENPYPE_API UAssetContainer : public UAssetUserData { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h index 3b4afe1408..59e9c8bd76 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h @@ -1,7 +1,7 @@ #pragma once #include "Engine.h" -#include "AvalonLib.generated.h" +#include "OpenPypeLib.generated.h" UCLASS(Blueprintable) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index 0e9400406a..fbc8bcdd5b 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -18,5 +18,5 @@ public: private: static TUniquePtr< FSlateStyleSet > Create(); - static TUniquePtr< FSlateStyleSet > AvalonStyleInstance; + static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance; }; \ No newline at end of file diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From 4fbf8f90319ce91b88025f39d1bee5125402139d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:42:28 +0100 Subject: [PATCH 21/38] =?UTF-8?q?fix=20=F0=9F=90=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/api/__init__.py | 2 +- openpype/hosts/unreal/api/helpers.py | 2 +- .../hosts/unreal/plugins/load/load_alembic_geometrycache.py | 2 +- openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py | 2 +- openpype/hosts/unreal/plugins/load/load_camera.py | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index e70749004b..1aad704c56 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -4,7 +4,7 @@ import logging from avalon import api as avalon from pyblish import api as pyblish import openpype.hosts.unreal -from .plugin import( +from .plugin import ( Loader, Creator ) diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py index 555133eae0..0b6f07f52f 100644 --- a/openpype/hosts/unreal/api/helpers.py +++ b/openpype/hosts/unreal/api/helpers.py @@ -15,7 +15,7 @@ class OpenPypeHelpers(unreal.OpenPypeLib): """ @unreal.ufunction(params=[str, unreal.LinearColor, bool]) - def set_folder_color(self, path: str, color: unreal.LinearColor) -> Bool: + def set_folder_color(self, path: str, color: unreal.LinearColor) -> None: """Set color on folder in Content Browser. This method sets color on folder in Content Browser. Unfortunately diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index a0cd69326f..027e9f4cd3 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index aec8b45041..3bcc8b476f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index c6bcfa08a9..34999faa23 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -111,7 +111,8 @@ class CameraLoader(plugin.Loader): ) # Create Asset Container - unreal_pipeline.create_container(container=container_name, path=asset_dir) + unreal_pipeline.create_container( + container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", From c654353c73b26c8a18273f9f98cbcc7f7b186fda Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:46:10 +0100 Subject: [PATCH 22/38] =?UTF-8?q?fix=20=F0=9F=90=95=E2=80=8D=F0=9F=A6=BA?= =?UTF-8?q?=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/plugins/load/load_camera.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_layout.py | 6 +++--- openpype/hosts/unreal/plugins/load/load_rig.py | 2 +- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 34999faa23..0de9470ef9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -2,8 +2,8 @@ """Load camera from FBX.""" import os -from avalon import api, io, pipeline -from openpype.hosts.unreal.api import lib, plugin +from avalon import io, pipeline +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index a5e93a009f..b802f5940a 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -12,7 +12,7 @@ from unreal import FBXImportType from unreal import MathLibrary as umath from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -356,8 +356,8 @@ class LayoutLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 1503477ec7..009d6bc656 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 14ca39c728..573e5bd7e6 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa From 5479a26b216507230694405156a6466e53bf5209 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:47:51 +0100 Subject: [PATCH 23/38] =?UTF-8?q?fix=20=F0=9F=90=A9=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/plugins/load/load_rig.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 009d6bc656..a7ecb0ef7d 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -32,8 +32,8 @@ class SkeletalMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 573e5bd7e6..c8a6964ffb 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -58,8 +58,8 @@ class StaticMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content From 6a463bfbb455321b90413b5795263f18e9d7c9b0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Mar 2022 14:35:35 +0100 Subject: [PATCH 24/38] OL-2799 - more detailed temp file name for environment json for Deadline Previous implementation probably wasn't detailed enough. --- .../repository/custom/plugins/GlobalJobPreLoad.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index ee137a2ee3..82c2494e7a 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import os import tempfile -import time +from datetime import datetime import subprocess import json import platform +import uuid from Deadline.Scripting import RepositoryUtils, FileUtils @@ -36,9 +37,11 @@ def inject_openpype_environment(deadlinePlugin): print("--- OpenPype executable: {}".format(openpype_app)) # tempfile.TemporaryFile cannot be used because of locking - export_url = os.path.join(tempfile.gettempdir(), - time.strftime('%Y%m%d%H%M%S'), - 'env.json') # add HHMMSS + delete later + temp_file_name = "{}_{}.json".format( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), + str(uuid.uuid1()) + ) + export_url = os.path.join(tempfile.gettempdir(), temp_file_name) print(">>> Temporary path: {}".format(export_url)) args = [ From ed526947ebb717d830da9f876e57b78ec6c6bed3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:32:46 +0100 Subject: [PATCH 25/38] fix adding 'root' key to format data --- openpype/lib/anatomy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 3d56c1f1ba..3fbc05ee88 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -726,10 +726,11 @@ class AnatomyTemplates(TemplatesDict): return output def format(self, data, strict=True): + copy_data = copy.deepcopy(data) roots = self.roots if roots: - data["root"] = roots - result = super(AnatomyTemplates, self).format(data) + copy_data["root"] = roots + result = super(AnatomyTemplates, self).format(copy_data) result.strict = strict return result From 32963fb56d9b0d4234f8fcc20488cbe2a0ece391 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:43:09 +0100 Subject: [PATCH 26/38] move lib out of host implementation --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 2 +- openpype/hosts/unreal/{api => }/lib.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename openpype/hosts/unreal/{api => }/lib.py (100%) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 6b787f4da7..f07e96551c 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -10,7 +10,7 @@ from openpype.lib import ( get_workdir_data, get_workfile_template_key ) -from openpype.hosts.unreal.api import lib as unreal_lib +import openpype.hosts.unreal.lib as unreal_lib class UnrealPrelaunchHook(PreLaunchHook): diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/lib.py similarity index 100% rename from openpype/hosts/unreal/api/lib.py rename to openpype/hosts/unreal/lib.py From 306eddd5493986d702ee6423b0b14e1b41629223 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:43:55 +0100 Subject: [PATCH 27/38] move install/uninstall to pipeline --- openpype/hosts/unreal/api/__init__.py | 49 +---------------------- openpype/hosts/unreal/api/pipeline.py | 56 ++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index 1aad704c56..ede71aa218 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -1,9 +1,6 @@ -import os -import logging +# -*- coding: utf-8 -*- +"""Unreal Editor OpenPype host API.""" -from avalon import api as avalon -from pyblish import api as pyblish -import openpype.hosts.unreal from .plugin import ( Loader, Creator @@ -24,17 +21,6 @@ from .pipeline import ( instantiate, ) - -logger = logging.getLogger("openpype.hosts.unreal") - -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") - - __all__ = [ "install", "uninstall", @@ -52,34 +38,3 @@ __all__ = [ "show_tools_popup", "instantiate" ] - - - -def install(): - """Install Unreal configuration for OpenPype.""" - print("-=" * 40) - logo = '''. -. - ____________ - / \\ __ \\ - \\ \\ \\/_\\ \\ - \\ \\ _____/ ______ - \\ \\ \\___// \\ \\ - \\ \\____\\ \\ \\_____\\ - \\/_____/ \\/______/ PYPE Club . -. -''' - print(logo) - print("installing OpenPype for Unreal ...") - print("-=" * 40) - logger.info("installing OpenPype for Unreal") - pyblish.register_plugin_path(str(PUBLISH_PATH)) - avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) - - -def uninstall(): - """Uninstall Unreal configuration for Avalon.""" - pyblish.deregister_plugin_path(str(PUBLISH_PATH)) - avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 02c89abadd..5a93709ada 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,22 +1,62 @@ # -*- coding: utf-8 -*- -import pyblish.api -from avalon.pipeline import AVALON_CONTAINER_ID - -import unreal # noqa +import os +import logging from typing import List -from openpype.tools.utils import host_tools + +import pyblish.api +import avalon +from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api +from openpype.tools.utils import host_tools +import openpype.hosts.unreal +import unreal # noqa + + +logger = logging.getLogger("openpype.hosts.unreal") OPENPYPE_CONTAINERS = "OpenPypeContainers" +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + def install(): - pyblish.api.register_host("unreal") + """Install Unreal configuration for OpenPype.""" + print("-=" * 40) + logo = '''. +. + ____________ + / \\ __ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ + \\ \\ \\___// \\ \\ + \\ \\____\\ \\ \\_____\\ + \\/_____/ \\/______/ PYPE Club . +. +''' + print(logo) + print("installing OpenPype for Unreal ...") + print("-=" * 40) + logger.info("installing OpenPype for Unreal") + pyblish.api.register_plugin_path(str(PUBLISH_PATH)) + api.register_plugin_path(api.Loader, str(LOAD_PATH)) + api.register_plugin_path(api.Creator, str(CREATE_PATH)) _register_callbacks() _register_events() +def uninstall(): + """Uninstall Unreal configuration for Avalon.""" + pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) + api.deregister_plugin_path(api.Loader, str(LOAD_PATH)) + api.deregister_plugin_path(api.Creator, str(CREATE_PATH)) + + def _register_callbacks(): """ TODO: Implement callbacks if supported by UE4 @@ -31,10 +71,6 @@ def _register_events(): pass -def uninstall(): - pyblish.api.deregister_host("unreal") - - class Creator(api.Creator): hosts = ["unreal"] asset_types = [] From 11b3d2cbaf0cb1a3201dae7e30a3958f51b848e8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:44:08 +0100 Subject: [PATCH 28/38] module relative path --- openpype/hosts/unreal/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e6ca1e833d..533f315df3 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -1,11 +1,12 @@ import os +import openpype.hosts def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( - os.environ["OPENPYPE_ROOT"], "openpype", "hosts", + os.path.dirname(os.path.abspath(openpype.hosts.__file__)), "unreal", "integration" ) env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path From 765ba59358e595aca45128914d8c40223fd5060c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:46:28 +0100 Subject: [PATCH 29/38] remove unused import --- openpype/hosts/unreal/api/pipeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 5a93709ada..ad64d56e9e 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -4,7 +4,6 @@ import logging from typing import List import pyblish.api -import avalon from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api From 0f8c297f85604de31d6b7c9dfddee47da1577548 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 2 Mar 2022 03:36:52 +0000 Subject: [PATCH 30/38] [Automated] Bump version --- CHANGELOG.md | 14 +++++++++----- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c945569545..348f7dc1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.9.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) @@ -14,21 +14,23 @@ - Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) - Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) -- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) **🚀 Enhancements** +- General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) - Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) -- Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) +- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) - RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) +- Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) - resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) @@ -38,13 +40,15 @@ - Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) +- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) -- Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) - Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) **Merged pull requests:** +- Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) +- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) - Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) @@ -54,10 +58,10 @@ - Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) - Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) +- General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) - Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) -- Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) diff --git a/openpype/version.py b/openpype/version.py index 0a799462ed..b41951a34c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.4" +__version__ = "3.9.0-nightly.5" diff --git a/pyproject.toml b/pyproject.toml index 44bc0acbcc..851bf3f735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.4" # OpenPype +version = "3.9.0-nightly.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From a252865b9ee3ecebf57806178e592b06eb748077 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 11:25:10 +0100 Subject: [PATCH 31/38] added missing deadline events folder --- .../custom/events/OpenPype/OpenPype.param | 37 ++++ .../custom/events/OpenPype/OpenPype.py | 191 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param create mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param new file mode 100644 index 0000000000..871ce47467 --- /dev/null +++ b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param @@ -0,0 +1,37 @@ +[State] +Type=Enum +Items=Global Enabled;Opt-In;Disabled +Category=Options +CategoryOrder=0 +CategoryIndex=0 +Label=State +Default=Global Enabled +Description=How this event plug-in should respond to events. If Global, all jobs and slaves will trigger the events for this plugin. If Opt-In, jobs and slaves can choose to trigger the events for this plugin. If Disabled, no events are triggered for this plugin. + +[PythonSearchPaths] +Type=MultiLineMultiFolder +Label=Additional Python Search Paths +Category=Options +CategoryOrder=0 +CategoryIndex=1 +Default= +Description=The list of paths to append to the PYTHONPATH environment variable. This allows the Python job to find custom modules in non-standard locations. + +[LoggingLevel] +Type=Enum +Label=Logging Level +Category=Options +CategoryOrder=0 +CategoryIndex=2 +Items=DEBUG;INFO;WARNING;ERROR +Default=DEBUG +Description=Logging level where printing will start. + +[OpenPypeExecutable] +Type=multilinemultifilename +Label=Path to OpenPype executable +Category=Job Plugins +CategoryOrder=1 +CategoryIndex=1 +Default= +Description= \ No newline at end of file diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py new file mode 100644 index 0000000000..e5e2cf52a8 --- /dev/null +++ b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py @@ -0,0 +1,191 @@ +import Deadline.Events +import Deadline.Scripting + + +def GetDeadlineEventListener(): + return OpenPypeEventListener() + + +def CleanupDeadlineEventListener(eventListener): + eventListener.Cleanup() + + +class OpenPypeEventListener(Deadline.Events.DeadlineEventListener): + """ + Called on every Deadline plugin event, used for injecting OpenPype + environment variables into rendering process. + + Expects that job already contains env vars: + AVALON_PROJECT + AVALON_ASSET + AVALON_TASK + AVALON_APP_NAME + Without these only global environment would be pulled from OpenPype + + Configure 'Path to OpenPype executable dir' in Deadlines + 'Tools > Configure Events > openpype ' + Only directory path is needed. + + """ + def __init__(self): + self.OnJobSubmittedCallback += self.OnJobSubmitted + self.OnJobStartedCallback += self.OnJobStarted + self.OnJobFinishedCallback += self.OnJobFinished + self.OnJobRequeuedCallback += self.OnJobRequeued + self.OnJobFailedCallback += self.OnJobFailed + self.OnJobSuspendedCallback += self.OnJobSuspended + self.OnJobResumedCallback += self.OnJobResumed + self.OnJobPendedCallback += self.OnJobPended + self.OnJobReleasedCallback += self.OnJobReleased + self.OnJobDeletedCallback += self.OnJobDeleted + self.OnJobErrorCallback += self.OnJobError + self.OnJobPurgedCallback += self.OnJobPurged + + self.OnHouseCleaningCallback += self.OnHouseCleaning + self.OnRepositoryRepairCallback += self.OnRepositoryRepair + + self.OnSlaveStartedCallback += self.OnSlaveStarted + self.OnSlaveStoppedCallback += self.OnSlaveStopped + self.OnSlaveIdleCallback += self.OnSlaveIdle + self.OnSlaveRenderingCallback += self.OnSlaveRendering + self.OnSlaveStartingJobCallback += self.OnSlaveStartingJob + self.OnSlaveStalledCallback += self.OnSlaveStalled + + self.OnIdleShutdownCallback += self.OnIdleShutdown + self.OnMachineStartupCallback += self.OnMachineStartup + self.OnThermalShutdownCallback += self.OnThermalShutdown + self.OnMachineRestartCallback += self.OnMachineRestart + + def Cleanup(self): + del self.OnJobSubmittedCallback + del self.OnJobStartedCallback + del self.OnJobFinishedCallback + del self.OnJobRequeuedCallback + del self.OnJobFailedCallback + del self.OnJobSuspendedCallback + del self.OnJobResumedCallback + del self.OnJobPendedCallback + del self.OnJobReleasedCallback + del self.OnJobDeletedCallback + del self.OnJobErrorCallback + del self.OnJobPurgedCallback + + del self.OnHouseCleaningCallback + del self.OnRepositoryRepairCallback + + del self.OnSlaveStartedCallback + del self.OnSlaveStoppedCallback + del self.OnSlaveIdleCallback + del self.OnSlaveRenderingCallback + del self.OnSlaveStartingJobCallback + del self.OnSlaveStalledCallback + + del self.OnIdleShutdownCallback + del self.OnMachineStartupCallback + del self.OnThermalShutdownCallback + del self.OnMachineRestartCallback + + def set_openpype_executable_path(self, job): + """ + Sets configurable OpenPypeExecutable value to job extra infos. + + GlobalJobPreLoad takes this value, pulls env vars for each task + from specific worker itself. GlobalJobPreLoad is not easily + configured, so we are configuring Event itself. + """ + openpype_execs = self.GetConfigEntryWithDefault("OpenPypeExecutable", + "") + job.SetJobExtraInfoKeyValue("openpype_executables", openpype_execs) + + Deadline.Scripting.RepositoryUtils.SaveJob(job) + + def updateFtrackStatus(self, job, statusName, createIfMissing=False): + """Updates version status on ftrack""" + pass + + def OnJobSubmitted(self, job): + # self.LogInfo("OnJobSubmitted LOGGING") + # for 1st time submit + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Render Queued") + + def OnJobStarted(self, job): + # self.LogInfo("OnJobStarted") + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Rendering") + + def OnJobFinished(self, job): + # self.LogInfo("OnJobFinished") + self.updateFtrackStatus(job, "Artist Review") + + def OnJobRequeued(self, job): + # self.LogInfo("OnJobRequeued LOGGING") + self.set_openpype_executable_path(job) + + def OnJobFailed(self, job): + pass + + def OnJobSuspended(self, job): + # self.LogInfo("OnJobSuspended LOGGING") + self.updateFtrackStatus(job, "Render Queued") + + def OnJobResumed(self, job): + # self.LogInfo("OnJobResumed LOGGING") + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Rendering") + + def OnJobPended(self, job): + # self.LogInfo("OnJobPended LOGGING") + pass + + def OnJobReleased(self, job): + pass + + def OnJobDeleted(self, job): + pass + + def OnJobError(self, job, task, report): + # self.LogInfo("OnJobError LOGGING") + pass + + def OnJobPurged(self, job): + pass + + def OnHouseCleaning(self): + pass + + def OnRepositoryRepair(self, job, *args): + pass + + def OnSlaveStarted(self, job): + # self.LogInfo("OnSlaveStarted LOGGING") + pass + + def OnSlaveStopped(self, job): + pass + + def OnSlaveIdle(self, job): + pass + + def OnSlaveRendering(self, host_name, job): + # self.LogInfo("OnSlaveRendering LOGGING") + pass + + def OnSlaveStartingJob(self, host_name, job): + # self.LogInfo("OnSlaveStartingJob LOGGING") + self.set_openpype_executable_path(job) + + def OnSlaveStalled(self, job): + pass + + def OnIdleShutdown(self, job): + pass + + def OnMachineStartup(self, job): + pass + + def OnThermalShutdown(self, job): + pass + + def OnMachineRestart(self, job): + pass From 4b1bbe668f6143bb822d14ef8869c277274903a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 11:59:08 +0100 Subject: [PATCH 32/38] removed event that should be removed --- .../custom/events/OpenPype/OpenPype.param | 37 ---- .../custom/events/OpenPype/OpenPype.py | 191 ------------------ 2 files changed, 228 deletions(-) delete mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param delete mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param deleted file mode 100644 index 871ce47467..0000000000 --- a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param +++ /dev/null @@ -1,37 +0,0 @@ -[State] -Type=Enum -Items=Global Enabled;Opt-In;Disabled -Category=Options -CategoryOrder=0 -CategoryIndex=0 -Label=State -Default=Global Enabled -Description=How this event plug-in should respond to events. If Global, all jobs and slaves will trigger the events for this plugin. If Opt-In, jobs and slaves can choose to trigger the events for this plugin. If Disabled, no events are triggered for this plugin. - -[PythonSearchPaths] -Type=MultiLineMultiFolder -Label=Additional Python Search Paths -Category=Options -CategoryOrder=0 -CategoryIndex=1 -Default= -Description=The list of paths to append to the PYTHONPATH environment variable. This allows the Python job to find custom modules in non-standard locations. - -[LoggingLevel] -Type=Enum -Label=Logging Level -Category=Options -CategoryOrder=0 -CategoryIndex=2 -Items=DEBUG;INFO;WARNING;ERROR -Default=DEBUG -Description=Logging level where printing will start. - -[OpenPypeExecutable] -Type=multilinemultifilename -Label=Path to OpenPype executable -Category=Job Plugins -CategoryOrder=1 -CategoryIndex=1 -Default= -Description= \ No newline at end of file diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py deleted file mode 100644 index e5e2cf52a8..0000000000 --- a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py +++ /dev/null @@ -1,191 +0,0 @@ -import Deadline.Events -import Deadline.Scripting - - -def GetDeadlineEventListener(): - return OpenPypeEventListener() - - -def CleanupDeadlineEventListener(eventListener): - eventListener.Cleanup() - - -class OpenPypeEventListener(Deadline.Events.DeadlineEventListener): - """ - Called on every Deadline plugin event, used for injecting OpenPype - environment variables into rendering process. - - Expects that job already contains env vars: - AVALON_PROJECT - AVALON_ASSET - AVALON_TASK - AVALON_APP_NAME - Without these only global environment would be pulled from OpenPype - - Configure 'Path to OpenPype executable dir' in Deadlines - 'Tools > Configure Events > openpype ' - Only directory path is needed. - - """ - def __init__(self): - self.OnJobSubmittedCallback += self.OnJobSubmitted - self.OnJobStartedCallback += self.OnJobStarted - self.OnJobFinishedCallback += self.OnJobFinished - self.OnJobRequeuedCallback += self.OnJobRequeued - self.OnJobFailedCallback += self.OnJobFailed - self.OnJobSuspendedCallback += self.OnJobSuspended - self.OnJobResumedCallback += self.OnJobResumed - self.OnJobPendedCallback += self.OnJobPended - self.OnJobReleasedCallback += self.OnJobReleased - self.OnJobDeletedCallback += self.OnJobDeleted - self.OnJobErrorCallback += self.OnJobError - self.OnJobPurgedCallback += self.OnJobPurged - - self.OnHouseCleaningCallback += self.OnHouseCleaning - self.OnRepositoryRepairCallback += self.OnRepositoryRepair - - self.OnSlaveStartedCallback += self.OnSlaveStarted - self.OnSlaveStoppedCallback += self.OnSlaveStopped - self.OnSlaveIdleCallback += self.OnSlaveIdle - self.OnSlaveRenderingCallback += self.OnSlaveRendering - self.OnSlaveStartingJobCallback += self.OnSlaveStartingJob - self.OnSlaveStalledCallback += self.OnSlaveStalled - - self.OnIdleShutdownCallback += self.OnIdleShutdown - self.OnMachineStartupCallback += self.OnMachineStartup - self.OnThermalShutdownCallback += self.OnThermalShutdown - self.OnMachineRestartCallback += self.OnMachineRestart - - def Cleanup(self): - del self.OnJobSubmittedCallback - del self.OnJobStartedCallback - del self.OnJobFinishedCallback - del self.OnJobRequeuedCallback - del self.OnJobFailedCallback - del self.OnJobSuspendedCallback - del self.OnJobResumedCallback - del self.OnJobPendedCallback - del self.OnJobReleasedCallback - del self.OnJobDeletedCallback - del self.OnJobErrorCallback - del self.OnJobPurgedCallback - - del self.OnHouseCleaningCallback - del self.OnRepositoryRepairCallback - - del self.OnSlaveStartedCallback - del self.OnSlaveStoppedCallback - del self.OnSlaveIdleCallback - del self.OnSlaveRenderingCallback - del self.OnSlaveStartingJobCallback - del self.OnSlaveStalledCallback - - del self.OnIdleShutdownCallback - del self.OnMachineStartupCallback - del self.OnThermalShutdownCallback - del self.OnMachineRestartCallback - - def set_openpype_executable_path(self, job): - """ - Sets configurable OpenPypeExecutable value to job extra infos. - - GlobalJobPreLoad takes this value, pulls env vars for each task - from specific worker itself. GlobalJobPreLoad is not easily - configured, so we are configuring Event itself. - """ - openpype_execs = self.GetConfigEntryWithDefault("OpenPypeExecutable", - "") - job.SetJobExtraInfoKeyValue("openpype_executables", openpype_execs) - - Deadline.Scripting.RepositoryUtils.SaveJob(job) - - def updateFtrackStatus(self, job, statusName, createIfMissing=False): - """Updates version status on ftrack""" - pass - - def OnJobSubmitted(self, job): - # self.LogInfo("OnJobSubmitted LOGGING") - # for 1st time submit - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Render Queued") - - def OnJobStarted(self, job): - # self.LogInfo("OnJobStarted") - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Rendering") - - def OnJobFinished(self, job): - # self.LogInfo("OnJobFinished") - self.updateFtrackStatus(job, "Artist Review") - - def OnJobRequeued(self, job): - # self.LogInfo("OnJobRequeued LOGGING") - self.set_openpype_executable_path(job) - - def OnJobFailed(self, job): - pass - - def OnJobSuspended(self, job): - # self.LogInfo("OnJobSuspended LOGGING") - self.updateFtrackStatus(job, "Render Queued") - - def OnJobResumed(self, job): - # self.LogInfo("OnJobResumed LOGGING") - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Rendering") - - def OnJobPended(self, job): - # self.LogInfo("OnJobPended LOGGING") - pass - - def OnJobReleased(self, job): - pass - - def OnJobDeleted(self, job): - pass - - def OnJobError(self, job, task, report): - # self.LogInfo("OnJobError LOGGING") - pass - - def OnJobPurged(self, job): - pass - - def OnHouseCleaning(self): - pass - - def OnRepositoryRepair(self, job, *args): - pass - - def OnSlaveStarted(self, job): - # self.LogInfo("OnSlaveStarted LOGGING") - pass - - def OnSlaveStopped(self, job): - pass - - def OnSlaveIdle(self, job): - pass - - def OnSlaveRendering(self, host_name, job): - # self.LogInfo("OnSlaveRendering LOGGING") - pass - - def OnSlaveStartingJob(self, host_name, job): - # self.LogInfo("OnSlaveStartingJob LOGGING") - self.set_openpype_executable_path(job) - - def OnSlaveStalled(self, job): - pass - - def OnIdleShutdown(self, job): - pass - - def OnMachineStartup(self, job): - pass - - def OnThermalShutdown(self, job): - pass - - def OnMachineRestart(self, job): - pass From d90c83a6b8b5cd7a4765a91ff47d773ffda7384f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 12:26:33 +0100 Subject: [PATCH 33/38] move pyblish ui logic into host_tools --- openpype/tools/utils/host_tools.py | 36 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index a7ad8fef3b..f9e38c0dee 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -3,8 +3,9 @@ It is possible to create `HostToolsHelper` in host implementation or use singleton approach with global functions (using helper anyway). """ - +import os import avalon.api +import pyblish.api from .lib import qt_app_context @@ -196,10 +197,29 @@ class HostToolsHelper: library_loader_tool.refresh() def show_publish(self, parent=None): - """Publish UI.""" - from avalon.tools import publish + """Try showing the most desirable publish GUI - publish.show(parent) + This function cycles through the currently registered + graphical user interfaces, if any, and presents it to + the user. + """ + + pyblish_show = self._discover_pyblish_gui() + return pyblish_show(parent) + + def _discover_pyblish_gui(): + """Return the most desirable of the currently registered GUIs""" + # Prefer last registered + guis = list(reversed(pyblish.api.registered_guis())) + for gui in guis: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + raise ImportError("No Pyblish GUI found") def get_look_assigner_tool(self, parent): """Create, cache and return look assigner tool window.""" @@ -394,3 +414,11 @@ def show_publish(parent=None): def show_experimental_tools_dialog(parent=None): _SingletonPoint.show_tool_by_name("experimental_tools", parent) + + +def get_pyblish_icon(): + pyblish_dir = os.path.abspath(os.path.dirname(pyblish.api.__file__)) + icon_path = os.path.join(pyblish_dir, "icons", "logo-32x32.svg") + if os.path.exists(icon_path): + return icon_path + return None From b22a3c9217230aff377fd083517647f326fd35da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 12:26:55 +0100 Subject: [PATCH 34/38] import qt_app_context in utils init file --- openpype/tools/utils/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index b4b0af106e..c15e9f8139 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,6 +15,7 @@ from .lib import ( get_warning_pixmap, set_style_property, DynamicQThread, + qt_app_context, ) from .models import ( @@ -39,6 +40,7 @@ __all__ = ( "get_warning_pixmap", "set_style_property", "DynamicQThread", + "qt_app_context", "RecursiveSortFilterProxyModel", ) From 4f0001c4f3ea709c02b15cc6a62ad0f4e5df4f7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 13:48:47 +0100 Subject: [PATCH 35/38] replace usages of avalon.tools with use classes from openpype.tools --- openpype/hosts/blender/api/pipeline.py | 11 ++++------- openpype/hosts/maya/api/commands.py | 4 ++-- openpype/hosts/maya/api/menu.py | 4 ++-- openpype/tools/mayalookassigner/widgets.py | 15 +++++++++------ openpype/tools/sceneinventory/model.py | 2 +- openpype/tools/sceneinventory/view.py | 12 ++++++++---- openpype/tools/standalonepublish/publish.py | 4 ++-- openpype/tools/workfiles/model.py | 4 ++-- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 0e5104fea9..6da0ba3dcb 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -202,13 +202,10 @@ def reload_pipeline(*args): avalon.api.uninstall() for module in ( - "avalon.io", - "avalon.lib", - "avalon.pipeline", - "avalon.tools.creator.app", - "avalon.tools.manager.app", - "avalon.api", - "avalon.tools", + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.api", ): module = importlib.import_module(module) importlib.reload(module) diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index c774afcc12..a1e0be2cfe 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -37,17 +37,17 @@ class ToolWindows: def edit_shader_definitions(): - from avalon.tools import lib from Qt import QtWidgets from openpype.hosts.maya.api.shader_definition_editor import ( ShaderDefinitionsEditor ) + from openpype.tools.utils import qt_app_context top_level_widgets = QtWidgets.QApplication.topLevelWidgets() main_window = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") - with lib.application(): + with qt_app_context(): window = ToolWindows.get_window("shader_definition_editor") if not window: window = ShaderDefinitionsEditor(parent=main_window) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index b1934c757d..5f0fc39bf3 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -36,7 +36,7 @@ def install(): return def deferred(): - from avalon.tools import publish + pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( MENU_NAME, @@ -80,7 +80,7 @@ def install(): command=lambda *args: host_tools.show_publish( parent=parent_widget ), - image=publish.ICON + image=pyblish_icon ) cmds.menuItem( diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index d575e647ce..e5a9968b01 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -4,8 +4,11 @@ from collections import defaultdict from Qt import QtWidgets, QtCore # TODO: expose this better in avalon core -from avalon.tools import lib -from avalon.tools.models import TreeModel +from openpype.tools.utils.models import TreeModel +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) from .models import ( AssetModel, @@ -88,8 +91,8 @@ class AssetOutliner(QtWidgets.QWidget): """Add all items from the current scene""" items = [] - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) @@ -100,8 +103,8 @@ class AssetOutliner(QtWidgets.QWidget): def get_selected_assets(self): """Add all selected items from the current scene""" - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_selected_nodes() items = commands.create_items_from_nodes(nodes) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index d2b7f8b70f..6435e5c488 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -8,7 +8,7 @@ from avalon import api, io, style, schema from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item from .lib import ( get_site_icons, diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 80f26a881d..f55a68df95 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -7,9 +7,13 @@ from Qt import QtWidgets, QtCore from avalon import io, api, style from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools import lib as tools_lib from openpype.modules import ModulesManager +from openpype.tools.utils.lib import ( + get_progress_for_repre, + iter_model_rows, + format_version +) from .switch_dialog import SwitchAssetDialog from .model import InventoryModel @@ -373,7 +377,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): if not repre_doc: continue - progress = tools_lib.get_progress_for_repre( + progress = get_progress_for_repre( repre_doc, active_site, remote_site @@ -544,7 +548,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): "toggle": selection_model.Toggle, }[options.get("mode", "select")] - for item in tools_lib.iter_model_rows(model, 0): + for item in iter_model_rows(model, 0): item = item.data(InventoryModel.ItemRole) if item.get("isGroupNode"): continue @@ -704,7 +708,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): labels = [] for version in all_versions: is_hero = version["type"] == "hero_version" - label = tools_lib.format_version(version["name"], is_hero) + label = format_version(version["name"], is_hero) labels.append(label) versions_by_label[label] = version["name"] diff --git a/openpype/tools/standalonepublish/publish.py b/openpype/tools/standalonepublish/publish.py index af269c4381..582e7eccf8 100644 --- a/openpype/tools/standalonepublish/publish.py +++ b/openpype/tools/standalonepublish/publish.py @@ -3,10 +3,10 @@ import sys import openpype import pyblish.api +from openpype.tools.utils.host_tools import show_publish def main(env): - from avalon.tools import publish # Registers pype's Global pyblish plugins openpype.install() @@ -19,7 +19,7 @@ def main(env): continue pyblish.api.register_plugin_path(path) - return publish.show() + return show_publish() if __name__ == "__main__": diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 583f495606..3425cc3df0 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -1,11 +1,11 @@ import os import logging -from Qt import QtCore, QtGui +from Qt import QtCore from avalon import style from avalon.vendor import qtawesome -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__) From 9bd774593e870e842e4889d0d198dcacdb1c4326 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 14:07:42 +0100 Subject: [PATCH 36/38] fix method arguments --- openpype/tools/utils/host_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index f9e38c0dee..6ce9e818d9 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -207,7 +207,7 @@ class HostToolsHelper: pyblish_show = self._discover_pyblish_gui() return pyblish_show(parent) - def _discover_pyblish_gui(): + def _discover_pyblish_gui(self): """Return the most desirable of the currently registered GUIs""" # Prefer last registered guis = list(reversed(pyblish.api.registered_guis())) From 171ddd66766f4e81165e605101ef160434c35909 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 2 Mar 2022 15:22:28 +0100 Subject: [PATCH 37/38] Update openpype/tools/mayalookassigner/widgets.py --- openpype/tools/mayalookassigner/widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index e5a9968b01..e546ee705d 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -3,7 +3,6 @@ from collections import defaultdict from Qt import QtWidgets, QtCore -# TODO: expose this better in avalon core from openpype.tools.utils.models import TreeModel from openpype.tools.utils.lib import ( preserve_expanded_rows, From d7b704d6e5a3eebaa5153beca41c8427be231ca2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 11:47:01 +0100 Subject: [PATCH 38/38] removed module_name logic from harmony --- openpype/hosts/harmony/api/lib.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 134f670dc4..66eeac1e3a 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -361,7 +361,7 @@ def zip_and_move(source, destination): log.debug(f"Saved '{source}' to '{destination}'") -def show(module_name): +def show(tool_name): """Call show on "module_name". This allows to make a QApplication ahead of time and always "exec_" to @@ -375,13 +375,6 @@ def show(module_name): # requests to be received properly. time.sleep(1) - # Get tool name from module name - # TODO this is for backwards compatibility not sure if `TB_sceneOpened.js` - # is automatically updated. - # Previous javascript sent 'module_name' which contained whole tool import - # string e.g. "avalon.tools.workfiles" now it should be only "workfiles" - tool_name = module_name.split(".")[-1] - kwargs = {} if tool_name == "loader": kwargs["use_context"] = True