From fa6c491d5286002e7eb6018a1b6f2c5f6dc721a2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 14 Apr 2021 10:23:39 +0100 Subject: [PATCH 01/73] Fixed alembic extraction --- openpype/hosts/blender/plugins/publish/extract_abc.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 6a89c6019b..e1eef61560 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -18,7 +18,7 @@ class ExtractABC(openpype.api.Extractor): # Define extract output file path stagingdir = self.staging_dir(instance) - filename = f"{instance.name}.fbx" + filename = f"{instance.name}.abc" filepath = os.path.join(stagingdir, filename) context = bpy.context @@ -72,9 +72,7 @@ class ExtractABC(openpype.api.Extractor): # We export the abc bpy.ops.wm.alembic_export( new_context, - filepath=filepath, - start=1, - end=1 + filepath=filepath ) view_layer.active_layer_collection = old_active_layer_collection From b1b7eda2acc33e564137d1c0690f6056b69ceaaf Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 14 Apr 2021 11:13:00 +0100 Subject: [PATCH 02/73] Implemented Alembic loader --- .../hosts/blender/plugins/load/load_model.py | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 7297e459a6..d679807534 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -7,6 +7,7 @@ from typing import Dict, List, Optional from avalon import api, blender import bpy +import openpype.api import openpype.hosts.blender.api.plugin as plugin @@ -108,6 +109,10 @@ class BlendModelLoader(plugin.AssetLoader): self.__class__.__name__, ) + print(lib_container) + print(container) + + container_metadata = container.get( blender.pipeline.AVALON_PROPERTY) @@ -271,36 +276,62 @@ class CacheModelLoader(plugin.AssetLoader): context: Full parenthood of representation to load options: Additional settings dictionary """ - raise NotImplementedError( - "Loading of Alembic files is not yet implemented.") - # TODO (jasper): implement Alembic import. libpath = self.fname asset = context["asset"]["name"] subset = context["subset"]["name"] - # TODO (jasper): evaluate use of namespace which is 'alien' to Blender. - lib_container = container_name = ( - plugin.asset_name(asset, subset, namespace) - ) - relative = bpy.context.preferences.filepaths.use_relative_paths - with bpy.data.libraries.load( - libpath, link=True, relative=relative - ) as (data_from, data_to): - data_to.collections = [lib_container] + lib_container = plugin.asset_name( + asset, subset + ) + unique_number = plugin.get_unique_number( + asset, subset + ) + namespace = namespace or f"{asset}_{unique_number}" + container_name = plugin.asset_name( + asset, subset, unique_number + ) + + container = bpy.data.collections.new(lib_container) + container.name = container_name + blender.pipeline.containerise_existing( + container, + name, + namespace, + context, + self.__class__.__name__, + ) + + container_metadata = container.get( + blender.pipeline.AVALON_PROPERTY) + + container_metadata["libpath"] = libpath + container_metadata["lib_container"] = lib_container + + bpy.ops.object.select_all(action='DESELECT') + + view_layer = bpy.context.view_layer + view_layer_collection = view_layer.active_layer_collection.collection + + relative = bpy.context.preferences.filepaths.use_relative_paths + bpy.ops.wm.alembic_import( + filepath=libpath, + relative_path=relative + ) + + parent = bpy.data.collections.new(container_name) + for obj in bpy.context.selected_objects: + parent.objects.link(obj) + view_layer_collection.objects.unlink(obj) + obj.name = f"{obj.name}:{container_name}" + obj.data.name = f"{obj.data.name}:{container_name}" + + bpy.ops.object.select_all(action='DESELECT') scene = bpy.context.scene - instance_empty = bpy.data.objects.new( - container_name, None - ) - scene.collection.objects.link(instance_empty) - instance_empty.instance_type = 'COLLECTION' - collection = bpy.data.collections[lib_container] - collection.name = container_name - instance_empty.instance_collection = collection + scene.collection.children.link(parent) - nodes = list(collection.objects) - nodes.append(collection) - nodes.append(instance_empty) + nodes = list(parent.objects) + nodes.append(parent) self[:] = nodes return nodes From 5e54d09ca1ce64d3f6cd5a9dd2248315330002f8 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 14 Apr 2021 11:37:36 +0100 Subject: [PATCH 03/73] Alembic loader is now more consistent with loaders for other formats --- .../hosts/blender/plugins/load/load_model.py | 69 +++++++++++-------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index d679807534..f587a20fa1 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -7,7 +7,6 @@ from typing import Dict, List, Optional from avalon import api, blender import bpy -import openpype.api import openpype.hosts.blender.api.plugin as plugin @@ -109,10 +108,6 @@ class BlendModelLoader(plugin.AssetLoader): self.__class__.__name__, ) - print(lib_container) - print(container) - - container_metadata = container.get( blender.pipeline.AVALON_PROPERTY) @@ -265,6 +260,41 @@ class CacheModelLoader(plugin.AssetLoader): icon = "code-fork" color = "orange" + def _process(self, libpath, container_name, parent_collection): + bpy.ops.object.select_all(action='DESELECT') + + view_layer = bpy.context.view_layer + view_layer_collection = view_layer.active_layer_collection.collection + + relative = bpy.context.preferences.filepaths.use_relative_paths + bpy.ops.wm.alembic_import( + filepath=libpath, + relative_path=relative + ) + + parent = parent_collection + + if parent is None: + parent = bpy.context.scene.collection + + model_container = bpy.data.collections.new(container_name) + parent.children.link(model_container) + for obj in bpy.context.selected_objects: + model_container.objects.link(obj) + view_layer_collection.objects.unlink(obj) + obj.name = f"{obj.name}:{container_name}" + obj.data.name = f"{obj.data.name}:{container_name}" + + if not obj.get(blender.pipeline.AVALON_PROPERTY): + obj[blender.pipeline.AVALON_PROPERTY] = dict() + + avalon_info = obj[blender.pipeline.AVALON_PROPERTY] + avalon_info.update({"container_name": container_name}) + + bpy.ops.object.select_all(action='DESELECT') + + return model_container + def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, options: Optional[Dict] = None @@ -308,30 +338,15 @@ class CacheModelLoader(plugin.AssetLoader): container_metadata["libpath"] = libpath container_metadata["lib_container"] = lib_container - bpy.ops.object.select_all(action='DESELECT') + obj_container = self._process( + libpath, container_name, None) - view_layer = bpy.context.view_layer - view_layer_collection = view_layer.active_layer_collection.collection + container_metadata["obj_container"] = obj_container - relative = bpy.context.preferences.filepaths.use_relative_paths - bpy.ops.wm.alembic_import( - filepath=libpath, - relative_path=relative - ) + # Save the list of objects in the metadata container + container_metadata["objects"] = obj_container.all_objects - parent = bpy.data.collections.new(container_name) - for obj in bpy.context.selected_objects: - parent.objects.link(obj) - view_layer_collection.objects.unlink(obj) - obj.name = f"{obj.name}:{container_name}" - obj.data.name = f"{obj.data.name}:{container_name}" - - bpy.ops.object.select_all(action='DESELECT') - - scene = bpy.context.scene - scene.collection.children.link(parent) - - nodes = list(parent.objects) - nodes.append(parent) + nodes = list(container.objects) + nodes.append(container) self[:] = nodes return nodes From e08b04a5ba137190cb16e14d95f2c7a024d6a401 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 14 Apr 2021 15:13:31 +0100 Subject: [PATCH 04/73] Implemented update and remove functions --- .../hosts/blender/plugins/load/load_model.py | 136 +++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index f587a20fa1..db12585efb 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -260,6 +260,12 @@ class CacheModelLoader(plugin.AssetLoader): icon = "code-fork" color = "orange" + def _remove(self, objects, container): + for obj in list(objects): + bpy.data.meshes.remove(obj.data) + + bpy.data.collections.remove(container) + def _process(self, libpath, container_name, parent_collection): bpy.ops.object.select_all(action='DESELECT') @@ -282,8 +288,20 @@ class CacheModelLoader(plugin.AssetLoader): for obj in bpy.context.selected_objects: model_container.objects.link(obj) view_layer_collection.objects.unlink(obj) - obj.name = f"{obj.name}:{container_name}" - obj.data.name = f"{obj.data.name}:{container_name}" + + name = obj.name + data_name = obj.data.name + obj.name = f"{name}:{container_name}" + obj.data.name = f"{data_name}:{container_name}" + + # Blender handles alembic with a modifier linked to a cache file. + # Here we create the modifier for the object and link it with the + # loaded cache file. + modifier = obj.modifiers.new( + name="MeshSequenceCache", type='MESH_SEQUENCE_CACHE') + cache_file = bpy.path.basename(libpath) + modifier.cache_file = bpy.data.cache_files[cache_file] + modifier.object_path = f"/{name}/{data_name}" if not obj.get(blender.pipeline.AVALON_PROPERTY): obj[blender.pipeline.AVALON_PROPERTY] = dict() @@ -350,3 +368,117 @@ class CacheModelLoader(plugin.AssetLoader): nodes.append(container) self[:] = nodes return nodes + + def update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all objects of the current collection, load the new + ones and add them to the collection. + If the objects of the collection are used in another collection they + will not be removed, only unlinked. Normally this should not be the + case though. + + Warning: + No nested collections are supported at the moment! + """ + collection = bpy.data.collections.get( + container["objectName"] + ) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert collection, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert not (collection.children), ( + "Nested collections are not supported." + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + collection_metadata = collection.get( + blender.pipeline.AVALON_PROPERTY) + collection_libpath = collection_metadata["libpath"] + + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + normalized_collection_libpath = ( + str(Path(bpy.path.abspath(collection_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_collection_libpath, + normalized_libpath, + ) + if normalized_collection_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + # Check if the cache file has already been loaded + if bpy.path.basename(str(libpath)) not in bpy.data.cache_files: + bpy.ops.cachefile.open(filepath=str(libpath)) + + # Set the new cache file in the objects that use the modifier + for obj in objects: + for modifier in obj.modifiers: + if modifier.type == 'MESH_SEQUENCE_CACHE': + cache_file = bpy.path.basename(str(libpath)) + modifier.cache_file = bpy.data.cache_files[cache_file] + + def remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + + Warning: + No nested collections are supported at the moment! + """ + collection = bpy.data.collections.get( + container["objectName"] + ) + if not collection: + return False + assert not (collection.children), ( + "Nested collections are not supported." + ) + + collection_metadata = collection.get( + blender.pipeline.AVALON_PROPERTY) + + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + self._remove(objects, obj_container) + + bpy.data.collections.remove(collection) + + # We should delete the cache file used in the modifier too, + # but Blender does not allow to do that from python. + + return True From 4a8339dd12ccec175eee24219db5bd9eab90617a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 14 Apr 2021 15:14:07 +0100 Subject: [PATCH 05/73] Added .abc as valid extension --- openpype/hosts/blender/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index eb88e7af63..de30da3319 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -9,7 +9,7 @@ from avalon import api import avalon.blender from openpype.api import PypeCreatorMixin -VALID_EXTENSIONS = [".blend", ".json"] +VALID_EXTENSIONS = [".blend", ".json", ".abc"] def asset_name( From 5f58f18cd95d71835614ff51f7107dece5202121 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 14 Apr 2021 15:59:15 +0100 Subject: [PATCH 06/73] Fixed problem that published objects not in the instance --- openpype/hosts/blender/plugins/publish/extract_abc.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index e1eef61560..a7653d9f5a 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -52,6 +52,8 @@ class ExtractABC(openpype.api.Extractor): old_scale = scene.unit_settings.scale_length + bpy.ops.object.select_all(action='DESELECT') + selected = list() for obj in instance: @@ -67,12 +69,11 @@ class ExtractABC(openpype.api.Extractor): # We set the scale of the scene for the export scene.unit_settings.scale_length = 0.01 - self.log.info(new_context) - # We export the abc bpy.ops.wm.alembic_export( new_context, - filepath=filepath + filepath=filepath, + selected=True ) view_layer.active_layer_collection = old_active_layer_collection From 5b907a9d661ea376fba05de86cd29b338aa21fc5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 15 Apr 2021 15:39:55 +0200 Subject: [PATCH 07/73] Initial version of column filtering --- openpype/modules/sync_server/tray/lib.py | 7 + openpype/modules/sync_server/tray/models.py | 140 +++++++++++++---- openpype/modules/sync_server/tray/widgets.py | 155 ++++++++++++++++++- 3 files changed, 262 insertions(+), 40 deletions(-) diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 0282d79ea1..051567ed6c 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -1,4 +1,5 @@ from Qt import QtCore +import attr from openpype.lib import PypeLogger @@ -20,8 +21,14 @@ ProviderRole = QtCore.Qt.UserRole + 2 ProgressRole = QtCore.Qt.UserRole + 4 DateRole = QtCore.Qt.UserRole + 6 FailedRole = QtCore.Qt.UserRole + 8 +HeaderNameRole = QtCore.Qt.UserRole + 10 +@attr.s +class FilterDefinition: + type = attr.ib() + values = attr.ib(factory=list) + def pretty_size(value, suffix='B'): for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: if abs(value) < 1024.0: diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 3cc53c6ec4..444422c56a 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -56,6 +56,10 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): """Returns project""" return self._project + @property + def column_filtering(self): + return self._column_filtering + def rowCount(self, _index): return len(self._data) @@ -65,7 +69,15 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): def headerData(self, section, orientation, role): if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - return self.COLUMN_LABELS[section][1] + name = self.COLUMN_LABELS[section][0] + txt = "" + if name in self.column_filtering.keys(): + txt = "(F)" + return self.COLUMN_LABELS[section][1] + txt # return label + + if role == lib.HeaderNameRole: + if orientation == Qt.Horizontal: + return self.COLUMN_LABELS[section][0] # return name def get_header_index(self, value): """ @@ -190,6 +202,35 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self.word_filter = word_filter self.refresh() + def get_column_filter_values(self, index): + """ + Returns list of available values for filtering in the column + + Args: + index(int): index of column in header + + Returns: + (dict) of value: label shown in filtering menu + 'value' is used in MongoDB query, 'label' is human readable for + menu + for some columns ('subset') might be 'value' and 'label' same + """ + column_name = self._header[index] + + filter_def = self.COLUMN_FILTERS.get(column_name) + if not filter_def: + return {} + + if filter_def['type'] == 'predefined_set': + return dict(filter_def['values']) + elif filter_def['type'] == 'available_values': + recs = self.dbcon.find({'type': column_name}, {"name": 1, + "_id": -1}) + values = {} + for item in recs: + values[item["name"]] = item["name"] + return dict(sorted(values.items(), key=lambda item: item[1])) + def set_project(self, project): """ Changes project, called after project selection is changed @@ -251,7 +292,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): ("files_count", "Files"), ("files_size", "Size"), ("priority", "Priority"), - ("state", "Status") + ("status", "Status") ] DEFAULT_SORT = { @@ -259,18 +300,26 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): "_id": 1 } SORT_BY_COLUMN = [ - "context.asset", # asset - "context.subset", # subset - "context.version", # version - "context.representation", # representation + "asset", # asset + "subset", # subset + "version", # version + "representation", # representation "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt "files_count", # count of files "files_size", # file size of all files "context.asset", # priority TODO - "status" # state + "status" # status ] + COLUMN_FILTERS = { + 'status': {'type': 'predefined_set', + 'values': {k: v for k, v in lib.STATUS.items()}}, + 'subset': {'type': 'available_values'}, + 'asset': {'type': 'available_values'}, + 'representation': {'type': 'available_values'} + } + refresh_started = QtCore.Signal() refresh_finished = QtCore.Signal() @@ -297,7 +346,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): files_count = attr.ib(default=None) files_size = attr.ib(default=None) priority = attr.ib(default=None) - state = attr.ib(default=None) + status = attr.ib(default=None) path = attr.ib(default=None) def __init__(self, sync_server, header, project=None): @@ -308,6 +357,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._rec_loaded = 0 self._total_records = 0 # how many documents query actually found self.word_filter = None + self._column_filtering = {} self._initialized = False if not self._project or self._project == lib.DUMMY_PROJECT: @@ -359,9 +409,9 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': - return item.state == lib.STATUS[2] and item.local_progress < 1 + return item.status == lib.STATUS[2] and item.local_progress < 1 if header_value == 'remote_site': - return item.state == lib.STATUS[2] and item.remote_progress < 1 + return item.status == lib.STATUS[2] and item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate @@ -397,7 +447,6 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): remote_site) for repre in result.get("paginatedResults"): - context = repre.get("context").pop() files = repre.get("files", []) if isinstance(files, dict): # aggregate returns dictionary files = [files] @@ -420,17 +469,17 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): avg_progress_local = lib.convert_progress( repre.get('avg_progress_local', '0')) - if context.get("version"): - version = "v{:0>3d}".format(context.get("version")) + if repre.get("version"): + version = "v{:0>3d}".format(repre.get("version")) else: version = "master" item = self.SyncRepresentation( repre.get("_id"), - context.get("asset"), - context.get("subset"), + repre.get("asset"), + repre.get("subset"), version, - context.get("representation"), + repre.get("representation"), local_updated, remote_updated, local_site, @@ -461,7 +510,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'sync_dt' - same for remote side 'local_site' - progress of repr on local side, 1 = finished 'remote_site' - progress on remote side, calculates from files - 'state' - + 'status' - 0 - in progress 1 - failed 2 - queued @@ -481,7 +530,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): if limit == 0: limit = SyncRepresentationSummaryModel.PAGE_SIZE - return [ + aggr = [ {"$match": self.get_match_part()}, {'$unwind': '$files'}, # merge potentially unwinded records back to single per repre @@ -584,16 +633,43 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'paused_local': {'$sum': '$paused_local'}, 'updated_dt_local': {'$max': "$updated_dt_local"} }}, - {"$project": self.projection}, - {"$sort": self.sort}, - { + {"$project": self.projection} + ] + + if self.column_filtering: + aggr.append( + {"$match": self.column_filtering} + ) + print(self.column_filtering) + + aggr.extend( + [{"$sort": self.sort}, + { '$facet': { 'paginatedResults': [{'$skip': self._rec_loaded}, {'$limit': limit}], 'totalCount': [{'$count': 'count'}] } - } - ] + }] + ) + + return aggr + + def set_column_filtering(self, checked_values): + """ + Sets dictionary used in '$match' part of MongoDB aggregate + + Args: + checked_values(dict): key:values ({'status':{1:"Foo",3:"Bar"}} + + Modifies: + self._column_filtering : {'status': {'$in': [1, 2, 3]}} + """ + filtering = {} + for key, dict_value in checked_values.items(): + filtering[key] = {'$in': list(dict_value.keys())} + + self._column_filtering = filtering def get_match_part(self): """ @@ -639,10 +715,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): (dict) """ return { - "context.subset": 1, - "context.asset": 1, - "context.version": 1, - "context.representation": 1, + "subset": {"$first": "$context.subset"}, + "asset": {"$first": "$context.asset"}, + "version": {"$first": "$context.version"}, + "representation": {"$first": "$context.representation"}, "data.path": 1, "files": 1, 'files_count': 1, @@ -721,7 +797,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): ("remote_site", "Remote site"), ("files_size", "Size"), ("priority", "Priority"), - ("state", "Status") + ("status", "Status") ] PAGE_SIZE = 30 @@ -734,7 +810,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): "updated_dt_remote", # remote created_dt "size", # remote progress "context.asset", # priority TODO - "status" # state + "status" # status ] refresh_started = QtCore.Signal() @@ -759,7 +835,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): remote_progress = attr.ib(default=None) size = attr.ib(default=None) priority = attr.ib(default=None) - state = attr.ib(default=None) + status = attr.ib(default=None) tries = attr.ib(default=None) error = attr.ib(default=None) path = attr.ib(default=None) @@ -821,9 +897,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': - return item.state == lib.STATUS[2] and item.local_progress < 1 + return item.status == lib.STATUS[2] and item.local_progress < 1 if header_value == 'remote_site': - return item.state == lib.STATUS[2] and item.remote_progress < 1 + return item.status == lib.STATUS[2] and item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 5071ffa2b0..5719d13716 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -1,6 +1,7 @@ import os import subprocess import sys +from functools import partial from Qt import QtWidgets, QtCore, QtGui from Qt.QtCore import Qt @@ -91,7 +92,7 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) + #menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} if self.sync_server.is_project_paused(self.project_name): @@ -150,7 +151,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("files_count", 50), ("files_size", 60), ("priority", 50), - ("state", 110) + ("status", 110) ) def __init__(self, sync_server, project=None, parent=None): @@ -217,6 +218,14 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) + self.checked_values = {} + + self.horizontal_header = self.table_view.horizontalHeader() + self.horizontal_header.setContextMenuPolicy( + QtCore.Qt.CustomContextMenu) + self.horizontal_header.customContextMenuRequested.connect( + self._on_section_clicked) + def _selection_changed(self, _new_selection): index = self.selection_model.currentIndex() self._selected_id = \ @@ -246,6 +255,136 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.sync_server, _id, self.table_view.model().project) detail_window.exec() + def _on_section_clicked(self, point): + + logical_index = self.horizontal_header.logicalIndexAt(point) + + model = self.table_view.model() + column_name = model.headerData(logical_index, + Qt.Horizontal, lib.HeaderNameRole) + items_dict = model.get_column_filter_values(logical_index) + + if not items_dict: + return + + menu = QtWidgets.QMenu(self) + + # text filtering only if labels same as values, not if codes are used + if list(items_dict.keys())[0] == list(items_dict.values())[0]: + self.line_edit = QtWidgets.QLineEdit(self) + self.line_edit.setPlaceholderText("Type and enter...") + action_le = QtWidgets.QWidgetAction(menu) + action_le.setDefaultWidget(self.line_edit) + self.line_edit.returnPressed.connect( + partial(self._apply_text_filter, column_name, items_dict)) + menu.addAction(action_le) + menu.addSeparator() + + action_all = QtWidgets.QAction("All", self) + state_checked = 2 + # action_all.triggered.connect(partial(self._apply_filter, column_name, + # items_dict, state_checked)) + action_all.triggered.connect(partial(self._reset_filter, column_name)) + menu.addAction(action_all) + + action_none = QtWidgets.QAction("Unselect all", self) + state_unchecked = 0 + action_none.triggered.connect(partial(self._apply_filter, column_name, + items_dict, state_unchecked)) + menu.addAction(action_none) + menu.addSeparator() + + # nothing explicitly >> ALL implicitly >> first time + if self.checked_values.get(column_name) is None: + checked_keys = items_dict.keys() + else: + checked_keys = self.checked_values[column_name] + + for value, label in items_dict.items(): + checkbox = QtWidgets.QCheckBox(str(label), menu) + if value in checked_keys: + checkbox.setChecked(True) + + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + checkbox.stateChanged.connect(partial(self._apply_filter, + column_name, {value: label})) + menu.addAction(action) + + self.menu = menu + self.menu_items_dict = items_dict # all available items + self.menu.exec_(QtGui.QCursor.pos()) + + def _reset_filter(self, column_name): + """ + Remove whole column from filter >> not in $match at all (faster) + """ + if self.checked_values.get(column_name) is not None: + self.checked_values.pop(column_name) + self._refresh_model_and_menu(column_name, True, True) + + def _apply_filter(self, column_name, values, state): + """ + Sets 'values' to specific 'state' (checked/unchecked), + sends to model. + """ + self._update_checked_values(column_name, values, state) + self._refresh_model_and_menu(column_name, True, False) + + def _apply_text_filter(self, column_name, items): + """ + Resets all checkboxes, prefers inserted text. + """ + self._update_checked_values(column_name, items, 0) # reset other + text_item = {self.line_edit.text(): self.line_edit.text()} + self._update_checked_values(column_name, text_item, 2) + self._refresh_model_and_menu(column_name, True, True) + + def _refresh_model_and_menu(self, column_name, model=True, menu=True): + """ + Refresh model and its content and possibly menu for big changes. + """ + if model: + self.table_view.model().set_column_filtering(self.checked_values) + self.table_view.model().refresh() + if menu: + self._menu_refresh(column_name) + + def _menu_refresh(self, column_name): + """ + Reset boxes after big change - word filtering or reset + """ + for action in self.menu.actions(): + if not isinstance(action, QtWidgets.QWidgetAction): + continue + + widget = action.defaultWidget() + if not isinstance(widget, QtWidgets.QCheckBox): + continue + + if not self.checked_values.get(column_name) or \ + widget.text() in self.checked_values[column_name].values(): + widget.setChecked(True) + else: + widget.setChecked(False) + + def _update_checked_values(self, column_name, values, state): + """ + Modify dictionary of set values in columns for filtering. + + Modifies 'self.checked_values' + """ + checked = self.checked_values.get(column_name, self.menu_items_dict) + set_items = dict(values.items()) # prevent dictionary change during iter + for value, label in set_items.items(): + if state == 2: # checked + checked[value] = label + elif state == 0 and checked.get(value): + checked.pop(value) + + self.checked_values[column_name] = checked + def _on_context_menu(self, point): """ Shows menu with loader actions on Right-click. @@ -291,17 +430,17 @@ class SyncRepresentationWidget(QtWidgets.QWidget): else: self.site_name = remote_site - if self.item.state in [lib.STATUS[0], lib.STATUS[1]]: + if self.item.status in [lib.STATUS[0], lib.STATUS[1]]: action = QtWidgets.QAction("Pause") actions_mapping[action] = self._pause menu.addAction(action) - if self.item.state == lib.STATUS[3]: + if self.item.status == lib.STATUS[3]: action = QtWidgets.QAction("Unpause") actions_mapping[action] = self._unpause menu.addAction(action) - # if self.item.state == lib.STATUS[1]: + # if self.item.status == lib.STATUS[1]: # action = QtWidgets.QAction("Open error detail") # actions_mapping[action] = self._show_detail # menu.addAction(action) @@ -467,7 +606,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): ("remote_site", 185), ("size", 60), ("priority", 25), - ("state", 110) + ("status", 110) ) def __init__(self, sync_server, _id=None, project=None, parent=None): @@ -579,7 +718,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.item = self.table_view.model()._data[point_index.row()] menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) + #menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -604,7 +743,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): actions_kwargs_mapping[action] = {'site': site} menu.addAction(action) - if self.item.state == lib.STATUS[2]: + if self.item.status == lib.STATUS[2]: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail menu.addAction(action) From 0f1e8b5de256e8c0f2c63a1c81d5c67236d5581b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 15 Apr 2021 18:21:44 +0200 Subject: [PATCH 08/73] add redshift proxy support --- .../plugins/create/create_redshift_proxy.py | 23 ++++ .../maya/plugins/load/load_redshift_proxy.py | 129 ++++++++++++++++++ .../plugins/publish/extract_redshift_proxy.py | 84 ++++++++++++ openpype/plugins/publish/integrate_new.py | 3 +- 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/maya/plugins/create/create_redshift_proxy.py create mode 100644 openpype/hosts/maya/plugins/load/load_redshift_proxy.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py diff --git a/openpype/hosts/maya/plugins/create/create_redshift_proxy.py b/openpype/hosts/maya/plugins/create/create_redshift_proxy.py new file mode 100644 index 0000000000..419a8d99d4 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_redshift_proxy.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Creator of Redshift proxy subset types.""" + +from openpype.hosts.maya.api import plugin, lib + + +class CreateRedshiftProxy(plugin.Creator): + """Create instance of Redshift Proxy subset.""" + + name = "redshiftproxy" + label = "Redshift Proxy" + family = "redshiftproxy" + icon = "gears" + + def __init__(self, *args, **kwargs): + super(CreateRedshiftProxy, self).__init__(*args, **kwargs) + + animation_data = lib.collect_animation_data() + + self.data["animation"] = False + self.data["proxyFrameStart"] = animation_data["frameStart"] + self.data["proxyFrameEnd"] = animation_data["frameEnd"] + self.data["proxyFrameStep"] = animation_data["step"] diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py new file mode 100644 index 0000000000..9836cd1b17 --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""Loader for Redshift proxy.""" +from avalon.maya import lib +from avalon import api +from openpype.api import get_project_settings +import os +import maya.cmds as cmds + + +class RedshiftProxyLoader(api.Loader): + """Load Redshift proxy""" + + families = ["redshiftproxy"] + representations = ["vrmesh"] + + label = "Import Redshift Proxy" + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, options=None): + """Plugin entry point.""" + + from avalon.maya.pipeline import containerise + from openpype.hosts.maya.api.lib import namespaced + + try: + family = context["representation"]["context"]["family"] + except ValueError: + family = "redshiftproxy" + + asset_name = context['asset']["name"] + namespace = namespace or lib.unique_namespace( + asset_name + "_", + prefix="_" if asset_name[0].isdigit() else "", + suffix="_", + ) + + # Ensure Redshift for Maya is loaded. + cmds.loadPlugin("redshift4maya", quiet=True) + + with lib.maintained_selection(): + cmds.namespace(addNamespace=namespace) + with namespaced(namespace, new=False): + nodes, group_node = self.create_redshift_proxy(name, + filename=self.fname) + + self[:] = nodes + if not nodes: + return + + # colour the group node + 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) + cmds.setAttr("{0}.outlinerColor".format(group_node), + c[0], c[1], c[2]) + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__) + + def update(self, container, representation): + + node = container['objectName'] + assert cmds.objExists(node), "Missing container" + + members = cmds.sets(node, query=True) or [] + rs_meshes = cmds.ls(members, type="RedshiftProxyMesh") + assert rs_meshes, "Cannot find RedshiftProxyMesh in container" + + filename = api.get_representation_path(representation) + + for rs_mesh in rs_meshes: + cmds.setAttr("{}.fileName".format(rs_mesh), + filename, + type="string") + + # Update metadata + cmds.setAttr("{}.representation".format(node), + str(representation["_id"]), + type="string") + + def remove(self, container): + + # Delete container and its contents + if cmds.objExists(container['objectName']): + members = cmds.sets(container['objectName'], query=True) or [] + cmds.delete([container['objectName']] + members) + + # Remove the namespace, if empty + namespace = container['namespace'] + if cmds.namespace(exists=namespace): + members = cmds.namespaceInfo(namespace, listNamespace=True) + if not members: + cmds.namespace(removeNamespace=namespace) + else: + self.log.warning("Namespace not deleted because it " + "still has members: %s", namespace) + + def switch(self, container, representation): + self.update(container, representation) + + def create_rs_proxy(self, name, path): + """Creates Redshift Proxies showing a proxy object. + + Args: + name (str): Proxy name. + path (str): Path to proxy file. + + Returns: + node + """ + import pymel.core as pm + + proxy_mesh_node = pm.createNode('RedshiftProxyMesh') + proxy_mesh_node.fileName.set(path) + proxy_mesh_shape = pm.createNode('mesh', n=name) + proxy_mesh_node.outMesh >> proxy_mesh_shape.inMesh + + # assign default material + pm.sets('initialShadingGroup', fe=proxy_mesh_shape) + + return proxy_mesh_node, proxy_mesh_shape \ No newline at end of file diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py new file mode 100644 index 0000000000..9dc401858e --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""Redshift Proxy extractor.""" +import os +import math + +import avalon.maya +import openpype.api + +from maya import cmds + + +class ExtractRedshiftProxy(openpype.api.Extractor): + """Extract the content of the instance to a redshift proxy file.""" + + label = "Redshift Proxy (.rs)" + hosts = ["maya"] + families = ["redshiftproxy"] + + def process(self, instance): + """Extractor entry point.""" + + staging_dir = self.staging_dir(instance) + file_name = "{}.rs".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + + anim_on = instance.data["animation"] + rs_options = "exportConnectivity=0;enableCompression=1;keepUnused=0;" + repr_files = file_name + + if not anim_on: + # Remove animation information because it is not required for + # non-animated subsets + instance.data.pop("proxyFrameStart", None) + instance.data.pop("proxyFrameEnd", None) + + else: + start_frame = instance.data["proxyFrameStart"] + end_frame = instance.data["proxyFrameEnd"] + rs_options = "{}startFrame={};endFrame={};frameStep={};".format( + rs_options, start_frame, + end_frame, instance.data["proxyFrameStep"] + ) + + root, ext = os.path.splitext(file_path) + # Padding is taken from number of digits of the end_frame. + # Not sure where Redshift is taking it. + repr_files = ["{}.{}{}".format( + root, + str(frame).rjust( + int(math.log10(int(end_frame)) + 1), "0"), + ext, + ) for frame in range( + int(start_frame), + int(end_frame) + 1, + int(instance.data["proxyFrameStep"]), + )] + # vertex_colors = instance.data.get("vertexColors", False) + + # Write out rs file + self.log.info("Writing: '%s'" % file_path) + with avalon.maya.maintained_selection(): + cmds.select(instance.data["setMembers"], noExpand=True) + cmds.file(file_path, + pr=False, + force=True, + type="Redshift Proxy", + exportSelected=True, + options=rs_options) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + self.log.debug("Files: {}".format(repr_files)) + + representation = { + 'name': 'rs', + 'ext': 'rs', + 'files': repr_files, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s" + % (instance.name, staging_dir)) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index ea90f284b2..ab9b85983b 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -93,7 +93,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "harmony.palette", "editorial", "background", - "camerarig" + "camerarig", + "redshiftproxy" ] exclude_families = ["clip"] db_representation_context_keys = [ From 313e0fbf1a074f8e8ced117ed67a720a2e1a534b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 16 Apr 2021 10:12:43 +0100 Subject: [PATCH 09/73] Support for grouping and changed the update process --- .../hosts/blender/plugins/load/load_model.py | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index db12585efb..168bdf9321 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -262,7 +262,10 @@ class CacheModelLoader(plugin.AssetLoader): def _remove(self, objects, container): for obj in list(objects): - bpy.data.meshes.remove(obj.data) + if obj.type == 'MESH': + bpy.data.meshes.remove(obj.data) + elif obj.type == 'EMPTY': + bpy.data.objects.remove(obj) bpy.data.collections.remove(container) @@ -290,18 +293,12 @@ class CacheModelLoader(plugin.AssetLoader): view_layer_collection.objects.unlink(obj) name = obj.name - data_name = obj.data.name obj.name = f"{name}:{container_name}" - obj.data.name = f"{data_name}:{container_name}" - # Blender handles alembic with a modifier linked to a cache file. - # Here we create the modifier for the object and link it with the - # loaded cache file. - modifier = obj.modifiers.new( - name="MeshSequenceCache", type='MESH_SEQUENCE_CACHE') - cache_file = bpy.path.basename(libpath) - modifier.cache_file = bpy.data.cache_files[cache_file] - modifier.object_path = f"/{name}/{data_name}" + # Groups are imported as Empty objects in Blender + if obj.type == 'MESH': + data_name = obj.data.name + obj.data.name = f"{data_name}:{container_name}" if not obj.get(blender.pipeline.AVALON_PROPERTY): obj[blender.pipeline.AVALON_PROPERTY] = dict() @@ -418,6 +415,8 @@ class CacheModelLoader(plugin.AssetLoader): ) objects = obj_container.all_objects + container_name = obj_container.name + normalized_collection_libpath = ( str(Path(bpy.path.abspath(collection_libpath)).resolve()) ) @@ -433,16 +432,17 @@ class CacheModelLoader(plugin.AssetLoader): self.log.info("Library already loaded, not updating...") return - # Check if the cache file has already been loaded - if bpy.path.basename(str(libpath)) not in bpy.data.cache_files: - bpy.ops.cachefile.open(filepath=str(libpath)) + parent = plugin.get_parent_collection(obj_container) - # Set the new cache file in the objects that use the modifier - for obj in objects: - for modifier in obj.modifiers: - if modifier.type == 'MESH_SEQUENCE_CACHE': - cache_file = bpy.path.basename(str(libpath)) - modifier.cache_file = bpy.data.cache_files[cache_file] + self._remove(objects, obj_container) + + obj_container = self._process( + str(libpath), container_name, parent) + + collection_metadata["obj_container"] = obj_container + collection_metadata["objects"] = obj_container.all_objects + collection_metadata["libpath"] = str(libpath) + collection_metadata["representation"] = str(representation["_id"]) def remove(self, container: Dict) -> bool: """Remove an existing container from a Blender scene. @@ -478,7 +478,4 @@ class CacheModelLoader(plugin.AssetLoader): bpy.data.collections.remove(collection) - # We should delete the cache file used in the modifier too, - # but Blender does not allow to do that from python. - return True From c93434812ee85c0cc6886c01cac89764bf31b00a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 16 Apr 2021 15:41:54 +0200 Subject: [PATCH 10/73] loading of redshift proxies --- .../maya/plugins/load/load_redshift_proxy.py | 40 +++++++++++++------ repos/avalon-core | 2 +- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py index 9836cd1b17..477d7767b6 100644 --- a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py @@ -5,13 +5,14 @@ from avalon import api from openpype.api import get_project_settings import os import maya.cmds as cmds +import clique class RedshiftProxyLoader(api.Loader): """Load Redshift proxy""" families = ["redshiftproxy"] - representations = ["vrmesh"] + representations = ["rs"] label = "Import Redshift Proxy" order = -10 @@ -42,8 +43,8 @@ class RedshiftProxyLoader(api.Loader): with lib.maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): - nodes, group_node = self.create_redshift_proxy(name, - filename=self.fname) + nodes, group_node = self.create_rs_proxy( + name, self.fname) self[:] = nodes if not nodes: @@ -114,16 +115,31 @@ class RedshiftProxyLoader(api.Loader): path (str): Path to proxy file. Returns: - node + (str, str): Name of mesh with Redshift proxy and its parent + transform. + """ - import pymel.core as pm + rs_mesh = cmds.createNode('RedshiftProxyMesh', name="{}_RS".format(name)) + mesh_shape = cmds.createNode("mesh", name="{}_GEOShape".format(name)) - proxy_mesh_node = pm.createNode('RedshiftProxyMesh') - proxy_mesh_node.fileName.set(path) - proxy_mesh_shape = pm.createNode('mesh', n=name) - proxy_mesh_node.outMesh >> proxy_mesh_shape.inMesh + cmds.setAttr("{}.fileName".format(rs_mesh), + path, + type="string") - # assign default material - pm.sets('initialShadingGroup', fe=proxy_mesh_shape) + cmds.connectAttr("{}.outMesh".format(rs_mesh), + "{}.inMesh".format(mesh_shape)) - return proxy_mesh_node, proxy_mesh_shape \ No newline at end of file + group_node = cmds.group(empty=True, name="{}_GRP".format(name)) + mesh_transform = cmds.listRelatives(mesh_shape, + parent=True, fullPath=True) + cmds.parent(mesh_transform, group_node) + nodes = [rs_mesh, mesh_shape, group_node] + + # determine if we need to enable animation support + files_in_folder = os.listdir(os.path.dirname(path)) + collections, remainder = clique.assemble(files_in_folder) + + if collections: + cmds.setAttr("{}.useFrameExtension".format(rs_mesh), 1) + + return nodes, group_node diff --git a/repos/avalon-core b/repos/avalon-core index 911bd8999a..807e8577a0 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 911bd8999ab0030d0f7412dde6fd545c1a73b62d +Subproject commit 807e8577a0268580a2934ba38889911adad26eb1 From 7dcd7bc317e28d5258225e41663bb1e67bf543cb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 16 Apr 2021 16:15:58 +0200 Subject: [PATCH 11/73] fix hound --- openpype/hosts/maya/plugins/load/load_redshift_proxy.py | 3 ++- .../hosts/maya/plugins/publish/extract_redshift_proxy.py | 9 +++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py index 477d7767b6..4c6a187bc3 100644 --- a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py @@ -119,7 +119,8 @@ class RedshiftProxyLoader(api.Loader): transform. """ - rs_mesh = cmds.createNode('RedshiftProxyMesh', name="{}_RS".format(name)) + rs_mesh = cmds.createNode( + 'RedshiftProxyMesh', name="{}_RS".format(name)) mesh_shape = cmds.createNode("mesh", name="{}_GEOShape".format(name)) cmds.setAttr("{}.fileName".format(rs_mesh), diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 9dc401858e..3b47a7cc97 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -44,12 +44,9 @@ class ExtractRedshiftProxy(openpype.api.Extractor): root, ext = os.path.splitext(file_path) # Padding is taken from number of digits of the end_frame. # Not sure where Redshift is taking it. - repr_files = ["{}.{}{}".format( - root, - str(frame).rjust( - int(math.log10(int(end_frame)) + 1), "0"), - ext, - ) for frame in range( + repr_files = [ + "{}.{}{}".format(root, str(frame).rjust(int(math.log10(int(end_frame)) + 1), "0"), ext) # noqa: E501 + for frame in range( int(start_frame), int(end_frame) + 1, int(instance.data["proxyFrameStep"]), From b04b464541e05d45d8d671db9f3a39e79f7f8775 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 16 Apr 2021 16:49:15 +0200 Subject: [PATCH 12/73] add to documentation --- website/docs/artist_hosts_maya.md | 24 +++++++++++++++++++ website/docs/assets/maya-create_rs_proxy.jpg | Bin 0 -> 87906 bytes 2 files changed, 24 insertions(+) create mode 100644 website/docs/assets/maya-create_rs_proxy.jpg diff --git a/website/docs/artist_hosts_maya.md b/website/docs/artist_hosts_maya.md index 1ed326ebe7..d19bde7b49 100644 --- a/website/docs/artist_hosts_maya.md +++ b/website/docs/artist_hosts_maya.md @@ -691,3 +691,27 @@ under selected hierarchies and match them with shapes loaded with rig (published under `input_SET`). This mechanism uses *cbId* attribute on those shapes. If match is found shapes are connected using their `outMesh` and `outMesh`. Thus you can easily connect existing animation to loaded rig. ::: + +## Using Redshift Proxies + +OpenPype supports working with Redshift Proxy files. You can create Redshift Proxy from almost +any hierarchy in Maya and it will be included there. Redshift can export animation +proxy file per frame. + +### Creating Redshift Proxy + +To mark data to publish as Redshift Proxy, select them in Maya and - **OpenPype → Create ...** and +then select **Redshift Proxy**. You can name your subset and hit **Create** button. + +You can enable animation in Attribute Editor: + +![Maya - Yeti Rig Setup](assets/maya-create_rs_proxy.jpg) + +### Publishing Redshift Proxies + +Once data are marked as Redshift Proxy instance, they can be published - **OpenPype → Publish ...** + +### Using Redshift Proxies + +Published proxy files can be loaded with OpenPype Loader. It will create mesh and attach Redshift Proxy +parameters to it - Redshift will then represent proxy with bounding box. diff --git a/website/docs/assets/maya-create_rs_proxy.jpg b/website/docs/assets/maya-create_rs_proxy.jpg new file mode 100644 index 0000000000000000000000000000000000000000..37680e6707ee437b5552f16378a2041d9c572df9 GIT binary patch literal 87906 zcmeFZ1z227moB=|Cb$QJ1`qBIjRXt9-AQn_;2H>okj5<`q=5hdg1fszZ~~!mx8QEg z?eEV2&*b~>eCL^&JLf#-oSEHSyLUakYgMgPwQH|h>s@vK^L`1ye*P+ku3PXd^lZ;{hfX;)ZH`02v7d1sN6Pk5(h@4n+J9pc0@FKIV}| zC(?KUqH`nWeI1{LK`&F@PNF$>$iVl~J@f%4=|eK|M~qC&EKgYZ1q6kJMMPzv%E>Dz zDm~NE*3s3|H!!rYw6eCbwX^ql<>}?^z_IM2fhdpd?BNvqM(BQ;0p=a8<9{5P|+Uqpc6`KfL^!}(eb{GDsO*4 z&!>4v^3r__lazsfnepfk*8X44{%wqf{)agGXU6`8FBpK0f`sTi6aoMY99^?~3Bmkd zo$9lL`(F0K8+-48EX+JTewQn3{d-{A=N>qHEJdwz5Ae|3Sl!tV>D~h$V(%*8)z%?m zTzL0DAKfAx4N>ONP5{R$j!U~Jr1l!Oz$0ft2{mOzpfJgT0>XO_(BY{M6me@4Z0PzW zuQ!<*^Et|GqN3b(>%)Iewr(y3FJi%ok*3CsQ_p*H?*T9h{L<_&`^G}^9>{oo4+L;s zs)NdIMUDb9-dLv?eHxOcOAejWmv$mPzi?ema@`k9?3)j32glT89m$NJblTb3SK1LV z7EM`CVX-jAD9#U}oupqyimUNH6q+ zL=zb3FtP)_VT++$yyNsbrPK=zcCz{ThuFj_p3nAN@fn762J zuUppX1$|Mb$Y|d9nd2lS5vP-y8{N-yUKR*{K6!M64!4U(ItOTp-}O`ofBNf(CN+vZet|pZuYN=T8hevr2nr(1b9~IG2WFI z1dp`c11%44S^n)qB9e>(th}t%(wi0hEqUuh-h}; z=vg#nWVZpOc>yK9f`v&&Cl$JhjEb^)8gfd?vU!9qZqYFb<3NN;Q6}$;g1s|mC6cTkcP+s@W zK=2-YxUf`I9Cq8n%LQC#^+tnqc1=NJ8%K;iVPBsvTQ_q{QPsCQVrLB3fiGoT?t#tR z+Pm|`TNN)D<@BS6KN)L3bbH#YS9vh&yy_tfqD!kjBWkV^`xK1rJ0;}j zV<0(JFdU836=dHttfNk*xIjUk2!U*()~a2~mh3!&cqy&Do84plp=)5s|N6GZy*!%- z4X#l|s3?v!WV6#EnyOyeQd>LzYM!xl#_-&MINo#>r_m1kSAJvJtOX`7hz!svjEyqP zJ+J1>@^!HCsx$};^%o)N;_Xpg{aixE6P&@?xv~Aan{o5b4sIp(YW9=~G5e}~Q*hcDq9n#3tqP+HT)KF;*2Ei{7@8@kJm|5# zp(w0~8`fa7pypfi^d$qIe0e`bz@qE7RS3131ota=Q|r>(v#WccO$fZKsr*eT%V;aL zd`Ht%UnS7Z6H|qOwSccTN$&m7uWX73b85~X{T_zuhzEKs-?>>M4>?12?P%pKh`DGn zzvlr-l*wCuTCrWbrM&Y*3gfF2%N2=gqV7ct&*PdRWXL`WZTe!935#R)j^3)nX^sef z6gzV8yi5G0=^KHcKu^!SxENjJ>TQ$96w}}fuen2;&JUUq3Cbwo8>@ApunOt9a<<>6=2dq9Ld`KLJf zR*sVxu1AvvN_-2`1&>U7yCi_XG+Xczx;>40G^!x8t%_FWM85d0N=XtP7&<>Yn|Q>dZ< zhD{oI8e61xuu9LuoOfS>30tp27gQlqMX00U{q04OH@hd zug2Tc;l2C(LXSlW9n2@zGt|hJAlxg!9*ph$qZ+;8M^z(*vS7{7dgtK!v&uq#)lU}e z(pYz+^1dh_e1vFv8gE`XH#kGn+zkXX?h*>QW+05U$;{tRc+EEFTSZXP-E_{(sLgh! z8ls!xW);v@XN6J;%kYaZ`ibpQgw=~<+*Ls$!-my}$@v~wqnTd62SA4cMJ+g&QP&b~ z<1B6C)+q~SDSCuTk5XKV^0i%&&<4Wl0Hu}8#O3fwLAnhS3uRp+BmXO>&Vlhx7al1_ zb9#E9uU=glH^^@IauS$7c>7QJ9bphE?#X>P> z52D9-jPR)1>;vZUM?ygt~dYk_(&#kp6eCC6s?^S*Hex{ zhNMhT--kk(mRs!8a-x3(KELip%xu1eA_)^d=;|IDFFuwjZK9xzVm96b4{pnLrmmBr z(Yf0fO7u!1wJ|2OMoEQ*5pm(V*h@wJprK!N$^Hx%@K(It(S5_%qtf)xnyb<9rCnOG~XGuz8|26v_lF)S~`LkZATSZm5&?u*( zEObY#PXOy?!4~-t}esb5jgUls+s;oL}G!OHD_iM zcTG>)tQ!QnM5?ZeEAJK~+_zCMnMzdsqpOk|Dd*)H*Ym`*m|Rm>(q16qJ^q#UOB4%$ zNGO$myZ9sXpPKUbO9+R@cXomWI5?a(>g(#Fc$niU-6~UA4V1C-jh_tbd-W6QX&jz~ zx8w!Bx(O)s7oiF(Fz-s%(F7Ip#?0BoOHob#n)qhq)3}WL*(dFlpX0c)8$(JThC19M z#TBCuH^`NO0Yq+n(_1$_eS?1Xi(INQer9i-hBb?!rUQf&2)^?_q}^7;_L0veDEyFJguC0epbIy+}=_x z?qMGWQ%i53Xpi9 zt->^++X(jH-KOAc{7gL7e_pQ^{#~JxBAN{*QBnWn^e=?W5}zGAk=m7HMkuK9aLoF~ zC!LBvL6KYwX3a88t@YA7%-OezIeMx8-9E~~J(I<0wn7*sIxo-E2cCm@19B>g7H|YUAdgL!wZP z_>yHCp?QfhKt96}F`s@FJCn78&0CI8A@bWczti5`=h*(mg2(+N`y9-#$&z(b1l1LH z(B(u;n64j;le3r5f1Ir78RLEl(nJ?^4QAasE5$c7vQOo395=08RB&Ce7H?8WN25*O zlyKz+NDRRQ1~VggGq4?P`MT7hc;q@eB3l$}9}r07metP~TX)X|j}oJOU%y2tv< z5!~EvljFTBRtCkUN=!-J`Jd`3>As$Iygm*K`Ki^b>Nf#z#pf;McWA^CQ!e;o6|wD2 zPeNZ$CPJmkcS@I{!q#79@Utu%(?3 z({ALZ-7Fn~SRCaxH&(QUGkb`8zbEXm(AJ_zMv`@&k8h)Nk`O& zQ_1I{Z~QPjrvy+ZnQAQ;NX2}z zIHgH|G@gr z$mD$HGtKSeN6fcZ!E1W=zzTw+H}p>tMiZ{vsC(d6#|*wO0p6mwy9a`&Z*70iI4m`{ zfUoi{`fk)LLgD&%eOdPahWxvGK+^1r>hXo{yih{PlP05u&yjF=-&Rv+GBfuc@J!4EYh9}bFIhDMbR9-vCTL^ymjNv?t!Rm zIDRw{JfQWM?H)MN3BK`veh*|u=iS`{W=Gk7&dnkwfwbU&u%An6NDaG+7W4#T zmajIxSCK19d18^?Lz%%*f$nud^XyLXc57X7=HbOXAgeG-2S=>jY;P#d=I&HzEZ*N_ z6l9|8#RmtU@&{IM9l#@E%e(JJCdbZ&TkEgWfd2x;c~`K3QW9Zr2tA9)pX^mMBWs>d73&1)jPg)vgXwIY}*nDtVwc_TN+RN-AwHIBFn zuWwt}x*GN~qZdElNK&Awx^?s+9Ry*wRMfRj!snS`oKHhx3%i4wx-9Q>DJoOg+ZQK6 z^qHGeT-6b&P7Sp~we%p=)zam@+rpKRnSB=mpM{Q%S^c`Or^;Tl=kG(a){|VcZb~qD ztIR3mFDgJ}r9R_pHHXg!(VLHqq$2#^yB^KX6D!z;@$RcT1I{`jteMDUNU#uzScMcP z)14g))Rc2aFZpr8+$3yEP9)&$R-xHvx~Hcyv0J^lX7ghvwd0&qBDOa5M+S4q+8SE# zv#Fy}2qzamg~6@BYO~;pT5Z4(I+TvflN^?1aKUqBlvw$qy`32(`64)9rNG#?lAsNi zpN*uTwXgK@yAne}*G34Nh8!{Qd(!#M3ZWzVC$lhcTzVCG56p7y>e|=db_MUHo)z*V zNz@)`|G+_+f849VOWi7p<*vQD7k&( zYg4}AzkHs>n(f-R!>62kTOuYla~%kSTFCijyba*`Vz+?X&^pP^S?cV<|A0&0>L~&5 zLgee%SI?zsxrU&hdXI2wKeaz{u4a6LonaYektww@#gddENqR!#CL>E}6BeqfFNjW6 zT|0Q>aLSw=JdLmw_(h%WftRPkSFnaCf2sxA*l?V!f1aLYG@uG^Qsz9Jp&6`KqVTHM zifuNyjt#$M!%bY_c-C=zqFGbvpjaI6(JMbeJFd>lM<<<4p&jERrCa*DmQ;B4>;dcW zIlPe)hgjpev>dTI(*GcVZ2t78>Da5vB71e%J6$v{_2aTWDShnKR)*lShUwDn;0FOB zkLptlO1&-6mAt~KXWcU3)qqbj$-Y(q+2P(~F}sIpC&jB6@}&H0Qq5pq2hN)%r$W}d zSe+Cm8Xq-$S3iq*i1%40-s3Ng&B{v)jn);-jYej48A) zQyy#~kv}|o{210tzT805Hi{njyJq|!yr$+m-0Zk$AwAl!%rV(|*diV(6m!0OApWDu zdw~JoAwE@yR-8+%PDz>@-A;a8R}3s{s;}~pfS+?e zcOmiZeWf^uoA0WVFJI^Tcz(_rXQ+D0?(+F6KG1mFy;mf#ul zY}aijWD1@Ugou9r&~}WCLO$&yFU&`b!@lj)Z!;kgC-5Eb@d! zq^T141xIf0N|6avW?stb9x+6*JLdR|+}2pp@eE5|Of|plL_FbDW_bCjS}uDO3Br(= zkNXcLdozkXi#^um2?(6NP)2@PJ>f@Mix0qQy}|02%yS@_nk&N0O@jJx%+?AYn7X*3 zz~+>(vmN8h<$XK%A8f0)?z?At6<1E8}kkJ02mtd~>fe{H``uR+Jmx*(BEv zvOV+|!4z6OS#^ZAqa$3k1o~!SD9&6YGAF4v=pZdWEKM}4kVD)zG?2hFmar(5R z^G0w29{A!OXthB2bpC7J#Rk?L-}B618%}u2vuPI_Q#Ya>h~@`6cgnlHMvSwd-b;1U6-1gErdU)qFOyD0sig;~Zzb9g&)^ zWslEG&CI@8gJ;N#EOSMJiEZ=HAG^?pM{9;HZ>h6Mw~Nv}=$>}qBu{0x3P@ zr0?4Ays+?_F{xhY)-Mnmx$KkPdh*SaQl7(_*5!kTi)xN>gIT*`crG?*D;W;{=C4NpOVT(D6J?Vf{BUg=iotpq%GaFiDx_- zOU)h-IEe7W{hAg2_NPgY_^b>uvsTC5xX9_Sgm9_w4>+;CZh|8JRrcf?ODlBxM6%(% zoHzy5x8kRz+Hd!GN_Lf@7(h1R;3)!p^6g7Q<1yTSP*-*=uSQdCGS6)y1aF#h6f`oe z>+y3={2_P0i)i51x=c}%O*ceWk8aKpXN8z0Jsz9|v! zcw;2(0fed^5OatdT;O=RM-6LXWGTN%H6F`B0`vTVGndJ;)_Arn+<2;-gy)fJj$HZ3 z&F`1+x^N@{D8A0yiAoIp;-GlyLMZ5QT&`Lqgf>8l(zb?+1_VADyZbWMRv)vDab<); zOZ`@|I>W~Q+=w`og>-SSqY|InEq#^B5AOx^vRiXp+|KD6>xx8UY6M#-w=Z>d@`{rR z|J0n7euCPE)L}k|&Fk&h;ShdKPvIBVk1IA)yu>qluQB^31@epc*6W~hQNqWjvSWeG zBhoQy0!$J$Bq6qo^%xN7@SvO~Uh3X@VA!XA-qSEnrS)&eE71K&gV^`0qGezz z9-Tv!2}Xv#pG;u^gvdO@^j?|QqGoBBuHG%JGF3UwvBikOXcv1K?e3M?zgobMpu_-mE^l3C zMQBA(kKjf6TU&&U?%M?1cL;%NiapB2bGlUaN(myF&Pmj%8&hA@FKDbC)?>fr>Fa>L zIeGo_w0QOaeOs%)Xh*as%-bV>DBqZ4s3s-CtekUbtnLNF3+dsz{US2l_d@}@2ueD&026Y;YI14*F8XM7;M_Q;a|KN79hbGJybR^KD0dMct#Xd z`OI?RSLpKy_fLuzJqr>JUm6Z$KH?_2eHqE4!jntvB0bEVNj~v>nDR5xBS8e*mB}Uf zVR#a{cR7@=FvU zIx_wx>Gkx)*4`D+EX1@H7#s$uQ7yYjw zrYK6n?88fMe9hV+k2%np zPd`=b)Fg{DP%CK}D8xk@oIa`dDDZ1ez2}>XHMTN6whSpbYZKer(Xa}&YV1SuvcPvE z#7IR`HPfH~PK(5_7Sc}Kx{+Guu-<9|<=sI~>pGoDufsa^gm~yLRXkHa|Ce>!6zWxp zOiu(urEQ6y6GJSia#pF&{8f&K*f2H>P|8ssfS$je6;}u8(VPcIeo4cC=-Jot&df1? zZ^oF5?%Vt-&lY+l)ZyTzo2cL&;fi<7u$S!4(^0-g-6ZI{&z2jXS(hLZKP9Bq=|Wrj zxb8)$2nv1Hfo^MU3A-kXr?BDv+zYum3nES=icgiBD6gwxWu>U`^6hF3RVHRlr5T%c ze3j6}R_Q0*Cf#%jw+8QoRE~5%t(V2>RDu%n2%k!m(JaVL#}@m#M75C4Z`)5B8YHS$ zl7092zP+!FtDve-s>&xt$?@;9;b=Nj#Ji)2z_P{5dtAXlFe_lVcT03wVn)jHNlQLV z&Cwb}W*6Zn^aWqSU3$+5j0-2}&*xp(aoaHxCcy=kf_$7sditKzbZMnNM`V>xCk_r7Z&ZvKazMGf$A!~3QEU3=9(cv7;3^D zGJRty_~ZDC*UBuf7UX*Z<=w8L%-)-k#&&*Yoz+h12sFJuJ{H5^a8m5@4-=zST-Kp# z?gQd@2W6LOy6~`VTgb-uUQL%r!H8i6R0~IC&N?{fKA+JQ8BP$6%x=KXC7K~j@kh4c zm@FkksDf_!y^UBiPx{TtVoh*V^%g&eAeH<+P1Vo|I}b19DxHMke2FN-#W2)`(#~(H z-0EDIT>wYMPIEH1c%PGmTj>R50kw`Sq1RfRFesO+SMjMS`GnUK3{C_zFGdIfJ&wN& zGpRdb$~|u^B30KEfUl%m;+3EWx}6s7{I5jxl?Nylk7VDaOU0KbdOZ(Gx1fIGH}%6E zD%yARVDF|q<7OiS;qC6Q3%&=$_gW7k5Dvk@y2HDw1=<@*HwvGxbr9hjxh_|ENt0eK zdNi7M=cP;w;O6V;uUoB5A1&(4BQ1u5=Seh_sy5}f$u>cHA8YjU*yCc)?AmuOO7T2G$y5kKbEi4mUI_qEqCRPwld>F#_c=*6oC zxPk@C^%Qz0a;Gj#(VT%bu3MS(g0RxlX`Y>OF!!q4nUYWMSx%kaX&KZ* zW!?Pq1DmJS!!t3y<;tX3iG>iAjOr6i2z(C^pJd;&g5nryPGpTSCGdIY?ck3*uG5Hb zpeJ_JltW#vWj3}9m6I&?1u3WdKQ_R0RTREbq(32X&>eTKl2F}LYkA+-R1??B?jPp# zV$zZjZ^>aS>*y>d^>9H#NUou}vQCMCPuAl+x54|Qvzy z4@~53i2(2KlE^7_(cvEpNtu@x_0MdJ_5frRBfn_t(~MYtg?K zcmgx6%=eMMHj!0+=e+VEqIrBSB-IB1Y5Ew10O(8HNMsQD9vBPCOn_K{T?_Zv(&Q`?S4bdh)HLZFRzJt%CPimaWqF>-dj#dqN;SZfIj79rmT8-pjDJ!W%#aEf~hm+$O6 zqj3*qT`i9H9ZPtprXzVZ^gYVM+%{Mkp1g=yrCwhep|YbPIQ13eiuc4qZJ0P93FxyV zW7M|{gTyGHlDvo;P(+93elgtD=aYFlz+KrjYmDp4D+)>96QTZ6&k@{N>*u;qJ`{+) zZc-Lkf+WnCg!kc9Kjm?dI@&8j9}n%(eces@dLhBbY9GHUHh#)mc-P-Ag0f~2R$ou~ zQi7L$}@2}sh%lnSXCu(^Wt_p zeOJiZ9cFWQcC_9UW#NTG&8wqb*W-(d9e>*})l<1lLG{e5QSh+cAA(ircGH2&i>rsz z@%jWM=4T1rjY|FaKBhgpdLLf0KzLFtK9K$qcZGh{?y5{gm>C36omFY@F~UEy%`DQk zC4fJ03)6D<;pW+1DN%Q;VoX;~qfJ6m%kDy-&c8o1sqt8X#Cem3m&xl&i3n>hIZN7E z@afA}wo44QckZ2}+M=`$WJCO>RlfKn^y=H#t{-y|J=w@JK7w3t(x?h=gz$@79qx=6 z>n48@G>lQWer~TXd=J=pj7*13On`a?4saJQop<|M-VV+gSw^`h0ItuV_rP#-%e3w% z#oB0cQis46CFc&j3|?z%i5mI;~`|<^^i1jyu>v5$*m=2+gnxE)6WjA17PsV!HEk z%kx&Z!-s=N4%J?tIhoF?9C^l6fBh)+{=2QYnCf$@!(boR#;4h~0YZaq%rl&O)>D~2 z`{KJS+fnRAMi+?CchI~z(MgE+0?`Cr`DJ*48A6Min>P;k&pBRCerS&q`rKKLPFU%A$g}+( z{lwCvqt=yFgbBN}dlNp>Y<(*fv8i5HRac)}Qi9bu!p1!)xFRi?o-7Pc&OX|O%Q@g( z-*|8$0(K8XJNpE~rAhCB zL#{iF>G4Gn*s7Um6&yaTJ$@T}?%(H?d;WQWOG;BNNwI`#G@_GT}*C-zsMGCZ*N?!0x?G=2%;Wis=b)qmvqzLWUVBl(o&sGcaX za$q$9q?)6x>7%A^^jV(lbha=tuWxK#qKSjwTjIm>-B%^aAazmAzpLAiwb8xzK1t zj!H1PXRe*BDGlS{AV%6YMY{YR(D&+qgv_y2(IgGxP%&lA>uVp9aptTnFC%nuSahR5 zo$CO9I4BY?$X(%dGZ!{sJ|<;z!>0uJxD>E{d`N}hhSW`pKEepa%gj2}|Y z%n;)K5Jp|OOzP%B);fOFYKi?c{)}CQXB*lctLdg%JJ~Xt6EYNo(LN`cO~8i{g&ax8+CurBXl*6Sni*O3G=I{#};vFSqzPkF(Wcz*V;MJjEwf`R%XQN?=RK zhZSbBCG&95UHmjNYNEk(ir|TfiobQG{tmmhvWu_|yKLYucU<2!SC8ou&lmNnN-YWF zXT!^V)*uA3mKuSaE8W&}PGjr-DXUBr&}r9CRmYE{rhJ43+mC?51fv_wA$TQ9BQ66q z#V*is%lZi`{q(@Su~chgb4z5Q5D-8agHRpZb+42BtAY!S6)T$f2ESTGC5S1G-B8#P1p;yzoQNSYxm2p;`sQI< zcGah#7&s~HGL4+eF?7~mpWNB2MNH^&wd@yLhX9V?u2O@)Lm&0RE8!=!y#bbfF`_Xn z$kl%zk0ukZR1^$tjIb!)kG}yO)7fC9uQK3@Qnw^OYvx-vi)!gTtg;spva@+VyXM^J z)$PenyDH(;lXEheq0WL5ek2s`NrMNQ?e!*h@bspWPuY8NliL>1Kv^)`=z#31$lw-s z&_EL*ch+RsQz}+HmkCaZLRBw7!OWL#?=$<9&%*8NG#49wY7`skw<>A$-*PXz@HnW>y`Itxu`(Q8|T|5D$VcNpD;}HB8i7HgFgevw-3ZhG{AcU>w>bTx`kOjcDoyAC>@KrA+h$Z@O=(@w4`HXBJ!F z5wiZ3X0GMZ&h33BqxZQdj28VgZXil7k27&-la-X&+g2>gv@@5|a<*EOnD^G&{% zDN+ub3ImPk9`5+w)Z>$0GRpF^+r|Xoii3lFc8}<(WpkqQ&lqEQ>49Zmij%n@P(5s% z-3o(w(!iUPkzRXQO2jxly{wmk(q=o6Bs#69V@2EQ6PZpB=H~PwtgOjt^+k5~xsj&n zS%eVJfV_m;96Vzngs2zFXTr4jE5+hrLiB=-rSaXq*d}Tsx) z`cwNJmPs_SJ~=7B^b@Ep%1&^EpMF(kjJ!&b7Stob?M`sYthXP$^0{2Kb=%(KQ~{wE z?F9(fa&XX-iF4&m+_Js7JfU^M?GE1FrH5ZS-UFh*5$iua4L_JG8$Od0 z%U8%3F|^Mqp)NsUjaQnOe-CHZ_$|Qs$zti*oh=)UbjEwwE767Gd<7d%FigP6;gT+i zb(vnK=s|y{I}hYE-!X{Mwn4Pp$+;nVz07|*ZDoVOKz4*&PAaPpCd{yz2y4Rnt2$Vn za$AcN?(omO;SKk|Cy7<<%jg{YM@%K(1khS1`Dcx|DzEI$ebP9ZA{G;HaV0e%(E3vT z66L?5_vEbZRqfgAsVIys;kWqg-($LgUE9Ad&w^6?rJn0wg6|c6i~G;St%+J|{Vizz zw|M=K|E}<_y5#>@k*b4q{sgZ4GZGUe>p10jz&DGixOnoR1NqC$CQbHt2>+QKF`VQK z{8n5I6v;o?P@T*O3BjVbaj-}>ca^5;i@Gu_xwO%5Jp9@OOHMjQ`-ti-=r|MVimUU{ zUzj4qs=d>E6DtI-@OEqCX36HVG0fn??|J<53YaLWgvQA=!KGJ;P)9I+)d8{Hw{5)m zU}Cp^H-`;8!Py|UW@$&{fhBxJMkCavX&t+7F1nBbGN)dg5y`hxAv~Ue63lMI15@K( zv`b;X0!8&62MD;}nevwsOr9=sv6p|{&ZZ@NmPzg^MZ?1QP8_#)d_T%ep+e4p46zpk zHqX*AC`f{hRMEg&YG(p%y9oiHvqedW)4xn1-=m&W!tNUzaqu8Tc|vAo`JzvxhbPAO z+p&>khK)p1Lg2S)M)7+5w-8tP4X|%8Y!8xt{3ENtCur6;?FTutYPpN!(S(b*1r_Lp zPRrb-q=Pefp$!3qi0w(dK+0g1!(&75Qy}`%vI*H2;RK z2FdsdNk{4T=sSgcxbdSC2Qv~Zk)ooG#+>inP=PiGT0aUDi(u5%cS-NtmLy$8({Y!3 z2*Xy9+pyd8G%Q5Y^@urw>y|C}+-R%qC2s=LGvPB3NursqPi03+5ZkzAb)5>rwa;7E zv-V=_Eh6Y;_j>v}ly-DKK&Wue?n3z;oihcc==qCZ3hNWafQ^Qn(fF&2A7^a-WS<;s zampX)zGy^+ED?ezp`FatfOaungc&Hnph#r}YlMjA7ZtCQq0%15i8w#Snv5e(OMVr0*wOXr{YFCR>XrjuouHoR^ z^B@N&0Rk`}g{6>FQhyXVrF}#i-?_Yqqv-TQLz|TK!Hc>j&Y)JVt09jCijbiKtitdU zLb4O`KCp;5rA;E_M@HnrlZjILv24b&%CW{RPqn z_VNMMgHHf;kX96MDkc^lZ)r!KZB6rvFqm3win;)24s6?!=E6Eac0r=yKu|Df*3u_0twS$oS1p?)kB&Zx;Z4u~Ug20=t2y{}2r~?I$ z3%X8FKtSCd0&q|Q?h#5&)K_w`b6|MK(v=>t&nw((QorNBv>D>?b-KEHf+GVve;#zT zXR1}`6MCW9a-3xouM^@*LnEX??MJz06^QpzUW%oHGvh7vY0ADa*$PjJ*t;v$Ip<-w z*Rhk+FLJku?9>>C<~DXVq~FBwcgWO{uQF5;E#^r9YgLBSsds7AJ2o8dt5BZnAm+0I zm_g}2vV2ZS(D?(Z5$0Oi+lEoJPbQLqFXi<|;%S%zLg1FJ4nvZNX(^-g{Mf#HM^^J) z4wL#0$tr3{Ug^*zF0l6+vO1L&@5p{zWv<3htI~DRR?3D@alD}#xe81&aVLA?uh8_i zN8!jJ;zlciMb})f&pv&CS*Ev{hc7vmRP;TyZCkUz-tmC)q@8ljoOIee8~w$h`m6I% zmwt^m34ns|s;wTD(qDddOqm1fVSi6Z~Vi~vr;ISYTX4x1f>UyZh@YN@F zC5FAZxpEut#P&y3aFV9cYKBPua~$jit-}Y;sq&7y1srym{cT_A1>-rd*48`V$aEUk zRlQO!ln}}BeZjsAT97k9KYqxQN={#nUS5otNzK$y1z{$8T@#JH#BN57{E#rtZ@^mq z4oLNEyf*Tb25)v}{L)WF$C8$}uSa))b?uv5VvMUOxg>|3`(=Xf z)Re!IV4$Q!Ky!|e3h{~ZrV?$0Y9`c^*)GOqRNK?kL_eH#=dH>bVs`w)98;p5SO?7Pc|$OlQV2PT z2?M22iKaV`I2ZTQo=sx%&#eALj00BGykBWyH zCIebpEe&Z;G4&PPkh{p0oGmr6%$F>2193B^Q?FX7$$Fy!?rGk>7TTd$EUPSB1@saJCgu07(c&Su=trD|Z0*`x3^)kxqCdX5e4!p>-t z7DomSch2!Z0k)&3YIrktu))uwSYn>x&Nhqe{uZ%}Ie~fR9YpoRVm1uUz^{$8Rh26j zIyO?7{UB{;^i1*!@WN{E}Dqqy%EdqKeqFVQ5ulvE6ccgu{O zPi}I|sWGdhMcRV@`D?_OMgDMq{X^xi)Pmrsw2uY zt5u^BdM@#bI6xw;89JgKt2-Y1oh%uwAw|PH{lsO(mK`qPW8JZ<`cOB1JC#s{EB&GU zKpQeXL1PD6Y>U`Z&;sxm%0ktg7HLW zzi!)mZ2)5`UeTYHALBnOyAhgMcmi=tN?ym`$BC1Y+4nZj%V%)L7Twco7o=wR@oHZ6 zMzz}a^wh$RzN?t`G00Wd+(0`9qpxqTzX*)!V>;2BrWQ$8uUkJ`WZb2lqWdl`FRAYe z6`znEp@>-pGm?sp>UJaqfD;tQ_VCPP`ACI*16(*x^S^G(r>xzH|CX zE}>jqDPIJxqB3~Yrt@#{PD=SN#}JrKh43Km?bS{wL(5x@8_{_j&K<8C%F!%DC6Wpi=NalgINTkD_4Lh~9Y#yfwC`w~A>a>-dV&rxvl3K}kqk zqTz8j6rIkMSN@8f@+Gm*##(GB{0^}d+^sv53i;qh(^t8p%n>%6HIoXkuuIK#hlVKj zOP)372LvT5p;=eGl6ipBSwJt2@P+l!Q~y|(-KqtJ+_(V$LQ`UB~JqehKd4)+b9ejs${+KmgtFP z6Ui}Y5N7?wSqaT@NmTi4Cy!Zj^4ryPx9zrW+W_?=+A&1uIj@&Gk0eEXmV(n?xk0<4 z5GyO$!l6?8cP_RGs^Kly$lu<_*R91iw*$en4@%y4!m0RpV`-)6L0C}w{^7u%7n02* zTFUKE^3a7Dt2&*8Rca5&nk7Niv6$EOX2FnG3e<9t+N@6#Nq@C{hi{}s^l0)d6T1}O z&dK3rtF^`B#p+xJd=ZghaOR%=TlMPEUkN{`4U*Na`;9ylyVg=9;UVU@Apo#%^Y+(a zYK#oY!+o#byr~xJBcXRpKhEB7)5i5$FhsA*qvOs&Te>V_7Eb>k_TDlouC3h?E-XcY z6WkLD2=49>JOpRiHEOK2_Nra`u{r01FyT?XDK$EFXTUuoLqp*g?B{<)U0^JHtMzo;S^F20v(of; z1J)1s&s{d@c?Zfcs=giY$GZ0kU@mO61KMAPzGDz*; z>`LwjbySG{vy$2WR+p?cCgd3WL|go}9tMl+g|rM-x-DIuuuQ80G-uS^ zK7lEYq0W&g{nTmRTVoveH>jlcw*7Hf>pkoSh&)LFOgtAtGEjS7>A%1Jc;RrG`-syV zb902XyS8C~Vr?ZtGIOutF`LSg=v%VFKum&$=AO}oh+tCuv`?1!7ajtDeeU7~%D2Kf z_e=M$03fve)}y!NO+8#orEtIAOZakD>$+OUP;7~&z9F(7V)tW6jOxLr732bx;Qyz$ z{Ey4zcFh^Jw!h$t<0$9AOFws7_MUZh)$P9zJQj)gL%DS4FZmn)pOktKqq9y^=lXOP zza|>bdR5<4`Bp2r*#FRfx8vNx{>yPW-FOfDV>|=~hHao%ViXkkjzfQxXHcaiIT43&yfy1IN(0+VZ=4@fJ0U22Cs7Kc znq@}m;p}^7(rFg`I!+EbCAtVCiJ)hxuPK&Q6S}FHV_g~F=3Jdmz4GswHa+Q zjcTXnT{)r7%(pU^g?S0CAQ!BSG|CzUwweHYr4h69IcEPSE|Hf+w;)E*J8}mShiZQU z9;))OhNG-*M;1zzgi$if%*guwLmrzh9$2XgTueVuj1L zQ6Az9+F*Uy#CeK_|M44CqZM3RfNJYXj_Aun)WNhX%OW%jjs&Tib&kEA9d9p%D@fJM zysz@Js>mN6T)ZYIEq9-JFK^qUP=&2=$9Z+#-))gz5u&{Cho zcO0oSQ(-Eg@Dp|Goqg;+qTSL^SYdxxKb@C|enf&k?q7_yRfM3GCDkv+UR`2?4Ts81 zJJ=u3PL3^7yKqJGMTybO--4ca!7xnVU>zf3L#aaDynZFw`{|5Lh6H;e)tK)AU^gvl zqAOz1VcDJ{nv!ymM-*aI|AevDr-RffXlm|3LZ!Fa4VCr))mLe4SnJ#xVsks8!}M1L z)L#%^3tP$tzK*TNt->w3TGK_ig`Tg9MQda?nTN-VsOs@M#RK8$tA1sSGJ>bJcCyJI z6NsZcpMinOS6s(!W;pSL-Q!ueH6c-Yoln!O}8XMh}m|B84k`f9_=A?S+S zNZi1123OAlB~j96N4%4-pNN0VDU#$M|Mbmmp1|9(;x+vPTqSF=vOWLw%5jElgkLiD zd2zGN?Z};WU-U6>p$hTboJ{+opUr}`Uxq}ooU9x_8$I`L#FRtd@&G^IPzuQ_R`hAk zGhf+>JMaJDJFYa@3YD8QnX*$cjj{jUaaj_#e`qn%063Brt&*NTRFQGh$_TDp%3X5$ zF~>o(E!F+2e0NL^b`}!)RrdwuYZ5Y-MLBA=zTz(pK32BX z_Gp>SCGCU}vTcc8e24Y?yfe&!gQkR z!E9$zSr>g^63|mk5S1T;+Qk*)=84%2PIu43GCti4xmUzX<;mUn8iIu?p*ivxTfeL9qV%D5+5yYw40-Xr?!3c!s0>AWOO51?96?!&8q;$C^l?WN3j z5%4sZuc%VsF(88NxHJeYjZ%f~+a>)59Wj#)gF+iG+&GB*&mSdpE(R+y4_N;+*q;Zk zu5CN?gYEqyk=Gw>=g?*NtM3&iu=cbc9Qgsv^s1Q^%13eF87K`%utizI!>xAo-jRj9 zK&h?_=vR_&%v=g4wmk)}T6h&ex_ zZOXor+Fge9Ti%xSh7x&yrf>P$qu&caH=yK^>(A$R_rF2d2U$=*@b@MFW}a5@{P4DR zM3*=8tG6(pH9_)XVt!R^Dpk~0-0`fs&a@2fH$naTpC1r869E0qZUKh?W9?EQ?&}{l z(1;$rVJI{rz>S-^GPgQVKa}{}#}Iv7R3r+V>t@&u{Nv+e1^oMeKb>l;|D%lBeS1K8 zDvEWnG;zz&M+xrUjB@3=K5e$nI*lK2eO}02!g?*IUs%oIGClg3yEmS=gdYHPU6&K$ z-MDKWsJVjPl_+vN;=Lv$xSm)RRCJWMA3B%Xrg!966NT)ys2u^jd2xpI@Bm0${pq|Y zrrCkfkHk;31YGRr{@7kVNRfEfb*R{I#$9E-_%`ipo(c!?`!b2KW5&b_vfQjH8Il+M zM2zhS3TEd%2Y+?<+L$zIgsiL#aL3%BEc+7v!LA&dQ!LT%TC_c=emx?wq0vdVOOZG- zgZekT`9Hq?OHleheu@5CAS6F$g?TKdP^o}Fc_foa=e0Z42S9JRw0Wq{U#_O=h+NB= z^A*5~JWcMkqwIx}v6H*?YrU3a$#N>-Dk2s}Nab7PA?MJ1gYGXYq~&7gMBP&=4!O;F z`x~?(6n+W|etcGUR#(eBFzs-8mq8)raa^+$A3`i2s4>0EGsJddCViv7j;NamyM9MPgI>4W2hh`l~3|1KcJt^+=B8CH8(NdcFn1T znd{(^7S2jtAlzs_0iz29@&i;;xQsl{uW42~5H?Oes-Vj`!Z`VZ4Pi~-cL>hwI7GH3 zb*vpR3TXcRl!u>SC^Kym(H4N7>6BpE(`Y9;p!pk7NyTj9p$Ui z(#I*CVB#@UzHCesTin^JNN6x)paATuI%`kC^1a=a)>GQq4l2tEr24(v*=F%wpDkoq zoCY@rt>A!m;Sw7qJ^4c+QrnDcp#>aTGR~am6)E{P%Qrpg(QDa+=Lsha<5(fOjU~bRKT>D*ZRiL(ejvzC&%)_cr|Yn6DsSBRoRisA!;VQsaVAK@|G=h2VU zs0}x3nfT6f)X$W_g{LQht|vcdNNP@RCh1OA`G!tR`1zQYoW_Yo)ka;LV1Z0EaMLJL z)dJ>=zy&5}ug5<;&5YTgVvngFwDJQM7E5Fq^a$ICm#<5YaCs)3Wj-%-9#WfzU#@|d z6ijl+C|A-cP{)U!KvhXmO+VuhX{Lkl5`$YT={@x&?qCYX=n$_OWs8RRzKSLH0~5|8!{ccmSbsAHUt^xLu=ikavI z4GmsTzJkK+{iz7GYPujZBaOCT`@VhsT~4l0*W zmKFt`GV&(fg;6r^AWr$AQki;;1Fu=v@{a}9?%zvZzf3aerFBV6T!^R&df4o#!=i&+ z43<~T$@sRJIR%1IupGK){W)uv829UlPt3?paF!CVkrCUw2M;F_#xP`DH_}|I@1BWO ziog71CiaA{f1`gV{O8fn$yYPEM5=rax)r|A-ymi@%k;PhE2PBl?UVO{_VIKl%sNNI zypX-UH{KwR%gZ8e41%SeR0-<--5aKO7HM66YU6>!am>=3Pl2dZ6kk%lc70p1apJ_* z<<3y4a@z_K%?1PvQ#Qz@EY6-ZT0u}u*vWH+bvV=AZhvK?)SYGY@VH~!Sn#w~y#R7L z=aW|Re#iP02tq}mSYnTLf1$fx7+!7*$CC93t*ao!9D>X@hUNFcX-zoTJqw!)4Z4D* zp3y&27f+V+Y5~cEDk$#a)p$AMUg5jwRpGdoeJOM{peJ&JqA6SmTnR#>Z89tm^S{3S z3uop(>(J;=F)JQ*eBc>f>)oH89lw9Mc6q(_YaC$fRksNK2E70xa>@tfxuSdFSs~#s z@GrA4d@Iq@I4i}w<}~$8S#v3f-#L$rK48mSqbE7|ARPXlCx03w1}@dU4=~BH9$S42 z&b;N}2!TQ$& zITQ{foERo-#)R-BJ<=N>$;O#9TD+vMsFgM2?#jKS=x82kr2I z`Cpbuo@U-|_52c{4APi7=2q}=h{kJl1@lpT`-ntl9)@;->9u~2G}Qj5aW7$A%)dcO zb^tg3H|XkcLg>YYO1Ve2_1wsH-R!2EgPAI?6q#`{)j4M$xDkJP@U;Dn5f1daebMeo z1{A=^ThEX4$i)%SYx)*C=R(LpN<`#V`;LdEz5l@^^R*7aY|?cnrsX=JTCx#p&GCn0 zT_IHFZoL_KaRI}QF3}9uegq3J^8-#fuT-;B8Iv37XULvtc<36EpD zkzIZ={JLidndWN8BIQmgV;Su&bk5^5HB*tft*qO zgK+)B1yBSBl12`<-VS$r0D#!Ave1Wowx*D^+<~^fzNR*uKdKwD&Cvky34D1Nl}CQi zz}*E<1T6mQ_D~Tc68($cyj2Qh{2yH3>=hnD&|fV}Z~CwA$p1Ix|B;Pl^!g7V{eOc} z|JPSaX&ZBNVwjvRtGx&)6q~5Q-H_J)`FF$@lc>4zOfnF#xWwZJ?+3g}AQ1rn^ z*DA}1#hyFk-+SIS*au1^m}gKzfn zO7-+T>xMR06wB)3Dt9;^fV)DtS$89%40a+BE+rwat<5O9po`bgp-bRTl@@9g%6#h! zou&4;T*A{b<|*g!URQoX7*?Rj(5FSCjxYveY0nT;4b_>K;2&d8I-GaW`9*0xC!}co zEN{@#{x`@fX+u7EIS()LBBSSi8LLSks++e(hdrwF6WdLmel%S4F-HuWg*x&Hbpf}{ z?15n6mpsixJTJFaYfM|UhS=ukcStGs z@DNf&(yVF^~bm&17Yxw$+#Qb2Ma>JL$o$=P#e`7!>D zn=gq-xn=+f1W+;H7_zICTX;SaD_QBD(SrL_1WwF%5`JOyL4+#vZZo+DjW<%d?#n_V zucf0W3!$ig80@6@N?1;n@oN^Tg!FcnUBMrZXZF)}?f3BBtpP%3-P+*vaH34@(f4xX zAud64#{jLf)&^I`$$`oFrI3BTj*c@C&H(?B5u4V;bDQwf&VRA<2J4JTngmYu^wcQ_Zx)ALVHGd{p*@GKDEsqEt?L)vyxMn zBEOAp8lB6%39!Mcmwl9{S!t%XGrN@%YhP{XmF5Nc#5pH8Zebn9eEJP?E7kEap80vj zcsE`-|K^u_Q6XL&dZ*lb&A?eJe3ESku%cFWfr-OF8Nrcs~L5D%k)g# zaqeoB1o1E4BuSq}zU}QZU4=6}t%L#ISY4a9Nviwj!)_33mKSczuOrED#eax{4-J{6 z$eABtx*zp2dqsPqB(@ZwwAEE8HCIz48!GpG!P*=_7e@nqoY5clgfxQTMKB0ww`=}> z{WnGOQailCSCkDzk@rQ6TLL&?L>L5J&1^ZUjq=T3A@eAfSMxeF32;ZZ;-t9X>q|d3-fn;hU+rr*wI-* z@+Fp{_sjk3tnTEgm3=SiEK{L4BK4j*A}Ok%^AuUXw|VJuF7fSRu;KLHz08#EPpoya$-;GANXP0Mvr|laK-lhVZitP4Z7w+IGoms1q?Uf zRlPO30g?-qxsT5sZk>VVaqSOLAg?R@ZPC*-*i`bP)NjyFE5K)2dQ5LWX9YNyVic`X zo>=#^YnMAf8Wna&BD%o?IDxG9fTJ@HfWcJ$->m-^cv&A1IV;`+W(p+3!Z1)>^*WQjj z@kHh{!**r^P%*})_!lVR+$FLbo2}DKdhqD1LM7|n_jiwK<&Ww1WBmPUVw6TKapu(i+9&TV|_O!_N} zS8cAIoN=KI0dBzb-hDdnPCa~zE$E1lnIV`|BXn7AY=;{8tey&^=Nnkf$-&!ulQF{T zi*{*Q94GyNr5iD0h`t@DX!+@&3E!eiInykWtcEcAxSGnsf##weP}`g;b$1zMns`FT z`NG1M#Ve6q><9aNE4C-_~t>;_AI#_Ut470mGi9+p~jKH~IX z&DlMQHyrGycD4a3D$Y5{9%_vAAjV34TwY#>!mJ~-!6S>z%~zKT8s(|nXyn60zwB(w zb=;cR=fwul8bhus*r&PC+6g=+7O73F^UtZ(zWW)YnNBz>g1jud(0Z1nChh+KpbY{K$Ue&v}uxp&zikmANpg-e^@L%rZMf;Dph_ zh|7tq_YARIsa!Ne(d8-d5b=$S+qwTvT_{B65-51}8`LiaVhu4-q|~mRjBGQXjve-P z?5uZ)%lZ|SFsR(aaMrOXs9`6ub*Hdto>)l~f{Ol`yi+aK*As1Nw(wrGva#}PFZ#IL z!K~@-^{mCKd=sfAG;^t^O=i_l`N?wdh zPL=!0w2E8a{h*HJv{zl4bE4Xd=RcZ=c^ecC3SX2m8`~7$+9jg;h3v+iQlgLM%5d03 z0AU*#*b+>R&V5o-Zi+2$kL^>|T@ItZ!jG!h)s~kTj&<7#if^H`uR#bqdoi+#?y1Si zzHV>KA>WkI@XP@g9VYSV+$eLA+U`r&iTPUbvio*19r7*~IMfU=0Z z%T&jzQ0u>-LPU!p_vCeAduF$AUCkNG;pNPEwSS|@8bM4uYt|S^6}|D=|K-+0LYA|7 zrZBW{xu0mLtF?tUFOB;5w4zg1=W-Ad+vnc#58~rcaqy8tz zWOQ|p45>MP9R7t|#h<#c{VWTp&#``_ScC1C0UW%2-%xmEbZt(|!x=()CI_ z+Ojz1qeGo~_D6f7d>s{#8Q30WT$IWGn}pESS1n6v-rhiw*i{7hdyx*wMDGMpJc>ds3g{Wc^4E z@2dyZyJ#q3M^kz2I?wEO-IPf~n#$B-@0(qE>KKl`om5`*Ds*WrC_4G-d>Q`P$52wa zGqJ|1q(l8P#D0{%FLUe=r=@aHx7cF;0<`)U->?&(34CX6U0sV>yT?lYEHmD|QE*S0 z73a{Kh5r6U{zHMxG~}a7_32|)$%=KEeIK2UDt_FDJ_+}ePduY(jvlu6xeC2TsP9A0 z-ppzy^W4$|!NY%p6vIAq4iw?x$cB>01+tqgsgvhb`;3W`dNM2Y8uHFu;(ssy8dXX_ z#2l*NZMAKc1Ck<@FD8$3L{;sv%xsx7@JQ=Rp3Aas=9wG6UZ_P(c1x<3o*D{|-sM#- zU?}M%LN)_=7Q5Lk*}(|f6)FxTznuIAA+BW>$mQJA1H8Z->udMqxm%WM_f5Shf>me~ zf8>~i_)gyGO@{=MpgmPHV#hoGflZ(1>#k=a&7P*-PQt;hUpSTA(H%KnOQMi2dTvDF zCHO0x%YtxiE<8T=RT=6T)DA);d=+a$^UOaSHQS{m6g+S$eY($eK+K!ESK2b*P9F3y z&|E2d?M^T|)xVQ60-rxnk7Fdjhz#_gQjIE4Z!aS}boADN>9$Yy)m5JID3ZHsC1~UJ zf91a9WyUYERYH!-Q;A8-;vEYOT4L^88f#7NWW-=ppUiJ+Oi-(A4pNJT>II;4>gnq( z)C`qVH6&Z3YccF-rPeeq9F5gCb)&rz7ztr=!w6uRz%T!{)%G|{rCEX$- zVp&EA$`mh2s;|E)eqg;gT-02Jor;c3O!^Vd+q98S)hetgTWgl&jF%|U)TSvh-njRz z+0TAL+-B<}CGc|xM z<{a6jTCK}Q%Et`=u&x&jI;ap=+Avq>?F`Ud@?N?Ob~yZqwf_EguWJos&7(xIa?-(- zQBmlJTkT&9Ji4-&<@>rc3T(pCnmrizgUh_B~2S58f^DAdn`OA^T zFbm(yFHL7M(X6hoxSy7{3^%y)%u*+qArabDbC?o|9!%|svXb+2S;GKwjr?yA;}_TZ ztNZr7!dM?RtOB?fJvXfqwnyO4rFm_dZ&rw5_fh8~`gp@1JJjt7A(1npVTLGGr?f?O zP}A}yXxuO^ULk z8#SEW*Jz+#6@NM*XPe}(v>p+bX~oA}sFnR^>~U+Toef#MoP~CxBs*j$6mmO#Xbwb# zb2(lHOZFcbrEFkboPfqj{o`w(KKqE10Z6&Dp9A4e6vvw;MzueGKzSMoNXHxj@e3h9 z=3@z;1_B&QRY3N{;Xbq-&}pR>02Ev{{bWOpZa7Nz4pPMF5}Mn==Fxp zfM#?oi>R{g-LoDb8y<vW2L^6IJWFS2j?90`$T zC5S+Qlu|>{A|fl6*AJHHvejoc0%ihQ+`BEIG%?O9PHrHIBlOVRc%Zj#jvVX?y{QX>R@*T z?Lmvgj-j3goG85WA!DskuX{3`2`~m}mybzzSBw z{oWN>3QR}-lxR^DvptyS?zv5De^gmziXP6Dxdz zS3;ZeZZB)npX0Jsd`{y`sDmz(+{6$QbLF7%zFlpyW%$~DX1(DwIchIJCso)@kD4*w zAY2?Vy>fda!u9r7c|y0>PR|-9Wluf(QtQgk6L*MPZ{WI5Y0CDx$URcJ8AW5M=)y5S z%RO>kD4(yX3gq15?)}2}H#~$R3W92aREN)6wh2D{eEUnMz1l79dKwmV#8)L8cD2=1 zn26ixx0sCa4UN9?H^vg$$2Wl&F#y!ojYQ*UCgv|?MPr}gK5|zPrMgI3 zmiFxHjNDrz7rvcYBeIG3^20uSi6+ji-(TRmL*)Jep@ahCyL80HornCUmJx@97IGyq z)q{Y`z8I-d_iW*OLzFf`x?F8#LUd4ln@IXh9jW+<2G@Kwc~|A#B#(SNR)NYu_EU1V z>b~x6d#ws8pnKS3t?@w|stF6{UEN^Eb~$W(??~^0Q(r=p-V_>YAbFN0F&ud+pUL4d zAoMG@P$KzRvp@R)k7xrm9>U%#M>W*<$vWK_B;9FZJy4XQJ-g7(!k1T)O3+A)N8Ao_YaMPjoSO_{kHUH6bF$i8N z@T(owh0dmu_f+gYeDzRD=B}aS3h!Ywz6jID~@v1+$E-GX^*&yJda&#iqmD1GPU{BOsm+ExNK;_CXSpPRPI8N z8x#NZ-~ZclLW|4c23;Gd1$q1iUA|C1_;c3p6;NaQTMO&rwF{izpgN=3(h212wO%!K z9^jz-SHmLCmRD{FyL3EqYg*xkKymE?zxYXix_) zHX^n;jr@s@w34$hbSbytG$SUAWgtQ7k`+?d%bFsZw8u*>`-%ekUHuGQJ z71@<%$1iTuEiMFoF@EuO zx9jQFYu5(OM5{H&yI#DvlCG3=!i?RnMmH#{usI-&Hlb*d@$39vYI2Vhh!l7LJ0U81 zg8>6j>TAqzN!a78I~xTk7Vdwf*Y_->oq z)DZ7V@EvvcmDz=kb9P<7!BC_Yaj_RmM82-|t!M~$E1J?Y;5PKmem;b{JAYdYQ82Uc zKf#n8SlT66#~zX!NIbFIfiK+;c{%LCU0~;QAwYWZzs(AWu-Pd*`oYdg04ZAUiV(Ai zPLZcgO|^A*Qt;FKA+-S$N7U60%z@)6#&0xT*jX$t3;P4Kmfrj=`vZ%~~_-*k)d&uNvdK=MUP+vTyrY%p3T&0qig zKTl3CI={;m68#kUUSP#0UX&sJHAS?JDRt%1$Dzx<0jChHru?B`SIzL5-{+#XIsj0X z^PI$)(qE4J3Q6tHKOW_2f{oDyP-2dqyJutx2mRkLt-0Za3t1)(# z_9bM>z26wh!AM!z8b_Ws&^9f%?q7ar7LC&3;POtqW zwbd+^{m=kgm)c=w7w5ElMXCKKyJdm*zp_zZc6AAo?6}@tem~|We``{Lk@z8**qjOP z6(k+g3v+o+iW}%+MjfP27_r;bN|IRiDON_f2YV66F+V`ZQhV7Sl^=CHXaKpW8zB&3 zWNAO%!+it%@qNVC$Q8YX06uc^v=g#lbWHtcB}P@pSxlKvc%AhWZDM>21t5pD3y_wi z*CxW>KSlRoi}&iob3D<#-dDV!lB#uo9;`M9xie1v(T068^@t1Cw0?oDzid6B$YdNS zS!3gtL`L?0JUbg>4aWuzBd-)wcHHMZvFOxAsS+{(tf<)i4bqfTXOh-A7lu&|s0&OD zy1$>;dm@1&F3KWH1~i)fsVmLF%p8? zc=%Rt!m|91kZ3_e^r{SlNn=_roA4acGFdv=9z_IZuAQK5h)2FLXB{sxz`Dcw3Qy}c zB(AKUvZIN@eB*X~{gXmR589}q?9PcDy)xMSe6?VPw8xODDubCTLXe;OQ>kK!QWm>o zfmdj~F}Ai(4_U<<<5lyXap3Kk4Gpu!GslnRj4WH5*VjPC|<7MxgE)1<<8{=Uv zuhsy~|Q!-ZHeX6i~d_%T|)S!XFvmd~D9AhP}3z+6}v zHg^jhgrM7xg1j^`v+E<}OK7!d^xq|ZM5df702gkC<)T;76N58r_ehj_Mme=-%b9Fh zbXYec##6^`SB}b6^ibr&#mYG_rDeoC*0-twtmgFRY*lhn==LSifau<;KYNQchxM$X z$k8MUR4r7Q3QBA(bD`=ljX*lA(7p8IUc1e6tuLk-WV>N&&38 zy>$Mr*2MU!oJcu~9C<@v@DHV3=pF-TVvhUn1V`-D=J~^y#n)D=h}qHy-RPy+?*hnN zG7F|>eg9sW=AWFF|2;ksf)svE_K#|hAkF%3fwDQCVI$R*+FQzKoRy#rG2~2vXVylq zpZMYz`>>X9@8;WKX1Tc1rWzf=EW`4!gCU&kNWnxxnG8Iq$EB6?g4XT<+(>n!QO%p% z+vqa=W6ok*!csKlot4Oo`A5A`t&#OE!{+unIrW~}{lxa##e1Mk_@R3F3%v->mnISe z`IrLhky^?kWbokkihAnF3QhGN6_WVuO;cB;;?U@A zhYbLVJe|3Pz+d71h3MrS@Gx(`V-Vc{stN^b_rXBlPc2phJW;Hx!x;3scJlbT)WXT* zyVnw?Bn?q{*vA(nYMhwrK)1JI6$)JEL zN~-!VFDse;=#n#M4)E-sgp+uAkABn{Ka1&=D)8({m;Xf8-{}p{=9d9 zaYa>n5CKdt>*G+P*DIvLzuKamAK93HULDY3`TsX_O8v3b|5HX+3n-r1(e^=y2jf>~ z@mV2NWGILw9^kEyAKtU#2=O*u@=UD}ZH8IsqXr-auG@*domAivK7B%7VU^Sd`R>5g zJiTV;iW3G$F8AWt#wH@ecIpJp?sZil!C{KkQ*m5&!mJvn$#L3gmY$~Qk)C@wK0He3 zj-w5@2$zv;iiLW;&KkP(cmi3R?#O1QWPY^+HmO8Y+|Z~ud$5G%bs`81<@jHmq6q546U_@H>gEWhtZ(BChV9)3 zcaPLrA>MCyr8sN{HTe675LCs46gFB^tzk7RW#y3X5va_gBLSV1FU@GDBKK^2P_9pw z4%#u{2UEQQHU>I_ZN$8Vai9F>C znq)I<7j0*yNWb-EH_Sc#C~e0pUP~cgF%Bu;dAq3}op=5gG`N>d-T=DvTq&pNx)^oA z?TXqm;3WAN=Uk;Ipp)0DIHK@8*Ib-*g6n%iteD-$@=1aD4feh<1pP>h7A?cG9ulJ? z(e)L~8`ElkEB6@k?a2*`_)V)%uQ@=-q{?3DH~z+CW{4+!*X{KawNJj)5hiefAc}#d z=K?(lD4!^_9knfmWoT1;&ytolM(ZNJEL)Q#{w zb)Zpuc7v(DF${tBsMaIji>l39^C;^pwDoRJ?YNp=`#kQ?$M=RJg$$a=cG5Ji1o&e_ z;VzQ%kY@dSAQ7bhnmm%_%;X2Pd)n{`qcQLci<0pZk1|G`+LA_y8ZrSxl>eB|8Pyet z1D`Twua4g$*_R$y%J;drbGFZZ^anCZ$+cu|rnCVe`-n(Ca+rKn4Y|l|-TXxM)L9sQ<~_nb>|TO z_)_`k50>#nlunUu)c70U);xkXcq2>Ko7=K_y|_E$dM}+Iyr@VPeB0+^kY~l><{HZ* zSuDI-69V~Jw*rdSMe>YP=W9@mY=3Fl!2zM|%+_X4Sl?37JyQ0(M?BQY)x?n)*vfS5&tB!173LGzY-6U+dGPpn==Lm_O`p@_&$DkXDVPQPrs8v)v0C-!Xh) z__4&JT6{(bESwp^X~@n#$aVKDaH)fCDb8P{)w4qApGofjM|6mqSW)5O zZPLsaSU^EZL>ej5-K(q6JOQ=?U5 zeCDppy%@f~tA5&~v5eE-ue2Fr9<8viiA*;wTex>-;p%xndpU=_OK8K)oor?=))<#% zTu2Bd6l-F0Kcz*ZVSKRH+$e!Q;vRwTP`L4CIDN4`ew_$OfN(s`l!@y|c2uJnjd$Sp zU7Egc*lomue{CO6let$j+8Zrk_0;(d(yN#W^DaNUUAm8(w#sDt9zWMz$4j_oVFZ_P zsEJ4mLi<*eWPPkd{Ou`Y*mEbbekd8DM>%wppv)BJnWwY=OQBG2-^-SV_6lc)&Xwus z5V=NO;Ewb+)v8cXf9K%!m#+NmM3CQd$qib>^^cuTq8nvX7W z+K_U(wCsLVWRaMupa!S(I$6>-ZU=$v@?B8WfsPzHg5X=>XKpxU9u@sqWJYi=VeU&k z&O(ESZ{7TMhxjcP#H21&LG?^=ne|3T`7r0UV`o;`2@Yb8z`fqmI?9P%yE=42# zIL?S>ST(h4!c_Snnrz%vWi4-qAQENuoC9)p^DN#VWL{BRA(_qoI}jsEQ^?IH0+mjc zq;*lMvy*XUD7DOKQkW-CEfn^Swy4NRpXpkQ8dvt`i*QQ%L1MJeA5zFb1|A)&2#W_^ zQN1yN{ejYDyrJCIx^&_6DJKVd>CiyVaI6T3i6gd1vn*jXWh?R#~O>x z3b(=M~c{xDKEB>qlmC z_(BC1rx&EShxzXWhvNE{Gb+Zx6J(Lkr(*>+zfRL8-L6Xn;f~#j+b;+uM9)-~?E|$t z+3;TTDC*~W%{LP%_|tEqhqN4bI;i6U`+<;}JV@7wzdjHy4A`XkdN(Zx9I-%R#>Uy*)lP%86krMRYq zyTSN#Jd<&G6z}Pp zH90?>uveL5;@Z9Kl;&LD%WJA##=G}vE-ZwOq_UKOMZ%9Z>#zQYJyJ_jdGAA*V23TR zTkK-jziZnGG_^MRTMb~w;U@GqXo>Yu(+bhsVY8^16gU+A)#yn1r?f%uHp%68(PmS1 zG!d_7c`ovB4HX*2qRd>)!N{>Z@k?$2@eER@%&WPs=GRv3)m!Z|dU7GNw-OeCD%CGl zsSUxO5ni}--bk5R-6pmrJ5K6bYo=BuF1~EBPE_AJ!D4vLf1B9FEPUv~juVT+C=!9qj;qUegw0ne67 z=H$Vy0&$)Lhr>%9$r~|R&{GOOH{|rAaq$eSZeRIB^@*B!+WIWznH;^(N4Yu*QqNgV zfBLkrEhXGl?d?z#Z{{n7&3)fj(PhafKnpd_TqKt?%k)thIi5thx!yi89|RnF-I39NP`6%kjI=YJYSVs>*76%v4q$MzQxDoV zSQbo6=DYK>_2v(xX?f041_HLw@wb@iZSRk|3CJ$@_1p5wXfxgy=cjye#6ypFw01>` zJstnxYDA>uNsx@}!ky9B)L>ms9q=-gqsYyZSRG{G{i}r2M|kd!4y0=D+GdYsx57+P z`Dtk-0Fr2_nerW}wK+m%CqF)Ah|eEE?omvr-Q(zW%#oeYy!{2wjZP{(?-tnow$wDH zHT}#WYOkO1E=shkglLAkX+n3Ttl6gY)~svkLC-hAa%dZQ7~GG%ZW%G9l)QTa*Uqqg zgypxBe!9X6)IHA1->>4-9V{|j(_eUqCT;el4l@pexO>j3k*sqbqf4)RNEqdmgR_<$ zK9Vaua46^vcNZ(>r%5%}Ygk6}Eqkh_xQ&dqY?uzdcuGvpMFX$R)2mCQNXR)@6t2xI za48nlJCwIK*o{*DGGKRI92FE8l?b6|EUvuQ4_Q;4EN8w%wWFH4m!k`%uC4zB@zxvc zd4~8-4Etf7{lYPqs7Uu@=X?>sd|#Plrg;KP{@Dl7l86n_pelHTY_fV2Am;NeLOxm5 z^79fn!Ms}nm)rXTjdM%2UFQ~Xi%ABW?Kd3!4a#gi2uhx$|UmaY9976m-(*KF3JGTL@I$MvHVtH0bUo*r#` ze(V-9FQwe*{zj1^|Ha z+Dx>-)&?w$X-?Pkr%9kNxd{|V$Ofn&7aP7THNpjJ(6|w-^l`?ee1$7)ekSRpd>b*+ zmPi#&)9T+Ozu)>YQF52g{H};fQy4@S-n{7$E@?EyT_(cJq_eonlUY}C9kshiDx1kS z5uZCkqi^1@O~KzWGcCfQZM4$*{q*Os@MEq+F(A)=#(u-{HCWF$6g40it(l$JZ4V)t zxS(L?*68Z4X*qf4!TJ$4$^ZG0z~hVT<(vVVsdC&)f6~x^<+GbmGKUdQrOysa{lfIF z4j;8si;0NT4Tt;%XR(XQQCh=U?sdPLaHk^n~( z{gM^aI@!o3ZOrpn7E>2ha$xp)>}Oc(r%`s3TzmP=Fqr%8%wrPFYqd-7@WHBmy`tg5 z>mM5c{Kb2bpnvF!`WH4~`80ABuc3v+A*_OSvfO@Gy|PKGN7WIWG%{F8R=FWZbINm$ zN=5Wu%XRZ4*gX}aG&LlPZTfP8H%a`i=k41~Fp60Jh)4rsgD!{x?JxmqKUPd4p+T*= zj3kI%{1g<6hLaE!Fm97s+k%s`3=itTc%=c=b5s9dw>=!x`0I%fNURgI&8zGT5~f>U z3tDf6KgAd5}G=x z+o$CSzQjXN>&c`FdRrft=lU$y1y`(3c+{uTcWc1s-5CcZ=1S{i?ZKPb?=`T-EUQ7y ze%05uTYQwP`jHe%o8ZXkO2^r{sbha0Gc$XlbDpe=r9*w8xDiA8GJIGKl_^b0ou8ar zBA7ZB)ue!2BJ{2H5^KXkAegO;u`#K9FBn`o34E3!5Re#|E^ciy}uJ}{!dUEAV-|G4< zHBNex9XN_O`k;;0J7q|OiToIsDQq;w`f^v3P|;bAf19R+a|I)QXyNPripL9@iEBSG z;=!nQFVSOrvhuRkwCAy4=@m$=tgb3f(+RWV=p3`cDgT7uh1PeP8>Zub>9i?Y% zzt%t7hsskwnM#rgi$%4>1#q9|ee!uyzlI%F#_)+mV`CWt2X~Z~Vq@eab77TU_zgRF zLse9Zi**TcI=ItbCYWQr6{sAzLU)<~@9C+4G#7`TkR7F5n*46u+4kQVdq4oRHld@+~ zw83j*yx8=mR%P(eL+#px#8HghXvdY`#WAP9Q9Ow6UfL1=T3H^Y?5!QBmgiL=rBoQO zqz!(t|E9!Hu5gVcs-kOys2@dAh&sN)m;-|nO`~xJCgJfmcvv(wHQg+fe}2lxR0s<_ zv`x}Www8F%`92q?&HiJ*vmjq|00D6l2FZ?pzVm_*bwb%X4orKpu3SwFAKDhOr8`8) zpouDw&~<8x9KO?l;M~&#w5v|RP-6+a<*dQ)-i4=-!eLJ^v+Ao_OqRIUF)8yXSf3S^ zD$qOO`ZdD%^ju3=TiSeWhTpIdZ8lx@lI-iPz|6Z_p;KXx%4~ndygz`CM|#8`n`Mf) zYGQ~rxy9RZG;EN#|FLoMkN5w#(gV|6xR-&0Jo^@QMpeI2ZEv*TmX~PA z9*lm@O?UrXUiE6 z2HrWEGgJ)mQd9#I%wJ{R|1K%Xqf3Na+USA|ezcl&FHz=sQ}Qx-h$z&tSuFu~ez~WP z`)G&Q5k?HT(k7LLL_WBW6@~En1-#NbKl z7xoI3s}%MDhyyAd2&gleznQsEVp(v{L9-2{nZ6cbIkK*4NgPuCVeBIbQK;M@=qn!a z{EX)oW6h|p*|Wdy?bV%gUS2eJQX3$xSdncFKAd}%LMirT(>zSk|Cn(ROvZ0!fh=IB zt_-iB9iDt~%j4Mpf8vP&a2oOdC!+X2o-_v~mO!`Xht#JZG*3I7SYkn52gCndT8J2& z={Ws^kg)S;u?p%^yK=BwJ`@Z)*_KB?`sk=osOP&VJ`D#2B?9T7B9H1gXWM1W$+XsUXA;FnPiS>NH8?=xZ@M|9(}!qf$lFZocG~GS zKljQUn9ug$4KVT(>yG6Y7v7`3vs=A~>{a2Xr8S<5oApSJh|rd1wib(*$BN_P-(bFv zZbQ9T5PUj~o@u3hJGqdzktfgIgn%lUtAVP%f>khL@y>ic)UAvEnKJw&Jt|_*u+Sy#)_Y3nFA8?V$-XGm z6AV?YD$tS#j#G~c*Xwh#FKfBF3Ls5bwVHR3iMkqz7tnc!t*W*bn`V2jb<-{|(%jks zGCt`0b8Pyt(a(@1XfysM*+xzZ;#L%b+gvxJO{x}JM;eUoKV=V|-IHyx?~5~EY%g+* zW0@!VG17uX@O>Ys?M`n7Ra`m?4dSv5!JU#wna`xT=)B1eY8RCzPnrZ(isPcx@XRK2 zimF4dbT+oRxj8`%|El8m&wOwC=ch^F??Ts8NLJb&w}59p)IzHn z@i(^h-!PqQo%SOEJIy>;RK8|BG4A^dI-|rRKzp(>#b%p~@s)+cLv-qFkkaURvv|P; zT2E>iE;FNlvkqNIs)i!%GvXm=LmwXfbCc|7igI3+H_+jSFi4#xRS+Up7SVvUv9S-9 zLd>0G;2QSq_{fSV;R-C;R5^d@d-Ug6U+f%*N2L?BC~u5P^fuXs-3~3C*P5ybFjf*C zihoRGVL@ze+@Wv-=MzmiuvR&jh{+~hn5giUQ|)sZ+%{1KOAll^QB3o zubmNa`LKT5_vfWiY6940Tq^j*L;B58TFNa~^5xE<-&V_hZ_3~sL!jVGb%6n^F=@6{3o!sNiR772>Fx+FB<+$e{C390+H^6&J@dDrXSwT~{3amCDw z*(EwHcZDwjOUeQ_m*VIShOAqo-;t(IdC3JmBaz$S?F&e7`P{& zsIQ)JVR7Z~PHjL>`!!q&Pnw2{(j#zJ`v2kud5=Ie*dG9#)&V!+{vUtAr>e5a7RTm< zPF<}Ps=AT~_F0Cp-#qYCn42RsJ+mg&12kmG9lYm*iV<;UFq43s9;EWnf6XC&Pl)h4 zMOP|q)=8PsNqg6NV1YVR*1i4y7LMlo1L&*JxAkoL=;H7ywVk}p%nh8Ss=R@_xZX>} zdqGDm;F;I`AboItB#^!fMVy;gPY~^C;D|48d}r!~^S+yCVb|y>jB3;Znm9!%>9^BX@Zwda|l6+Qg~84 z6fsFxJ8(qGFvCxdHnO#a18(Cid9`f)YBPaHb3fBy8Zbn*9c|&vLW_zcj zhxOBsb?|BOgUnE(-){btZ7GlM6)LZfcp1Ywq=FD<>Hs=Hn z*o>kG+*u51#6iMG3YOf*EpM-A-;tn&OC#$<{sJxM<>klKhr%9@`m1aOEBxxTyf~dv zjw?|wUX!0Dg>-gqH8Ll_g#{JFe=HII$$gx$f3o54c2Htxpjue<>0`@h>$t4u28*{! zr@9oSrV%SIXc^KWvH-nbO>?!1Z=&W-oZO%KIgPj!Y0+HnEi_!wPG^{mKS!FW0?*e^ zP`27}mpY3MsVJJ;gZmb>IDs#$(v9}xkrQR~hAl|BPe&a3d50L0CLIFBv4N_#uP2?# ztXYwIJ&9hJH6E=~Aj(M{tFgq*iz@n6j-0r$kbDb%vzknD0KNL4t$|=%a9-45S}p2{AKNyN_nx(b31IX%hS;uA#`8*e`Z8SLDQ>eN2b-|5Ad z7^<{4%MBv<;fMiAAF?y&lBYp-#xJIyq_@c%yW$Rg=v$Ju16w@ETN%3eQ#F^3>;)X` zvErLi^>q}prD8Tp;=5xU0f~%=>VV(XcIJ<2yx8JDN{MP5u~4e5@tr>?BrL87Z}0}{ z%IUVc>9mD(tr1zo@Y)l;rNwY|3s$zCKNGMUJ2wtR&9IAeLUuNIg9#MeCehzi|H=$r z0Z0h%`TG>d;d}q+mWEJS_(di;iGK+h2gIX#ca8$wLD<)~)3rxgy53#muC9%Sc-h|L zf`fe$i+t120M(nF^bqP4X>)j^x|$~mnq#6gHkUIPjLnZUNldrC9>nc(0z3LXHESM8 z8)J$m`93?yGZ(v9ZOcyeyDKQXm3#dZ5k=pU+}WFw5h=$Z-RrhsW_0&Q#lBfle2Lmw zU~4N2(#A182&=7>RT*okpeJ?YWuk;n7=Yk6XS)uszwul~_d4#)aKkN6w5KE;8npn` zk9vQ+B2bsWMlAj0&Q)*ukKj$ul&<|kH3?xrh;}?%{dcCfJ&7fyen_wq32fCiIY10x zq2Hv*wkf_ZBIpl*BIIGt`ZGK6CG?8~leN`8RB0nHUjq$fjoS=p# zz4fU3!gIajyax4@QxO(CcT#~}E9;{f9XC`3GomoDu4;&9ymU;6iG{JOXLk#ZPX+Oi zwlrq!EWWtWo2*Q z5)*5bPu!3&z^-#~>C!S1F59J0hrC)$P(KS`%lsL z}Dlx(aC{ak@h3Lb1uF&@-rcVZd7xrO;cEi z*R7FS*S=cg68T_r=nsFYaC?A@_J8Uhb*G}QD-GV(jQ_CXEEwXA=S@juOxE$@VV564 zg!OzQ8kDs z+jl?s`_{J>E{uFIYix{qWkJKK>x@o%79vUH;Y8^#Jugmr@Ql#1#~Ur*&=tjJQQ)`gGWwYxWp35ojprNWrt8UTFcf#DB;-s%rv3*HJ1 zmDr?yK>k~iu?{m5IRYI$%<#kQ>;2%-3!rW`e0UU6jj!zH<=#QN?a}k*71G{sLJPl} zait_xXx&fS%jJP3#|&?W*o+Q#d_(!SwN`>FDDf62qNfAnG#EE$Q1}cVT>MafRvG;R z=!MJj7@#hRYq)7~Fs&WhWO6Qn9rExvP871mchllYQeACZme&-e%ysz z)@~q`9iwN8eHX_YIVL7%J0`9;7uOETvUxWxD&SLQ2$ZMX%X_WQgOBe2ZBS?BaI{g` z(OIy=p9qbwrbINFv3Bi;ZTv9HRpT3ESMe5bI7Dgg{3I0e1%_Lc?>X$`U}+aDqQAZH z{g=3o$tHQ68sMOVDDIZjk;7B{%E(wE%hj2%1T1ZV6Ou6Dc90IB(Vo8cVTk{fXj-R7 zx2X>B%bB^Z%<@NYbGyLY4v+NcHF_{kCAV4n6y-+V{*BBBO+#%1^`?UfQYmLxV{z0O8J!HP?86j-9Jchu3T6ma5p0$T$ zHt4WZnn~;y*8p%!X`&U$AB!C7#l^a-cwZmN8ju`kcajH* z?g}KS`}O%fWmieD;YXYePpVZ^S%A2Z>Rhq@{?4aop`B-sd0UH{@lRx1wV^8;*Y}`u zk7NiF?f4D6E_{LFD_BVm4Ga4yN8;% zSMlt3m6?yncAa=mvlh(wqfVX?VZs;ihViu#z67!N@}r)5h+Np51oj_*#d7yk4x@gD zNo`-x@NTHhrT3@P+!r8QWu2K?5axjm#RmG?c>384sX*{3C!M-wux8R-vW^ZKc1mad>r(D6D$V*I-73Dp7a zCM>FRke;whah9{Hu}vLS$V8;J-M3|fhzaIm6EUb30Alc_bYKb&{*q+XcUlDCxlT*G;7T{klYwMjPFO1XZW7_Q7O-~f3S(MDM zj;#C7*R%hwwd@Xj9sU7 za^9|_VqX-$C$T})Ei%v0=FXTvu|h7Y{t~z@UQ7pG6X|sKA`uPkNYH!IMV6$wzw@eO z`|)E5IJ7>0Gl}LzyIB6IR@KUZL7^5h^|ozh%TG*dLqF?Bv%SYa+G99pHpIdz`?R;G;Vd- zH!(1GS^Y}xamd+6YsI~!cCEaf0s04!3IiChfJ~-E$_4bN3eVdk3dlU;3RcHmtdY4Q zMotJVRNi`j89B>oPQ;}!MS!QODpo6S0s5kH9y`^xC9G)#9piGnp7{J z_j{I_S~HnnpSfM-(5$Puu*`<|x#fL;hHHW!$Oq;gmTqSkbImE1qlG&@3}_bo^68s> zYtPK0N*YLE$KofRB=!j8>(P{Fi&CcSUy|f3dE+yB872ZbW?s{CnOhD*@5HHnW$5QZTWhJ{QKwvch1}pN32v0DceM^d7N*I4Q~HkZ`PoYT za}yX?_|+2#E=lAOSC!1A7Axi->ZVSV)Yeba4OF0O4s`^%EWasWLAW;QGvcRI-*k#1 zw)*zMeK{?4Fk7K|7;}Tak34l2$m|zlXx+nj-Poe)+Y|&5rzK7SoyR=oA*u_EnC&f# zo~{s#FpZ67H1vu*lhHmGz=t8S;9|Zzs9Od!17{y3wLew-6hUtNy*z&bxyL#WC`|I% zdi1EfobTmUFD{BmC&ls#Cbq`AFUuE>xfj=U`9iA3^5dLxw>{jS?Dpl*pqopX-CPPL zlaGO-?%qL%l@Q%YY^0Uwc7?hi_4y?A4cy7G0~f07^Zt5x4-Wl?uURcO?eP9>p&PM_ zx4W(GNg*caSgy9(g!n71xYN?h!P^ngu!_%`SK5HI*M*khyG_+{sB-H2XdCp;&s)=fv`W@BKgKC;{ZbIFKh6h( zeh;#YvgBQ?$K+r1)kDtg>Z9VznD8523GrRkP}THrDa;Rjh7yDSJ4^ike9nKbAMjs0 zY%&S+l?jB0bV~PN0=diGI4zko9hv1$IyQ<7U1(@Uw^MS)ZH5efsJbeB3C-8B9xef7 zL}2Y^>1Zco9Y;5MS-@4tU>G^P%ykOgo(@kH>xr>y^xihB9_guZz#19KhucNQ0WMcL~%OUa_M;gfs_jON)~@TOFpGvXi} zi~l84;+;lQx`Mqk1$oQMvo0IcrLd5!=@{+(;2xtR{O%fXvKaf>$mFCh(yE8DXl=1qT-! zq!DS$V$P`3CmrvhWcQ$3uk+Uas6a+TJDxrnGWS4b1%1#zpR-r6qC>Qk#p(g!=Rj+|E zpct_-QuNgi=%jnxNd`wf$l{Rmu->HA8&+CevpabCYc;li_tVdDnz`ib41t%b9Qdr zPUQ zo;h%}0--D}^!|Cq7MIqXW11%O4`90%;c&v#GzO(BlmzME{lKnvda{~1TG4#ePZtI! z1HhKSJ^lGwA)X|Y=jgoP+rj>WoW7QvJ`jeZsB8%| z{mrb-=wtE**U>`e;p!J}M(Y`0H(sU7ePT4psqjUpuZ2nclm|Kan#aE3R@uP{Cf|Dt z6g)_jKRS@4Q13FqfF5;|CAWf0e;TYDFgCwYof_wsDIxMw=trXg$eT$~cHW4nJpJ5e zaJPWvyYEjEy$UxzyrBB<9cYo@$v&OJknMf25EJOpT(lVZ8m!vvDugscp@A0V?=qD= z51c-8ZBy+*bhL_S>T|5%Jp$+d`2A&g~%!ti#}tE&gYox+AoqgY8x?_9`Uz*aI9NKl6>UXX5FiX47` zDMUK8u!-$2vzw>~ACeD}1n(9lYEJbjyKY*dWm=|Q6y8lgOvA2m_TPGJ##;4l+52VD zKdgBxp_`>F&gOA+IU-v)o@BR{h*aVprmzXG96&rz9HZ%mS#b*a=m4V$9dtqHr}h)P zphyFWaE|M{I_0AU-bVy;f^jUDmd*;SierIanrTl9D8Giv6#B5$iH=exVB%A?(2BgK zwgwTSIT;_6Ovf{f0itiTNlb5f5_T*6H2rL!KtTAl1ltFW>X=(=D-sb1>8%WBt+UUv zR)^RxWZU}@e^~M%valInjmUlb^~Ob`^0U9vwCc7%0}W|BhN2j)196h3My%Rct^sNQiNq>wwmD7^E$f?YqiL@rW+%r~C=wCck4#${$I zP6{2WMp5#?U{^W9&%v%`g4g27$l{c<75GnE(;RP&BqTmrKwR0XGVi}pGrXIp#P-ZN zuVzi^J}Ht*O~^TJJpRh45iH+I5OUY_iG+c4oTQ&GFW|zgYl|8$n#ZMZ)TemG1gPRZ zY+smQ8D|x!$J>NY-eK=aVgrYX&7#q9U0}`FV@`tk+#Hdaqd?|JKqN2-*%BC6`cq28Fq}4P@ zGlg9oDLxWQ(C6RrcqxUqPmYR7rDmV_cXeI(1#f$~2@GHYUu2f29=RQ~<3sZl&jXX# zi^@&9i}d?&EYqrzf<225M}y!UI+u=;^nUQQDw=lP^qFHm_;7=kBh`U5lC1fYT=J|^ z4{3GofHwXM($2gs)KrfOZ`>YVkFUlayK7l4$XutGtxNPeq;v?0@^o8`AR=$C4~;W* zej-NP%B8BFb8{<)c%%5y1fC0Es}3@Bd; z6m1ffc6evM>+tpL@niyTw@uEKYk-u9BMkPT5L!KFdwsz0=GEmc+*JXz)kww?kY_Fp zl0wGkl-g>{A%ZGv&DC)b^VPmTZku#)aLBd=DAI$0sf-JQn2$Y!{c?h1*z6fj<%h2x zvcI#3yA-lln^*h!Wqk8rR>%v}+J~f^mKn%tW0a|3B*ow<8PLyF^zxkni(r_~)Zo6@ z%3cYqHU0I~UJ%FP@vPC}@xB&wGojoULR1eca_!y*E*h*vCiXUFZ@Y1oy7rXIjd#vd zC4Kd;u(^G#@zdVmrABIOy9pN?#v~*d8bGakQd1sechZrTR-nF1b1NSLejxH8QB<|W zqT@&!RXv}La%GKPAydp8eTPP?&p7c7z{L1g(tEsI_%m*!UEW5<_t0_^(06^2QOO1h zA;*4iKg7H7XL4AB}rI79RRp_;tu@JG%>}2v}kfo&%GSwF{kM|SqbCd3DBI>p( zqqYtdsPATniVdFyOJ_B!9bU`UC;8<_zk z(+iYhmTv`Mg*c=gQBGH7ERF$rK<2OC2o-%>7i?Q8ia4{eUR-_7TG3Tv+SzXi|lhJHy<+aF02z#DRS zN%6B!r+I$5jaqxIF1p;9@TwqqNgi*g1fkKq1&qs#mco<$v$NM9nD=|%0Qz~S=Lfl5 zOZutIilQcv(q-n=HBx0AAb{af04nRxu`!%62j;U)%jfm%V1dcjKo6;YpL zsI_lDiB%&)kbI4d!;UFIyYnzgs99WcF3P-TO{4}B!#|D zO8R^VQUdI_C%=CxW&D&=*9*p#5HY{uGwGNpwZ*qozV}%{+ggCdiuZlkp1Ps$yFv%c z(ZQ2!s(l}k4BDt`LX?yvEp0X+c)W=<@ugINL9wdOCvm=jmGjeLxsui0F4GcK%?qi2DFJZDNPg6cRHW z+g5U^roZ)zjBtdbj%(LLn+kv0TRW%fzJKSf%i@)`>OVN>{E?agABl886suB#b5xbK zvrwJL+gf?%EhbgP?eUT0LL%dClFis|heJE(A!ql2w4UcZ`tJ;DE?KXK%I; zPnRD$EGngsm`_0JZ@yy5;bR0c!tgcTek_^!SA&jOkuReo`PpmBjjLXk8%!~< zTGVx`#O7^uv5~jI8jtU-Z~pE1cm>-OUqeC%hvOUlw7jp@Hft-}F63%*4sl&ZdWPW! z%~J(YSL)LRiF^(E`z%5-?49b#m+=jIszHeW`i!Z&XLvIU=WWHBNgRx;Tl?0;=Fx)M z;wo@{k0WVkKM)3Mb{TVvF>7*w&nKSd?5V)DQIctZyZ9|XbKjv zuomp+OWkO{r*P+Z_z$Oz92?~)N_Jlpn9=yvt+2$_DJZ9EKF|B8m%v+~s|j3#lgeN5 zY13zEjnq62qgcO&Cn1z%-^OY`l#d}2_KrT}NBL}P(Q+k23X7k;7DozQiX6uG95lO_ zoqBWtK7!~=(EN%|<=^dGilJxKSjJvi9S68_l+tb!AU$&U)-3cj%X5VZV;X5sga)BX z%ua<+jr9e;k-Rn}ab5`?j>tB`Z?fdz73qIqu4nk}R|u8SOUO5zE$4i!1ixjF-ee$M zeaaueEabV_&UO95OJaNht_4OnJ_q(256U8m!ZpYJ`$tOZ|JZW02#*p>hPgboKefQ4 zq!NAFm&6UWN0wu=>QC-n=1=d&{pTf&Q!gH$<;f);&efkt-})5%OWV~y0-QYmd!#1? zeK_O@VP*nllxo%k+iY&)(&BB@MozMvqTp{~tcPExvVIkjpX%%5yU9>=q`G3*gVV+P?vdeC9c@7Blk4hDB6|^unVXLTA>|=)l4~?blhsi-brmv zU>1*IexPS1JY*wXk)5_?@WJ0Fqij{V-5VR+ zNP*A~p_?Z^zyFcZ`LG`$V3<&*$_F#Mm9|*8x0y*)|BvZ(sin%q+%O%=%uB0 zTrEhi`{$Y^5PRl{qn%ecQYmb3u>cxCiEprCq}f^pUCvF^iCh%Y>xyjLWHn(uiF}UJ zu3Nz;`#_GSz*+>9tLKwA2}zcYHkNhzf(xKzHDrd)mbX>-LnW)TG_F{?2fZ)8D1?NR zhU*|H$79wCud64g+<)4-*wDLs+bgD0vqP3BsD5%vRwUi_&6UxwMml)l4#@_E?+yUB zpcbfQpMNkn3c2II=<~e3*TxZR_^wNuq0#*QakNd**)3T^0T%tMRKT_08l82R!lorf z5JB=#eX1ta-Bey*RH}XkQaj(t}_lu`1@9I zMcsI4Q;Fo2!7wX5-j4q$lMbskoCCe=tk*zimD)?C9f;i+rOP$!;zII4I=F|F$Q1{d zmZ7rJ_vD66n2n{I%l17AwiB5=K5uA-6Ve{trTXZYPcCjuATf0EI#$`>{JAN|taj!- zc3J659q>mlBF|w?ldqf`NR5pv+5#Rf$SF00Sf$na3wa#h(UA3Ywkm^~tYgike9Q*C z?lVsIE=^pH8o%y<8!36f4McsNPL%n|SgN#rEi5X_nU6_FlKB7Z692cy|4o;9gf@R$ zUw_$rdvTrirP|&)1ugqerijI=-9kbbJi#K{ME38Xp?=@{hU#_oLLEn+Y79%0Y@Ovq z2W}gX&s)SdFFA;Y-%C=a#BJ~QWtbKtI2A0ZQg$Sc5zt)>fyb;=rx>|~MD&wlP_}mr zwPr;f#OCATB2PBJOmCW%Gwgl12ck)-3@BbIAZR!;gVBgPjnHV-8 zNpeWY_@IX?i>caPiZpj8>;(-i9xQB42%C|a)6`FSv0N|Kzl8p{?gv7u7qL$Nxs^lM z9?hO|xvgGP-|pzhUf$9%&lV&gXw8gEdQT*$SSHL!IaaC;4H1vFx5r5>uzhfob#wBd zoAepG!$p59aF!56N|*H+#Lg_#;_S$K^oZ_m#IGz89o7`}#`+IHCOMvkS4KH0lc_s} z^nI@zug1-DtHdBC+)8=sMMVdVX##&s5%$bMfut2K=^L&Kb2cWJ$*iQU>6yBty?*x+ zRo!mipO)8ijb+j*T{epG%+xDk0HcIXQ;{JTvD!A3fUUJ9@z+~(&3#H|RGY&EcZG-b zUSDb$-Sey2Ymmh8`Vz65fzPtQz28K|Nh4{WTTIm0#j8-Ky50n{+2x9gKbxvzN6rv=Su1rq0go~nolA|+Cc@4R#gmK;Y=V0?Gj4!kCLD_CH3bMuIejn`q z$g*Tq1WWLeEF#;ztZ6_J!p@B5m%uhro!JDhYsUHuQI6cF+T36vt&NMcL_M3q57RE}r5BM*!K5(zi|C1vW|F1U(iFI$!Ob%}= zdnghuiO|XG&?GyYItSpqkdS5>J%Vda|1(KQ{x9bOIAp9a`AY;5^Q5P6nw7fpPX!va1_h`wa1jCg8Ucj_g~#=K{dywds96x@2eDxA`xPjq^dt* zF9$up?c>kfNXj0vr!@`}DHmKC8%nv;?6zn6* zhBunLx!FTEN;!ho=MBwiSC!%Cq-<<}aYNe4>nL&}J-RKD`5eA}bMOM%GT6n4;q$S- zr8VE|SYALuMV3nvQAD-FggD+i)*n)hFLWKs3aa<_F%V;-i%(i4iVbQNFGq$+$;XAp z_ow*|JgXqg%W39pWGQEdprTE<0ufoaYPY^6|D{&pZsmjB%avg-=H)nc3A!%Mwa>6V z0Kv>Y1BqBlRVgN=`13JCCQammd^5uBPr-3fILa&ORuTRYdD}9xFk&9I>fm5@IgQcJ zMiun|wYIymCly~nz^kx3P_v0pSSNm0>Q6e&vUXG6zo zI&B%}DU63V*axB>DuMBYmiY@CIFy*{-s@!}3D7Q+zz$dUzS|eU#08Stt~@L{J+n>x znOo6h-_BT9iQkAP+p+lzQM|7~OKYj81@nv_BuSD!bF`((L*2^P^*Awl`fpOUmRhnkX}lWDi+q$Qu9l~G1nB3zN3Pcd4Hn(QO`HZxxsS3zoQ8OPPPjGE@8djXG9q`FolNd3NqA-e?`8xeEq&49>t}$dBwWT zdHWaN2^pPDHKsd~g`cE&TVn-Mhp=dhLj|;CS9;yvFZmDE-lrTT zhkg#()`DfA1Fg7ULJ2-B66ERnu7oKI*@k>(850c~3Ktrypoy+k-}<^N zE#vGn5iw2MFIIB8(tfHDpQ*-pBP>&yzYn-TXwj>|I4 zpHT;P6)&#H;W$B)>6>y&U*{C-Oybvi;I_QWQOl!=TCI0>)IJ)~4Hwz)#^CNV+rjTO zj9P^gGT$8j0%6pC_ z%pQa6@GW)JXnUQu$*<;GktUzw`)Plab;ocQ%dZsG3uFDd8Am;6Q%yH+`pAuh0a@1( zO$$ngXfmyKiB!BVcy5{`o!S2Kqb|;I-WhQ#C+%bBq(IPJ~a{L?`c*$ zHCjk8Z|7Uj>?1!AQ9k&v{mYfzzsrR559D8>87T3X5V=9?Bdgw>Z$O9<&|vKI}((6v{-hYLNMVzi3qW3{oiE*>SIE-{?E+rcQlCZFmq z$yiTEU5sTfC!za36d(Fh_-Ie__yZh>^nhM|236v#JWha{UhLQZ0IUGo9|8(s@#ur( zJP^|TB7@%6&GO3w$L^pH*bXpD=DpTs!AxR9IMX=s#=9!`9ZNtHK+=U!j2Z?PfhZow$y#qR5o3 zB00Ca(9qb##3oW~1ZPr(b{W}=o3$D4vqb!r5bxID-@uR=*AvJCsjGt2@;|tHy7r4& ztJl@lStW8xi^h`u{2G(_itR3AW;PD0(&F}%hrQKW8>m{F5@}DF{KLS9U0Im?WA=M2 zA0agb=5qP>xhNQL6-6<>t(F9NYskU8hcFo2vW3|Gg9{v#9_2kyp!VYs=ok zGhrx&i*a>NU^FIKYMwUUDO6bP`z8oSj=g9~RL`x@?v>^TMZ4n~MF-x;6g_Jyq5sBnPYuP)m?>3N36_qqk zm0!e%&D^AB+2~B6LJz-o-sCxG^ zUG@1%cF~Xi?`!ME%jKoOm63V9+wX?sbWz9e-x2A^=}0Qlpf0%(`l=G9!C@ z4p-%Jg$_;D2#Td9rrqM;#Oiz@wq6>+gVN9mWnDM`{uSBLrK{Qi9;2D)hGtjSt? z&cT!Im5jap-^6Rxd>49GU#dvJtOooQN1BJ0f#-2(OeP*Ev zCmv1GOW5OQq1EidB1@+ur&+kbPI%W|d2{)k#$U9NgKcM!0{JOhj7=*!tvQAncRY}O zFTTv|f6?8h!}ikR+Zp=`V;?NUh^lp?9TRA`#&!N2XP&RTh3TMl!Nz*SRN3=mPVgPa z;v$KhAyNF6`52&c442$~tCCcI&Daq5t_b*4!3*~Dt{o<-4~T6fAHci!o4KT5-^{L6 z-5MuX=l>~^q2>2ekuFuBYiPoVE8ZcX=F8Pf&Vkn?SomCnLn$T0HILVjooBeAk;O>j z2)^!`IOKBzK>eiar_2&~qcfCIrz`l53bM}+xq1b4UA4`qIqp77#_)#h4kBm1qmSKW z;lK~5(SV$fkP)4xu^bS*qc5A|QK|Vd(oMgs57-DI^46Nzxl-S9B2zB>aB#`jQlHYM zHk&v%J5oyQpPv@BXJL4rNc{eDKVmXWaqHfLv~efTQ|$ zN3JHG(9A4kY{B9R%j}a+F!vvTKnl5|qTpWQ@0^>0b}^;H<>$IT05%D#h--C1sCs80 z>4k39trJegPg7KgLvBGPoHnz&YgCZRBEqBKjF>3x+&_Gx@0(Xv z>nwQglSS*R$-dNV&B2!_4r!hwmz=;ob1=8iqX{u|Z(MJtjyGGNWrpFE9=2F(ZHX0f za`O9`5`{)VcZ4vt7o(bFIZ>(7sS02b8wV?Lt3-03&8d~qbIo z$;0xE`dR&#qbGm33KK9doD(UII8p27o+3adTbNcb?o!lxlVogYH0(uTk*|j+O^f|n zu3NhK)!xo_Qg`6134fi-iBA~FGW5L*tn2wLWqW< zdT0DCw?}pPm7~8hUknqr84^#X!4_bRRGKBCgBUJ*;wDMse7G>WY##7j*V@AJ!NtNT z<%r4RTuObJrKGe-tx};J0KyJD6Zko-2fPpTh1Xi_CviG=@UX7uwm{`ybE0gUew0+( zy`D?_872C_1Ng6?F1B}L`jj1eBHHVW?iG@SWAoSKVM1DFpK?&Xw;!B4`T-NFtlm83 zzHHe9-rCx`I)P7s*?bHl%+fga5gH<>7Z_wn-(T)C7V;a|XS1UPqt*l8R@D$21H~^3 z7Z?&6LCU>}|E&(rf z*`kg>5=RgBr(Wr2osBSp^V6|E0PXXNB9C(UlI4K%C86!e?($ zN%?@AZK?2*0AI@7kri%70)M@0I_8H@E0x(eS|g2gcaa)=B#Oog#+ZMbYvNtn zjfr^BYpU;r|3tJMrokXzOsO_yS-^J!4tYioHixnKM5l(${Kzd{Wwlzbd)gLiOUHk$Nu;|ma zeE7{hM?+;y!5iopJ=ROm@IGCBuloAEH4^r<52W~WGd^r@PU1T+pSN9M`LYligb54? zLrjYU>zvlT!6z?I2h46S;$%~bLorf`lM#hcvTI$0M>u3c@r)i7u99-k;VA;4nHp6Je5D$NW{X5265_}O zX9Mp1ATbmM&o=nK8ugBHj=MmUJ!eAQqQ3^4{Wa8V8ZY(l($jtfiM9QgiS}RIVl)+I zr`M*Cjr{U7YT zbzD^KzBfDw(lR2_semXcjnsfhiKKLjfW$C#*AN1NG}0~7CEeYf(w))_9mCwuy6^Yw zz1`rW*}=J(6}l8dGa-tciXb z#jBCHx~ZZq38;D2Qi1rX)=&Z7+WVz~Uy-lz z&h-+_bQ_VtfB4RaeX~vLznTj3pq~g2ManX+2H;U(GI|TwFy$`a2aDFryNhPG@AoCu zw1^LbqOaj+x#$r|uWK7F3(j|I7fx@eNgh^x!f1(d&Em((-un_TPhcZztpbg_^j{(O zutq@JPs+r2iue{+by^v87G@<0v8RMVeU!ou_rhf9$6)GEPlFEU>yIc{b!w>P*GD%= z4%HzsS5gKS0Zv!7l%J^Hf3Il#KD%HrM7pn0M!rP0Oj}pm(t|#===~fip3C6o_ju)f z$h-V7w4V?$h~V_mgsj2% zZKv*y;ah63#MR`t0d+e2vApeka-0j4Z=8C~G3$omi~@uiw8pV1TXfc&O41P66C)h2 zn!un--3QuYqI290UPIlP*R@c0ll-w2#bNS2%)DtpwX^v>HwN?T{UIB1dTN&1vHh6} z5h=qOp&-sQwYPp}gLV+~wLsZtCJ$KozH8;*6}H6A5bNDTOD8~0^3@|$!y_L{^#w}{ zwDi)Z?xA=C5wESk?`cpyitPmU$ zxusyL9D5~YKNkf*r2q{hZof~^2iS2^N?{)kOPns3Y-pp(4?d;iiM%nSe2crD4nOamD%q#U-Vj)-4pud}T zGG=}A6YfuY6g&$bmTf-FKoRdk?s;%zxO^5z5bff_HbUmw&0TAKTNy(7@IW>ikHm*; zxW|huT%uRxn-cCu*06oUV`I?Uu?imVIll78>|jM>SgQ-Mm$-07Rn)LKpz3KDf6b)y zFL!lALU8vB*+qZkSrqd4v3+f2;OqRBtiaE{zQ_kk--?p9{IPGeLLGM=P-Nq%qlep~ zKd+vO+RL)hpHn50mUU#Yf<#B#3#61-KB{WAi`!?rws*E*TMUvdi-bcnsMQ+@I zV%?OP7qg_)±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 Date: Mon, 19 Apr 2021 12:11:15 +0200 Subject: [PATCH 13/73] AE added scene duration validation --- openpype/hosts/aftereffects/api/__init__.py | 68 ++++++++++- .../plugins/publish/collect_render.py | 8 +- .../publish/validate_scene_settings.py | 110 ++++++++++++++++++ 3 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index 9a80801652..7ad10cde25 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -5,7 +5,7 @@ import logging from avalon import io from avalon import api as avalon from avalon.vendor import Qt -from openpype import lib +from openpype import lib, api import pyblish.api as pyblish import openpype.hosts.aftereffects @@ -81,3 +81,69 @@ def uninstall(): def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle layer visibility on instance toggles.""" instance[0].Visible = new_value + + +def get_asset_settings(): + """Get settings on current asset from database. + + Returns: + dict: Scene data. + + """ + asset_data = lib.get_asset()["data"] + fps = asset_data.get("fps") + frame_start = asset_data.get("frameStart") + frame_end = asset_data.get("frameEnd") + handle_start = asset_data.get("handleStart") + handle_end = asset_data.get("handleEnd") + resolution_width = asset_data.get("resolutionWidth") + resolution_height = asset_data.get("resolutionHeight") + duration = frame_end + handle_end - min(frame_start - handle_start, 0) + entity_type = asset_data.get("entityType") + + scene_data = { + "fps": fps, + "frameStart": frame_start, + "frameEnd": frame_end, + "handleStart": handle_start, + "handleEnd": handle_end, + "resolutionWidth": resolution_width, + "resolutionHeight": resolution_height, + "duration": duration + } + + try: + # temporary, in pype3 replace with api.get_current_project_settings + skip_resolution_check = ( + api.get_current_project_settings() + ["plugins"] + ["aftereffects"] + ["publish"] + ["ValidateSceneSettings"] + ["skip_resolution_check"] + ) + skip_timelines_check = ( + api.get_current_project_settings() + ["plugins"] + ["aftereffects"] + ["publish"] + ["ValidateSceneSettings"] + ["skip_timelines_check"] + ) + except KeyError: + skip_resolution_check = ['*'] + skip_timelines_check = ['*'] + + if os.getenv('AVALON_TASK') in skip_resolution_check or \ + '*' in skip_timelines_check: + scene_data.pop("resolutionWidth") + scene_data.pop("resolutionHeight") + + if entity_type in skip_timelines_check or '*' in skip_timelines_check: + scene_data.pop('fps', None) + scene_data.pop('frameStart', None) + scene_data.pop('frameEnd', None) + scene_data.pop('handleStart', None) + scene_data.pop('handleEnd', None) + + return scene_data diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index ba64551283..baac64ed0c 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -12,6 +12,7 @@ class AERenderInstance(RenderInstance): # extend generic, composition name is needed comp_name = attr.ib(default=None) comp_id = attr.ib(default=None) + fps = attr.ib(default=None) class CollectAERender(abstract_collect_render.AbstractCollectRender): @@ -45,6 +46,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): raise ValueError("Couldn't find id, unable to publish. " + "Please recreate instance.") item_id = inst["members"][0] + work_area_info = self.stub.get_work_area(int(item_id)) if not work_area_info: @@ -57,6 +59,8 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): frameEnd = round(work_area_info.workAreaStart + float(work_area_info.workAreaDuration) * float(work_area_info.frameRate)) - 1 + fps = work_area_info.frameRate + # TODO add resolution when supported by extension if inst["family"] == "render" and inst["active"]: instance = AERenderInstance( @@ -86,7 +90,8 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): frameStart=frameStart, frameEnd=frameEnd, frameStep=1, - toBeRenderedOn='deadline' + toBeRenderedOn='deadline', + fps=fps ) comp = compositions_by_id.get(int(item_id)) @@ -102,7 +107,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): instances.append(instance) - self.log.debug("instances::{}".format(instances)) return instances def get_expected_files(self, render_instance): diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py new file mode 100644 index 0000000000..cc7db3141f --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Validate scene settings.""" +import os + +import pyblish.api + +from avalon import aftereffects + +import openpype.hosts.aftereffects.api as api + +stub = aftereffects.stub() + + +class ValidateSceneSettings(pyblish.api.InstancePlugin): + """ + Ensures that Composition Settings (right mouse on comp) are same as + in FTrack on task. + + By default checks only duration - how many frames should be rendered. + Compares: + Frame start - Frame end + 1 from FTrack + against + Duration in Composition Settings. + + If this complains: + Check error message where is discrepancy. + Check FTrack task 'pype' section of task attributes for expected + values. + Check/modify rendered Composition Settings. + + If you know what you are doing run publishing again, uncheck this + validation before Validation phase. + """ + + """ + Dev docu: + Could be configured by 'presets/plugins/aftereffects/publish' + + skip_timelines_check - fill task name for which skip validation of + frameStart + frameEnd + fps + handleStart + handleEnd + skip_resolution_check - fill entity type ('asset') to skip validation + resolutionWidth + resolutionHeight + TODO support in extension is missing for now + + By defaults validates duration (how many frames should be published) + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Scene Settings" + families = ["render.farm"] + hosts = ["aftereffects"] + optional = True + + skip_timelines_check = ["*"] # * >> skip for all + skip_resolution_check = ["*"] + + def process(self, instance): + """Plugin entry point.""" + expected_settings = api.get_asset_settings() + self.log.info("expected_settings::{}".format(expected_settings)) + + # handle case where ftrack uses only two decimal places + # 23.976023976023978 vs. 23.98 + fps = instance.data.get("fps") + if fps: + if isinstance(fps, float): + fps = float( + "{:.2f}".format(fps)) + expected_settings["fps"] = fps + + duration = instance.data.get("frameEndHandle") - \ + instance.data.get("frameStartHandle") + 1 + + current_settings = { + "fps": fps, + "frameStartHandle": instance.data.get("frameStartHandle"), + "frameEndHandle": instance.data.get("frameEndHandle"), + "resolutionWidth": instance.data.get("resolutionWidth"), + "resolutionHeight": instance.data.get("resolutionHeight"), + "duration": duration + } + self.log.info("current_settings:: {}".format(current_settings)) + + invalid_settings = [] + for key, value in expected_settings.items(): + if value != current_settings[key]: + invalid_settings.append( + "{} expected: {} found: {}".format(key, value, + current_settings[key]) + ) + + if ((expected_settings.get("handleStart") + or expected_settings.get("handleEnd")) + and invalid_settings): + msg = "Handles included in calculation. Remove handles in DB " +\ + "or extend frame range in Composition Setting." + invalid_settings[-1]["reason"] = msg + + msg = "Found invalid settings:\n{}".format( + "\n".join(invalid_settings) + ) + assert not invalid_settings, msg + assert os.path.exists(instance.data.get("source")), ( + "Scene file not found (saved under wrong name)" + ) From 8c99d51cc37dc87b6ecd35ac5a84746b13187128 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 11:15:29 +0200 Subject: [PATCH 14/73] added "use_python_2" key to settings for each variat --- .../host_settings/template_host_variant_items.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json index bba4634c46..472840d8fc 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/template_host_variant_items.json @@ -1,4 +1,10 @@ [ + { + "type": "boolean", + "key": "use_python_2", + "label": "Use Python 2", + "default": false + }, { "type": "path", "key": "executables", From 5c6a746452989de041499961a4f8e82478640212 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 11:15:53 +0200 Subject: [PATCH 15/73] BooleanEntity can have defined default value --- openpype/settings/entities/input_entities.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index e406c7797a..e897576d43 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -376,7 +376,10 @@ class BoolEntity(InputEntity): def _item_initalization(self): self.valid_value_types = (bool, ) - self.value_on_not_set = True + value_on_not_set = self.convert_to_valid_type( + self.schema_data.get("default", True) + ) + self.value_on_not_set = value_on_not_set class TextEntity(InputEntity): From c5e2b84185e1e1fca8e61a51bb66232cb5d0d976 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 11:17:00 +0200 Subject: [PATCH 16/73] resaved defaults for use_python_2 --- .../system_settings/applications.json | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 58a9818465..a19f093be2 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -20,6 +20,7 @@ }, "variants": { "2022": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Autodesk\\Maya2022\\bin\\maya.exe" @@ -39,6 +40,7 @@ } }, "2020": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Autodesk\\Maya2020\\bin\\maya.exe" @@ -58,6 +60,7 @@ } }, "2019": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Autodesk\\Maya2019\\bin\\maya.exe" @@ -77,6 +80,7 @@ } }, "2018": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Autodesk\\Maya2018\\bin\\maya.exe" @@ -118,6 +122,7 @@ }, "variants": { "13-0": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Nuke13.0v1\\Nuke13.0.exe" @@ -135,6 +140,7 @@ "environment": {} }, "12-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.2v3\\Nuke12.2.exe" @@ -152,6 +158,7 @@ "environment": {} }, "12-0": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" @@ -169,6 +176,7 @@ "environment": {} }, "11-3": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" @@ -186,6 +194,7 @@ "environment": {} }, "11-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" @@ -227,6 +236,7 @@ }, "variants": { "13-0": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Nuke13.0v1\\Nuke13.0.exe" @@ -250,6 +260,7 @@ "environment": {} }, "12-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.2v3\\Nuke12.2.exe" @@ -273,6 +284,7 @@ "environment": {} }, "12-0": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" @@ -296,6 +308,7 @@ "environment": {} }, "11-3": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" @@ -319,6 +332,7 @@ "environment": {} }, "11-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" @@ -366,6 +380,7 @@ }, "variants": { "13-0": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Nuke13.0v1\\Nuke13.0.exe" @@ -389,6 +404,7 @@ "environment": {} }, "12-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.2v3\\Nuke12.2.exe" @@ -412,6 +428,7 @@ "environment": {} }, "12-0": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" @@ -435,6 +452,7 @@ "environment": {} }, "11-3": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" @@ -458,6 +476,7 @@ "environment": {} }, "11-2": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -503,6 +522,7 @@ }, "variants": { "13-0": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Nuke13.0v1\\Nuke13.0.exe" @@ -526,6 +546,7 @@ "environment": {} }, "12-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.2v3\\Nuke12.2.exe" @@ -549,6 +570,7 @@ "environment": {} }, "12-0": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke12.0v1\\Nuke12.0.exe" @@ -572,6 +594,7 @@ "environment": {} }, "11-3": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.3v1\\Nuke11.3.exe" @@ -595,6 +618,7 @@ "environment": {} }, "11-2": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Nuke11.2v2\\Nuke11.2.exe" @@ -657,6 +681,7 @@ "16": { "enabled": true, "variant_label": "16", + "use_python_2": false, "executables": { "windows": [], "darwin": [], @@ -672,6 +697,7 @@ "9": { "enabled": true, "variant_label": "9", + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blackmagic Design\\Fusion 9\\Fusion.exe" @@ -735,6 +761,7 @@ "16": { "enabled": true, "variant_label": "16", + "use_python_2": false, "executables": { "windows": [ "C:/Program Files/Blackmagic Design/DaVinci Resolve/Resolve.exe" @@ -770,6 +797,7 @@ }, "variants": { "18-5": { + "use_python_2": true, "executables": { "windows": [ "C:\\Program Files\\Side Effects Software\\Houdini 18.5.499\\bin\\houdini.exe" @@ -785,6 +813,7 @@ "environment": {} }, "18": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -798,6 +827,7 @@ "environment": {} }, "17": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -832,6 +862,7 @@ }, "variants": { "2-83": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blender Foundation\\Blender 2.83\\blender.exe" @@ -853,6 +884,7 @@ "environment": {} }, "2-90": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blender Foundation\\Blender 2.90\\blender.exe" @@ -874,6 +906,7 @@ "environment": {} }, "2-91": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Blender Foundation\\Blender 2.91\\blender.exe" @@ -914,6 +947,7 @@ "20": { "enabled": true, "variant_label": "20", + "use_python_2": false, "executables": { "windows": [], "darwin": [], @@ -929,6 +963,7 @@ "17": { "enabled": true, "variant_label": "17", + "use_python_2": false, "executables": { "windows": [], "darwin": [ @@ -955,6 +990,7 @@ }, "variants": { "animation_11-64bits": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\TVPaint Developpement\\TVPaint Animation 11 (64bits)\\TVPaint Animation 11 (64bits).exe" @@ -970,6 +1006,7 @@ "environment": {} }, "animation_11-32bits": { + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files (x86)\\TVPaint Developpement\\TVPaint Animation 11 (32bits)\\TVPaint Animation 11 (32bits).exe" @@ -1005,6 +1042,7 @@ "2020": { "enabled": true, "variant_label": "2020", + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe" @@ -1022,6 +1060,7 @@ "2021": { "enabled": true, "variant_label": "2021", + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe" @@ -1053,6 +1092,7 @@ "2020": { "enabled": true, "variant_label": "2020", + "use_python_2": false, "executables": { "windows": [ "" @@ -1070,6 +1110,7 @@ "2021": { "enabled": true, "variant_label": "2021", + "use_python_2": false, "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe After Effects 2021\\Support Files\\AfterFX.exe" @@ -1098,6 +1139,7 @@ "local": { "enabled": true, "variant_label": "Local", + "use_python_2": false, "executables": { "windows": [], "darwin": [], @@ -1124,6 +1166,7 @@ }, "variants": { "4-24": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -1143,6 +1186,7 @@ "environment": {}, "variants": { "python_3-7": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -1156,6 +1200,7 @@ "environment": {} }, "python_2-7": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -1169,6 +1214,7 @@ "environment": {} }, "terminal": { + "use_python_2": true, "executables": { "windows": [], "darwin": [], @@ -1195,6 +1241,7 @@ "environment": {}, "variants": { "1-1": { + "use_python_2": false, "executables": { "windows": [], "darwin": [], From 0a709d704ef100d937d7fb04059c4cefe2a350c5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 11:17:35 +0200 Subject: [PATCH 17/73] Application has defined "use_python_2" attribute --- openpype/lib/applications.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 51c646d494..730d4230b6 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -179,6 +179,7 @@ class Application: if group.enabled: enabled = data.get("enabled", True) self.enabled = enabled + self.use_python_2 = data["use_python_2"] self.label = data.get("variant_label") or name self.full_name = "/".join((group.name, name)) From 5d584adb8e548a822f21c1a90986e4747856d7ba Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 11:18:08 +0200 Subject: [PATCH 18/73] python 2 prelaunch hooks are used when `use_python_2` is True --- openpype/hooks/pre_python_2_prelaunch.py | 6 +++--- openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/openpype/hooks/pre_python_2_prelaunch.py b/openpype/hooks/pre_python_2_prelaunch.py index 8232f35623..84272d2e5d 100644 --- a/openpype/hooks/pre_python_2_prelaunch.py +++ b/openpype/hooks/pre_python_2_prelaunch.py @@ -4,12 +4,12 @@ from openpype.lib import PreLaunchHook class PrePython2Vendor(PreLaunchHook): """Prepend python 2 dependencies for py2 hosts.""" - # WARNING This hook will probably be deprecated in OpenPype 3 - kept for - # test order = 10 - app_groups = ["hiero", "nuke", "nukex", "unreal", "maya", "houdini"] def execute(self): + if not self.application.use_python_2: + return + # Prepare vendor dir path self.log.info("adding global python 2 vendor") pype_root = os.getenv("OPENPYPE_REPOS_ROOT") diff --git a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py index f14857bc98..d34b6533fb 100644 --- a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py +++ b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py @@ -8,10 +8,13 @@ class PrePython2Support(PreLaunchHook): Path to vendor modules is added to the beggining of PYTHONPATH. """ - # There will be needed more granular filtering in future - app_groups = ["maya", "nuke", "nukex", "hiero", "nukestudio", "unreal"] def execute(self): + if not self.application.use_python_2: + return + + self.log.info("Adding Ftrack Python 2 packages to PYTHONPATH.") + # Prepare vendor dir path python_2_vendor = os.path.join(FTRACK_MODULE_DIR, "python2_vendor") From 7440de9b9b90b98dbebe5bb33e423b4c94566941 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 20 Apr 2021 10:43:54 +0100 Subject: [PATCH 19/73] Unreal: Added support for version 4.26 --- openpype/hooks/pre_python_2_prelaunch.py | 2 +- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 2 +- openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py | 2 +- openpype/settings/defaults/system_settings/applications.json | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/hooks/pre_python_2_prelaunch.py b/openpype/hooks/pre_python_2_prelaunch.py index 8232f35623..d6b1fe57a5 100644 --- a/openpype/hooks/pre_python_2_prelaunch.py +++ b/openpype/hooks/pre_python_2_prelaunch.py @@ -7,7 +7,7 @@ class PrePython2Vendor(PreLaunchHook): # WARNING This hook will probably be deprecated in OpenPype 3 - kept for # test order = 10 - app_groups = ["hiero", "nuke", "nukex", "unreal", "maya", "houdini"] + app_groups = ["hiero", "nuke", "nukex", "maya", "houdini"] def execute(self): # Prepare vendor dir path diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index c698be63de..f084cccfc3 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -24,7 +24,7 @@ class UnrealPrelaunchHook(PreLaunchHook): asset_name = self.data["asset_name"] task_name = self.data["task_name"] workdir = self.launch_context.env["AVALON_WORKDIR"] - engine_version = self.app_name.split("_")[-1].replace("-", ".") + engine_version = self.app_name.split("/")[-1].replace("-", ".") unreal_project_name = f"{asset_name}_{task_name}" # Unreal is sensitive about project names longer then 20 chars diff --git a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py index f14857bc98..7826d833ac 100644 --- a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py +++ b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py @@ -9,7 +9,7 @@ class PrePython2Support(PreLaunchHook): Path to vendor modules is added to the beggining of PYTHONPATH. """ # There will be needed more granular filtering in future - app_groups = ["maya", "nuke", "nukex", "hiero", "nukestudio", "unreal"] + app_groups = ["maya", "nuke", "nukex", "hiero", "nukestudio"] def execute(self): # Prepare vendor dir path diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 58a9818465..e7e934e1ea 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1119,11 +1119,10 @@ "host_name": "unreal", "environment": { "AVALON_UNREAL_PLUGIN": "{OPENPYPE_REPOS_ROOT}/repos/avalon-unreal-integration", - "OPENPYPE_LOG_NO_COLORS": "True", - "QT_PREFERRED_BINDING": "PySide" + "OPENPYPE_LOG_NO_COLORS": "True" }, "variants": { - "4-24": { + "4-26": { "executables": { "windows": [], "darwin": [], From 3aa20ba0b6a8473e2a70784972df300739d886d1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 13:55:42 +0200 Subject: [PATCH 20/73] fix import of export_in_rs_layer --- openpype/hosts/maya/plugins/publish/extract_vrayscene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index d3a3df6b1c..c9edfc8343 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -5,7 +5,7 @@ import re import avalon.maya import openpype.api -from openpype.hosts.maya.render_setup_tools import export_in_rs_layer +from openpype.hosts.maya.api.render_setup_tools import export_in_rs_layer from maya import cmds From 4f9f0807e32150763d35e3ee4c8c5017da632925 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 13:55:53 +0200 Subject: [PATCH 21/73] fix name of ValidateMeshOrder variable --- .../maya/plugins/publish/validate_unreal_mesh_triangulated.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py index 1c6aa3078e..b2ef174374 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py @@ -8,7 +8,7 @@ import openpype.api class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): """Validate if mesh is made of triangles for Unreal Engine""" - order = openpype.api.ValidateMeshOder + order = openpype.api.ValidateMeshOrder hosts = ["maya"] families = ["unrealStaticMesh"] category = "geometry" From 0ec066af7ed1a34f40d162a6e40ad9f6aa0f9d88 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 18:12:47 +0200 Subject: [PATCH 22/73] defined constants in ftrack lib --- openpype/modules/ftrack/lib/__init__.py | 13 +++++++++++++ openpype/modules/ftrack/lib/constants.py | 12 ++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 openpype/modules/ftrack/lib/constants.py diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index 82b6875590..87dadf6480 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -1,3 +1,10 @@ +from .constants import ( + CUST_ATTR_ID_KEY, + CUST_ATTR_AUTO_SYNC, + CUST_ATTR_GROUP, + CUST_ATTR_TOOLS, + CUST_ATTR_APPLICATIONS +) from . settings import ( get_ftrack_url_from_settings, get_ftrack_event_mongo_info @@ -10,6 +17,12 @@ from .ftrack_action_handler import BaseAction, ServerAction, statics_icon __all__ = ( + "CUST_ATTR_ID_KEY", + "CUST_ATTR_AUTO_SYNC", + "CUST_ATTR_GROUP", + "CUST_ATTR_TOOLS", + "CUST_ATTR_APPLICATIONS", + "get_ftrack_url_from_settings", "get_ftrack_event_mongo_info", diff --git a/openpype/modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py new file mode 100644 index 0000000000..73d5112e6d --- /dev/null +++ b/openpype/modules/ftrack/lib/constants.py @@ -0,0 +1,12 @@ +# Group name of custom attributes +CUST_ATTR_GROUP = "openpype" + +# name of Custom attribute that stores mongo_id from avalon db +CUST_ATTR_ID_KEY = "avalon_mongo_id" +# Auto sync of project +CUST_ATTR_AUTO_SYNC = "avalon_auto_sync" + +# Applications custom attribute name +CUST_ATTR_APPLICATIONS = "applications" +# Environment tools custom attribute +CUST_ATTR_TOOLS = "tools_env" From 5fbc62678b168ff1134ccb6e75803ce162e9b428 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 18:13:13 +0200 Subject: [PATCH 23/73] fixed and replaced import of constants --- .../action_create_cust_attrs.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index 63025d35b3..8d585dee20 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -2,9 +2,16 @@ import collections import json import arrow import ftrack_api -from openpype.modules.ftrack.lib import BaseAction, statics_icon +from openpype.modules.ftrack.lib import ( + BaseAction, + statics_icon, + CUST_ATTR_ID_KEY, + CUST_ATTR_GROUP, + CUST_ATTR_TOOLS, + CUST_ATTR_APPLICATIONS +) from openpype.modules.ftrack.lib.avalon_sync import ( - CUST_ATTR_ID_KEY, CUST_ATTR_GROUP, default_custom_attributes_definition + default_custom_attributes_definition ) from openpype.api import get_system_settings from openpype.lib import ApplicationManager @@ -387,7 +394,7 @@ class CustomAttributes(BaseAction): applications_custom_attr_data = { "label": "Applications", - "key": "applications", + "key": CUST_ATTR_APPLICATIONS, "type": "enumerator", "entity_type": "show", "group": CUST_ATTR_GROUP, @@ -411,7 +418,7 @@ class CustomAttributes(BaseAction): tools_custom_attr_data = { "label": "Tools", - "key": "tools_env", + "key": CUST_ATTR_TOOLS, "type": "enumerator", "is_hierarchical": True, "group": CUST_ATTR_GROUP, From 3f63fc7a9b4eb6105f33dd509dd8a4ee155c3f40 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 18:13:27 +0200 Subject: [PATCH 24/73] avalon_sync is using constants defined in constants.py --- openpype/modules/ftrack/lib/avalon_sync.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 79e1366a0d..5f44181e5f 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -26,6 +26,12 @@ from pymongo import UpdateOne import ftrack_api from openpype.lib import ApplicationManager +from .constants import ( + CUST_ATTR_ID_KEY, + CUST_ATTR_AUTO_SYNC, + CUST_ATTR_GROUP +) + log = Logger.get_logger(__name__) @@ -36,14 +42,6 @@ EntitySchemas = { "config": "openpype:config-2.0" } -# Group name of custom attributes -CUST_ATTR_GROUP = "openpype" - -# name of Custom attribute that stores mongo_id from avalon db -CUST_ATTR_ID_KEY = "avalon_mongo_id" -CUST_ATTR_AUTO_SYNC = "avalon_auto_sync" - - def default_custom_attributes_definition(): json_file_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), From 13fb2409ed0622fa55bd1a2e091aa6f891303bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Tue, 20 Apr 2021 18:16:49 +0200 Subject: [PATCH 25/73] fix sequence padding --- openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 3b47a7cc97..d0c6c4eb14 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- """Redshift Proxy extractor.""" import os -import math import avalon.maya import openpype.api @@ -45,7 +44,7 @@ class ExtractRedshiftProxy(openpype.api.Extractor): # Padding is taken from number of digits of the end_frame. # Not sure where Redshift is taking it. repr_files = [ - "{}.{}{}".format(root, str(frame).rjust(int(math.log10(int(end_frame)) + 1), "0"), ext) # noqa: E501 + "{}.{}{}".format(root, str(frame).rjust(4, "0"), ext) # noqa: E501 for frame in range( int(start_frame), int(end_frame) + 1, From 10b362937b1ce0887204882e5b9334e74dc7987b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 18:27:42 +0200 Subject: [PATCH 26/73] application manager can be initialized to use different settings --- openpype/lib/applications.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 51c646d494..dc83037378 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -261,14 +261,32 @@ class Application: class ApplicationManager: - def __init__(self): - self.log = PypeLogger().get_logger(self.__class__.__name__) + """Load applications and tools and store them by their full name. + + Args: + system_settings (dict): Preloaded system settings. When passed manager + will always use these values. Gives ability to create manager + using different settings. + """ + def __init__(self, system_settings=None): + self.log = PypeLogger.get_logger(self.__class__.__name__) self.app_groups = {} self.applications = {} self.tool_groups = {} self.tools = {} + self._system_settings = system_settings + + self.refresh() + + def set_system_settings(self, system_settings): + """Ability to change init system settings. + + This will trigger refresh of manager. + """ + self._system_settings = system_settings + self.refresh() def refresh(self): @@ -278,9 +296,12 @@ class ApplicationManager: self.tool_groups.clear() self.tools.clear() - settings = get_system_settings( - clear_metadata=False, exclude_locals=False - ) + if self._system_settings is not None: + settings = copy.deepcopy(self._system_settings) + else: + settings = get_system_settings( + clear_metadata=False, exclude_locals=False + ) app_defs = settings["applications"] for group_name, variant_defs in app_defs.items(): From d746c4009f487ec34ae75c34a7ae9355b77af903 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 18:49:29 +0200 Subject: [PATCH 27/73] added custom_attributes.py to ftrack lib --- openpype/modules/ftrack/lib/__init__.py | 10 +++++ .../modules/ftrack/lib/custom_attributes.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 openpype/modules/ftrack/lib/custom_attributes.py diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index 87dadf6480..bc0c989c02 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -9,6 +9,12 @@ from . settings import ( get_ftrack_url_from_settings, get_ftrack_event_mongo_info ) +from .custm_attributes import ( + default_custom_attributes_definition, + app_definitions_from_app_manager, + tool_definitions_from_app_manager +) + from . import avalon_sync from . import credentials from .ftrack_base_handler import BaseHandler @@ -26,6 +32,10 @@ __all__ = ( "get_ftrack_url_from_settings", "get_ftrack_event_mongo_info", + "default_custom_attributes_definition", + "app_definitions_from_app_manager", + "tool_definitions_from_app_manager", + "avalon_sync", "credentials", diff --git a/openpype/modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py new file mode 100644 index 0000000000..18efd7bcae --- /dev/null +++ b/openpype/modules/ftrack/lib/custom_attributes.py @@ -0,0 +1,38 @@ +import os +import json + + +def default_custom_attributes_definition(): + json_file_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "custom_attributes.json" + ) + with open(json_file_path, "r") as json_stream: + data = json.load(json_stream) + return data + + +def app_definitions_from_app_manager(app_manager): + app_definitions = [] + for app_name, app in app_manager.applications.items(): + if app.enabled and app.is_host: + app_definitions.append({ + app_name: app.full_label + }) + + if not app_definitions: + app_definitions.append({"empty": "< Empty >"}) + return app_definitions + + +def tool_definitions_from_app_manager(app_manager): + tools_data = [] + for tool_name, tool in app_manager.tools.items(): + tools_data.append({ + tool_name: tool.label + }) + + # Make sure there is at least one item + if not tools_data: + tools_data.append({"empty": "< Empty >"}) + return tools_data From d9ef2b59abdbeb7147cf21db47592147cb7f0d0f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 18:49:51 +0200 Subject: [PATCH 28/73] create update custom attributes is using functions from lib --- .../action_create_cust_attrs.py | 35 +++++-------------- openpype/modules/ftrack/lib/avalon_sync.py | 9 ----- 2 files changed, 9 insertions(+), 35 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index 8d585dee20..63605eda5e 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -5,14 +5,17 @@ import ftrack_api from openpype.modules.ftrack.lib import ( BaseAction, statics_icon, + CUST_ATTR_ID_KEY, CUST_ATTR_GROUP, CUST_ATTR_TOOLS, - CUST_ATTR_APPLICATIONS -) -from openpype.modules.ftrack.lib.avalon_sync import ( - default_custom_attributes_definition + CUST_ATTR_APPLICATIONS, + + default_custom_attributes_definition, + app_definitions_from_app_manager, + tool_definitions_from_app_manager ) + from openpype.api import get_system_settings from openpype.lib import ApplicationManager @@ -377,20 +380,8 @@ class CustomAttributes(BaseAction): exc_info=True ) - def app_defs_from_app_manager(self): - app_definitions = [] - for app_name, app in self.app_manager.applications.items(): - if app.enabled and app.is_host: - app_definitions.append({ - app_name: app.full_label - }) - - if not app_definitions: - app_definitions.append({"empty": "< Empty >"}) - return app_definitions - def applications_attribute(self, event): - apps_data = self.app_defs_from_app_manager() + apps_data = app_definitions_from_app_manager(self.app_manager) applications_custom_attr_data = { "label": "Applications", @@ -406,15 +397,7 @@ class CustomAttributes(BaseAction): self.process_attr_data(applications_custom_attr_data, event) def tools_attribute(self, event): - tools_data = [] - for tool_name, tool in self.app_manager.tools.items(): - tools_data.append({ - tool_name: tool.label - }) - - # Make sure there is at least one item - if not tools_data: - tools_data.append({"empty": "< Empty >"}) + tools_data = tool_definitions_from_app_manager(self.app_manager) tools_custom_attr_data = { "label": "Tools", diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 5f44181e5f..6e83be8b64 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -42,15 +42,6 @@ EntitySchemas = { "config": "openpype:config-2.0" } -def default_custom_attributes_definition(): - json_file_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "custom_attributes.json" - ) - with open(json_file_path, "r") as json_stream: - data = json.load(json_stream) - return data - def check_regex(name, entity_type, in_schema=None, schema_patterns=None): schema_name = "asset-3.0" From 02238eef7cbbdcce38d6fe0ccc64f44991907f40 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 19:07:53 +0200 Subject: [PATCH 29/73] added `get_openpype_attr` to custom_attributes --- openpype/modules/ftrack/lib/__init__.py | 6 ++-- .../modules/ftrack/lib/custom_attributes.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index bc0c989c02..ce6d5284b6 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -9,10 +9,11 @@ from . settings import ( get_ftrack_url_from_settings, get_ftrack_event_mongo_info ) -from .custm_attributes import ( +from .custom_attributes import ( default_custom_attributes_definition, app_definitions_from_app_manager, - tool_definitions_from_app_manager + tool_definitions_from_app_manager, + get_openpype_attr ) from . import avalon_sync @@ -35,6 +36,7 @@ __all__ = ( "default_custom_attributes_definition", "app_definitions_from_app_manager", "tool_definitions_from_app_manager", + "get_openpype_attr", "avalon_sync", diff --git a/openpype/modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py index 18efd7bcae..33eea32baa 100644 --- a/openpype/modules/ftrack/lib/custom_attributes.py +++ b/openpype/modules/ftrack/lib/custom_attributes.py @@ -1,6 +1,8 @@ import os import json +from .constants import CUST_ATTR_GROUP + def default_custom_attributes_definition(): json_file_path = os.path.join( @@ -36,3 +38,36 @@ def tool_definitions_from_app_manager(app_manager): if not tools_data: tools_data.append({"empty": "< Empty >"}) return tools_data + + +def get_openpype_attr(session, split_hierarchical=True, query_keys=None): + custom_attributes = [] + hier_custom_attributes = [] + if not query_keys: + query_keys = [ + "id", + "entity_type", + "object_type_id", + "is_hierarchical", + "default" + ] + # TODO remove deprecated "pype" group from query + cust_attrs_query = ( + "select {}" + " from CustomAttributeConfiguration" + # Kept `pype` for Backwards Compatiblity + " where group.name in (\"pype\", \"{}\")" + ).format(", ".join(query_keys), CUST_ATTR_GROUP) + all_avalon_attr = session.query(cust_attrs_query).all() + for cust_attr in all_avalon_attr: + if split_hierarchical and cust_attr["is_hierarchical"]: + hier_custom_attributes.append(cust_attr) + continue + + custom_attributes.append(cust_attr) + + if split_hierarchical: + # return tuple + return custom_attributes, hier_custom_attributes + + return custom_attributes From eb1b9556673b63d51ef324fb5a13e2caceb07b43 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 19:09:30 +0200 Subject: [PATCH 30/73] replaced usage of get_pype_attr with get_openpype_attr --- .../action_prepare_project.py | 8 ++-- .../event_sync_to_avalon.py | 9 +++-- .../action_clean_hierarchical_attributes.py | 9 +++-- .../action_prepare_project.py | 8 ++-- openpype/modules/ftrack/ftrack_module.py | 4 +- openpype/modules/ftrack/lib/avalon_sync.py | 38 ++----------------- 6 files changed, 24 insertions(+), 52 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py index 8248bf532e..12d687bbf2 100644 --- a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py @@ -2,9 +2,9 @@ import json from openpype.api import ProjectSettings -from openpype.modules.ftrack.lib import ServerAction -from openpype.modules.ftrack.lib.avalon_sync import ( - get_pype_attr, +from openpype.modules.ftrack.lib import ( + ServerAction, + get_openpype_attr, CUST_ATTR_AUTO_SYNC ) @@ -159,7 +159,7 @@ class PrepareProjectServer(ServerAction): for key, entity in project_anatom_settings["attributes"].items(): attribute_values_by_key[key] = entity.value - cust_attrs, hier_cust_attrs = get_pype_attr(self.session, True) + cust_attrs, hier_cust_attrs = get_openpype_attr(self.session, True) for attr in hier_cust_attrs: key = attr["key"] diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 347b227dd3..3bb01798e4 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -18,12 +18,15 @@ from avalon import schema from avalon.api import AvalonMongoDB from openpype.modules.ftrack.lib import ( + get_openpype_attr, + CUST_ATTR_ID_KEY, + CUST_ATTR_AUTO_SYNC, + avalon_sync, + BaseEvent ) from openpype.modules.ftrack.lib.avalon_sync import ( - CUST_ATTR_ID_KEY, - CUST_ATTR_AUTO_SYNC, EntitySchemas ) @@ -125,7 +128,7 @@ class SyncToAvalonEvent(BaseEvent): @property def avalon_cust_attrs(self): if self._avalon_cust_attrs is None: - self._avalon_cust_attrs = avalon_sync.get_pype_attr( + self._avalon_cust_attrs = get_openpype_attr( self.process_session, query_keys=self.cust_attr_query_keys ) return self._avalon_cust_attrs diff --git a/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py b/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py index c326c56a7c..45cc9adf55 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py +++ b/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py @@ -1,7 +1,10 @@ import collections import ftrack_api -from openpype.modules.ftrack.lib import BaseAction, statics_icon -from openpype.modules.ftrack.lib.avalon_sync import get_pype_attr +from openpype.modules.ftrack.lib import ( + BaseAction, + statics_icon, + get_openpype_attr +) class CleanHierarchicalAttrsAction(BaseAction): @@ -52,7 +55,7 @@ class CleanHierarchicalAttrsAction(BaseAction): ) entity_ids_joined = ", ".join(all_entities_ids) - attrs, hier_attrs = get_pype_attr(session) + attrs, hier_attrs = get_openpype_attr(session) for attr in hier_attrs: configuration_key = attr["key"] diff --git a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py index bd25f995fe..5298c06371 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py +++ b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py @@ -4,10 +4,8 @@ from openpype.api import ProjectSettings from openpype.modules.ftrack.lib import ( BaseAction, - statics_icon -) -from openpype.modules.ftrack.lib.avalon_sync import ( - get_pype_attr, + statics_icon, + get_openpype_attr, CUST_ATTR_AUTO_SYNC ) @@ -162,7 +160,7 @@ class PrepareProjectLocal(BaseAction): for key, entity in project_anatom_settings["attributes"].items(): attribute_values_by_key[key] = entity.value - cust_attrs, hier_cust_attrs = get_pype_attr(self.session, True) + cust_attrs, hier_cust_attrs = get_openpype_attr(self.session, True) for attr in hier_cust_attrs: key = attr["key"] diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index d242268048..b057503007 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -150,7 +150,7 @@ class FtrackModule( return import ftrack_api - from openpype.modules.ftrack.lib import avalon_sync + from openpype.modules.ftrack.lib import get_openpype_attr session = self.create_ftrack_session() project_entity = session.query( @@ -166,7 +166,7 @@ class FtrackModule( project_id = project_entity["id"] - cust_attr, hier_attr = avalon_sync.get_pype_attr(session) + cust_attr, hier_attr = get_openpype_attr(session) cust_attr_by_key = {attr["key"]: attr for attr in cust_attr} hier_attrs_by_key = {attr["key"]: attr for attr in hier_attr} for key, value in attributes_changes.items(): diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 6e83be8b64..cfe1f011e7 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -31,6 +31,7 @@ from .constants import ( CUST_ATTR_AUTO_SYNC, CUST_ATTR_GROUP ) +from .custom_attributes import get_openpype_attr log = Logger.get_logger(__name__) @@ -80,39 +81,6 @@ def join_query_keys(keys): return ",".join(["\"{}\"".format(key) for key in keys]) -def get_pype_attr(session, split_hierarchical=True, query_keys=None): - custom_attributes = [] - hier_custom_attributes = [] - if not query_keys: - query_keys = [ - "id", - "entity_type", - "object_type_id", - "is_hierarchical", - "default" - ] - # TODO remove deprecated "pype" group from query - cust_attrs_query = ( - "select {}" - " from CustomAttributeConfiguration" - # Kept `pype` for Backwards Compatiblity - " where group.name in (\"pype\", \"{}\")" - ).format(", ".join(query_keys), CUST_ATTR_GROUP) - all_avalon_attr = session.query(cust_attrs_query).all() - for cust_attr in all_avalon_attr: - if split_hierarchical and cust_attr["is_hierarchical"]: - hier_custom_attributes.append(cust_attr) - continue - - custom_attributes.append(cust_attr) - - if split_hierarchical: - # return tuple - return custom_attributes, hier_custom_attributes - - return custom_attributes - - def get_python_type_for_custom_attribute(cust_attr, cust_attr_type_name=None): """Python type that should value of custom attribute have. @@ -910,7 +878,7 @@ class SyncEntitiesFactory: def set_cutom_attributes(self): self.log.debug("* Preparing custom attributes") # Get custom attributes and values - custom_attrs, hier_attrs = get_pype_attr( + custom_attrs, hier_attrs = get_openpype_attr( self.session, query_keys=self.cust_attr_query_keys ) ent_types = self.session.query("select id, name from ObjectType").all() @@ -2497,7 +2465,7 @@ class SyncEntitiesFactory: if new_entity_id not in p_chilren: self.entities_dict[parent_id]["children"].append(new_entity_id) - cust_attr, _ = get_pype_attr(self.session) + cust_attr, _ = get_openpype_attr(self.session) for _attr in cust_attr: key = _attr["key"] if key not in av_entity["data"]: From 4996903f23dbc7174270649d88f775c1a1d7a767 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 19:19:41 +0200 Subject: [PATCH 31/73] settings has defined few api exceptions --- openpype/settings/__init__.py | 7 +++++++ openpype/settings/exceptions.py | 11 +++++++++++ openpype/settings/lib.py | 4 ++++ 3 files changed, 22 insertions(+) create mode 100644 openpype/settings/exceptions.py diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index b4187829fc..3755fabeb2 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -1,3 +1,7 @@ +from .exceptions import ( + SaveWarning, + SaveSettingsValidation +) from .lib import ( get_system_settings, get_project_settings, @@ -12,6 +16,9 @@ from .entities import ( __all__ = ( + "SaveWarning", + "SaveSettingsValidation", + "get_system_settings", "get_project_settings", "get_current_project_settings", diff --git a/openpype/settings/exceptions.py b/openpype/settings/exceptions.py new file mode 100644 index 0000000000..86f04c76b9 --- /dev/null +++ b/openpype/settings/exceptions.py @@ -0,0 +1,11 @@ +class SaveSettingsValidation(Exception): + pass + + +class SaveWarning(SaveSettingsValidation): + def __init__(self, warnings): + if isinstance(warnings, str): + warnings = [warnings] + self.warnings = warnings + msg = ", ".join(warnings) + super(SaveWarning, self).__init__(msg) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 3bf2141808..31c7c902cb 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -4,6 +4,10 @@ import functools import logging import platform import copy +from .exceptions import ( + SaveSettingsValidation, + SaveWarning +) from .constants import ( M_OVERRIDEN_KEY, M_ENVIRONMENT_KEY, From 9ac5427aaf4e79939d586e3b320e07fed8ea8660 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 19:19:55 +0200 Subject: [PATCH 32/73] ftrack updates custom attributes on application save --- openpype/modules/ftrack/ftrack_module.py | 84 +++++++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index b057503007..28281786b3 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -1,4 +1,5 @@ import os +import json import collections from abc import ABCMeta, abstractmethod import six @@ -12,6 +13,7 @@ from openpype.modules import ( ILaunchHookPaths, ISettingsChangeListener ) +from openpype.settings import SaveWarning FTRACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -128,10 +130,86 @@ class FtrackModule( if self.tray_module: self.tray_module.changed_user() - def on_system_settings_save(self, *_args, **_kwargs): + def on_system_settings_save(self, old_value, new_value, changes): """Implementation of ISettingsChangeListener interface.""" - # Ignore - return + try: + session = self.create_ftrack_session() + except Exception: + self.log.warning("Couldn't create ftrack session.", exc_info=True) + raise SaveWarning(( + "Couldn't create Ftrack session." + " You may need to update applications" + " and tools in Ftrack custom attributes using defined action." + )) + + from .lib import ( + get_openpype_attr, + CUST_ATTR_APPLICATIONS, + CUST_ATTR_TOOLS, + app_definitions_from_app_manager, + tool_definitions_from_app_manager + ) + from openpype.api import ApplicationManager + query_keys = [ + "id", + "key", + "config" + ] + custom_attributes = get_openpype_attr( + session, + split_hierarchical=False, + query_keys=query_keys + ) + app_attribute = None + tool_attribute = None + for custom_attribute in custom_attributes: + key = custom_attribute["key"] + if key == CUST_ATTR_APPLICATIONS: + app_attribute = custom_attribute + elif key == CUST_ATTR_TOOLS: + tool_attribute = custom_attribute + + app_manager = ApplicationManager(new_value) + missing_attributes = [] + if not app_attribute: + missing_attributes.append(CUST_ATTR_APPLICATIONS) + else: + config = json.loads(app_attribute["config"]) + new_data = app_definitions_from_app_manager(app_manager) + prepared_data = [] + for item in new_data: + for key, label in item.items(): + prepared_data.append({ + "menu": label, + "value": key + }) + + config["data"] = json.dumps(prepared_data) + app_attribute["config"] = json.dumps(config) + + if not tool_attribute: + missing_attributes.append(CUST_ATTR_TOOLS) + else: + config = json.loads(tool_attribute["config"]) + new_data = tool_definitions_from_app_manager(app_manager) + prepared_data = [] + for item in new_data: + for key, label in item.items(): + prepared_data.append({ + "menu": label, + "value": key + }) + config["data"] = json.dumps(prepared_data) + tool_attribute["config"] = json.dumps(config) + + session.commit() + + if missing_attributes: + raise SaveWarning(( + "Couldn't find custom attribute/s ({}) to update." + " You may need to update applications" + " and tools in Ftrack custom attributes using defined action." + ).format(", ".join(missing_attributes))) def on_project_settings_save(self, *_args, **_kwargs): """Implementation of ISettingsChangeListener interface.""" From cbc0fca2e3114b279470361334036224b269d42f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 20 Apr 2021 19:32:56 +0200 Subject: [PATCH 33/73] removed user module with all settings --- openpype/modules/__init__.py | 7 - openpype/modules/ftrack/ftrack_module.py | 7 - openpype/modules/user/__init__.py | 10 -- openpype/modules/user/rest_api.py | 35 ---- openpype/modules/user/user_module.py | 169 ------------------ openpype/modules/user/widget_user.py | 88 --------- .../defaults/system_settings/modules.json | 3 - .../schemas/system_schema/schema_modules.json | 14 -- 8 files changed, 333 deletions(-) delete mode 100644 openpype/modules/user/__init__.py delete mode 100644 openpype/modules/user/rest_api.py delete mode 100644 openpype/modules/user/user_module.py delete mode 100644 openpype/modules/user/widget_user.py diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index d7c6d99fe6..bae48c540b 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -18,10 +18,6 @@ from .webserver import ( WebServerModule, IWebServerRoutes ) -from .user import ( - UserModule, - IUserModule -) from .idle_manager import ( IdleManager, IIdleManager @@ -60,9 +56,6 @@ __all__ = ( "WebServerModule", "IWebServerRoutes", - "UserModule", - "IUserModule", - "IdleManager", "IIdleManager", diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index d242268048..e639e1a634 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -8,7 +8,6 @@ from openpype.modules import ( ITrayModule, IPluginPaths, ITimersManager, - IUserModule, ILaunchHookPaths, ISettingsChangeListener ) @@ -32,7 +31,6 @@ class FtrackModule( ITrayModule, IPluginPaths, ITimersManager, - IUserModule, ILaunchHookPaths, ISettingsChangeListener ): @@ -123,11 +121,6 @@ class FtrackModule( if self.tray_module: self.tray_module.stop_timer_manager() - def on_pype_user_change(self, username): - """Implementation of IUserModule interface.""" - if self.tray_module: - self.tray_module.changed_user() - def on_system_settings_save(self, *_args, **_kwargs): """Implementation of ISettingsChangeListener interface.""" # Ignore diff --git a/openpype/modules/user/__init__.py b/openpype/modules/user/__init__.py deleted file mode 100644 index a97ac0eef6..0000000000 --- a/openpype/modules/user/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .user_module import ( - UserModule, - IUserModule -) - - -__all__ = ( - "UserModule", - "IUserModule" -) diff --git a/openpype/modules/user/rest_api.py b/openpype/modules/user/rest_api.py deleted file mode 100644 index 566425a19b..0000000000 --- a/openpype/modules/user/rest_api.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -from aiohttp.web_response import Response - - -class UserModuleRestApi: - def __init__(self, user_module, server_manager): - self.module = user_module - self.server_manager = server_manager - - self.prefix = "/user" - - self.register() - - def register(self): - self.server_manager.add_route( - "GET", - self.prefix + "/username", - self.get_username - ) - self.server_manager.add_route( - "GET", - self.prefix + "/show_widget", - self.show_user_widget - ) - - async def get_username(self, request): - return Response( - status=200, - body=json.dumps(self.module.cred, indent=4), - content_type="application/json" - ) - - async def show_user_widget(self, request): - self.module.action_show_widget.trigger() - return Response(status=200) diff --git a/openpype/modules/user/user_module.py b/openpype/modules/user/user_module.py deleted file mode 100644 index 7d257f1781..0000000000 --- a/openpype/modules/user/user_module.py +++ /dev/null @@ -1,169 +0,0 @@ -import os -import json -import getpass - -from abc import ABCMeta, abstractmethod - -import six -import appdirs - -from .. import ( - PypeModule, - ITrayModule, - IWebServerRoutes -) - - -@six.add_metaclass(ABCMeta) -class IUserModule: - """Interface for other modules to use user change callbacks.""" - - @abstractmethod - def on_pype_user_change(self, username): - """What should happen on Pype user change.""" - pass - - -class UserModule(PypeModule, ITrayModule, IWebServerRoutes): - cred_folder_path = os.path.normpath( - appdirs.user_data_dir('pype-app', 'pype') - ) - cred_filename = 'user_info.json' - env_name = "OPENPYPE_USERNAME" - - name = "user" - - def initialize(self, modules_settings): - user_settings = modules_settings[self.name] - self.enabled = user_settings["enabled"] - - self.callbacks_on_user_change = [] - self.cred = {} - self.cred_path = os.path.normpath(os.path.join( - self.cred_folder_path, self.cred_filename - )) - - # Tray attributes - self.widget_login = None - self.action_show_widget = None - - self.rest_api_obj = None - - def tray_init(self): - from .widget_user import UserWidget - self.widget_login = UserWidget(self) - - self.load_credentials() - - def register_callback_on_user_change(self, callback): - self.callbacks_on_user_change.append(callback) - - def tray_start(self): - """Store credentials to env and preset them to widget""" - username = "" - if self.cred: - username = self.cred.get("username") or "" - - os.environ[self.env_name] = username - self.widget_login.set_user(username) - - def tray_exit(self): - """Nothing special for User.""" - return - - def get_user(self): - return self.cred.get("username") or getpass.getuser() - - def webserver_initialization(self, server_manager): - """Implementation of IWebServerRoutes interface.""" - from .rest_api import UserModuleRestApi - - self.rest_api_obj = UserModuleRestApi(self, server_manager) - - def connect_with_modules(self, enabled_modules): - for module in enabled_modules: - if isinstance(module, IUserModule): - self.callbacks_on_user_change.append( - module.on_pype_user_change - ) - - # Definition of Tray menu - def tray_menu(self, parent_menu): - from Qt import QtWidgets - """Add menu or action to Tray(or parent)'s menu""" - action = QtWidgets.QAction("Username", parent_menu) - action.triggered.connect(self.show_widget) - parent_menu.addAction(action) - parent_menu.addSeparator() - - self.action_show_widget = action - - def load_credentials(self): - """Get credentials from JSON file """ - credentials = {} - try: - file = open(self.cred_path, "r") - credentials = json.load(file) - file.close() - - self.cred = credentials - username = credentials.get("username") - if username: - self.log.debug("Loaded Username \"{}\"".format(username)) - else: - self.log.debug("Pype Username is not set") - - return credentials - - except FileNotFoundError: - return self.save_credentials(getpass.getuser()) - - except json.decoder.JSONDecodeError: - self.log.warning(( - "File where users credentials should be stored" - " has invalid json format. Loading system username." - )) - return self.save_credentials(getpass.getuser()) - - def change_credentials(self, username): - self.save_credentials(username) - for callback in self.callbacks_on_user_change: - try: - callback(username) - except Exception: - self.log.warning( - "Failed to execute callback \"{}\".".format( - str(callback) - ), - exc_info=True - ) - - def save_credentials(self, username): - """Save credentials to JSON file, env and widget""" - if username is None: - username = "" - - username = str(username).strip() - - self.cred = {"username": username} - os.environ[self.env_name] = username - if self.widget_login: - self.widget_login.set_user(username) - try: - file = open(self.cred_path, "w") - file.write(json.dumps(self.cred)) - file.close() - self.log.debug("Username \"{}\" stored".format(username)) - except Exception: - self.log.error( - "Could not store username to file \"{}\"".format( - self.cred_path - ), - exc_info=True - ) - - return self.cred - - def show_widget(self): - """Show dialog to enter credentials""" - self.widget_login.show() diff --git a/openpype/modules/user/widget_user.py b/openpype/modules/user/widget_user.py deleted file mode 100644 index f8ecadf56b..0000000000 --- a/openpype/modules/user/widget_user.py +++ /dev/null @@ -1,88 +0,0 @@ -from Qt import QtCore, QtGui, QtWidgets -from avalon import style -from openpype import resources - - -class UserWidget(QtWidgets.QWidget): - - MIN_WIDTH = 300 - - def __init__(self, module): - - super(UserWidget, self).__init__() - - self.module = module - - # Style - icon = QtGui.QIcon(resources.pype_icon_filepath()) - self.setWindowIcon(icon) - self.setWindowTitle("Username Settings") - self.setMinimumWidth(self.MIN_WIDTH) - self.setStyleSheet(style.load_stylesheet()) - - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint - ) - - self.setLayout(self._main()) - - def show(self, *args, **kwargs): - super().show(*args, **kwargs) - # Move widget to center of active screen on show - screen = QtWidgets.QApplication.desktop().screen() - screen_center = lambda self: ( - screen.rect().center() - self.rect().center() - ) - self.move(screen_center(self)) - - def _main(self): - main_layout = QtWidgets.QVBoxLayout() - - form_layout = QtWidgets.QFormLayout() - form_layout.setContentsMargins(10, 15, 10, 5) - - label_username = QtWidgets.QLabel("Username:") - label_username.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - label_username.setTextFormat(QtCore.Qt.RichText) - - input_username = QtWidgets.QLineEdit() - input_username.setPlaceholderText( - QtCore.QCoreApplication.translate("main", "e.g. John Smith") - ) - - form_layout.addRow(label_username, input_username) - - btn_save = QtWidgets.QPushButton("Save") - btn_save.clicked.connect(self.click_save) - - btn_cancel = QtWidgets.QPushButton("Cancel") - btn_cancel.clicked.connect(self.close) - - btn_group = QtWidgets.QHBoxLayout() - btn_group.addStretch(1) - btn_group.addWidget(btn_save) - btn_group.addWidget(btn_cancel) - - main_layout.addLayout(form_layout) - main_layout.addLayout(btn_group) - - self.input_username = input_username - - return main_layout - - def set_user(self, username): - self.input_username.setText(username) - - def click_save(self): - # all what should happen - validations and saving into appsdir - username = self.input_username.text() - self.module.change_credentials(username) - self._close_widget() - - def closeEvent(self, event): - event.ignore() - self._close_widget() - - def _close_widget(self): - self.hide() diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index b3065058a1..6e4b493116 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -161,9 +161,6 @@ "log_viewer": { "enabled": true }, - "user": { - "enabled": true - }, "standalonepublish_tool": { "enabled": true } diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index a30cafd0c2..878958b12d 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -154,20 +154,6 @@ } ] }, - { - "type": "dict", - "key": "user", - "label": "User setting", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - } - ] - }, { "type": "dict", "key": "standalonepublish_tool", From 72ac25e60e949ad3ab8185983f2d011dc9703c5d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 20 Apr 2021 20:18:31 +0200 Subject: [PATCH 34/73] SyncServer GUI - rework of header and menus Reiplemented QHeaderView Refactored accessing models Still wip --- openpype/modules/sync_server/tray/lib.py | 96 ++++ openpype/modules/sync_server/tray/models.py | 102 ++-- openpype/modules/sync_server/tray/widgets.py | 505 ++++++++++++------- 3 files changed, 488 insertions(+), 215 deletions(-) diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 051567ed6c..41b0eb43f9 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -1,5 +1,7 @@ from Qt import QtCore import attr +import abc +import six from openpype.lib import PypeLogger @@ -24,6 +26,100 @@ FailedRole = QtCore.Qt.UserRole + 8 HeaderNameRole = QtCore.Qt.UserRole + 10 +@six.add_metaclass(abc.ABCMeta) +class AbstractColumnFilter: + + def __init__(self, column_name, dbcon=None): + self.column_name = column_name + self.dbcon = dbcon + self._search_variants = [] + + def search_variants(self): + """ + Returns all flavors of search available for this column, + """ + return self._search_variants + + @abc.abstractmethod + def values(self): + """ + Returns dict of available values for filter {'label':'value'} + """ + pass + + @abc.abstractmethod + def prepare_match_part(self, values): + """ + Prepares format valid for $match part from 'values + + Args: + values (dict): {'label': 'value'} + Returns: + (dict): {'COLUMN_NAME': {'$in': ['val1', 'val2']}} + """ + pass + + +class PredefinedSetFilter(AbstractColumnFilter): + + def __init__(self, column_name, values): + super().__init__(column_name) + self._search_variants = ['text', 'checkbox'] + self._values = values + + def values(self): + return {k: v for k, v in self._values.items()} + + def prepare_match_part(self, values): + return {'$in': list(values.keys())} + + +class RegexTextFilter(AbstractColumnFilter): + + def __init__(self, column_name): + super().__init__(column_name) + self._search_variants = ['text'] + + def values(self): + return {} + + def prepare_match_part(self, values): + """ values = {'text1 text2': 'text1 text2'} """ + if not values: + return {} + + regex_strs = set() + text = list(values.keys())[0] # only single key always expected + for word in text.split(): + regex_strs.add('.*{}.*'.format(word)) + + return {"$regex": "|".join(regex_strs), + "$options": 'i'} + + +class MultiSelectFilter(AbstractColumnFilter): + + def __init__(self, column_name, values=None, dbcon=None): + super().__init__(column_name) + self._values = values + self.dbcon = dbcon + self._search_variants = ['checkbox'] + + def values(self): + if self._values: + return {k: v for k, v in self._values.items()} + + recs = self.dbcon.find({'type': self.column_name}, {"name": 1, + "_id": -1}) + values = {} + for item in recs: + values[item["name"]] = item["name"] + return dict(sorted(values.items(), key=lambda it: it[1])) + + def prepare_match_part(self, values): + return {'$in': list(values.keys())} + + @attr.s class FilterDefinition: type = attr.ib() diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 444422c56a..4b70fbae15 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -6,6 +6,7 @@ from Qt import QtCore from Qt.QtCore import Qt from avalon.tools.delegates import pretty_timestamp +from avalon.vendor import qtawesome from openpype.lib import PypeLogger @@ -67,18 +68,24 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): return len(self._header) def headerData(self, section, orientation, role): + name = self.COLUMN_LABELS[section][0] if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - name = self.COLUMN_LABELS[section][0] - txt = "" - if name in self.column_filtering.keys(): - txt = "(F)" - return self.COLUMN_LABELS[section][1] + txt # return label + return self.COLUMN_LABELS[section][1] + + if role == Qt.DecorationRole: + if name in self.column_filtering.keys(): + return qtawesome.icon("fa.filter", color="white") + if self.COLUMN_FILTERS.get(name): + return qtawesome.icon("fa.filter", color="gray") if role == lib.HeaderNameRole: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][0] # return name + def get_column(self, index): + return self.COLUMN_LABELS[index] + def get_header_index(self, value): """ Returns index of 'value' in headers @@ -199,9 +206,28 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): Args: word_filter (str): string inputted by user """ - self.word_filter = word_filter + self._word_filter = word_filter self.refresh() + def get_column_filter(self, index): + """ + Returns filter object for column 'index + + Args: + index(int): index of column in header + + Returns: + (AbstractColumnFilter) + """ + column_name = self._header[index] + + filter_rec = self.COLUMN_FILTERS.get(column_name) + if filter_rec: + filter_rec.dbcon = self.dbcon # up-to-date db connection + + return filter_rec + + def get_column_filter_values(self, index): """ Returns list of available values for filtering in the column @@ -215,21 +241,11 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): menu for some columns ('subset') might be 'value' and 'label' same """ - column_name = self._header[index] - - filter_def = self.COLUMN_FILTERS.get(column_name) - if not filter_def: + filter_rec = self.get_column_filter(index) + if not filter_rec: return {} - if filter_def['type'] == 'predefined_set': - return dict(filter_def['values']) - elif filter_def['type'] == 'available_values': - recs = self.dbcon.find({'type': column_name}, {"name": 1, - "_id": -1}) - values = {} - for item in recs: - values[item["name"]] = item["name"] - return dict(sorted(values.items(), key=lambda item: item[1])) + return filter_rec.values() def set_project(self, project): """ @@ -313,11 +329,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): ] COLUMN_FILTERS = { - 'status': {'type': 'predefined_set', - 'values': {k: v for k, v in lib.STATUS.items()}}, - 'subset': {'type': 'available_values'}, - 'asset': {'type': 'available_values'}, - 'representation': {'type': 'available_values'} + 'status': lib.PredefinedSetFilter('status', lib.STATUS), + 'subset': lib.RegexTextFilter('subset'), + 'asset': lib.RegexTextFilter('asset'), + 'representation': lib.MultiSelectFilter('representation') } refresh_started = QtCore.Signal() @@ -356,9 +371,11 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._project = project self._rec_loaded = 0 self._total_records = 0 # how many documents query actually found - self.word_filter = None + self._word_filter = None self._column_filtering = {} + self._word_filter = None + self._initialized = False if not self._project or self._project == lib.DUMMY_PROJECT: return @@ -383,6 +400,19 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self.timer.timeout.connect(self.tick) self.timer.start(self.REFRESH_SEC) + def get_filters(self): + """ + Returns all available filter editors per column_name keys. + """ + filters = {} + for column_name, _ in self.COLUMN_LABELS: + filter_rec = self.COLUMN_FILTERS.get(column_name) + if filter_rec: + filter_rec.dbcon = self.dbcon + filters[column_name] = filter_rec + + return filters + def data(self, index, role): item = self._data[index.row()] @@ -666,8 +696,12 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._column_filtering : {'status': {'$in': [1, 2, 3]}} """ filtering = {} - for key, dict_value in checked_values.items(): - filtering[key] = {'$in': list(dict_value.keys())} + for column_name, dict_value in checked_values.items(): + column_f = self.COLUMN_FILTERS.get(column_name) + if not column_f: + continue + column_f.dbcon = self.dbcon + filtering[column_name] = column_f.prepare_match_part(dict_value) self._column_filtering = filtering @@ -690,18 +724,18 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'files.sites.name': {'$all': [self.local_site, self.remote_site]} } - if not self.word_filter: + if not self._word_filter: return base_match else: - regex_str = '.*{}.*'.format(self.word_filter) + regex_str = '.*{}.*'.format(self._word_filter) base_match['$or'] = [ {'context.subset': {'$regex': regex_str, '$options': 'i'}}, {'context.asset': {'$regex': regex_str, '$options': 'i'}}, {'context.representation': {'$regex': regex_str, '$options': 'i'}}] - if ObjectId.is_valid(self.word_filter): - base_match['$or'] = [{'_id': ObjectId(self.word_filter)}] + if ObjectId.is_valid(self._word_filter): + base_match['$or'] = [{'_id': ObjectId(self._word_filter)}] return base_match @@ -848,7 +882,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self._project = project self._rec_loaded = 0 self._total_records = 0 # how many documents query actually found - self.word_filter = None + self._word_filter = None self._id = _id self._initialized = False @@ -1114,13 +1148,13 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): Returns: (dict) """ - if not self.word_filter: + if not self._word_filter: return { "type": "representation", "_id": self._id } else: - regex_str = '.*{}.*'.format(self.word_filter) + regex_str = '.*{}.*'.format(self._word_filter) return { "type": "representation", "_id": self._id, diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 5719d13716..f9f904cd03 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -15,6 +15,7 @@ from openpype.api import get_local_site_id from openpype.lib import PypeLogger from avalon.tools.delegates import pretty_timestamp +from avalon.vendor import qtawesome from openpype.modules.sync_server.tray.models import ( SyncRepresentationSummaryModel, @@ -142,15 +143,15 @@ class SyncRepresentationWidget(QtWidgets.QWidget): message_generated = QtCore.Signal(str) default_widths = ( - ("asset", 220), - ("subset", 190), - ("version", 55), - ("representation", 95), + ("asset", 200), + ("subset", 170), + ("version", 60), + ("representation", 135), ("local_site", 170), ("remote_site", 170), ("files_count", 50), ("files_size", 60), - ("priority", 50), + ("priority", 70), ("status", 110) ) @@ -183,8 +184,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): QtWidgets.QAbstractItemView.SelectRows) self.table_view.horizontalHeader().setSortIndicator( -1, Qt.AscendingOrder) - self.table_view.setSortingEnabled(True) - self.table_view.horizontalHeader().setSortIndicatorShown(True) self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() @@ -196,10 +195,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - for column_name, width in self.default_widths: - idx = model.get_header_index(column_name) - self.table_view.setColumnWidth(idx, width) - layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) @@ -213,23 +208,26 @@ class SyncRepresentationWidget(QtWidgets.QWidget): model.refresh_started.connect(self._save_scrollbar) model.refresh_finished.connect(self._set_scrollbar) - self.table_view.model().modelReset.connect(self._set_selection) + model.modelReset.connect(self._set_selection) + + self.model = model self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) - self.checked_values = {} + self.horizontal_header = HorizontalHeader(self) + self.table_view.setHorizontalHeader(self.horizontal_header) + # self.table_view.setSortingEnabled(True) + # self.table_view.horizontalHeader().setSortIndicatorShown(True) - self.horizontal_header = self.table_view.horizontalHeader() - self.horizontal_header.setContextMenuPolicy( - QtCore.Qt.CustomContextMenu) - self.horizontal_header.customContextMenuRequested.connect( - self._on_section_clicked) + for column_name, width in self.default_widths: + idx = model.get_header_index(column_name) + self.table_view.setColumnWidth(idx, width) def _selection_changed(self, _new_selection): index = self.selection_model.currentIndex() self._selected_id = \ - self.table_view.model().data(index, Qt.UserRole) + self.model.data(index, Qt.UserRole) def _set_selection(self): """ @@ -238,7 +236,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): Keep selection during model refresh. """ if self._selected_id: - index = self.table_view.model().get_index(self._selected_id) + index = self.model.get_index(self._selected_id) if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows @@ -250,141 +248,11 @@ class SyncRepresentationWidget(QtWidgets.QWidget): """ Opens representation dialog with all files after doubleclick """ - _id = self.table_view.model().data(index, Qt.UserRole) + _id = self.model.data(index, Qt.UserRole) detail_window = SyncServerDetailWindow( - self.sync_server, _id, self.table_view.model().project) + self.sync_server, _id, self.model.project) detail_window.exec() - def _on_section_clicked(self, point): - - logical_index = self.horizontal_header.logicalIndexAt(point) - - model = self.table_view.model() - column_name = model.headerData(logical_index, - Qt.Horizontal, lib.HeaderNameRole) - items_dict = model.get_column_filter_values(logical_index) - - if not items_dict: - return - - menu = QtWidgets.QMenu(self) - - # text filtering only if labels same as values, not if codes are used - if list(items_dict.keys())[0] == list(items_dict.values())[0]: - self.line_edit = QtWidgets.QLineEdit(self) - self.line_edit.setPlaceholderText("Type and enter...") - action_le = QtWidgets.QWidgetAction(menu) - action_le.setDefaultWidget(self.line_edit) - self.line_edit.returnPressed.connect( - partial(self._apply_text_filter, column_name, items_dict)) - menu.addAction(action_le) - menu.addSeparator() - - action_all = QtWidgets.QAction("All", self) - state_checked = 2 - # action_all.triggered.connect(partial(self._apply_filter, column_name, - # items_dict, state_checked)) - action_all.triggered.connect(partial(self._reset_filter, column_name)) - menu.addAction(action_all) - - action_none = QtWidgets.QAction("Unselect all", self) - state_unchecked = 0 - action_none.triggered.connect(partial(self._apply_filter, column_name, - items_dict, state_unchecked)) - menu.addAction(action_none) - menu.addSeparator() - - # nothing explicitly >> ALL implicitly >> first time - if self.checked_values.get(column_name) is None: - checked_keys = items_dict.keys() - else: - checked_keys = self.checked_values[column_name] - - for value, label in items_dict.items(): - checkbox = QtWidgets.QCheckBox(str(label), menu) - if value in checked_keys: - checkbox.setChecked(True) - - action = QtWidgets.QWidgetAction(menu) - action.setDefaultWidget(checkbox) - - checkbox.stateChanged.connect(partial(self._apply_filter, - column_name, {value: label})) - menu.addAction(action) - - self.menu = menu - self.menu_items_dict = items_dict # all available items - self.menu.exec_(QtGui.QCursor.pos()) - - def _reset_filter(self, column_name): - """ - Remove whole column from filter >> not in $match at all (faster) - """ - if self.checked_values.get(column_name) is not None: - self.checked_values.pop(column_name) - self._refresh_model_and_menu(column_name, True, True) - - def _apply_filter(self, column_name, values, state): - """ - Sets 'values' to specific 'state' (checked/unchecked), - sends to model. - """ - self._update_checked_values(column_name, values, state) - self._refresh_model_and_menu(column_name, True, False) - - def _apply_text_filter(self, column_name, items): - """ - Resets all checkboxes, prefers inserted text. - """ - self._update_checked_values(column_name, items, 0) # reset other - text_item = {self.line_edit.text(): self.line_edit.text()} - self._update_checked_values(column_name, text_item, 2) - self._refresh_model_and_menu(column_name, True, True) - - def _refresh_model_and_menu(self, column_name, model=True, menu=True): - """ - Refresh model and its content and possibly menu for big changes. - """ - if model: - self.table_view.model().set_column_filtering(self.checked_values) - self.table_view.model().refresh() - if menu: - self._menu_refresh(column_name) - - def _menu_refresh(self, column_name): - """ - Reset boxes after big change - word filtering or reset - """ - for action in self.menu.actions(): - if not isinstance(action, QtWidgets.QWidgetAction): - continue - - widget = action.defaultWidget() - if not isinstance(widget, QtWidgets.QCheckBox): - continue - - if not self.checked_values.get(column_name) or \ - widget.text() in self.checked_values[column_name].values(): - widget.setChecked(True) - else: - widget.setChecked(False) - - def _update_checked_values(self, column_name, values, state): - """ - Modify dictionary of set values in columns for filtering. - - Modifies 'self.checked_values' - """ - checked = self.checked_values.get(column_name, self.menu_items_dict) - set_items = dict(values.items()) # prevent dictionary change during iter - for value, label in set_items.items(): - if state == 2: # checked - checked[value] = label - elif state == 0 and checked.get(value): - checked.pop(value) - - self.checked_values[column_name] = checked - def _on_context_menu(self, point): """ Shows menu with loader actions on Right-click. @@ -393,7 +261,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): if not point_index.isValid(): return - self.item = self.table_view.model()._data[point_index.row()] + self.item = self.model._data[point_index.row()] self.representation_id = self.item._id log.debug("menu representation _id:: {}". format(self.representation_id)) @@ -410,7 +278,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): for site, progress in {local_site: local_progress, remote_site: remote_progress}.items(): - project = self.table_view.model().project + project = self.model.project provider = self.sync_server.get_provider_for_site(project, site) if provider == 'local_drive': @@ -476,10 +344,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): if to_run: to_run(**to_run_kwargs) - self.table_view.model().refresh() + self.model.refresh() def _pause(self): - self.sync_server.pause_representation(self.table_view.model().project, + self.sync_server.pause_representation(self.model.project, self.representation_id, self.site_name) self.site_name = None @@ -487,7 +355,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): def _unpause(self): self.sync_server.unpause_representation( - self.table_view.model().project, + self.model.project, self.representation_id, self.site_name) self.site_name = None @@ -497,7 +365,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): # temporary here for testing, will be removed TODO def _add_site(self): log.info(self.representation_id) - project_name = self.table_view.model().project + project_name = self.model.project local_site_name = get_local_site_id() try: self.sync_server.add_site( @@ -525,15 +393,15 @@ class SyncRepresentationWidget(QtWidgets.QWidget): try: local_site = get_local_site_id() self.sync_server.remove_site( - self.table_view.model().project, + self.model.project, self.representation_id, local_site, True) self.message_generated.emit("Site {} removed".format(local_site)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _reset_local_site(self): """ @@ -541,11 +409,11 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'local') - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _reset_remote_site(self): """ @@ -553,18 +421,18 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'remote') - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _open_in_explorer(self, site): if not self.item: return fpath = self.item.path - project = self.table_view.model().project + project = self.model.project fpath = self.sync_server.get_local_file_path(project, site, fpath) @@ -647,11 +515,11 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() - column = self.table_view.model().get_header_index("local_site") + column = model.get_header_index("local_site") delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("remote_site") + column = model.get_header_index("remote_site") delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) @@ -671,14 +539,15 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): model.refresh_started.connect(self._save_scrollbar) model.refresh_finished.connect(self._set_scrollbar) - self.table_view.model().modelReset.connect(self._set_selection) + model.modelReset.connect(self._set_selection) + self.model = model self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) def _selection_changed(self): index = self.selection_model.currentIndex() - self._selected_id = self.table_view.model().data(index, Qt.UserRole) + self._selected_id = self.model.data(index, Qt.UserRole) def _set_selection(self): """ @@ -687,7 +556,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): Keep selection during model refresh. """ if self._selected_id: - index = self.table_view.model().get_index(self._selected_id) + index = self.model.get_index(self._selected_id) if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows @@ -715,7 +584,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): if not point_index.isValid(): return - self.item = self.table_view.model()._data[point_index.row()] + self.item = self.model._data[point_index.row()] menu = QtWidgets.QMenu() #menu.setStyleSheet(style.load_stylesheet()) @@ -729,7 +598,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): for site, progress in {local_site: local_progress, remote_site: remote_progress}.items(): - project = self.table_view.model().project + project = self.model.project provider = self.sync_server.get_provider_for_site(project, site) if provider == 'local_drive': @@ -776,12 +645,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'local', self.item._id) - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _reset_remote_site(self): """ @@ -789,12 +658,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model().project, + self.model.project, self.representation_id, 'remote', self.item._id) - self.table_view.model().refresh( - load_records=self.table_view.model()._rec_loaded) + self.model.refresh( + load_records=self.model._rec_loaded) def _open_in_explorer(self, site): if not self.item: @@ -957,3 +826,277 @@ class SyncRepresentationErrorWindow(QtWidgets.QDialog): self.setLayout(body_layout) self.setWindowTitle("Sync Representation Error Detail") + + +class HorizontalHeader(QtWidgets.QHeaderView): + + def __init__(self, parent=None): + super(HorizontalHeader, self).__init__(QtCore.Qt.Horizontal, parent) + self._parent = parent + self.checked_values = {} + + self.setSectionsMovable(True) + self.setSectionsClickable(True) + self.setHighlightSections(True) + + self.menu_items_dict = {} + self.menu = None + self.header_cells = [] + self.filter_buttons = {} + + self.init_layout() + + self.filter_icon = qtawesome.icon("fa.filter", color="gray") + self.filter_set_icon = qtawesome.icon("fa.filter", color="white") + + self._resetting = False + + self.sectionResized.connect(self.handleSectionResized) + self.sectionMoved.connect(self.handleSectionMoved) + #self.sectionPressed.connect(self.model.sort) + + + @property + def model(self): + """Keep model synchronized with parent widget""" + return self._parent.model + + def init_layout(self): + for i in range(self.count()): + cell_content = QtWidgets.QWidget(self) + column_name, column_label = self.model.get_column(i) + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(5, 5, 5, 0) + layout.setAlignment(Qt.AlignVCenter) + layout.addWidget(QtWidgets.QLabel(column_label)) + + filter_rec = self.model.get_filters().get(column_name) + if filter_rec: + icon = self.filter_icon + button = QtWidgets.QPushButton(icon, "") + layout.addWidget(button) + + # button.setMenu(menu) + button.setFixedSize(24, 24) + # button.setAlignment(Qt.AlignRight) + button.setStyleSheet("QPushButton::menu-indicator{width:0px;}" + "QPushButton{border: none}") + button.clicked.connect(partial(self._get_menu, + column_name, i)) + button.setFlat(True) + self.filter_buttons[column_name] = button + + cell_content.setLayout(layout) + + self.header_cells.append(cell_content) + + def showEvent(self, event): + if not self.header_cells: + self.init_layout() + + for i in range(len(self.header_cells)): + cell_content = self.header_cells[i] + cell_content.setGeometry(self.sectionViewportPosition(i), 0, + self.sectionSize(i)-1, self.height()) + + cell_content.show() + + if len(self.model.get_filters()) > self.count(): + for i in range(self.count(), len(self.header_cells)): + self.header_cells[i].deleteLater() + + super(HorizontalHeader, self).showEvent(event) + + def _set_filter_icon(self, column_name): + button = self.filter_buttons.get(column_name) + if button: + if self.checked_values.get(column_name): + button.setIcon(self.filter_set_icon) + else: + button.setIcon(self.filter_icon) + + def _reset_filter(self, column_name): + """ + Remove whole column from filter >> not in $match at all (faster) + """ + self._resetting = True # mark changes to consume them + if self.checked_values.get(column_name) is not None: + self.checked_values.pop(column_name) + self._set_filter_icon(column_name) + self._filter_and_refresh_model_and_menu(column_name, True, True) + self._resetting = False + + def _apply_filter(self, column_name, values, state): + """ + Sets 'values' to specific 'state' (checked/unchecked), + sends to model. + """ + if self._resetting: # event triggered by _resetting, skip it + return + + self._update_checked_values(column_name, values, state) + self._set_filter_icon(column_name) + self._filter_and_refresh_model_and_menu(column_name, True, False) + + def _apply_text_filter(self, column_name, items): + """ + Resets all checkboxes, prefers inserted text. + """ + self._update_checked_values(column_name, items, 0) # reset other + if self.checked_values.get(column_name) is not None or \ + self.line_edit.text() == '': + self.checked_values.pop(column_name) # reset during typing + + text_item = {self.line_edit.text(): self.line_edit.text()} + if self.line_edit.text(): + self._update_checked_values(column_name, text_item, 2) + self._set_filter_icon(column_name) + self._filter_and_refresh_model_and_menu(column_name, True, True) + + def _filter_and_refresh_model_and_menu(self, column_name, + model=True, menu=True): + """ + Refresh model and its content and possibly menu for big changes. + """ + if model: + self.model.set_column_filtering(self.checked_values) + self.model.refresh() + if menu: + self._menu_refresh(column_name) + + def _get_menu(self, column_name, index): + """Prepares content of menu for 'column_name'""" + menu = QtWidgets.QMenu(self) + filter_rec = self.model.get_filters()[column_name] + self.menu_items_dict[column_name] = filter_rec.values() + self.line_edit = None + + # text filtering only if labels same as values, not if codes are used + if 'text' in filter_rec.search_variants(): + self.line_edit = QtWidgets.QLineEdit(self) + self.line_edit.setSizePolicy( + QtWidgets.QSizePolicy.Maximum, + QtWidgets.QSizePolicy.Maximum) + txt = "Type..." + if self.checked_values.get(column_name): + txt = list(self.checked_values.get(column_name).keys())[0] + self.line_edit.setPlaceholderText(txt) + + action_le = QtWidgets.QWidgetAction(menu) + action_le.setDefaultWidget(self.line_edit) + self.line_edit.textChanged.connect( + partial(self._apply_text_filter, column_name, + filter_rec.values())) + menu.addAction(action_le) + menu.addSeparator() + + if 'checkbox' in filter_rec.search_variants(): + action_all = QtWidgets.QAction("All", self) + action_all.triggered.connect(partial(self._reset_filter, + column_name)) + menu.addAction(action_all) + + action_none = QtWidgets.QAction("Unselect all", self) + state_unchecked = 0 + action_none.triggered.connect(partial(self._apply_filter, + column_name, + filter_rec.values(), + state_unchecked)) + menu.addAction(action_none) + menu.addSeparator() + + # nothing explicitly >> ALL implicitly >> first time + if self.checked_values.get(column_name) is None: + checked_keys = self.menu_items_dict[column_name].keys() + else: + checked_keys = self.checked_values[column_name] + + for value, label in self.menu_items_dict[column_name].items(): + checkbox = QtWidgets.QCheckBox(str(label), menu) + + # temp + checkbox.setStyleSheet("QCheckBox{spacing: 5px;" + "padding:5px 5px 5px 5px;}") + if value in checked_keys: + checkbox.setChecked(True) + + action = QtWidgets.QWidgetAction(menu) + action.setDefaultWidget(checkbox) + + checkbox.stateChanged.connect(partial(self._apply_filter, + column_name, {value: label})) + menu.addAction(action) + + self.menu = menu + + self._show_menu(index, menu) + + def _show_menu(self, index, menu): + """Shows 'menu' under header column of 'index'""" + global_pos_point = self.mapToGlobal( + QtCore.QPoint(self.sectionViewportPosition(index), 0)) + menu.setMinimumWidth(self.sectionSize(index)) + menu.setMinimumHeight(self.height()) + menu.exec_(QtCore.QPoint(global_pos_point.x(), + global_pos_point.y() + self.height())) + + def _menu_refresh(self, column_name): + """ + Reset boxes after big change - word filtering or reset + """ + for action in self.menu.actions(): + if not isinstance(action, QtWidgets.QWidgetAction): + continue + + widget = action.defaultWidget() + if not isinstance(widget, QtWidgets.QCheckBox): + continue + + if not self.checked_values.get(column_name) or \ + widget.text() in self.checked_values[column_name].values(): + widget.setChecked(True) + else: + widget.setChecked(False) + + def _update_checked_values(self, column_name, values, state): + """ + Modify dictionary of set values in columns for filtering. + + Modifies 'self.checked_values' + """ + checked = self.checked_values.get(column_name, + dict(self.menu_items_dict[column_name])) + set_items = dict(values.items()) # prevent dict change during loop + for value, label in set_items.items(): + if state == 2 and label: # checked + checked[value] = label + elif state == 0 and checked.get(value): + checked.pop(value) + + self.checked_values[column_name] = checked + + def handleSectionResized(self, i): + if not self.header_cells: + self.init_layout() + for i in range(self.count()): + j = self.visualIndex(i) + logical = self.logicalIndex(j) + self.header_cells[i].setGeometry( + self.sectionViewportPosition(logical), 0, + self.sectionSize(logical) - 1, self.height()) + + def handleSectionMoved(self, i, oldVisualIndex, newVisualIndex): + if not self.header_cells: + self.init_layout() + for i in range(min(oldVisualIndex, newVisualIndex), self.count()): + logical = self.logicalIndex(i) + self.header_cells[i].setGeometry( + self.ectionViewportPosition(logical), 0, + self.sectionSize(logical) - 2, self.height()) + + def fixComboPositions(self): + for i in range(self.count()): + self.header_cells[i].setGeometry( + self.sectionViewportPosition(i), 0, + self.sectionSize(i) - 2, self.height()) From a8319ad336d6013d011cfbfbdf2932e4f267918e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 10:25:57 +0200 Subject: [PATCH 35/73] local settings can store open pype username --- .../settings/local_settings/general_widget.py | 22 ++++++++++++++----- .../tools/settings/local_settings/window.py | 13 +++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/openpype/tools/settings/local_settings/general_widget.py b/openpype/tools/settings/local_settings/general_widget.py index e820d8ab8b..f2147e626a 100644 --- a/openpype/tools/settings/local_settings/general_widget.py +++ b/openpype/tools/settings/local_settings/general_widget.py @@ -5,16 +5,28 @@ class LocalGeneralWidgets(QtWidgets.QWidget): def __init__(self, parent): super(LocalGeneralWidgets, self).__init__(parent) + username_input = QtWidgets.QLineEdit(self) + + layout = QtWidgets.QFormLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addRow("OpenPype Username", username_input) + + self.username_input = username_input def update_local_settings(self, value): - return - - # RETURNING EARLY TO HIDE WIDGET WITHOUT CONTENT + username = "" + if value: + username = value.get("username", username) + self.username_input.setText(username) def settings_value(self): # Add changed # If these have changed then output = {} - # TEMPORARILY EMPTY AS THERE IS NOTHING TO PUT HERE - + username = self.username_input.text() + if username: + output["username"] = username + # Do not return output yet since we don't have mechanism to save or + # load these data through api calls return output diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py index a12a2289b5..b6ca56d348 100644 --- a/openpype/tools/settings/local_settings/window.py +++ b/openpype/tools/settings/local_settings/window.py @@ -80,7 +80,6 @@ class LocalSettingsWidget(QtWidgets.QWidget): general_widget = LocalGeneralWidgets(general_content) general_layout.addWidget(general_widget) - general_expand_widget.hide() self.main_layout.addWidget(general_expand_widget) @@ -127,9 +126,9 @@ class LocalSettingsWidget(QtWidgets.QWidget): self.system_settings.reset() self.project_settings.reset() - # self.general_widget.update_local_settings( - # value.get(LOCAL_GENERAL_KEY) - # ) + self.general_widget.update_local_settings( + value.get(LOCAL_GENERAL_KEY) + ) self.app_widget.update_local_settings( value.get(LOCAL_APPS_KEY) ) @@ -139,9 +138,9 @@ class LocalSettingsWidget(QtWidgets.QWidget): def settings_value(self): output = {} - # general_value = self.general_widget.settings_value() - # if general_value: - # output[LOCAL_GENERAL_KEY] = general_value + general_value = self.general_widget.settings_value() + if general_value: + output[LOCAL_GENERAL_KEY] = general_value app_value = self.app_widget.settings_value() if app_value: From 7aa222327bffcfca727ed201c4ece88cbec6d103 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 10:26:18 +0200 Subject: [PATCH 36/73] get_local_settings is part of `openpype.settings` --- openpype/settings/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index b4187829fc..c8dd64a41c 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -3,7 +3,8 @@ from .lib import ( get_project_settings, get_current_project_settings, get_anatomy_settings, - get_environments + get_environments, + get_local_settings ) from .entities import ( SystemSettings, @@ -17,6 +18,7 @@ __all__ = ( "get_current_project_settings", "get_anatomy_settings", "get_environments", + "get_local_settings", "SystemSettings", "ProjectSettings" From fee9e9bd563caf5d9e5e99ebde1e04b234efb53e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 10:26:48 +0200 Subject: [PATCH 37/73] implemented function to get openpype username --- openpype/lib/local_settings.py | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 56bdd047c9..67845c77cf 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- """Package to deal with saving and retrieving user specific settings.""" import os +import json +import getpass +import platform from datetime import datetime from abc import ABCMeta, abstractmethod -import json # TODO Use pype igniter logic instead of using duplicated code # disable lru cache in Python 2 @@ -24,11 +26,11 @@ try: except ImportError: import ConfigParser as configparser -import platform - import six import appdirs +from openpype.settings import get_local_settings + from .import validate_mongo_connection _PLACEHOLDER = object() @@ -538,3 +540,25 @@ def change_openpype_mongo_url(new_mongo_url): if existing_value is not None: registry.delete_item(key) registry.set_item(key, new_mongo_url) + + +def get_openpype_username(): + """OpenPype username used for templates and publishing. + + May be different than machine's username. + + Always returns "OPENPYPE_USERNAME" environment if is set then tries local + settings and last option is to use `getpass.getuser()` which returns + machine username. + """ + username = os.environ.get("OPENPYPE_USERNAME") + if not username: + local_settings = get_local_settings() + username = ( + local_settings + .get("general", {}) + .get("username") + ) + if not username: + username = getpass.getuser() + return username From 7d9e665e2d7c08f649c5c72eeef325f993e9c8f3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 10:28:05 +0200 Subject: [PATCH 38/73] get_openpype_username replaced usage of OPENPYPE_USERNAME environment in code --- openpype/lib/__init__.py | 34 ++++++++++--------- openpype/lib/applications.py | 3 +- .../publish/collect_current_pype_user.py | 6 ++-- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index f46c81bf7a..895d11601f 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -79,6 +79,16 @@ from .avalon_context import ( change_timer_to_current_context ) +from .local_settings import ( + IniSettingRegistry, + JSONSettingRegistry, + OpenPypeSecureRegistry, + OpenPypeSettingsRegistry, + get_local_site_id, + change_openpype_mongo_url, + get_openpype_username +) + from .applications import ( ApplicationLaunchFailed, ApplictionExecutableNotFound, @@ -112,15 +122,6 @@ from .plugin_tools import ( should_decompress ) -from .local_settings import ( - IniSettingRegistry, - JSONSettingRegistry, - OpenPypeSecureRegistry, - OpenPypeSettingsRegistry, - get_local_site_id, - change_openpype_mongo_url -) - from .path_tools import ( version_up, get_version_from_path, @@ -179,6 +180,14 @@ __all__ = [ "change_timer_to_current_context", + "IniSettingRegistry", + "JSONSettingRegistry", + "OpenPypeSecureRegistry", + "OpenPypeSettingsRegistry", + "get_local_site_id", + "change_openpype_mongo_url", + "get_openpype_username", + "ApplicationLaunchFailed", "ApplictionExecutableNotFound", "ApplicationNotFound", @@ -224,13 +233,6 @@ __all__ = [ "validate_mongo_connection", "OpenPypeMongoConnection", - "IniSettingRegistry", - "JSONSettingRegistry", - "OpenPypeSecureRegistry", - "OpenPypeSettingsRegistry", - "get_local_site_id", - "change_openpype_mongo_url", - "timeit", "is_overlapping_otio_ranges", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 51c646d494..a0b5569b02 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -25,6 +25,7 @@ from . import ( PypeLogger, Anatomy ) +from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, get_workdir_with_workdir_data @@ -1224,7 +1225,7 @@ def _prepare_last_workfile(data, workdir): file_template = anatomy.templates["work"]["file"] workdir_data.update({ "version": 1, - "user": os.environ.get("OPENPYPE_USERNAME") or getpass.getuser(), + "user": get_openpype_username(), "ext": extensions[0] }) diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py index de4e950d56..003c779836 100644 --- a/openpype/plugins/publish/collect_current_pype_user.py +++ b/openpype/plugins/publish/collect_current_pype_user.py @@ -1,6 +1,7 @@ import os import getpass import pyblish.api +from openpype.lib import get_openpype_username class CollectCurrentUserPype(pyblish.api.ContextPlugin): @@ -11,9 +12,6 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): label = "Collect Pype User" def process(self, context): - user = os.getenv("OPENPYPE_USERNAME", "").strip() - if not user: - user = context.data.get("user", getpass.getuser()) - + user = get_openpype_username() context.data["user"] = user self.log.debug("Colected user \"{}\"".format(user)) From e5737db9032c5db26d08559186d090bbf31910b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 10:28:23 +0200 Subject: [PATCH 39/73] render jobs do not care about OPENPYPE_USERNAME --- .../deadline/plugins/publish/submit_aftereffects_deadline.py | 1 - .../modules/deadline/plugins/publish/submit_harmony_deadline.py | 1 - .../modules/deadline/plugins/publish/submit_maya_deadline.py | 1 - 3 files changed, 3 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py index 38a6b9b246..69159fda1a 100644 --- a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py @@ -64,7 +64,6 @@ class AfterEffectsSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", - "OPENPYPE_USERNAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" ] diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py index ba1ffdcf30..37041a84b1 100644 --- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -273,7 +273,6 @@ class HarmonySubmitDeadline( "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", - "OPENPYPE_USERNAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" ] diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 3aea837bb1..0e92fb38bb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -441,7 +441,6 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "AVALON_ASSET", "AVALON_TASK", "AVALON_APP_NAME", - "OPENPYPE_USERNAME", "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" ] From f593f3330bc3e4f5483169c982bf29ef2bd4c32f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 10:38:08 +0200 Subject: [PATCH 40/73] added machine user as placeholder --- openpype/tools/settings/local_settings/general_widget.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/settings/local_settings/general_widget.py b/openpype/tools/settings/local_settings/general_widget.py index f2147e626a..78bc53fdd2 100644 --- a/openpype/tools/settings/local_settings/general_widget.py +++ b/openpype/tools/settings/local_settings/general_widget.py @@ -1,3 +1,5 @@ +import getpass + from Qt import QtWidgets @@ -6,6 +8,7 @@ class LocalGeneralWidgets(QtWidgets.QWidget): super(LocalGeneralWidgets, self).__init__(parent) username_input = QtWidgets.QLineEdit(self) + username_input.setPlaceholderText(getpass.getuser()) layout = QtWidgets.QFormLayout(self) layout.setContentsMargins(0, 0, 0, 0) From cc3f483aec706303e68faaa4d7b28a22d8f04b2b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 11:14:39 +0200 Subject: [PATCH 41/73] removed SaveSettingsValidation from settings init --- openpype/settings/__init__.py | 4 +--- openpype/settings/exceptions.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index 3755fabeb2..78a287f07e 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -1,6 +1,5 @@ from .exceptions import ( - SaveWarning, - SaveSettingsValidation + SaveWarning ) from .lib import ( get_system_settings, @@ -17,7 +16,6 @@ from .entities import ( __all__ = ( "SaveWarning", - "SaveSettingsValidation", "get_system_settings", "get_project_settings", diff --git a/openpype/settings/exceptions.py b/openpype/settings/exceptions.py index 86f04c76b9..758a778794 100644 --- a/openpype/settings/exceptions.py +++ b/openpype/settings/exceptions.py @@ -7,5 +7,5 @@ class SaveWarning(SaveSettingsValidation): if isinstance(warnings, str): warnings = [warnings] self.warnings = warnings - msg = ", ".join(warnings) + msg = " | ".join(warnings) super(SaveWarning, self).__init__(msg) From 00c55d0990b25303932ef1453acb2f3435ae37e4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 11:14:57 +0200 Subject: [PATCH 42/73] save warning are raised afterwards --- openpype/settings/lib.py | 42 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 31c7c902cb..dd3f79b5b3 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -5,7 +5,6 @@ import logging import platform import copy from .exceptions import ( - SaveSettingsValidation, SaveWarning ) from .constants import ( @@ -118,11 +117,18 @@ def save_studio_settings(data): changes = calculate_changes(old_data, new_data) modules_manager = ModulesManager(_system_settings=new_data) + + warnings = [] for module in modules_manager.get_enabled_modules(): if isinstance(module, ISettingsChangeListener): - module.on_system_settings_save(old_data, new_data, changes) + try: + module.on_system_settings_save(old_data, new_data, changes) + except SaveWarning as exc: + warnings.extend(exc.warnings) - return _SETTINGS_HANDLER.save_studio_settings(data) + _SETTINGS_HANDLER.save_studio_settings(data) + if warnings: + raise SaveWarning(warnings) @require_handler @@ -159,13 +165,20 @@ def save_project_settings(project_name, overrides): changes = calculate_changes(old_data, new_data) modules_manager = ModulesManager() + warnings = [] for module in modules_manager.get_enabled_modules(): if isinstance(module, ISettingsChangeListener): - module.on_project_settings_save( - old_data, new_data, project_name, changes - ) + try: + module.on_project_settings_save( + old_data, new_data, project_name, changes + ) + except SaveWarning as exc: + warnings.extend(exc.warnings) - return _SETTINGS_HANDLER.save_project_settings(project_name, overrides) + _SETTINGS_HANDLER.save_project_settings(project_name, overrides) + + if warnings: + raise SaveWarning(warnings) @require_handler @@ -202,13 +215,20 @@ def save_project_anatomy(project_name, anatomy_data): changes = calculate_changes(old_data, new_data) modules_manager = ModulesManager() + warnings = [] for module in modules_manager.get_enabled_modules(): if isinstance(module, ISettingsChangeListener): - module.on_project_anatomy_save( - old_data, new_data, changes, project_name - ) + try: + module.on_project_anatomy_save( + old_data, new_data, changes, project_name + ) + except SaveWarning as exc: + warnings.extend(exc.warnings) - return _SETTINGS_HANDLER.save_project_anatomy(project_name, anatomy_data) + _SETTINGS_HANDLER.save_project_anatomy(project_name, anatomy_data) + + if warnings: + raise SaveWarning(warnings) @require_handler From 32336eeb63392c397661d2837d62ff48c8819882 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 11:27:01 +0200 Subject: [PATCH 43/73] added use_python_2 to unreal's defaults --- openpype/settings/defaults/system_settings/applications.json | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 2355f39aa1..56d63ecf09 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1165,6 +1165,7 @@ }, "variants": { "4-26": { + "use_python_2": false, "executables": { "windows": [], "darwin": [], From d8f84a750d1b65676b228c5db2bbb44d16ae4705 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 11:41:08 +0200 Subject: [PATCH 44/73] ftrack module raise SaveWarning exception if crashes during changing values --- openpype/modules/ftrack/ftrack_module.py | 62 +++++++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 28281786b3..46403bbc26 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -230,30 +230,45 @@ class FtrackModule( import ftrack_api from openpype.modules.ftrack.lib import get_openpype_attr - session = self.create_ftrack_session() + try: + session = self.create_ftrack_session() + except Exception: + self.log.warning("Couldn't create ftrack session.", exc_info=True) + raise SaveWarning(( + "Couldn't create Ftrack session." + " You may need to update applications" + " and tools in Ftrack custom attributes using defined action." + )) + project_entity = session.query( "Project where full_name is \"{}\"".format(project_name) ).first() if not project_entity: - self.log.warning(( - "Ftrack project with names \"{}\" was not found." + msg = ( + "Ftrack project with names \"{}\" was not found in Ftrack." " Skipping settings attributes change callback." - )) - return + ).format(project_name) + self.log.warning(msg) + raise SaveWarning(msg) project_id = project_entity["id"] cust_attr, hier_attr = get_openpype_attr(session) cust_attr_by_key = {attr["key"]: attr for attr in cust_attr} hier_attrs_by_key = {attr["key"]: attr for attr in hier_attr} + + failed = {} + missing = {} for key, value in attributes_changes.items(): configuration = hier_attrs_by_key.get(key) if not configuration: configuration = cust_attr_by_key.get(key) if not configuration: + missing[key] = value continue + # TODO add add permissions check # TODO add value validations # - value type and list items entity_key = collections.OrderedDict() @@ -267,10 +282,43 @@ class FtrackModule( "value", ftrack_api.symbol.NOT_SET, value - ) ) - session.commit() + try: + session.commit() + self.log.debug( + "Changed project custom attribute \"{}\" to \"{}\"".format( + key, value + ) + ) + except Exception: + self.log.warning( + "Failed to set \"{}\" to \"{}\"".format(key, value), + exc_info=True + ) + session.rollback() + failed[key] = value + + if not failed and not missing: + return + + error_msg = ( + "Values were not updated on Ftrack which may cause issues." + ) + if missing: + error_msg += " Missing Custom attributes on Ftrack: {}.".format( + ", ".join([ + '"{}"'.format(key) + for key in missing.keys() + ]) + ) + if failed: + joined_failed = ", ".join([ + '"{}": "{}"'.format(key, value) + for key, value in failed.items() + ]) + error_msg += " Failed to set: {}".format(joined_failed) + raise SaveWarning(error_msg) def create_ftrack_session(self, **session_kwargs): import ftrack_api From c5ac2f1f3c361ee44ca5063abc35259a910b3cbc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 11:45:27 +0200 Subject: [PATCH 45/73] AE fix max instead of min 0 is minimum value, not preferred --- openpype/hosts/aftereffects/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index 7ad10cde25..99636e8dda 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -98,7 +98,7 @@ def get_asset_settings(): handle_end = asset_data.get("handleEnd") resolution_width = asset_data.get("resolutionWidth") resolution_height = asset_data.get("resolutionHeight") - duration = frame_end + handle_end - min(frame_start - handle_start, 0) + duration = frame_end + handle_end - max(frame_start - handle_start, 0) entity_type = asset_data.get("entityType") scene_data = { From ad64ef3ff95e089c9526aacdeb2f4d86437627e5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 11:53:28 +0200 Subject: [PATCH 46/73] added some docstrings --- openpype/settings/lib.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index dd3f79b5b3..c4ed9453f1 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -104,8 +104,14 @@ def save_studio_settings(data): For saving of data cares registered Settings handler. + Warning messages are not logged as module raising them should log it within + it's logger. + Args: data(dict): Overrides data with metadata defying studio overrides. + + Raises: + SaveWarning: If any module raises the exception. """ # Notify Pype modules from openpype.modules import ModulesManager, ISettingsChangeListener @@ -140,10 +146,16 @@ def save_project_settings(project_name, overrides): For saving of data cares registered Settings handler. + Warning messages are not logged as module raising them should log it within + it's logger. + Args: project_name (str): Project name for which overrides are passed. Default project's value is None. overrides(dict): Overrides data with metadata defying studio overrides. + + Raises: + SaveWarning: If any module raises the exception. """ # Notify Pype modules from openpype.modules import ModulesManager, ISettingsChangeListener @@ -190,10 +202,16 @@ def save_project_anatomy(project_name, anatomy_data): For saving of data cares registered Settings handler. + Warning messages are not logged as module raising them should log it within + it's logger. + Args: project_name (str): Project name for which overrides are passed. Default project's value is None. overrides(dict): Overrides data with metadata defying studio overrides. + + Raises: + SaveWarning: If any module raises the exception. """ # Notify Pype modules from openpype.modules import ModulesManager, ISettingsChangeListener From 55dd8f7005785fef10ba7585fa30e2a30af15aed Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 11:53:54 +0200 Subject: [PATCH 47/73] settings gui catch SaveWarning --- .../tools/settings/settings/widgets/categories.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/widgets/categories.py b/openpype/tools/settings/settings/widgets/categories.py index 9d286485a3..d782aaf54f 100644 --- a/openpype/tools/settings/settings/widgets/categories.py +++ b/openpype/tools/settings/settings/widgets/categories.py @@ -27,7 +27,7 @@ from openpype.settings.entities import ( SchemaError ) -from openpype.settings.lib import get_system_settings +from openpype.settings import SaveWarning from .widgets import ProjectListWidget from . import lib @@ -272,6 +272,15 @@ class SettingsCategoryWidget(QtWidgets.QWidget): # not required. self.reset() + except SaveWarning as exc: + msg = "Settings were saved but few issues happened.\n\n" + msg += "\n".join(exc.warnings) + + dialog = QtWidgets.QMessageBox(self) + dialog.setText(msg) + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.exec_() + except Exception as exc: formatted_traceback = traceback.format_exception(*sys.exc_info()) dialog = QtWidgets.QMessageBox(self) From d9424cdf39ec0657919390a57858c117e3125c26 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 12:08:21 +0200 Subject: [PATCH 48/73] replcaed spaces with new line char --- openpype/modules/ftrack/ftrack_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 46403bbc26..c7ce7d6d07 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -306,7 +306,7 @@ class FtrackModule( "Values were not updated on Ftrack which may cause issues." ) if missing: - error_msg += " Missing Custom attributes on Ftrack: {}.".format( + error_msg += "\nMissing Custom attributes on Ftrack: {}.".format( ", ".join([ '"{}"'.format(key) for key in missing.keys() @@ -317,7 +317,7 @@ class FtrackModule( '"{}": "{}"'.format(key, value) for key, value in failed.items() ]) - error_msg += " Failed to set: {}".format(joined_failed) + error_msg += "\nFailed to set: {}".format(joined_failed) raise SaveWarning(error_msg) def create_ftrack_session(self, **session_kwargs): From b01d541c59ebb9358aaa40855f4507fbd49ff03a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 12:08:36 +0200 Subject: [PATCH 49/73] fixed save warning catch --- .../tools/settings/settings/widgets/categories.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/tools/settings/settings/widgets/categories.py b/openpype/tools/settings/settings/widgets/categories.py index d782aaf54f..be31a063fe 100644 --- a/openpype/tools/settings/settings/widgets/categories.py +++ b/openpype/tools/settings/settings/widgets/categories.py @@ -273,14 +273,21 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self.reset() except SaveWarning as exc: - msg = "Settings were saved but few issues happened.\n\n" - msg += "\n".join(exc.warnings) + warnings = [ + "Settings were saved but few issues happened." + ] + for item in exc.warnings: + warnings.append(item.replace("\n", "
")) + + msg = "

".join(warnings) dialog = QtWidgets.QMessageBox(self) dialog.setText(msg) dialog.setIcon(QtWidgets.QMessageBox.Warning) dialog.exec_() + self.reset() + except Exception as exc: formatted_traceback = traceback.format_exception(*sys.exc_info()) dialog = QtWidgets.QMessageBox(self) From 4358aace7ef8a8a0f6e5909aa48078405849f1b2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 12:09:11 +0200 Subject: [PATCH 50/73] ProjectSettings will try to store project settings if anatomy crashes --- openpype/settings/entities/root_entities.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index eed3d47f46..05c2b61700 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -23,6 +23,7 @@ from openpype.settings.constants import ( PROJECT_ANATOMY_KEY, KEY_REGEX ) +from openpype.settings.exceptions import SaveWarning from openpype.settings.lib import ( DEFAULTS_DIR, @@ -724,8 +725,19 @@ class ProjectSettings(RootEntity): project_settings = settings_value.get(PROJECT_SETTINGS_KEY) or {} project_anatomy = settings_value.get(PROJECT_ANATOMY_KEY) or {} - save_project_settings(self.project_name, project_settings) - save_project_anatomy(self.project_name, project_anatomy) + warnings = [] + try: + save_project_settings(self.project_name, project_settings) + except SaveWarning as exc: + warnings.extend(exc.warnings) + + try: + save_project_anatomy(self.project_name, project_anatomy) + except SaveWarning as exc: + warnings.extend(exc.warnings) + + if warnings: + raise SaveWarning(warnings) def _validate_defaults_to_save(self, value): """Valiations of default values before save.""" From 4ba8eb7610e3b95d352e8430222f65bf9b2f6d71 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 12:09:33 +0200 Subject: [PATCH 51/73] defined max length of exc info marks --- openpype/lib/log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/lib/log.py b/openpype/lib/log.py index 9745279e28..39b6c67080 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -123,6 +123,8 @@ class PypeFormatter(logging.Formatter): if record.exc_info is not None: line_len = len(str(record.exc_info[1])) + if line_len > 30: + line_len = 30 out = "{}\n{}\n{}\n{}\n{}".format( out, line_len * "=", From 1418d9734ac5029ccc68ebb328a69b918bae295b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 12:15:19 +0200 Subject: [PATCH 52/73] added more messages --- openpype/modules/ftrack/ftrack_module.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index c7ce7d6d07..54e92af42c 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -246,8 +246,8 @@ class FtrackModule( if not project_entity: msg = ( - "Ftrack project with names \"{}\" was not found in Ftrack." - " Skipping settings attributes change callback." + "Ftrack project with name \"{}\" was not found in Ftrack." + " Can't push attribute changes." ).format(project_name) self.log.warning(msg) raise SaveWarning(msg) @@ -265,6 +265,9 @@ class FtrackModule( if not configuration: configuration = cust_attr_by_key.get(key) if not configuration: + self.log.warning( + "Custom attribute \"{}\" was not found.".format(key) + ) missing[key] = value continue @@ -304,6 +307,8 @@ class FtrackModule( error_msg = ( "Values were not updated on Ftrack which may cause issues." + " Try to update OpenPype custom attributes and resave" + " project settings." ) if missing: error_msg += "\nMissing Custom attributes on Ftrack: {}.".format( From bfb0874f0399a2e67f8cb389ddea1404329cd53a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 13:35:04 +0200 Subject: [PATCH 53/73] renamed SaveWarning to SaveWarningExc --- openpype/modules/ftrack/ftrack_module.py | 12 +++++------ openpype/settings/__init__.py | 4 ++-- openpype/settings/entities/root_entities.py | 8 ++++---- openpype/settings/exceptions.py | 4 ++-- openpype/settings/lib.py | 20 +++++++++---------- .../settings/settings/widgets/categories.py | 4 ++-- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 54e92af42c..60eed5c941 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -13,7 +13,7 @@ from openpype.modules import ( ILaunchHookPaths, ISettingsChangeListener ) -from openpype.settings import SaveWarning +from openpype.settings import SaveWarningExc FTRACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -136,7 +136,7 @@ class FtrackModule( session = self.create_ftrack_session() except Exception: self.log.warning("Couldn't create ftrack session.", exc_info=True) - raise SaveWarning(( + raise SaveWarningExc(( "Couldn't create Ftrack session." " You may need to update applications" " and tools in Ftrack custom attributes using defined action." @@ -205,7 +205,7 @@ class FtrackModule( session.commit() if missing_attributes: - raise SaveWarning(( + raise SaveWarningExc(( "Couldn't find custom attribute/s ({}) to update." " You may need to update applications" " and tools in Ftrack custom attributes using defined action." @@ -234,7 +234,7 @@ class FtrackModule( session = self.create_ftrack_session() except Exception: self.log.warning("Couldn't create ftrack session.", exc_info=True) - raise SaveWarning(( + raise SaveWarningExc(( "Couldn't create Ftrack session." " You may need to update applications" " and tools in Ftrack custom attributes using defined action." @@ -250,7 +250,7 @@ class FtrackModule( " Can't push attribute changes." ).format(project_name) self.log.warning(msg) - raise SaveWarning(msg) + raise SaveWarningExc(msg) project_id = project_entity["id"] @@ -323,7 +323,7 @@ class FtrackModule( for key, value in failed.items() ]) error_msg += "\nFailed to set: {}".format(joined_failed) - raise SaveWarning(error_msg) + raise SaveWarningExc(error_msg) def create_ftrack_session(self, **session_kwargs): import ftrack_api diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index 78a287f07e..e65c89e603 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -1,5 +1,5 @@ from .exceptions import ( - SaveWarning + SaveWarningExc ) from .lib import ( get_system_settings, @@ -15,7 +15,7 @@ from .entities import ( __all__ = ( - "SaveWarning", + "SaveWarningExc", "get_system_settings", "get_project_settings", diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 05c2b61700..b89473d9fb 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -23,7 +23,7 @@ from openpype.settings.constants import ( PROJECT_ANATOMY_KEY, KEY_REGEX ) -from openpype.settings.exceptions import SaveWarning +from openpype.settings.exceptions import SaveWarningExc from openpype.settings.lib import ( DEFAULTS_DIR, @@ -728,16 +728,16 @@ class ProjectSettings(RootEntity): warnings = [] try: save_project_settings(self.project_name, project_settings) - except SaveWarning as exc: + except SaveWarningExc as exc: warnings.extend(exc.warnings) try: save_project_anatomy(self.project_name, project_anatomy) - except SaveWarning as exc: + except SaveWarningExc as exc: warnings.extend(exc.warnings) if warnings: - raise SaveWarning(warnings) + raise SaveWarningExc(warnings) def _validate_defaults_to_save(self, value): """Valiations of default values before save.""" diff --git a/openpype/settings/exceptions.py b/openpype/settings/exceptions.py index 758a778794..a06138eeaf 100644 --- a/openpype/settings/exceptions.py +++ b/openpype/settings/exceptions.py @@ -2,10 +2,10 @@ class SaveSettingsValidation(Exception): pass -class SaveWarning(SaveSettingsValidation): +class SaveWarningExc(SaveSettingsValidation): def __init__(self, warnings): if isinstance(warnings, str): warnings = [warnings] self.warnings = warnings msg = " | ".join(warnings) - super(SaveWarning, self).__init__(msg) + super(SaveWarningExc, self).__init__(msg) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index c4ed9453f1..9c05c8e86c 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -5,7 +5,7 @@ import logging import platform import copy from .exceptions import ( - SaveWarning + SaveWarningExc ) from .constants import ( M_OVERRIDEN_KEY, @@ -111,7 +111,7 @@ def save_studio_settings(data): data(dict): Overrides data with metadata defying studio overrides. Raises: - SaveWarning: If any module raises the exception. + SaveWarningExc: If any module raises the exception. """ # Notify Pype modules from openpype.modules import ModulesManager, ISettingsChangeListener @@ -129,12 +129,12 @@ def save_studio_settings(data): if isinstance(module, ISettingsChangeListener): try: module.on_system_settings_save(old_data, new_data, changes) - except SaveWarning as exc: + except SaveWarningExc as exc: warnings.extend(exc.warnings) _SETTINGS_HANDLER.save_studio_settings(data) if warnings: - raise SaveWarning(warnings) + raise SaveWarningExc(warnings) @require_handler @@ -155,7 +155,7 @@ def save_project_settings(project_name, overrides): overrides(dict): Overrides data with metadata defying studio overrides. Raises: - SaveWarning: If any module raises the exception. + SaveWarningExc: If any module raises the exception. """ # Notify Pype modules from openpype.modules import ModulesManager, ISettingsChangeListener @@ -184,13 +184,13 @@ def save_project_settings(project_name, overrides): module.on_project_settings_save( old_data, new_data, project_name, changes ) - except SaveWarning as exc: + except SaveWarningExc as exc: warnings.extend(exc.warnings) _SETTINGS_HANDLER.save_project_settings(project_name, overrides) if warnings: - raise SaveWarning(warnings) + raise SaveWarningExc(warnings) @require_handler @@ -211,7 +211,7 @@ def save_project_anatomy(project_name, anatomy_data): overrides(dict): Overrides data with metadata defying studio overrides. Raises: - SaveWarning: If any module raises the exception. + SaveWarningExc: If any module raises the exception. """ # Notify Pype modules from openpype.modules import ModulesManager, ISettingsChangeListener @@ -240,13 +240,13 @@ def save_project_anatomy(project_name, anatomy_data): module.on_project_anatomy_save( old_data, new_data, changes, project_name ) - except SaveWarning as exc: + except SaveWarningExc as exc: warnings.extend(exc.warnings) _SETTINGS_HANDLER.save_project_anatomy(project_name, anatomy_data) if warnings: - raise SaveWarning(warnings) + raise SaveWarningExc(warnings) @require_handler diff --git a/openpype/tools/settings/settings/widgets/categories.py b/openpype/tools/settings/settings/widgets/categories.py index be31a063fe..e4832c989a 100644 --- a/openpype/tools/settings/settings/widgets/categories.py +++ b/openpype/tools/settings/settings/widgets/categories.py @@ -27,7 +27,7 @@ from openpype.settings.entities import ( SchemaError ) -from openpype.settings import SaveWarning +from openpype.settings import SaveWarningExc from .widgets import ProjectListWidget from . import lib @@ -272,7 +272,7 @@ class SettingsCategoryWidget(QtWidgets.QWidget): # not required. self.reset() - except SaveWarning as exc: + except SaveWarningExc as exc: warnings = [ "Settings were saved but few issues happened." ] From e918acea48755bab9b4744cfe6ecb8dd8e67f08c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 15:13:33 +0200 Subject: [PATCH 54/73] SyncServer GUI - working version for Summary list --- openpype/modules/sync_server/tray/models.py | 14 +- openpype/modules/sync_server/tray/widgets.py | 172 ++++++++++--------- 2 files changed, 95 insertions(+), 91 deletions(-) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 4b70fbae15..266a9289ca 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -64,20 +64,20 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): def rowCount(self, _index): return len(self._data) - def columnCount(self, _index): + def columnCount(self, _index=None): return len(self._header) - def headerData(self, section, orientation, role): + def headerData(self, section, orientation, role=Qt.DisplayRole): name = self.COLUMN_LABELS[section][0] if role == Qt.DisplayRole: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][1] - if role == Qt.DecorationRole: - if name in self.column_filtering.keys(): - return qtawesome.icon("fa.filter", color="white") - if self.COLUMN_FILTERS.get(name): - return qtawesome.icon("fa.filter", color="gray") + # if role == Qt.DecorationRole: + # if name in self.column_filtering.keys(): + # return qtawesome.icon("fa.filter", color="white") + # if self.COLUMN_FILTERS.get(name): + # return qtawesome.icon("fa.filter", color="gray") if role == lib.HeaderNameRole: if orientation == Qt.Horizontal: diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index f9f904cd03..25abc73a70 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -42,6 +42,8 @@ class SyncProjectListWidget(ProjectListWidget): self.local_site = None self.icons = {} + self.layout().setContentsMargins(0, 0, 0, 0) + def validate_context_change(self): return True @@ -143,12 +145,12 @@ class SyncRepresentationWidget(QtWidgets.QWidget): message_generated = QtCore.Signal(str) default_widths = ( - ("asset", 200), + ("asset", 190), ("subset", 170), ("version", 60), - ("representation", 135), - ("local_site", 170), - ("remote_site", 170), + ("representation", 145), + ("local_site", 160), + ("remote_site", 160), ("files_count", 50), ("files_size", 60), ("priority", 70), @@ -215,10 +217,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) - self.horizontal_header = HorizontalHeader(self) - self.table_view.setHorizontalHeader(self.horizontal_header) - # self.table_view.setSortingEnabled(True) - # self.table_view.horizontalHeader().setSortIndicatorShown(True) + horizontal_header = HorizontalHeader(self) + + self.table_view.setHorizontalHeader(horizontal_header) + self.table_view.setSortingEnabled(True) for column_name, width in self.default_widths: idx = model.get_header_index(column_name) @@ -828,6 +830,21 @@ class SyncRepresentationErrorWindow(QtWidgets.QDialog): self.setWindowTitle("Sync Representation Error Detail") +class TransparentWidget(QtWidgets.QWidget): + clicked = QtCore.Signal(str) + + def __init__(self, column_name, *args, **kwargs): + super(TransparentWidget, self).__init__(*args, **kwargs) + self.column_name = column_name + # self.setStyleSheet("background: red;") + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.clicked.emit(self.column_name) + + super(TransparentWidget, self).mouseReleaseEvent(event) + + class HorizontalHeader(QtWidgets.QHeaderView): def __init__(self, parent=None): @@ -835,26 +852,23 @@ class HorizontalHeader(QtWidgets.QHeaderView): self._parent = parent self.checked_values = {} - self.setSectionsMovable(True) + self.setModel(self._parent.model) + self.setSectionsClickable(True) - self.setHighlightSections(True) self.menu_items_dict = {} self.menu = None self.header_cells = [] self.filter_buttons = {} - self.init_layout() - self.filter_icon = qtawesome.icon("fa.filter", color="gray") self.filter_set_icon = qtawesome.icon("fa.filter", color="white") + self.init_layout() + self._resetting = False - self.sectionResized.connect(self.handleSectionResized) - self.sectionMoved.connect(self.handleSectionMoved) - #self.sectionPressed.connect(self.model.sort) - + self.sectionClicked.connect(self.on_section_clicked) @property def model(self): @@ -862,38 +876,30 @@ class HorizontalHeader(QtWidgets.QHeaderView): return self._parent.model def init_layout(self): - for i in range(self.count()): - cell_content = QtWidgets.QWidget(self) - column_name, column_label = self.model.get_column(i) - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 5, 5, 0) - layout.setAlignment(Qt.AlignVCenter) - layout.addWidget(QtWidgets.QLabel(column_label)) - + for column_idx in range(self.model.columnCount()): + column_name, column_label = self.model.get_column(column_idx) filter_rec = self.model.get_filters().get(column_name) - if filter_rec: - icon = self.filter_icon - button = QtWidgets.QPushButton(icon, "") - layout.addWidget(button) + if not filter_rec: + continue - # button.setMenu(menu) - button.setFixedSize(24, 24) - # button.setAlignment(Qt.AlignRight) - button.setStyleSheet("QPushButton::menu-indicator{width:0px;}" - "QPushButton{border: none}") - button.clicked.connect(partial(self._get_menu, - column_name, i)) - button.setFlat(True) - self.filter_buttons[column_name] = button + icon = self.filter_icon + button = QtWidgets.QPushButton(icon, "", self) - cell_content.setLayout(layout) + # button.setMenu(menu) + button.setFixedSize(24, 24) + # button.setAlignment(Qt.AlignRight) + button.setStyleSheet("QPushButton::menu-indicator{width:0px;}" + "QPushButton{border: none;background: transparent;}") + button.clicked.connect(partial(self._get_menu, + column_name, column_idx)) + button.setFlat(True) + self.filter_buttons[column_name] = button - self.header_cells.append(cell_content) + def on_section_clicked(self, column_name): + print("on_section_clicked {}".format(column_name)) def showEvent(self, event): - if not self.header_cells: - self.init_layout() + super(HorizontalHeader, self).showEvent(event) for i in range(len(self.header_cells)): cell_content = self.header_cells[i] @@ -902,12 +908,6 @@ class HorizontalHeader(QtWidgets.QHeaderView): cell_content.show() - if len(self.model.get_filters()) > self.count(): - for i in range(self.count(), len(self.header_cells)): - self.header_cells[i].deleteLater() - - super(HorizontalHeader, self).showEvent(event) - def _set_filter_icon(self, column_name): button = self.filter_buttons.get(column_name) if button: @@ -939,18 +939,18 @@ class HorizontalHeader(QtWidgets.QHeaderView): self._set_filter_icon(column_name) self._filter_and_refresh_model_and_menu(column_name, True, False) - def _apply_text_filter(self, column_name, items): + def _apply_text_filter(self, column_name, items, line_edit): """ Resets all checkboxes, prefers inserted text. """ + le_text = line_edit.text() self._update_checked_values(column_name, items, 0) # reset other if self.checked_values.get(column_name) is not None or \ - self.line_edit.text() == '': + le_text == '': self.checked_values.pop(column_name) # reset during typing - text_item = {self.line_edit.text(): self.line_edit.text()} - if self.line_edit.text(): - self._update_checked_values(column_name, text_item, 2) + if le_text: + self._update_checked_values(column_name, {le_text: le_text}, 2) self._set_filter_icon(column_name) self._filter_and_refresh_model_and_menu(column_name, True, True) @@ -970,24 +970,23 @@ class HorizontalHeader(QtWidgets.QHeaderView): menu = QtWidgets.QMenu(self) filter_rec = self.model.get_filters()[column_name] self.menu_items_dict[column_name] = filter_rec.values() - self.line_edit = None # text filtering only if labels same as values, not if codes are used if 'text' in filter_rec.search_variants(): - self.line_edit = QtWidgets.QLineEdit(self) - self.line_edit.setSizePolicy( - QtWidgets.QSizePolicy.Maximum, - QtWidgets.QSizePolicy.Maximum) + line_edit = QtWidgets.QLineEdit(menu) + line_edit.setClearButtonEnabled(True) + + line_edit.setFixedHeight(line_edit.height()) txt = "Type..." if self.checked_values.get(column_name): txt = list(self.checked_values.get(column_name).keys())[0] - self.line_edit.setPlaceholderText(txt) + line_edit.setPlaceholderText(txt) action_le = QtWidgets.QWidgetAction(menu) - action_le.setDefaultWidget(self.line_edit) - self.line_edit.textChanged.connect( + action_le.setDefaultWidget(line_edit) + line_edit.textChanged.connect( partial(self._apply_text_filter, column_name, - filter_rec.values())) + filter_rec.values(), line_edit)) menu.addAction(action_le) menu.addSeparator() @@ -1076,27 +1075,32 @@ class HorizontalHeader(QtWidgets.QHeaderView): self.checked_values[column_name] = checked - def handleSectionResized(self, i): - if not self.header_cells: - self.init_layout() - for i in range(self.count()): - j = self.visualIndex(i) - logical = self.logicalIndex(j) - self.header_cells[i].setGeometry( - self.sectionViewportPosition(logical), 0, - self.sectionSize(logical) - 1, self.height()) + def paintEvent(self, event): + self._fix_size() + super(HorizontalHeader, self).paintEvent(event) - def handleSectionMoved(self, i, oldVisualIndex, newVisualIndex): - if not self.header_cells: - self.init_layout() - for i in range(min(oldVisualIndex, newVisualIndex), self.count()): - logical = self.logicalIndex(i) - self.header_cells[i].setGeometry( - self.ectionViewportPosition(logical), 0, - self.sectionSize(logical) - 2, self.height()) + def _fix_size(self): + for column_idx in range(self.count()): + vis_index = self.visualIndex(column_idx) + index = self.logicalIndex(vis_index) + section_width = self.sectionSize(index) + + column_name = self.model.headerData(column_idx, + QtCore.Qt.Horizontal, + lib.HeaderNameRole) + button = self.filter_buttons.get(column_name) + if not button: + continue + + pos_x = self.sectionViewportPosition( + index) + section_width - self.height() + + pos_y = 0 + if button.height() < self.height(): + pos_y = int((self.height() - button.height()) / 2) + button.setGeometry( + pos_x, + pos_y, + self.height(), + self.height()) - def fixComboPositions(self): - for i in range(self.count()): - self.header_cells[i].setGeometry( - self.sectionViewportPosition(i), 0, - self.sectionSize(i) - 2, self.height()) From 981443b5c3a789dba845703ea2a512707753db4e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 15:15:59 +0200 Subject: [PATCH 55/73] removed unused imports --- openpype/modules/ftrack/lib/avalon_sync.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index cfe1f011e7..f58e858a5a 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -14,24 +14,21 @@ else: from avalon.api import AvalonMongoDB import avalon + from openpype.api import ( Logger, Anatomy, get_anatomy_settings ) +from openpype.lib import ApplicationManager + +from .constants import CUST_ATTR_ID_KEY +from .custom_attributes import get_openpype_attr from bson.objectid import ObjectId from bson.errors import InvalidId from pymongo import UpdateOne import ftrack_api -from openpype.lib import ApplicationManager - -from .constants import ( - CUST_ATTR_ID_KEY, - CUST_ATTR_AUTO_SYNC, - CUST_ATTR_GROUP -) -from .custom_attributes import get_openpype_attr log = Logger.get_logger(__name__) From d9cbb6eea6cfc18e8ad7b4a2f2c7357f60a9e94b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 16:04:58 +0200 Subject: [PATCH 56/73] SyncServer GUI - working version for Detail list --- openpype/modules/sync_server/tray/models.py | 101 +++++++++++-------- openpype/modules/sync_server/tray/widgets.py | 63 ++++++------ 2 files changed, 90 insertions(+), 74 deletions(-) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 266a9289ca..d2dc3594c6 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -68,17 +68,14 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): return len(self._header) def headerData(self, section, orientation, role=Qt.DisplayRole): + if section >= len(self.COLUMN_LABELS): + return + name = self.COLUMN_LABELS[section][0] if role == Qt.DisplayRole: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][1] - # if role == Qt.DecorationRole: - # if name in self.column_filtering.keys(): - # return qtawesome.icon("fa.filter", color="white") - # if self.COLUMN_FILTERS.get(name): - # return qtawesome.icon("fa.filter", color="gray") - if role == lib.HeaderNameRole: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][0] # return name @@ -199,7 +196,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): representations = self.dbcon.aggregate(self.query) self.refresh(representations) - def set_filter(self, word_filter): + def set_word_filter(self, word_filter): """ Adds text value filtering @@ -209,6 +206,19 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self._word_filter = word_filter self.refresh() + def get_filters(self): + """ + Returns all available filter editors per column_name keys. + """ + filters = {} + for column_name, _ in self.COLUMN_LABELS: + filter_rec = self.COLUMN_FILTERS.get(column_name) + if filter_rec: + filter_rec.dbcon = self.dbcon + filters[column_name] = filter_rec + + return filters + def get_column_filter(self, index): """ Returns filter object for column 'index @@ -227,6 +237,25 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): return filter_rec + def set_column_filtering(self, checked_values): + """ + Sets dictionary used in '$match' part of MongoDB aggregate + + Args: + checked_values(dict): key:values ({'status':{1:"Foo",3:"Bar"}} + + Modifies: + self._column_filtering : {'status': {'$in': [1, 2, 3]}} + """ + filtering = {} + for column_name, dict_value in checked_values.items(): + column_f = self.COLUMN_FILTERS.get(column_name) + if not column_f: + continue + column_f.dbcon = self.dbcon + filtering[column_name] = column_f.prepare_match_part(dict_value) + + self._column_filtering = filtering def get_column_filter_values(self, index): """ @@ -400,19 +429,6 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self.timer.timeout.connect(self.tick) self.timer.start(self.REFRESH_SEC) - def get_filters(self): - """ - Returns all available filter editors per column_name keys. - """ - filters = {} - for column_name, _ in self.COLUMN_LABELS: - filter_rec = self.COLUMN_FILTERS.get(column_name) - if filter_rec: - filter_rec.dbcon = self.dbcon - filters[column_name] = filter_rec - - return filters - def data(self, index, role): item = self._data[index.row()] @@ -685,26 +701,6 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): return aggr - def set_column_filtering(self, checked_values): - """ - Sets dictionary used in '$match' part of MongoDB aggregate - - Args: - checked_values(dict): key:values ({'status':{1:"Foo",3:"Bar"}} - - Modifies: - self._column_filtering : {'status': {'$in': [1, 2, 3]}} - """ - filtering = {} - for column_name, dict_value in checked_values.items(): - column_f = self.COLUMN_FILTERS.get(column_name) - if not column_f: - continue - column_f.dbcon = self.dbcon - filtering[column_name] = column_f.prepare_match_part(dict_value) - - self._column_filtering = filtering - def get_match_part(self): """ Extend match part with word_filter if present. @@ -843,10 +839,15 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt "size", # remote progress - "context.asset", # priority TODO + "size", # priority TODO "status" # status ] + COLUMN_FILTERS = { + 'status': lib.PredefinedSetFilter('status', lib.STATUS), + 'file': lib.RegexTextFilter('file'), + } + refresh_started = QtCore.Signal() refresh_finished = QtCore.Signal() @@ -885,6 +886,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self._word_filter = None self._id = _id self._initialized = False + self._column_filtering = {} self.sync_server = sync_server # TODO think about admin mode @@ -1033,7 +1035,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): if limit == 0: limit = SyncRepresentationSummaryModel.PAGE_SIZE - return [ + aggr = [ {"$match": self.get_match_part()}, {"$unwind": "$files"}, {'$addFields': { @@ -1129,7 +1131,16 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): ]} ]}} }}, - {"$project": self.projection}, + {"$project": self.projection} + ] + + if self.column_filtering: + aggr.append( + {"$match": self.column_filtering} + ) + print(self.column_filtering) + + aggr.extend([ {"$sort": self.sort}, { '$facet': { @@ -1138,7 +1149,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): 'totalCount': [{'$count': 'count'}] } } - ] + ]) + + return aggr def get_match_part(self): """ diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 25abc73a70..e3d5d0fd12 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -203,7 +203,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): layout.addWidget(self.table_view) self.table_view.doubleClicked.connect(self._double_clicked) - self.filter.textChanged.connect(lambda: model.set_filter( + self.filter.textChanged.connect(lambda: model.set_word_filter( self.filter.text())) self.table_view.customContextMenuRequested.connect( self._on_context_menu) @@ -475,7 +475,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): ("local_site", 185), ("remote_site", 185), ("size", 60), - ("priority", 25), + ("priority", 60), ("status", 110) ) @@ -499,53 +499,58 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.filter) - self.table_view = QtWidgets.QTableView() + table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] model = SyncRepresentationDetailModel(sync_server, headers, _id, project) - self.table_view.setModel(model) - self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.table_view.setSelectionMode( + table_view.setModel(model) + table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + table_view.setSelectionMode( QtWidgets.QAbstractItemView.SingleSelection) - self.table_view.setSelectionBehavior( + table_view.setSelectionBehavior( QtWidgets.QTableView.SelectRows) - self.table_view.horizontalHeader().setSortIndicator(-1, - Qt.AscendingOrder) - self.table_view.setSortingEnabled(True) - self.table_view.horizontalHeader().setSortIndicatorShown(True) - self.table_view.setAlternatingRowColors(True) - self.table_view.verticalHeader().hide() + table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) + table_view.horizontalHeader().setSortIndicatorShown(True) + table_view.setAlternatingRowColors(True) + table_view.verticalHeader().hide() column = model.get_header_index("local_site") delegate = ImageDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) + table_view.setItemDelegateForColumn(column, delegate) column = model.get_header_index("remote_site") delegate = ImageDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) - - for column_name, width in self.default_widths: - idx = model.get_header_index(column_name) - self.table_view.setColumnWidth(idx, width) + table_view.setItemDelegateForColumn(column, delegate) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) - layout.addWidget(self.table_view) + layout.addWidget(table_view) - self.filter.textChanged.connect(lambda: model.set_filter( + self.model = model + + self.selection_model = table_view.selectionModel() + self.selection_model.selectionChanged.connect(self._selection_changed) + + horizontal_header = HorizontalHeader(self) + + table_view.setHorizontalHeader(horizontal_header) + table_view.setSortingEnabled(True) + + for column_name, width in self.default_widths: + idx = model.get_header_index(column_name) + table_view.setColumnWidth(idx, width) + + self.table_view = table_view + + self.filter.textChanged.connect(lambda: model.set_word_filter( self.filter.text())) - self.table_view.customContextMenuRequested.connect( - self._on_context_menu) + table_view.customContextMenuRequested.connect(self._on_context_menu) model.refresh_started.connect(self._save_scrollbar) model.refresh_finished.connect(self._set_scrollbar) model.modelReset.connect(self._set_selection) - self.model = model - - self.selection_model = self.table_view.selectionModel() - self.selection_model.selectionChanged.connect(self._selection_changed) def _selection_changed(self): index = self.selection_model.currentIndex() @@ -885,9 +890,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): icon = self.filter_icon button = QtWidgets.QPushButton(icon, "", self) - # button.setMenu(menu) button.setFixedSize(24, 24) - # button.setAlignment(Qt.AlignRight) button.setStyleSheet("QPushButton::menu-indicator{width:0px;}" "QPushButton{border: none;background: transparent;}") button.clicked.connect(partial(self._get_menu, @@ -1080,7 +1083,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): super(HorizontalHeader, self).paintEvent(event) def _fix_size(self): - for column_idx in range(self.count()): + for column_idx in range(self.model.columnCount()): vis_index = self.visualIndex(column_idx) index = self.logicalIndex(vis_index) section_width = self.sectionSize(index) From 7c53a6587d27f2849c7d40d9bd86c4f80d4c8a47 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 16:11:28 +0200 Subject: [PATCH 57/73] SyncServer GUI - renamed methods --- openpype/modules/sync_server/tray/models.py | 27 +++++++++------------ 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index d2dc3594c6..8a4bc53b25 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -119,7 +119,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self._rec_loaded = 0 if not representations: - self.query = self.get_default_query(load_records) + self.query = self.get_query(load_records) representations = self.dbcon.aggregate(self.query) self.add_page_records(self.local_site, self.remote_site, @@ -154,7 +154,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): log.debug("fetchMore") items_to_fetch = min(self._total_records - self._rec_loaded, self.PAGE_SIZE) - self.query = self.get_default_query(self._rec_loaded) + self.query = self.get_query(self._rec_loaded) representations = self.dbcon.aggregate(self.query) self.beginInsertRows(index, self._rec_loaded, @@ -187,7 +187,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): order = -1 self.sort = {self.SORT_BY_COLUMN[index]: order, '_id': 1} - self.query = self.get_default_query() + self.query = self.get_query() # import json # log.debug(json.dumps(self.query, indent=4).\ # replace('False', 'false').\ @@ -415,12 +415,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self.local_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) - self.projection = self.get_default_projection() - self.sort = self.DEFAULT_SORT - self.query = self.get_default_query() - self.default_query = list(self.get_default_query()) + self.query = self.get_query() + self.default_query = list(self.get_query()) representations = self.dbcon.aggregate(self.query) self.refresh(representations) @@ -544,7 +542,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._data.append(item) self._rec_loaded += 1 - def get_default_query(self, limit=0): + def get_query(self, limit=0): """ Returns basic aggregate query for main table. @@ -735,7 +733,8 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): return base_match - def get_default_projection(self): + @property + def projection(self): """ Projection part for aggregate query. @@ -896,10 +895,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self.sort = self.DEFAULT_SORT - # in case we would like to hide/show some columns - self.projection = self.get_default_projection() - - self.query = self.get_default_query() + self.query = self.get_query() representations = self.dbcon.aggregate(self.query) self.refresh(representations) @@ -1021,7 +1017,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self._data.append(item) self._rec_loaded += 1 - def get_default_query(self, limit=0): + def get_query(self, limit=0): """ Gets query that gets used when no extra sorting, filtering or projecting is needed. @@ -1174,7 +1170,8 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): '$or': [{'files.path': {'$regex': regex_str, '$options': 'i'}}] } - def get_default_projection(self): + @property + def projection(self): """ Projection part for aggregate query. From ef208e715de6e19e7c611388ba64895cb7835546 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 16:15:59 +0200 Subject: [PATCH 58/73] changed how config actions are registered --- openpype/launcher_actions.py | 30 ------------------------------ openpype/tools/launcher/actions.py | 23 ++++++++--------------- 2 files changed, 8 insertions(+), 45 deletions(-) delete mode 100644 openpype/launcher_actions.py diff --git a/openpype/launcher_actions.py b/openpype/launcher_actions.py deleted file mode 100644 index cf68dfb5c1..0000000000 --- a/openpype/launcher_actions.py +++ /dev/null @@ -1,30 +0,0 @@ -import os -import sys - -from avalon import api, pipeline - -PACKAGE_DIR = os.path.dirname(__file__) -PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins", "launcher") -ACTIONS_DIR = os.path.join(PLUGINS_DIR, "actions") - - -def register_launcher_actions(): - """Register specific actions which should be accessible in the launcher""" - - actions = [] - ext = ".py" - sys.path.append(ACTIONS_DIR) - - for f in os.listdir(ACTIONS_DIR): - file, extention = os.path.splitext(f) - if ext in extention: - module = __import__(file) - klass = getattr(module, file) - actions.append(klass) - - if actions is []: - return - - for action in actions: - print("Using launcher action from config @ '{}'".format(action.name)) - pipeline.register_plugin(api.Action, action) diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 6261fe91ca..f2308c0150 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -2,6 +2,7 @@ import os import importlib from avalon import api, lib, style +from openpype import PLUGINS_DIR from openpype.api import Logger, resources from openpype.lib import ( ApplictionExecutableNotFound, @@ -70,21 +71,6 @@ def register_default_actions(): api.register_plugin(api.Action, LoaderLibrary) -def register_config_actions(): - """Register actions from the configuration for Launcher""" - - module_name = os.environ["AVALON_CONFIG"] - config = importlib.import_module(module_name) - if not hasattr(config, "register_launcher_actions"): - print( - "Current configuration `%s` has no 'register_launcher_actions'" - % config.__name__ - ) - return - - config.register_launcher_actions() - - def register_actions_from_paths(paths): if not paths: return @@ -105,6 +91,13 @@ def register_actions_from_paths(paths): api.register_plugin_path(api.Action, path) + +def register_config_actions(): + """Register actions from the configuration for Launcher""" + + actions_dir = os.path.join(PLUGINS_DIR, "actions") + register_actions_from_paths([actions_dir]) + def register_environment_actions(): """Register actions from AVALON_ACTIONS for Launcher.""" From 9efe5d4f6e675de00b5688c2858e6f7c704efb8c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 16:16:30 +0200 Subject: [PATCH 59/73] SyncServer GUI - added icon, clean up --- openpype/modules/sync_server/tray/models.py | 1 - openpype/modules/sync_server/tray/widgets.py | 7 ++----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 8a4bc53b25..3ee372d27d 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -684,7 +684,6 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): aggr.append( {"$match": self.column_filtering} ) - print(self.column_filtering) aggr.extend( [{"$sort": self.sort}, diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index e3d5d0fd12..9771d656ff 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -873,8 +873,6 @@ class HorizontalHeader(QtWidgets.QHeaderView): self._resetting = False - self.sectionClicked.connect(self.on_section_clicked) - @property def model(self): """Keep model synchronized with parent widget""" @@ -898,9 +896,6 @@ class HorizontalHeader(QtWidgets.QHeaderView): button.setFlat(True) self.filter_buttons[column_name] = button - def on_section_clicked(self, column_name): - print("on_section_clicked {}".format(column_name)) - def showEvent(self, event): super(HorizontalHeader, self).showEvent(event) @@ -978,6 +973,8 @@ class HorizontalHeader(QtWidgets.QHeaderView): if 'text' in filter_rec.search_variants(): line_edit = QtWidgets.QLineEdit(menu) line_edit.setClearButtonEnabled(True) + line_edit.addAction(self.filter_icon, + QtWidgets.QLineEdit.LeadingPosition) line_edit.setFixedHeight(line_edit.height()) txt = "Type..." From db0f6dcefe6d9091b280f5fd422487f541a706ae Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 16:16:33 +0200 Subject: [PATCH 60/73] removed "default actions" and it's registration function --- openpype/modules/launcher_action.py | 1 - openpype/tools/launcher/actions.py | 63 +---------------------------- 2 files changed, 1 insertion(+), 63 deletions(-) diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index da0468d495..5ed8585b6a 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -22,7 +22,6 @@ class LauncherAction(PypeModule, ITrayAction): # Register actions if self.tray_initialized: from openpype.tools.launcher import actions - # actions.register_default_actions() actions.register_config_actions() actions_paths = self.manager.collect_plugin_paths()["actions"] actions.register_actions_from_paths(actions_paths) diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index f2308c0150..eb89ad3d88 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -1,7 +1,6 @@ import os -import importlib -from avalon import api, lib, style +from avalon import api, style from openpype import PLUGINS_DIR from openpype.api import Logger, resources from openpype.lib import ( @@ -11,66 +10,6 @@ from openpype.lib import ( from Qt import QtWidgets, QtGui -class ProjectManagerAction(api.Action): - name = "projectmanager" - label = "Project Manager" - icon = "gear" - order = 999 # at the end - - def is_compatible(self, session): - return "AVALON_PROJECT" in session - - def process(self, session, **kwargs): - return lib.launch( - executable="python", - args=[ - "-u", "-m", "avalon.tools.projectmanager", - session['AVALON_PROJECT'] - ] - ) - - -class LoaderAction(api.Action): - name = "loader" - label = "Loader" - icon = "cloud-download" - order = 998 - - def is_compatible(self, session): - return "AVALON_PROJECT" in session - - def process(self, session, **kwargs): - return lib.launch( - executable="python", - args=[ - "-u", "-m", "avalon.tools.loader", session['AVALON_PROJECT'] - ] - ) - - -class LoaderLibrary(api.Action): - name = "loader_os" - label = "Library Loader" - icon = "book" - order = 997 # at the end - - def is_compatible(self, session): - return True - - def process(self, session, **kwargs): - return lib.launch( - executable="python", - args=["-u", "-m", "avalon.tools.libraryloader"] - ) - - -def register_default_actions(): - """Register default actions for Launcher""" - api.register_plugin(api.Action, ProjectManagerAction) - api.register_plugin(api.Action, LoaderAction) - api.register_plugin(api.Action, LoaderLibrary) - - def register_actions_from_paths(paths): if not paths: return From ac0e2b34a96e53fac9851c3bf3da4b2e0df9fe33 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 21 Apr 2021 16:24:57 +0200 Subject: [PATCH 61/73] removed empty spaces --- openpype/tools/launcher/actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index eb89ad3d88..72c7aece72 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -30,7 +30,7 @@ def register_actions_from_paths(paths): api.register_plugin_path(api.Action, path) - + def register_config_actions(): """Register actions from the configuration for Launcher""" From a5441f153351480d1125317a4697cf7b836b270c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 21 Apr 2021 15:51:21 +0100 Subject: [PATCH 62/73] Added support for point caches --- .../plugins/create/create_pointcache.py | 35 +++ .../hosts/blender/plugins/load/load_abc.py | 247 ++++++++++++++++++ .../hosts/blender/plugins/load/load_model.py | 237 ----------------- .../blender/plugins/publish/extract_abc.py | 2 +- 4 files changed, 283 insertions(+), 238 deletions(-) create mode 100644 openpype/hosts/blender/plugins/create/create_pointcache.py create mode 100644 openpype/hosts/blender/plugins/load/load_abc.py diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py new file mode 100644 index 0000000000..03a468f82e --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -0,0 +1,35 @@ +"""Create a pointcache asset.""" + +import bpy + +from avalon import api +from avalon.blender import lib +import openpype.hosts.blender.api.plugin + + +class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): + """Polygonal static geometry""" + + name = "pointcacheMain" + label = "Point Cache" + family = "pointcache" + icon = "gears" + + def process(self): + + asset = self.data["asset"] + subset = self.data["subset"] + name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) + collection = bpy.data.collections.new(name=name) + bpy.context.scene.collection.children.link(collection) + self.data['task'] = api.Session.get('AVALON_TASK') + lib.imprint(collection, self.data) + + if (self.options or {}).get("useSelection"): + objects = lib.get_selection() + for obj in objects: + collection.objects.link(obj) + if obj.type == 'EMPTY': + objects.extend(obj.children) + + return collection diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py new file mode 100644 index 0000000000..9e20ccabbc --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -0,0 +1,247 @@ +"""Load an asset in Blender from an Alembic file.""" + +import logging +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +from avalon import api, blender +import bpy +import openpype.hosts.blender.api.plugin as plugin + + +class CacheModelLoader(plugin.AssetLoader): + """Load cache models. + + Stores the imported asset in a collection named after the asset. + + Note: + At least for now it only supports Alembic files. + """ + + families = ["model", "pointcache"] + representations = ["abc"] + + label = "Link Alembic" + icon = "code-fork" + color = "orange" + + def _remove(self, objects, container): + for obj in list(objects): + if obj.type == 'MESH': + bpy.data.meshes.remove(obj.data) + elif obj.type == 'EMPTY': + bpy.data.objects.remove(obj) + + bpy.data.collections.remove(container) + + def _process(self, libpath, container_name, parent_collection): + bpy.ops.object.select_all(action='DESELECT') + + view_layer = bpy.context.view_layer + view_layer_collection = view_layer.active_layer_collection.collection + + relative = bpy.context.preferences.filepaths.use_relative_paths + bpy.ops.wm.alembic_import( + filepath=libpath, + relative_path=relative + ) + + parent = parent_collection + + if parent is None: + parent = bpy.context.scene.collection + + model_container = bpy.data.collections.new(container_name) + parent.children.link(model_container) + for obj in bpy.context.selected_objects: + model_container.objects.link(obj) + view_layer_collection.objects.unlink(obj) + + name = obj.name + obj.name = f"{name}:{container_name}" + + # Groups are imported as Empty objects in Blender + if obj.type == 'MESH': + data_name = obj.data.name + obj.data.name = f"{data_name}:{container_name}" + + if not obj.get(blender.pipeline.AVALON_PROPERTY): + obj[blender.pipeline.AVALON_PROPERTY] = dict() + + avalon_info = obj[blender.pipeline.AVALON_PROPERTY] + avalon_info.update({"container_name": container_name}) + + bpy.ops.object.select_all(action='DESELECT') + + return model_container + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + lib_container = plugin.asset_name( + asset, subset + ) + unique_number = plugin.get_unique_number( + asset, subset + ) + namespace = namespace or f"{asset}_{unique_number}" + container_name = plugin.asset_name( + asset, subset, unique_number + ) + + container = bpy.data.collections.new(lib_container) + container.name = container_name + blender.pipeline.containerise_existing( + container, + name, + namespace, + context, + self.__class__.__name__, + ) + + container_metadata = container.get( + blender.pipeline.AVALON_PROPERTY) + + container_metadata["libpath"] = libpath + container_metadata["lib_container"] = lib_container + + obj_container = self._process( + libpath, container_name, None) + + container_metadata["obj_container"] = obj_container + + # Save the list of objects in the metadata container + container_metadata["objects"] = obj_container.all_objects + + nodes = list(container.objects) + nodes.append(container) + self[:] = nodes + return nodes + + def update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all objects of the current collection, load the new + ones and add them to the collection. + If the objects of the collection are used in another collection they + will not be removed, only unlinked. Normally this should not be the + case though. + + Warning: + No nested collections are supported at the moment! + """ + collection = bpy.data.collections.get( + container["objectName"] + ) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert collection, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert not (collection.children), ( + "Nested collections are not supported." + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + collection_metadata = collection.get( + blender.pipeline.AVALON_PROPERTY) + collection_libpath = collection_metadata["libpath"] + + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + container_name = obj_container.name + + normalized_collection_libpath = ( + str(Path(bpy.path.abspath(collection_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_collection_libpath, + normalized_libpath, + ) + if normalized_collection_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + parent = plugin.get_parent_collection(obj_container) + + self._remove(objects, obj_container) + + obj_container = self._process( + str(libpath), container_name, parent) + + collection_metadata["obj_container"] = obj_container + collection_metadata["objects"] = obj_container.all_objects + collection_metadata["libpath"] = str(libpath) + collection_metadata["representation"] = str(representation["_id"]) + + def remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + + Warning: + No nested collections are supported at the moment! + """ + collection = bpy.data.collections.get( + container["objectName"] + ) + if not collection: + return False + assert not (collection.children), ( + "Nested collections are not supported." + ) + + collection_metadata = collection.get( + blender.pipeline.AVALON_PROPERTY) + + obj_container = plugin.get_local_collection_with_name( + collection_metadata["obj_container"].name + ) + objects = obj_container.all_objects + + self._remove(objects, obj_container) + + bpy.data.collections.remove(collection) + + return True \ No newline at end of file diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 168bdf9321..d645bedfcc 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -242,240 +242,3 @@ class BlendModelLoader(plugin.AssetLoader): bpy.data.collections.remove(collection) return True - - -class CacheModelLoader(plugin.AssetLoader): - """Load cache models. - - Stores the imported asset in a collection named after the asset. - - Note: - At least for now it only supports Alembic files. - """ - - families = ["model"] - representations = ["abc"] - - label = "Link Model" - icon = "code-fork" - color = "orange" - - def _remove(self, objects, container): - for obj in list(objects): - if obj.type == 'MESH': - bpy.data.meshes.remove(obj.data) - elif obj.type == 'EMPTY': - bpy.data.objects.remove(obj) - - bpy.data.collections.remove(container) - - def _process(self, libpath, container_name, parent_collection): - bpy.ops.object.select_all(action='DESELECT') - - view_layer = bpy.context.view_layer - view_layer_collection = view_layer.active_layer_collection.collection - - relative = bpy.context.preferences.filepaths.use_relative_paths - bpy.ops.wm.alembic_import( - filepath=libpath, - relative_path=relative - ) - - parent = parent_collection - - if parent is None: - parent = bpy.context.scene.collection - - model_container = bpy.data.collections.new(container_name) - parent.children.link(model_container) - for obj in bpy.context.selected_objects: - model_container.objects.link(obj) - view_layer_collection.objects.unlink(obj) - - name = obj.name - obj.name = f"{name}:{container_name}" - - # Groups are imported as Empty objects in Blender - if obj.type == 'MESH': - data_name = obj.data.name - obj.data.name = f"{data_name}:{container_name}" - - if not obj.get(blender.pipeline.AVALON_PROPERTY): - obj[blender.pipeline.AVALON_PROPERTY] = dict() - - avalon_info = obj[blender.pipeline.AVALON_PROPERTY] - avalon_info.update({"container_name": container_name}) - - bpy.ops.object.select_all(action='DESELECT') - - return model_container - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - - lib_container = plugin.asset_name( - asset, subset - ) - unique_number = plugin.get_unique_number( - asset, subset - ) - namespace = namespace or f"{asset}_{unique_number}" - container_name = plugin.asset_name( - asset, subset, unique_number - ) - - container = bpy.data.collections.new(lib_container) - container.name = container_name - blender.pipeline.containerise_existing( - container, - name, - namespace, - context, - self.__class__.__name__, - ) - - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) - - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container - - obj_container = self._process( - libpath, container_name, None) - - container_metadata["obj_container"] = obj_container - - # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects - - nodes = list(container.objects) - nodes.append(container) - self[:] = nodes - return nodes - - def update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all objects of the current collection, load the new - ones and add them to the collection. - If the objects of the collection are used in another collection they - will not be removed, only unlinked. Normally this should not be the - case though. - - Warning: - No nested collections are supported at the moment! - """ - collection = bpy.data.collections.get( - container["objectName"] - ) - libpath = Path(api.get_representation_path(representation)) - extension = libpath.suffix.lower() - - self.log.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert collection, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert not (collection.children), ( - "Nested collections are not supported." - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - collection_libpath = collection_metadata["libpath"] - - obj_container = plugin.get_local_collection_with_name( - collection_metadata["obj_container"].name - ) - objects = obj_container.all_objects - - container_name = obj_container.name - - normalized_collection_libpath = ( - str(Path(bpy.path.abspath(collection_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - self.log.debug( - "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_collection_libpath, - normalized_libpath, - ) - if normalized_collection_libpath == normalized_libpath: - self.log.info("Library already loaded, not updating...") - return - - parent = plugin.get_parent_collection(obj_container) - - self._remove(objects, obj_container) - - obj_container = self._process( - str(libpath), container_name, parent) - - collection_metadata["obj_container"] = obj_container - collection_metadata["objects"] = obj_container.all_objects - collection_metadata["libpath"] = str(libpath) - collection_metadata["representation"] = str(representation["_id"]) - - def remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - - Warning: - No nested collections are supported at the moment! - """ - collection = bpy.data.collections.get( - container["objectName"] - ) - if not collection: - return False - assert not (collection.children), ( - "Nested collections are not supported." - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - - obj_container = plugin.get_local_collection_with_name( - collection_metadata["obj_container"].name - ) - objects = obj_container.all_objects - - self._remove(objects, obj_container) - - bpy.data.collections.remove(collection) - - return True diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index a7653d9f5a..a6315908fc 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -11,7 +11,7 @@ class ExtractABC(openpype.api.Extractor): label = "Extract ABC" hosts = ["blender"] - families = ["model"] + families = ["model", "pointcache"] optional = True def process(self, instance): From 5d150a73f65bcbfbef2018a90b8e6d9418a5f71c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Wed, 21 Apr 2021 17:09:49 +0200 Subject: [PATCH 63/73] fix first frame for sequence --- openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index d0c6c4eb14..7c9e201986 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -74,6 +74,8 @@ class ExtractRedshiftProxy(openpype.api.Extractor): 'files': repr_files, "stagingDir": staging_dir, } + if anim_on: + representation["frameStart"] = instance.data["proxyFrameStart"] instance.data["representations"].append(representation) self.log.info("Extracted instance '%s' to: %s" From ee598d1521677d7d87635d6cc952452c3c64ebe1 Mon Sep 17 00:00:00 2001 From: jezscha Date: Wed, 21 Apr 2021 15:16:29 +0000 Subject: [PATCH 64/73] Create draft PR for #1391 From a8ac382c8a219a4941c6baebb3fc59f4cbb84d52 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 21 Apr 2021 17:17:54 +0200 Subject: [PATCH 65/73] Nuke: fixing menu and publish callback --- openpype/hosts/nuke/api/__init__.py | 2 +- openpype/hosts/nuke/api/menu.py | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index c80507e7ea..bd7a95f916 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -106,7 +106,7 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( instance, old_value, new_value)) - from avalon.api.nuke import ( + from avalon.nuke import ( viewer_update_and_undo_stop, add_publish_knob ) diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py index 2317066528..8434712138 100644 --- a/openpype/hosts/nuke/api/menu.py +++ b/openpype/hosts/nuke/api/menu.py @@ -26,9 +26,9 @@ def install(): menu.addCommand( name, workfiles.show, - index=(rm_item[0]) + index=2 ) - + menu.addSeparator(index=3) # replace reset resolution from avalon core to pype's name = "Reset Resolution" new_name = "Set Resolution" @@ -63,27 +63,27 @@ def install(): # add colorspace menu item name = "Set Colorspace" menu.addCommand( - name, lambda: WorkfileSettings().set_colorspace(), - index=(rm_item[0] + 2) + name, lambda: WorkfileSettings().set_colorspace() ) log.debug("Adding menu item: {}".format(name)) + # add item that applies all setting above + name = "Apply All Settings" + menu.addCommand( + name, + lambda: WorkfileSettings().set_context_settings() + ) + log.debug("Adding menu item: {}".format(name)) + + menu.addSeparator() + # add workfile builder menu item name = "Build Workfile" menu.addCommand( - name, lambda: BuildWorkfile().process(), - index=(rm_item[0] + 7) + name, lambda: BuildWorkfile().process() ) log.debug("Adding menu item: {}".format(name)) - # add item that applies all setting above - name = "Apply All Settings" - menu.addCommand( - name, - lambda: WorkfileSettings().set_context_settings(), - index=(rm_item[0] + 3) - ) - log.debug("Adding menu item: {}".format(name)) # adding shortcuts add_shortcuts_from_presets() From b40579a861752fb5736884e84038f834367dd145 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 21 Apr 2021 17:19:18 +0200 Subject: [PATCH 66/73] Hound: suggestion --- openpype/hosts/nuke/api/menu.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py index 8434712138..021ea04159 100644 --- a/openpype/hosts/nuke/api/menu.py +++ b/openpype/hosts/nuke/api/menu.py @@ -67,7 +67,7 @@ def install(): ) log.debug("Adding menu item: {}".format(name)) - # add item that applies all setting above + # add item that applies all setting above name = "Apply All Settings" menu.addCommand( name, From 88feba27243e7681e85bd14dc71990ae801a7655 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 21 Apr 2021 17:48:17 +0100 Subject: [PATCH 67/73] Hound fixes --- openpype/hosts/blender/plugins/load/load_abc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 9e20ccabbc..4248cffd69 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -1,6 +1,5 @@ """Load an asset in Blender from an Alembic file.""" -import logging from pathlib import Path from pprint import pformat from typing import Dict, List, Optional @@ -244,4 +243,4 @@ class CacheModelLoader(plugin.AssetLoader): bpy.data.collections.remove(collection) - return True \ No newline at end of file + return True From 6742fc89009642df555e20aefdaad736a55da637 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 21:42:10 +0200 Subject: [PATCH 68/73] Hound --- openpype/modules/sync_server/tray/models.py | 14 ++++++++------ openpype/modules/sync_server/tray/widgets.py | 12 +++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 3ee372d27d..8e97f5207d 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -6,7 +6,6 @@ from Qt import QtCore from Qt.QtCore import Qt from avalon.tools.delegates import pretty_timestamp -from avalon.vendor import qtawesome from openpype.lib import PypeLogger @@ -71,7 +70,6 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): if section >= len(self.COLUMN_LABELS): return - name = self.COLUMN_LABELS[section][0] if role == Qt.DisplayRole: if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][1] @@ -453,9 +451,11 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': - return item.status == lib.STATUS[2] and item.local_progress < 1 + return item.status == lib.STATUS[2] and \ + item.local_progress < 1 if header_value == 'remote_site': - return item.status == lib.STATUS[2] and item.remote_progress < 1 + return item.status == lib.STATUS[2] and \ + item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate @@ -928,9 +928,11 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': - return item.status == lib.STATUS[2] and item.local_progress < 1 + return item.status == lib.STATUS[2] and \ + item.local_progress < 1 if header_value == 'remote_site': - return item.status == lib.STATUS[2] and item.remote_progress < 1 + return item.status == lib.STATUS[2] and \ + item.remote_progress <1 if role == Qt.DisplayRole: # because of ImageDelegate diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 9771d656ff..e8b276580a 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -95,7 +95,6 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu() - #menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} if self.sync_server.is_project_paused(self.project_name): @@ -269,7 +268,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): format(self.representation_id)) menu = QtWidgets.QMenu() - menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -594,7 +592,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.item = self.model._data[point_index.row()] menu = QtWidgets.QMenu() - #menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -889,7 +886,8 @@ class HorizontalHeader(QtWidgets.QHeaderView): button = QtWidgets.QPushButton(icon, "", self) button.setFixedSize(24, 24) - button.setStyleSheet("QPushButton::menu-indicator{width:0px;}" + button.setStyleSheet( + "QPushButton::menu-indicator{width:0px;}" "QPushButton{border: none;background: transparent;}") button.clicked.connect(partial(self._get_menu, column_name, column_idx)) @@ -902,7 +900,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): for i in range(len(self.header_cells)): cell_content = self.header_cells[i] cell_content.setGeometry(self.sectionViewportPosition(i), 0, - self.sectionSize(i)-1, self.height()) + self.sectionSize(i) - 1, self.height()) cell_content.show() @@ -1064,8 +1062,8 @@ class HorizontalHeader(QtWidgets.QHeaderView): Modifies 'self.checked_values' """ - checked = self.checked_values.get(column_name, - dict(self.menu_items_dict[column_name])) + copy_menu_items = dict(self.menu_items_dict[column_name]) + checked = self.checked_values.get(column_name, copy_menu_items) set_items = dict(values.items()) # prevent dict change during loop for value, label in set_items.items(): if state == 2 and label: # checked From 498151db82eba4656f669c3c9dc562c5e1483765 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 21 Apr 2021 22:15:50 +0200 Subject: [PATCH 69/73] Hound --- openpype/modules/sync_server/tray/models.py | 8 ++++---- openpype/modules/sync_server/tray/widgets.py | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 8e97f5207d..dc2094825e 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -452,10 +452,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': return item.status == lib.STATUS[2] and \ - item.local_progress < 1 + item.local_progress < 1 if header_value == 'remote_site': return item.status == lib.STATUS[2] and \ - item.remote_progress < 1 + item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate @@ -929,10 +929,10 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): if role == lib.FailedRole: if header_value == 'local_site': return item.status == lib.STATUS[2] and \ - item.local_progress < 1 + item.local_progress < 1 if header_value == 'remote_site': return item.status == lib.STATUS[2] and \ - item.remote_progress <1 + item.remote_progress <1 if role == Qt.DisplayRole: # because of ImageDelegate diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index e8b276580a..2cdc99c671 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -1101,4 +1101,3 @@ class HorizontalHeader(QtWidgets.QHeaderView): pos_y, self.height(), self.height()) - From 8b00525f9d3da0efe8512513e75af01ed2ca44f3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 10:20:56 +0200 Subject: [PATCH 70/73] also pass new values with metadata --- openpype/modules/ftrack/ftrack_module.py | 8 +++++--- openpype/modules/settings_action.py | 8 +++++--- openpype/settings/lib.py | 19 ++++++++++++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 60eed5c941..848f4cea82 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -130,7 +130,9 @@ class FtrackModule( if self.tray_module: self.tray_module.changed_user() - def on_system_settings_save(self, old_value, new_value, changes): + def on_system_settings_save( + self, old_value, new_value, changes, new_value_metadata + ): """Implementation of ISettingsChangeListener interface.""" try: session = self.create_ftrack_session() @@ -169,7 +171,7 @@ class FtrackModule( elif key == CUST_ATTR_TOOLS: tool_attribute = custom_attribute - app_manager = ApplicationManager(new_value) + app_manager = ApplicationManager(new_value_metadata) missing_attributes = [] if not app_attribute: missing_attributes.append(CUST_ATTR_APPLICATIONS) @@ -217,7 +219,7 @@ class FtrackModule( return def on_project_anatomy_save( - self, old_value, new_value, changes, project_name + self, old_value, new_value, changes, project_name, new_value_metadata ): """Implementation of ISettingsChangeListener interface.""" if not project_name: diff --git a/openpype/modules/settings_action.py b/openpype/modules/settings_action.py index 371e190c12..3f7cb8c3ba 100644 --- a/openpype/modules/settings_action.py +++ b/openpype/modules/settings_action.py @@ -16,18 +16,20 @@ class ISettingsChangeListener: } """ @abstractmethod - def on_system_settings_save(self, old_value, new_value, changes): + def on_system_settings_save( + self, old_value, new_value, changes, new_value_metadata + ): pass @abstractmethod def on_project_settings_save( - self, old_value, new_value, changes, project_name + self, old_value, new_value, changes, project_name, new_value_metadata ): pass @abstractmethod def on_project_anatomy_save( - self, old_value, new_value, changes, project_name + self, old_value, new_value, changes, project_name, new_value_metadata ): pass diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 9c05c8e86c..f61166fa69 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -119,6 +119,7 @@ def save_studio_settings(data): old_data = get_system_settings() default_values = get_default_settings()[SYSTEM_SETTINGS_KEY] new_data = apply_overrides(default_values, copy.deepcopy(data)) + new_data_with_metadata = copy.deepcopy(new_data) clear_metadata_from_settings(new_data) changes = calculate_changes(old_data, new_data) @@ -128,7 +129,9 @@ def save_studio_settings(data): for module in modules_manager.get_enabled_modules(): if isinstance(module, ISettingsChangeListener): try: - module.on_system_settings_save(old_data, new_data, changes) + module.on_system_settings_save( + old_data, new_data, changes, new_data_with_metadata + ) except SaveWarningExc as exc: warnings.extend(exc.warnings) @@ -173,6 +176,7 @@ def save_project_settings(project_name, overrides): old_data = get_default_project_settings(exclude_locals=True) new_data = apply_overrides(default_values, copy.deepcopy(overrides)) + new_data_with_metadata = copy.deepcopy(new_data) clear_metadata_from_settings(new_data) changes = calculate_changes(old_data, new_data) @@ -182,7 +186,11 @@ def save_project_settings(project_name, overrides): if isinstance(module, ISettingsChangeListener): try: module.on_project_settings_save( - old_data, new_data, project_name, changes + old_data, + new_data, + project_name, + changes, + new_data_with_metadata ) except SaveWarningExc as exc: warnings.extend(exc.warnings) @@ -229,6 +237,7 @@ def save_project_anatomy(project_name, anatomy_data): old_data = get_default_anatomy_settings(exclude_locals=True) new_data = apply_overrides(default_values, copy.deepcopy(anatomy_data)) + new_data_with_metadata = copy.deepcopy(new_data) clear_metadata_from_settings(new_data) changes = calculate_changes(old_data, new_data) @@ -238,7 +247,11 @@ def save_project_anatomy(project_name, anatomy_data): if isinstance(module, ISettingsChangeListener): try: module.on_project_anatomy_save( - old_data, new_data, changes, project_name + old_data, + new_data, + changes, + project_name, + new_data_with_metadata ) except SaveWarningExc as exc: warnings.extend(exc.warnings) From 02447101bf4e83b2229279ebb602fdfe1a4a7474 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 22 Apr 2021 10:43:19 +0200 Subject: [PATCH 71/73] SyncServer GUI - small fixes in Status filtering Clear text in column filtering --- openpype/modules/sync_server/tray/lib.py | 5 +++- openpype/modules/sync_server/tray/models.py | 2 +- openpype/modules/sync_server/tray/widgets.py | 30 ++++++++++++-------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 41b0eb43f9..3597213b31 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -64,8 +64,11 @@ class PredefinedSetFilter(AbstractColumnFilter): def __init__(self, column_name, values): super().__init__(column_name) - self._search_variants = ['text', 'checkbox'] + self._search_variants = ['checkbox'] self._values = values + if self._values and \ + list(self._values.keys())[0] == list(self._values.values())[0]: + self._search_variants.append('text') def values(self): return {k: v for k, v in self._values.items()} diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index dc2094825e..981299c6cf 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -932,7 +932,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): item.local_progress < 1 if header_value == 'remote_site': return item.status == lib.STATUS[2] and \ - item.remote_progress <1 + item.remote_progress < 1 if role == Qt.DisplayRole: # because of ImageDelegate diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 2cdc99c671..6d8348becb 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -165,13 +165,16 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.representation_id = None self.site_name = None # to pause/unpause representation - self.filter = QtWidgets.QLineEdit() - self.filter.setPlaceholderText("Filter representations..") + self.txt_filter = QtWidgets.QLineEdit() + self.txt_filter.setPlaceholderText("Quick filter representations..") + self.txt_filter.setClearButtonEnabled(True) + self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"), + QtWidgets.QLineEdit.LeadingPosition) self._scrollbar_pos = None top_bar_layout = QtWidgets.QHBoxLayout() - top_bar_layout.addWidget(self.filter) + top_bar_layout.addWidget(self.txt_filter) self.table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] @@ -202,8 +205,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): layout.addWidget(self.table_view) self.table_view.doubleClicked.connect(self._double_clicked) - self.filter.textChanged.connect(lambda: model.set_word_filter( - self.filter.text())) + self.txt_filter.textChanged.connect(lambda: model.set_word_filter( + self.txt_filter.text())) self.table_view.customContextMenuRequested.connect( self._on_context_menu) @@ -489,13 +492,16 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self._selected_id = None - self.filter = QtWidgets.QLineEdit() - self.filter.setPlaceholderText("Filter representation..") + self.txt_filter = QtWidgets.QLineEdit() + self.txt_filter.setPlaceholderText("Quick filter representation..") + self.txt_filter.setClearButtonEnabled(True) + self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"), + QtWidgets.QLineEdit.LeadingPosition) self._scrollbar_pos = None top_bar_layout = QtWidgets.QHBoxLayout() - top_bar_layout.addWidget(self.filter) + top_bar_layout.addWidget(self.txt_filter) table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] @@ -542,8 +548,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view = table_view - self.filter.textChanged.connect(lambda: model.set_word_filter( - self.filter.text())) + self.txt_filter.textChanged.connect(lambda: model.set_word_filter( + self.txt_filter.text())) table_view.customContextMenuRequested.connect(self._on_context_menu) model.refresh_started.connect(self._save_scrollbar) @@ -975,10 +981,10 @@ class HorizontalHeader(QtWidgets.QHeaderView): QtWidgets.QLineEdit.LeadingPosition) line_edit.setFixedHeight(line_edit.height()) - txt = "Type..." + txt = "" if self.checked_values.get(column_name): txt = list(self.checked_values.get(column_name).keys())[0] - line_edit.setPlaceholderText(txt) + line_edit.setText(txt) action_le = QtWidgets.QWidgetAction(menu) action_le.setDefaultWidget(line_edit) From 94a0e79e44e0fafc4f555ba7b5210259edd2bef5 Mon Sep 17 00:00:00 2001 From: antirotor Date: Thu, 22 Apr 2021 13:45:17 +0000 Subject: [PATCH 72/73] Create draft PR for #1397 From 673d589653ca211c4185363e0dcad3858627e25b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 22 Apr 2021 16:08:29 +0200 Subject: [PATCH 73/73] specifically list history of VrayPluginNodeMtl --- openpype/hosts/maya/plugins/publish/collect_look.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index 238213c000..bf24b463ac 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -348,6 +348,13 @@ class CollectLook(pyblish.api.InstancePlugin): history = [] for material in materials: history.extend(cmds.listHistory(material)) + + # handle VrayPluginNodeMtl node - see #1397 + vray_plugin_nodes = cmds.ls( + history, type="VRayPluginNodeMtl", long=True) + for vray_node in vray_plugin_nodes: + history.extend(cmds.listHistory(vray_node)) + files = cmds.ls(history, type="file", long=True) files.extend(cmds.ls(history, type="aiImage", long=True))