diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py index 088a27566d..6d253300d9 100644 --- a/openpype/hosts/blender/hooks/pre_pyside_install.py +++ b/openpype/hosts/blender/hooks/pre_pyside_install.py @@ -1,4 +1,5 @@ import os +import re import subprocess from openpype.lib import PreLaunchHook @@ -31,10 +32,46 @@ class InstallPySideToBlender(PreLaunchHook): def inner_execute(self): # Get blender's python directory + version_regex = re.compile(r"^2\.[0-9]{2}$") + executable = self.launch_context.executable.executable_path - # Blender installation contain subfolder named with it's version where - # python binaries are stored. - version_subfolder = self.launch_context.app_name.split("_")[1] + if os.path.basename(executable).lower() != "blender.exe": + self.log.info(( + "Executable does not lead to blender.exe file. Can't determine" + " blender's python to check/install PySide2." + )) + return + + executable_dir = os.path.dirname(executable) + version_subfolders = [] + for name in os.listdir(executable_dir): + fullpath = os.path.join(name, executable_dir) + if not os.path.isdir(fullpath): + continue + + if not version_regex.match(name): + continue + + version_subfolders.append(name) + + if not version_subfolders: + self.log.info( + "Didn't find version subfolder next to Blender executable" + ) + return + + if len(version_subfolders) > 1: + self.log.info(( + "Found more than one version subfolder next" + " to blender executable. {}" + ).format(", ".join([ + '"./{}"'.format(name) + for name in version_subfolders + ]))) + return + + version_subfolder = version_subfolders[0] + pythond_dir = os.path.join( os.path.dirname(executable), version_subfolder, @@ -65,6 +102,7 @@ class InstallPySideToBlender(PreLaunchHook): # Check if PySide2 is installed and skip if yes if self.is_pyside_installed(python_executable): + self.log.debug("Blender has already installed PySide2.") return # Install PySide2 in blender's python diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py new file mode 100644 index 0000000000..a9279bf6e0 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -0,0 +1,162 @@ +import os + +from avalon import api, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline +import unreal + + +class PointCacheAlembicLoader(api.Loader): + """Load Point Cache from Alembic""" + + families = ["model", "pointcache"] + label = "Import Alembic Point Cache" + representations = ["abc"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + "{}/{}/{}".format(root, asset, name), suffix="") + + container_name += suffix + + unreal.EditorAssetLibrary.make_directory(asset_dir) + + task = unreal.AssetImportTask() + + task.set_editor_property('filename', self.fname) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + # set import options here + # Unreal 4.24 ignores the settings. It works with Unreal 4.26 + options = unreal.AbcImportSettings() + options.set_editor_property( + 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE) + + options.geometry_cache_settings.set_editor_property( + 'flatten_tracks', False) + + task.options = options + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + # Create Asset Container + lib.create_avalon_container( + container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + name = container["asset_name"] + source_path = api.get_representation_path(representation) + destination_path = container["namespace"] + + task = unreal.AssetImportTask() + + task.set_editor_property('filename', source_path) + task.set_editor_property('destination_path', destination_path) + # strip suffix + task.set_editor_property('destination_name', name) + task.set_editor_property('replace_existing', True) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + # set import options here + # Unreal 4.24 ignores the settings. It works with Unreal 4.26 + options = unreal.AbcImportSettings() + options.set_editor_property( + 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE) + + options.geometry_cache_settings.set_editor_property( + 'flatten_tracks', False) + + task.options = options + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + container_path = "{}/{}".format(container["namespace"], + container["objectName"]) + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = unreal.EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py new file mode 100644 index 0000000000..b652af0b89 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py @@ -0,0 +1,156 @@ +import os + +from avalon import api, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline +import unreal + + +class SkeletalMeshAlembicLoader(api.Loader): + """Load Unreal SkeletalMesh from Alembic""" + + families = ["pointcache"] + label = "Import Alembic Skeletal Mesh" + representations = ["abc"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + "{}/{}/{}".format(root, asset, name), suffix="") + + container_name += suffix + + unreal.EditorAssetLibrary.make_directory(asset_dir) + + task = unreal.AssetImportTask() + + task.set_editor_property('filename', self.fname) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + # set import options here + # Unreal 4.24 ignores the settings. It works with Unreal 4.26 + options = unreal.AbcImportSettings() + options.set_editor_property( + 'import_type', unreal.AlembicImportType.SKELETAL) + + task.options = options + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + # Create Asset Container + lib.create_avalon_container( + container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + name = container["asset_name"] + source_path = api.get_representation_path(representation) + destination_path = container["namespace"] + + task = unreal.AssetImportTask() + + task.set_editor_property('filename', source_path) + task.set_editor_property('destination_path', destination_path) + # strip suffix + task.set_editor_property('destination_name', name) + task.set_editor_property('replace_existing', True) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + # set import options here + # Unreal 4.24 ignores the settings. It works with Unreal 4.26 + options = unreal.AbcImportSettings() + options.set_editor_property( + 'import_type', unreal.AlembicImportType.SKELETAL) + + task.options = options + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + container_path = "{}/{}".format(container["namespace"], + container["objectName"]) + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = unreal.EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py new file mode 100644 index 0000000000..12b9320f72 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -0,0 +1,156 @@ +import os + +from avalon import api, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline +import unreal + + +class StaticMeshAlembicLoader(api.Loader): + """Load Unreal StaticMesh from Alembic""" + + families = ["model"] + label = "Import Alembic Static Mesh" + representations = ["abc"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset_dir, container_name = tools.create_unique_asset_name( + "{}/{}/{}".format(root, asset, name), suffix="") + + container_name += suffix + + unreal.EditorAssetLibrary.make_directory(asset_dir) + + task = unreal.AssetImportTask() + + task.set_editor_property('filename', self.fname) + task.set_editor_property('destination_path', asset_dir) + task.set_editor_property('destination_name', asset_name) + task.set_editor_property('replace_existing', False) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + # set import options here + # Unreal 4.24 ignores the settings. It works with Unreal 4.26 + options = unreal.AbcImportSettings() + options.set_editor_property( + 'import_type', unreal.AlembicImportType.STATIC_MESH) + + task.options = options + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 + + # Create Asset Container + lib.create_avalon_container( + container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + name = container["asset_name"] + source_path = api.get_representation_path(representation) + destination_path = container["namespace"] + + task = unreal.AssetImportTask() + + task.set_editor_property('filename', source_path) + task.set_editor_property('destination_path', destination_path) + # strip suffix + task.set_editor_property('destination_name', name) + task.set_editor_property('replace_existing', True) + task.set_editor_property('automated', True) + task.set_editor_property('save', True) + + # set import options here + # Unreal 4.24 ignores the settings. It works with Unreal 4.26 + options = unreal.AbcImportSettings() + options.set_editor_property( + 'import_type', unreal.AlembicImportType.STATIC_MESH) + + task.options = options + # do import fbx and replace existing data + unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) + container_path = "{}/{}".format(container["namespace"], + container["objectName"]) + # update metadata + unreal_pipeline.imprint( + container_path, + { + "representation": str(representation["_id"]), + "parent": str(representation["parent"]) + }) + + asset_content = unreal.EditorAssetLibrary.list_assets( + destination_path, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index dbea1d5951..dcb566fa4c 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -1,7 +1,6 @@ import os from avalon import api, pipeline -from avalon import unreal as avalon_unreal from avalon.unreal import lib from avalon.unreal import pipeline as unreal_pipeline import unreal diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 25fbf0e49a..d91ba76335 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -7,7 +7,7 @@ from openpype import resources from openpype.modules.sync_server.tray.widgets import ( SyncProjectListWidget, - SyncRepresentationWidget + SyncRepresentationSummaryWidget ) log = PypeLogger().get_logger("SyncServer") @@ -47,7 +47,7 @@ class SyncServerWindow(QtWidgets.QDialog): left_column_layout.addWidget(self.pause_btn) left_column.setLayout(left_column_layout) - repres = SyncRepresentationWidget( + repres = SyncRepresentationSummaryWidget( sync_server, project=self.projects.current_project, parent=self) diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 3597213b31..04bd1f568e 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -24,6 +24,7 @@ ProgressRole = QtCore.Qt.UserRole + 4 DateRole = QtCore.Qt.UserRole + 6 FailedRole = QtCore.Qt.UserRole + 8 HeaderNameRole = QtCore.Qt.UserRole + 10 +FullItemRole = QtCore.Qt.UserRole + 12 @six.add_metaclass(abc.ABCMeta) @@ -128,6 +129,7 @@ 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: @@ -156,3 +158,9 @@ def translate_provider_for_icon(sync_server, project, site): if site == sync_server.DEFAULT_SITE: return sync_server.DEFAULT_SITE return sync_server.get_provider_for_site(project, site) + + +def get_item_by_id(model, object_id): + index = model.get_index(object_id) + item = model.data(index, FullItemRole) + return item diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index 981299c6cf..ffd81a1588 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -120,7 +120,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self.query = self.get_query(load_records) representations = self.dbcon.aggregate(self.query) - self.add_page_records(self.local_site, self.remote_site, + self.add_page_records(self.active_site, self.remote_site, representations) self.endResetModel() self.refresh_finished.emit() @@ -158,7 +158,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): self._rec_loaded, self._rec_loaded + items_to_fetch - 1) - self.add_page_records(self.local_site, self.remote_site, + self.add_page_records(self.active_site, self.remote_site, representations) self.endInsertRows() @@ -283,7 +283,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): """ self._project = project self.sync_server.set_sync_project_settings() - self.local_site = self.sync_server.get_active_site(self.project) + self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) self.refresh() @@ -410,7 +410,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self.sync_server = sync_server # TODO think about admin mode # this is for regular user, always only single local and single remote - self.local_site = self.sync_server.get_active_site(self.project) + self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) self.sort = self.DEFAULT_SORT @@ -428,6 +428,9 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): def data(self, index, role): item = self._data[index.row()] + if role == lib.FullItemRole: + return item + header_value = self._header[index.column()] if role == lib.ProviderRole: if header_value == 'local_site': @@ -585,7 +588,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): }}, 'order_local': { '$filter': {'input': '$files.sites', 'as': 'p', - 'cond': {'$eq': ['$$p.name', self.local_site]} + 'cond': {'$eq': ['$$p.name', self.active_site]} }} }}, {'$addFields': { @@ -714,7 +717,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): """ base_match = { "type": "representation", - 'files.sites.name': {'$all': [self.local_site, + 'files.sites.name': {'$all': [self.active_site, self.remote_site]} } if not self._word_filter: @@ -889,7 +892,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self.sync_server = sync_server # TODO think about admin mode # this is for regular user, always only single local and single remote - self.local_site = self.sync_server.get_active_site(self.project) + self.active_site = self.sync_server.get_active_site(self.project) self.remote_site = self.sync_server.get_remote_site(self.project) self.sort = self.DEFAULT_SORT @@ -905,6 +908,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): def data(self, index, role): item = self._data[index.row()] + if role == lib.FullItemRole: + return item + header_value = self._header[index.column()] if role == lib.ProviderRole: if header_value == 'local_site': @@ -1042,7 +1048,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): }}, 'order_local': { '$filter': {'input': '$files.sites', 'as': 'p', - 'cond': {'$eq': ['$$p.name', self.local_site]} + 'cond': {'$eq': ['$$p.name', self.active_site]} }} }}, {'$addFields': { diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 6d8348becb..21236dc64a 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -135,7 +135,7 @@ class SyncProjectListWidget(ProjectListWidget): self.refresh() -class SyncRepresentationWidget(QtWidgets.QWidget): +class _SyncRepresentationWidget(QtWidgets.QWidget): """ Summary dialog with list of representations that matches current settings 'local_site' and 'remote_site'. @@ -143,6 +143,283 @@ class SyncRepresentationWidget(QtWidgets.QWidget): active_changed = QtCore.Signal() # active index changed message_generated = QtCore.Signal(str) + def _selection_changed(self, _new_selected, _all_selected): + idxs = self.selection_model.selectedRows() + self._selected_ids = [] + + for index in idxs: + self._selected_ids.append(self.model.data(index, Qt.UserRole)) + + def _set_selection(self): + """ + Sets selection to 'self._selected_id' if exists. + + Keep selection during model refresh. + """ + existing_ids = [] + for selected_id in self._selected_ids: + index = self.model.get_index(selected_id) + if index and index.isValid(): + mode = QtCore.QItemSelectionModel.Select | \ + QtCore.QItemSelectionModel.Rows + self.selection_model.select(index, mode) + existing_ids.append(selected_id) + + self._selected_ids = existing_ids + + def _double_clicked(self, index): + """ + Opens representation dialog with all files after doubleclick + """ + _id = self.model.data(index, Qt.UserRole) + detail_window = SyncServerDetailWindow( + self.sync_server, _id, self.model.project) + detail_window.exec() + + def _on_context_menu(self, point): + """ + Shows menu with loader actions on Right-click. + + Supports multiple selects - adds all available actions, each + action handles if it appropriate for item itself, if not it skips. + """ + is_multi = len(self._selected_ids) > 1 + point_index = self.table_view.indexAt(point) + if not point_index.isValid() and not is_multi: + return + + if is_multi: + index = self.model.get_index(self._selected_ids[0]) + item = self.model.data(index, lib.FullItemRole) + else: + item = self.model.data(point_index, lib.FullItemRole) + + action_kwarg_map, actions_mapping, menu = self._prepare_menu(item, + is_multi) + + result = menu.exec_(QtGui.QCursor.pos()) + if result: + to_run = actions_mapping[result] + to_run_kwargs = action_kwarg_map.get(result, {}) + if to_run: + to_run(**to_run_kwargs) + + self.model.refresh() + + def _prepare_menu(self, item, is_multi): + menu = QtWidgets.QMenu() + + actions_mapping = {} + action_kwarg_map = {} + + active_site = self.model.active_site + remote_site = self.model.remote_site + + local_progress = item.local_progress + remote_progress = item.remote_progress + + project = self.model.project + + for site, progress in {active_site: local_progress, + remote_site: remote_progress}.items(): + provider = self.sync_server.get_provider_for_site(project, site) + if provider == 'local_drive': + if 'studio' in site: + txt = " studio version" + else: + txt = " local version" + action = QtWidgets.QAction("Open in explorer" + txt) + if progress == 1.0 or is_multi: + actions_mapping[action] = self._open_in_explorer + action_kwarg_map[action] = \ + self._get_action_kwargs(site) + menu.addAction(action) + + if remote_progress == 1.0 or is_multi: + action = QtWidgets.QAction("Re-sync Active site") + action_kwarg_map[action] = self._get_action_kwargs(active_site) + actions_mapping[action] = self._reset_site + menu.addAction(action) + + if local_progress == 1.0 or is_multi: + action = QtWidgets.QAction("Re-sync Remote site") + action_kwarg_map[action] = self._get_action_kwargs(remote_site) + actions_mapping[action] = self._reset_site + menu.addAction(action) + + if active_site == get_local_site_id(): + action = QtWidgets.QAction("Completely remove from local") + action_kwarg_map[action] = self._get_action_kwargs(active_site) + actions_mapping[action] = self._remove_site + menu.addAction(action) + + # # temp for testing only !!! + # action = QtWidgets.QAction("Download") + # action_kwarg_map[action] = self._get_action_kwargs(active_site) + # actions_mapping[action] = self._add_site + # menu.addAction(action) + + if not actions_mapping: + action = QtWidgets.QAction("< No action >") + actions_mapping[action] = None + menu.addAction(action) + + return action_kwarg_map, actions_mapping, menu + + def _pause(self, selected_ids=None): + log.debug("Pause {}".format(selected_ids)) + for representation_id in selected_ids: + item = lib.get_item_by_id(self.model, representation_id) + if item.status not in [lib.STATUS[0], lib.STATUS[1]]: + continue + for site_name in [self.model.active_site, self.model.remote_site]: + check_progress = self._get_progress(item, site_name) + if check_progress < 1: + self.sync_server.pause_representation(self.model.project, + representation_id, + site_name) + + self.message_generated.emit("Paused {}".format(representation_id)) + + def _unpause(self, selected_ids=None): + log.debug("UnPause {}".format(selected_ids)) + for representation_id in selected_ids: + item = lib.get_item_by_id(self.model, representation_id) + if item.status not in lib.STATUS[3]: + continue + for site_name in [self.model.active_site, self.model.remote_site]: + check_progress = self._get_progress(item, site_name) + if check_progress < 1: + self.sync_server.unpause_representation( + self.model.project, + representation_id, + site_name) + + self.message_generated.emit("Unpause {}".format(representation_id)) + + # temporary here for testing, will be removed TODO + def _add_site(self, selected_ids=None, site_name=None): + log.debug("Add site {}:{}".format(selected_ids, site_name)) + for representation_id in selected_ids: + item = lib.get_item_by_id(self.model, representation_id) + if item.local_site == site_name or item.remote_site == site_name: + # site already exists skip + continue + + try: + self.sync_server.add_site( + self.model.project, + representation_id, + site_name) + self.message_generated.emit( + "Site {} added for {}".format(site_name, + representation_id)) + except ValueError as exp: + self.message_generated.emit("Error {}".format(str(exp))) + + def _remove_site(self, selected_ids=None, site_name=None): + """ + Removes site record AND files. + + This is ONLY for representations stored on local site, which + cannot be same as SyncServer.DEFAULT_SITE. + + This could only happen when artist work on local machine, not + connected to studio mounted drives. + """ + log.debug("Remove site {}:{}".format(selected_ids, site_name)) + for representation_id in selected_ids: + log.info("Removing {}".format(representation_id)) + try: + self.sync_server.remove_site( + self.model.project, + representation_id, + site_name, + True) + self.message_generated.emit( + "Site {} removed".format(site_name)) + except ValueError as exp: + self.message_generated.emit("Error {}".format(str(exp))) + + self.model.refresh( + load_records=self.model._rec_loaded) + + def _reset_site(self, selected_ids=None, site_name=None): + """ + Removes errors or success metadata for particular file >> forces + redo of upload/download + """ + log.debug("Reset site {}:{}".format(selected_ids, site_name)) + for representation_id in selected_ids: + item = lib.get_item_by_id(self.model, representation_id) + check_progress = self._get_progress(item, site_name, True) + + # do not reset if opposite side is not fully there + if check_progress != 1: + log.debug("Not fully available {} on other side, skipping". + format(check_progress)) + continue + + self.sync_server.reset_provider_for_file( + self.model.project, + representation_id, + site_name=site_name, + force=True) + + self.model.refresh( + load_records=self.model._rec_loaded) + + def _open_in_explorer(self, selected_ids=None, site_name=None): + log.debug("Open in Explorer {}:{}".format(selected_ids, site_name)) + for selected_id in selected_ids: + item = lib.get_item_by_id(self.model, selected_id) + if not item: + return + + fpath = item.path + project = self.model.project + fpath = self.sync_server.get_local_file_path(project, + site_name, + fpath) + + fpath = os.path.normpath(os.path.dirname(fpath)) + if os.path.isdir(fpath): + if 'win' in sys.platform: # windows + subprocess.Popen('explorer "%s"' % fpath) + elif sys.platform == 'darwin': # macOS + subprocess.Popen(['open', fpath]) + else: # linux + try: + subprocess.Popen(['xdg-open', fpath]) + except OSError: + raise OSError('unsupported xdg-open call??') + + def _get_progress(self, item, site_name, opposite=False): + """Returns progress value according to site (side)""" + progress = {'local': item.local_progress, + 'remote': item.remote_progress} + side = 'remote' + if site_name == self.model.active_site: + side = 'local' + if opposite: + side = 'remote' if side == 'local' else 'local' + + return progress[side] + + def _get_action_kwargs(self, site_name): + """Default format of kwargs for action""" + return {"selected_ids": self._selected_ids, "site_name": site_name} + + def _save_scrollbar(self): + self._scrollbar_pos = self.table_view.verticalScrollBar().value() + + def _set_scrollbar(self): + if self._scrollbar_pos: + self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + + +class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): + default_widths = ( ("asset", 190), ("subset", 170), @@ -157,310 +434,139 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ) def __init__(self, sync_server, project=None, parent=None): - super(SyncRepresentationWidget, self).__init__(parent) + super(SyncRepresentationSummaryWidget, self).__init__(parent) self.sync_server = sync_server - self._selected_id = None # keep last selected _id - self.representation_id = None - self.site_name = None # to pause/unpause representation + self._selected_ids = [] # keep last selected _id - 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) + txt_filter = QtWidgets.QLineEdit() + txt_filter.setPlaceholderText("Quick filter representations..") + txt_filter.setClearButtonEnabled(True) + txt_filter.addAction( + qtawesome.icon("fa.filter", color="gray"), + QtWidgets.QLineEdit.LeadingPosition) + self.txt_filter = txt_filter self._scrollbar_pos = None top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.txt_filter) - self.table_view = QtWidgets.QTableView() + table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] model = SyncRepresentationSummaryModel(sync_server, headers, project) - self.table_view.setModel(model) - self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.table_view.setSelectionMode( - QtWidgets.QAbstractItemView.SingleSelection) - self.table_view.setSelectionBehavior( + table_view.setModel(model) + table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + table_view.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + table_view.setSelectionBehavior( QtWidgets.QAbstractItemView.SelectRows) - self.table_view.horizontalHeader().setSortIndicator( + table_view.horizontalHeader().setSortIndicator( -1, Qt.AscendingOrder) - self.table_view.setAlternatingRowColors(True) - self.table_view.verticalHeader().hide() + table_view.setAlternatingRowColors(True) + table_view.verticalHeader().hide() - column = self.table_view.model().get_header_index("local_site") + column = table_view.model().get_header_index("local_site") delegate = ImageDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) + table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("remote_site") + column = table_view.model().get_header_index("remote_site") delegate = ImageDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) + 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.table_view.doubleClicked.connect(self._double_clicked) + self.table_view = table_view + self.model = model + + 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) + + table_view.doubleClicked.connect(self._double_clicked) self.txt_filter.textChanged.connect(lambda: model.set_word_filter( self.txt_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) - horizontal_header = HorizontalHeader(self) + def _prepare_menu(self, item, is_multi): + action_kwarg_map, actions_mapping, menu = \ + super()._prepare_menu(item, is_multi) - 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) - self.table_view.setColumnWidth(idx, width) - - def _selection_changed(self, _new_selection): - index = self.selection_model.currentIndex() - self._selected_id = \ - self.model.data(index, Qt.UserRole) - - def _set_selection(self): - """ - Sets selection to 'self._selected_id' if exists. - - Keep selection during model refresh. - """ - if self._selected_id: - index = self.model.get_index(self._selected_id) - if index and index.isValid(): - mode = QtCore.QItemSelectionModel.Select | \ - QtCore.QItemSelectionModel.Rows - self.selection_model.setCurrentIndex(index, mode) - else: - self._selected_id = None - - def _double_clicked(self, index): - """ - Opens representation dialog with all files after doubleclick - """ - _id = self.model.data(index, Qt.UserRole) - detail_window = SyncServerDetailWindow( - self.sync_server, _id, self.model.project) - detail_window.exec() - - def _on_context_menu(self, point): - """ - Shows menu with loader actions on Right-click. - """ - point_index = self.table_view.indexAt(point) - if not point_index.isValid(): - return - - self.item = self.model._data[point_index.row()] - self.representation_id = self.item._id - log.debug("menu representation _id:: {}". - format(self.representation_id)) - - menu = QtWidgets.QMenu() - actions_mapping = {} - actions_kwargs_mapping = {} - - local_site = self.item.local_site - local_progress = self.item.local_progress - remote_site = self.item.remote_site - remote_progress = self.item.remote_progress - - for site, progress in {local_site: local_progress, - remote_site: remote_progress}.items(): - project = self.model.project - provider = self.sync_server.get_provider_for_site(project, - site) - if provider == 'local_drive': - if 'studio' in site: - txt = " studio version" - else: - txt = " local version" - action = QtWidgets.QAction("Open in explorer" + txt) - if progress == 1.0: - actions_mapping[action] = self._open_in_explorer - actions_kwargs_mapping[action] = {'site': site} - menu.addAction(action) - - # progress smaller then 1.0 --> in progress or queued - if local_progress < 1.0: - self.site_name = local_site - else: - self.site_name = remote_site - - if self.item.status in [lib.STATUS[0], lib.STATUS[1]]: - action = QtWidgets.QAction("Pause") + if item.status in [lib.STATUS[0], lib.STATUS[1]] or is_multi: + action = QtWidgets.QAction("Pause in queue") actions_mapping[action] = self._pause + # pause handles which site_name it will pause itself + action_kwarg_map[action] = {"selected_ids": self._selected_ids} menu.addAction(action) - if self.item.status == lib.STATUS[3]: - action = QtWidgets.QAction("Unpause") + if item.status == lib.STATUS[3] or is_multi: + action = QtWidgets.QAction("Unpause in queue") actions_mapping[action] = self._unpause + action_kwarg_map[action] = {"selected_ids": self._selected_ids} menu.addAction(action) - # if self.item.status == lib.STATUS[1]: - # action = QtWidgets.QAction("Open error detail") - # actions_mapping[action] = self._show_detail - # menu.addAction(action) - - if remote_progress == 1.0: - action = QtWidgets.QAction("Re-sync Active site") - actions_mapping[action] = self._reset_local_site - menu.addAction(action) - - if local_progress == 1.0: - action = QtWidgets.QAction("Re-sync Remote site") - actions_mapping[action] = self._reset_remote_site - menu.addAction(action) - - if local_site != self.sync_server.DEFAULT_SITE: - action = QtWidgets.QAction("Completely remove from local") - actions_mapping[action] = self._remove_site - menu.addAction(action) - else: - action = QtWidgets.QAction("Mark for sync to local") - actions_mapping[action] = self._add_site - menu.addAction(action) - - if not actions_mapping: - action = QtWidgets.QAction("< No action >") - actions_mapping[action] = None - menu.addAction(action) - - result = menu.exec_(QtGui.QCursor.pos()) - if result: - to_run = actions_mapping[result] - to_run_kwargs = actions_kwargs_mapping.get(result, {}) - if to_run: - to_run(**to_run_kwargs) - - self.model.refresh() - - def _pause(self): - self.sync_server.pause_representation(self.model.project, - self.representation_id, - self.site_name) - self.site_name = None - self.message_generated.emit("Paused {}".format(self.representation_id)) - - def _unpause(self): - self.sync_server.unpause_representation( - self.model.project, - self.representation_id, - self.site_name) - self.site_name = None - self.message_generated.emit("Unpaused {}".format( - self.representation_id)) - - # temporary here for testing, will be removed TODO - def _add_site(self): - log.info(self.representation_id) - project_name = self.model.project - local_site_name = get_local_site_id() - try: - self.sync_server.add_site( - project_name, - self.representation_id, - local_site_name - ) - self.message_generated.emit( - "Site {} added for {}".format(local_site_name, - self.representation_id)) - except ValueError as exp: - self.message_generated.emit("Error {}".format(str(exp))) - - def _remove_site(self): - """ - Removes site record AND files. - - This is ONLY for representations stored on local site, which - cannot be same as SyncServer.DEFAULT_SITE. - - This could only happen when artist work on local machine, not - connected to studio mounted drives. - """ - log.info("Removing {}".format(self.representation_id)) - try: - local_site = get_local_site_id() - self.sync_server.remove_site( - 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.model.refresh( - load_records=self.model._rec_loaded) - - def _reset_local_site(self): - """ - Removes errors or success metadata for particular file >> forces - redo of upload/download - """ - self.sync_server.reset_provider_for_file( - self.model.project, - self.representation_id, - 'local') - self.model.refresh( - load_records=self.model._rec_loaded) - - def _reset_remote_site(self): - """ - Removes errors or success metadata for particular file >> forces - redo of upload/download - """ - self.sync_server.reset_provider_for_file( - self.model.project, - self.representation_id, - 'remote') - 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.model.project - fpath = self.sync_server.get_local_file_path(project, - site, - fpath) - - fpath = os.path.normpath(os.path.dirname(fpath)) - if os.path.isdir(fpath): - if 'win' in sys.platform: # windows - subprocess.Popen('explorer "%s"' % fpath) - elif sys.platform == 'darwin': # macOS - subprocess.Popen(['open', fpath]) - else: # linux - try: - subprocess.Popen(['xdg-open', fpath]) - except OSError: - raise OSError('unsupported xdg-open call??') - - def _save_scrollbar(self): - self._scrollbar_pos = self.table_view.verticalScrollBar().value() - - def _set_scrollbar(self): - if self._scrollbar_pos: - self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + return action_kwarg_map, actions_mapping, menu -class SyncRepresentationDetailWidget(QtWidgets.QWidget): +class SyncServerDetailWindow(QtWidgets.QDialog): + """Wrapper window for SyncRepresentationDetailWidget + + Creates standalone window with list of files for selected repre_id. + """ + def __init__(self, sync_server, _id, project, parent=None): + log.debug( + "!!! SyncServerDetailWindow _id:: {}".format(_id)) + super(SyncServerDetailWindow, self).__init__(parent) + self.setWindowFlags(QtCore.Qt.Window) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self.setStyleSheet(style.load_stylesheet()) + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + self.resize(1000, 400) + + body = QtWidgets.QWidget() + footer = QtWidgets.QWidget() + footer.setFixedHeight(20) + + container = SyncRepresentationDetailWidget(sync_server, _id, project, + parent=self) + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.addWidget(container) + body_layout.setContentsMargins(0, 0, 0, 0) + + self.message = QtWidgets.QLabel() + self.message.hide() + + footer_layout = QtWidgets.QVBoxLayout(footer) + footer_layout.addWidget(self.message) + footer_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + layout.addWidget(footer) + + self.setLayout(body_layout) + self.setWindowTitle("Sync Representation Detail") + + +class SyncRepresentationDetailWidget(_SyncRepresentationWidget): """ Widget to display list of synchronizable files for single repre. @@ -484,13 +590,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): super(SyncRepresentationDetailWidget, self).__init__(parent) log.debug("Representation_id:{}".format(_id)) - self.representation_id = _id - self.item = None # set to item that mouse was clicked over self.project = project self.sync_server = sync_server - self._selected_id = None + self.representation_id = _id + self._selected_ids = [] self.txt_filter = QtWidgets.QLineEdit() self.txt_filter.setPlaceholderText("Quick filter representation..") @@ -511,7 +616,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): table_view.setModel(model) table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) table_view.setSelectionMode( - QtWidgets.QAbstractItemView.SingleSelection) + QtWidgets.QAbstractItemView.ExtendedSelection) table_view.setSelectionBehavior( QtWidgets.QTableView.SelectRows) table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) @@ -556,171 +661,118 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): model.refresh_finished.connect(self._set_scrollbar) model.modelReset.connect(self._set_selection) - def _selection_changed(self): - index = self.selection_model.currentIndex() - self._selected_id = self.model.data(index, Qt.UserRole) - - def _set_selection(self): - """ - Sets selection to 'self._selected_id' if exists. - - Keep selection during model refresh. - """ - if self._selected_id: - index = self.model.get_index(self._selected_id) - if index and index.isValid(): - mode = QtCore.QItemSelectionModel.Select | \ - QtCore.QItemSelectionModel.Rows - self.selection_model.setCurrentIndex(index, mode) - else: - self._selected_id = None - - def _show_detail(self): + def _show_detail(self, selected_ids=None): """ Shows windows with error message for failed sync of a file. """ - dt = max(self.item.created_dt, self.item.sync_dt) - detail_window = SyncRepresentationErrorWindow(self.item._id, - self.project, - dt, - self.item.tries, - self.item.error) + detail_window = SyncRepresentationErrorWindow(self.model, selected_ids) + detail_window.exec() - def _on_context_menu(self, point): - """ - Shows menu with loader actions on Right-click. - """ - point_index = self.table_view.indexAt(point) - if not point_index.isValid(): - return + def _prepare_menu(self, item, is_multi): + """Adds view (and model) dependent actions to default ones""" + action_kwarg_map, actions_mapping, menu = \ + super()._prepare_menu(item, is_multi) - self.item = self.model._data[point_index.row()] - - menu = QtWidgets.QMenu() - actions_mapping = {} - actions_kwargs_mapping = {} - - local_site = self.item.local_site - local_progress = self.item.local_progress - remote_site = self.item.remote_site - remote_progress = self.item.remote_progress - - for site, progress in {local_site: local_progress, - remote_site: remote_progress}.items(): - project = self.model.project - provider = self.sync_server.get_provider_for_site(project, - site) - if provider == 'local_drive': - if 'studio' in site: - txt = " studio version" - else: - txt = " local version" - action = QtWidgets.QAction("Open in explorer" + txt) - if progress == 1: - actions_mapping[action] = self._open_in_explorer - actions_kwargs_mapping[action] = {'site': site} - menu.addAction(action) - - if self.item.status == lib.STATUS[2]: + if item.status == lib.STATUS[2] or is_multi: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail + action_kwarg_map[action] = {"selected_ids": self._selected_ids} + menu.addAction(action) - if float(remote_progress) == 1.0: - action = QtWidgets.QAction("Re-sync active site") - actions_mapping[action] = self._reset_local_site - menu.addAction(action) + return action_kwarg_map, actions_mapping, menu - if float(local_progress) == 1.0: - action = QtWidgets.QAction("Re-sync remote site") - actions_mapping[action] = self._reset_remote_site - menu.addAction(action) - - if not actions_mapping: - action = QtWidgets.QAction("< No action >") - actions_mapping[action] = None - menu.addAction(action) - - result = menu.exec_(QtGui.QCursor.pos()) - if result: - to_run = actions_mapping[result] - to_run_kwargs = actions_kwargs_mapping.get(result, {}) - if to_run: - to_run(**to_run_kwargs) - - def _reset_local_site(self): + def _reset_site(self, selected_ids=None, site_name=None): """ Removes errors or success metadata for particular file >> forces redo of upload/download """ - self.sync_server.reset_provider_for_file( - self.model.project, - self.representation_id, - 'local', - self.item._id) + for file_id in selected_ids: + item = lib.get_item_by_id(self.model, file_id) + check_progress = self._get_progress(item, site_name, True) + + # do not reset if opposite side is not fully there + if check_progress != 1: + log.debug("Not fully available {} on other side, skipping". + format(check_progress)) + continue + + self.sync_server.reset_provider_for_file( + self.model.project, + self.representation_id, + site_name=site_name, + file_id=file_id, + force=True) self.model.refresh( load_records=self.model._rec_loaded) - def _reset_remote_site(self): - """ - Removes errors or success metadata for particular file >> forces - redo of upload/download - """ - self.sync_server.reset_provider_for_file( - self.model.project, - self.representation_id, - 'remote', - self.item._id) - self.model.refresh( - load_records=self.model._rec_loaded) - def _open_in_explorer(self, site): - if not self.item: - return +class SyncRepresentationErrorWindow(QtWidgets.QDialog): + """Wrapper window to show errors during sync on file(s)""" + def __init__(self, model, selected_ids, parent=None): + super(SyncRepresentationErrorWindow, self).__init__(parent) + self.setWindowFlags(QtCore.Qt.Window) + self.setFocusPolicy(QtCore.Qt.StrongFocus) - fpath = self.item.path - project = self.project - fpath = self.sync_server.get_local_file_path(project, site, fpath) + self.setStyleSheet(style.load_stylesheet()) + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + self.resize(900, 150) - fpath = os.path.normpath(os.path.dirname(fpath)) - if os.path.isdir(fpath): - if 'win' in sys.platform: # windows - subprocess.Popen('explorer "%s"' % fpath) - elif sys.platform == 'darwin': # macOS - subprocess.Popen(['open', fpath]) - else: # linux - try: - subprocess.Popen(['xdg-open', fpath]) - except OSError: - raise OSError('unsupported xdg-open call??') + body = QtWidgets.QWidget() - def _save_scrollbar(self): - self._scrollbar_pos = self.table_view.verticalScrollBar().value() + container = SyncRepresentationErrorWidget(model, + selected_ids, + parent=self) + body_layout = QtWidgets.QHBoxLayout(body) + body_layout.addWidget(container) + body_layout.setContentsMargins(0, 0, 0, 0) - def _set_scrollbar(self): - if self._scrollbar_pos: - self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + message = QtWidgets.QLabel() + message.hide() + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + + self.setLayout(body_layout) + self.setWindowTitle("Sync Representation Error Detail") class SyncRepresentationErrorWidget(QtWidgets.QWidget): """ - Dialog to show when sync error happened, prints error message + Dialog to show when sync error happened, prints formatted error message """ - - def __init__(self, _id, dt, tries, msg, parent=None): + def __init__(self, model, selected_ids, parent=None): super(SyncRepresentationErrorWidget, self).__init__(parent) - layout = QtWidgets.QHBoxLayout(self) + layout = QtWidgets.QVBoxLayout(self) - txts = [] - txts.append("{}: {}".format("Last update date", pretty_timestamp(dt))) - txts.append("{}: {}".format("Retries", str(tries))) - txts.append("{}: {}".format("Error message", msg)) + no_errors = True + for file_id in selected_ids: + item = lib.get_item_by_id(model, file_id) + if not item.created_dt or not item.sync_dt or not item.error: + continue - text_area = QtWidgets.QPlainTextEdit("\n\n".join(txts)) - text_area.setReadOnly(True) - layout.addWidget(text_area) + no_errors = False + dt = max(item.created_dt, item.sync_dt) + + txts = [] + txts.append("{}: {}
".format("Last update date", + pretty_timestamp(dt))) + txts.append("{}: {}
".format("Retries", + str(item.tries))) + txts.append("{}: {}
".format("Error message", + item.error)) + + text_area = QtWidgets.QTextEdit("\n\n".join(txts)) + text_area.setReadOnly(True) + layout.addWidget(text_area) + + if no_errors: + text_area = QtWidgets.QTextEdit() + text_area.setText("

No errors located

") + text_area.setReadOnly(True) + layout.addWidget(text_area) class ImageDelegate(QtWidgets.QStyledItemDelegate): @@ -773,72 +825,8 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): QtGui.QBrush(QtGui.QColor(255, 0, 0, 35))) -class SyncServerDetailWindow(QtWidgets.QDialog): - def __init__(self, sync_server, _id, project, parent=None): - log.debug( - "!!! SyncServerDetailWindow _id:: {}".format(_id)) - super(SyncServerDetailWindow, self).__init__(parent) - self.setWindowFlags(QtCore.Qt.Window) - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - self.setStyleSheet(style.load_stylesheet()) - self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) - self.resize(1000, 400) - - body = QtWidgets.QWidget() - footer = QtWidgets.QWidget() - footer.setFixedHeight(20) - - container = SyncRepresentationDetailWidget(sync_server, _id, project, - parent=self) - body_layout = QtWidgets.QHBoxLayout(body) - body_layout.addWidget(container) - body_layout.setContentsMargins(0, 0, 0, 0) - - self.message = QtWidgets.QLabel() - self.message.hide() - - footer_layout = QtWidgets.QVBoxLayout(footer) - footer_layout.addWidget(self.message) - footer_layout.setContentsMargins(0, 0, 0, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - layout.addWidget(footer) - - self.setLayout(body_layout) - self.setWindowTitle("Sync Representation Detail") - - -class SyncRepresentationErrorWindow(QtWidgets.QDialog): - def __init__(self, _id, project, dt, tries, msg, parent=None): - super(SyncRepresentationErrorWindow, self).__init__(parent) - self.setWindowFlags(QtCore.Qt.Window) - self.setFocusPolicy(QtCore.Qt.StrongFocus) - - self.setStyleSheet(style.load_stylesheet()) - self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) - self.resize(900, 150) - - body = QtWidgets.QWidget() - - container = SyncRepresentationErrorWidget(_id, dt, tries, msg, - parent=self) - body_layout = QtWidgets.QHBoxLayout(body) - body_layout.addWidget(container) - body_layout.setContentsMargins(0, 0, 0, 0) - - message = QtWidgets.QLabel() - message.hide() - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - - self.setLayout(body_layout) - self.setWindowTitle("Sync Representation Error Detail") - - class TransparentWidget(QtWidgets.QWidget): + """Used for header cell for resizing to work properly""" clicked = QtCore.Signal(str) def __init__(self, column_name, *args, **kwargs): @@ -854,7 +842,7 @@ class TransparentWidget(QtWidgets.QWidget): class HorizontalHeader(QtWidgets.QHeaderView): - + """Reiplemented QHeaderView to contain clickable changeable button""" def __init__(self, parent=None): super(HorizontalHeader, self).__init__(QtCore.Qt.Horizontal, parent) self._parent = parent @@ -882,6 +870,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): return self._parent.model def init_layout(self): + """Initial preparation of header's content""" 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) @@ -901,6 +890,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): self.filter_buttons[column_name] = button def showEvent(self, event): + """Paint header""" super(HorizontalHeader, self).showEvent(event) for i in range(len(self.header_cells)): @@ -911,6 +901,7 @@ class HorizontalHeader(QtWidgets.QHeaderView): cell_content.show() def _set_filter_icon(self, column_name): + """Set different states of button depending on its engagement""" button = self.filter_buttons.get(column_name) if button: if self.checked_values.get(column_name): diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index a71b1db66b..00b671199d 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -704,6 +704,105 @@ class ExtractReview(pyblish.api.InstancePlugin): return audio_in_args, audio_filters, audio_out_args + def get_letterbox_filters( + self, + letter_box_def, + input_res_ratio, + output_res_ratio, + pixel_aspect, + scale_factor_by_width, + scale_factor_by_height + ): + output = [] + + ratio = letter_box_def["ratio"] + state = letter_box_def["state"] + fill_color = letter_box_def["fill_color"] + f_red, f_green, f_blue, f_alpha = fill_color + fill_color_hex = "{0:0>2X}{1:0>2X}{2:0>2X}".format( + f_red, f_green, f_blue + ) + fill_color_alpha = float(f_alpha) / 255 + + line_thickness = letter_box_def["line_thickness"] + line_color = letter_box_def["line_color"] + l_red, l_green, l_blue, l_alpha = line_color + line_color_hex = "{0:0>2X}{1:0>2X}{2:0>2X}".format( + l_red, l_green, l_blue + ) + line_color_alpha = float(l_alpha) / 255 + + if input_res_ratio == output_res_ratio: + ratio /= pixel_aspect + elif input_res_ratio < output_res_ratio: + ratio /= scale_factor_by_width + else: + ratio /= scale_factor_by_height + + if state == "letterbox": + if fill_color_alpha > 0: + top_box = ( + "drawbox=0:0:iw:round((ih-(iw*(1/{})))/2):t=fill:c={}@{}" + ).format(ratio, fill_color_hex, fill_color_alpha) + + bottom_box = ( + "drawbox=0:ih-round((ih-(iw*(1/{0})))/2)" + ":iw:round((ih-(iw*(1/{0})))/2):t=fill:c={1}@{2}" + ).format(ratio, fill_color_hex, fill_color_alpha) + + output.extend([top_box, bottom_box]) + + if line_color_alpha > 0 and line_thickness > 0: + top_line = ( + "drawbox=0:round((ih-(iw*(1/{0})))/2)-{1}:iw:{1}:" + "t=fill:c={2}@{3}" + ).format( + ratio, line_thickness, line_color_hex, line_color_alpha + ) + bottom_line = ( + "drawbox=0:ih-round((ih-(iw*(1/{})))/2)" + ":iw:{}:t=fill:c={}@{}" + ).format( + ratio, line_thickness, line_color_hex, line_color_alpha + ) + output.extend([top_line, bottom_line]) + + elif state == "pillar": + if fill_color_alpha > 0: + left_box = ( + "drawbox=0:0:round((iw-(ih*{}))/2):ih:t=fill:c={}@{}" + ).format(ratio, fill_color_hex, fill_color_alpha) + + right_box = ( + "drawbox=iw-round((iw-(ih*{0}))/2))" + ":0:round((iw-(ih*{0}))/2):ih:t=fill:c={1}@{2}" + ).format(ratio, fill_color_hex, fill_color_alpha) + + output.extend([left_box, right_box]) + + if line_color_alpha > 0 and line_thickness > 0: + left_line = ( + "drawbox=round((iw-(ih*{}))/2):0:{}:ih:t=fill:c={}@{}" + ).format( + ratio, line_thickness, line_color_hex, line_color_alpha + ) + + right_line = ( + "drawbox=iw-round((iw-(ih*{}))/2))" + ":0:{}:ih:t=fill:c={}@{}" + ).format( + ratio, line_thickness, line_color_hex, line_color_alpha + ) + + output.extend([left_line, right_line]) + + else: + raise ValueError( + "Letterbox state \"{}\" is not recognized".format(state) + ) + + return output + def rescaling_filters(self, temp_data, output_def, new_repre): """Prepare vieo filters based on tags in new representation. @@ -715,7 +814,8 @@ class ExtractReview(pyblish.api.InstancePlugin): """ filters = [] - letter_box = output_def.get("letter_box") + letter_box_def = output_def["letter_box"] + letter_box_enabled = letter_box_def["enabled"] # Get instance data pixel_aspect = temp_data["pixel_aspect"] @@ -795,7 +895,7 @@ class ExtractReview(pyblish.api.InstancePlugin): if ( output_width == input_width and output_height == input_height - and not letter_box + and not letter_box_enabled and pixel_aspect == 1 ): self.log.debug( @@ -834,30 +934,24 @@ class ExtractReview(pyblish.api.InstancePlugin): ) # letter_box - if letter_box: - if input_res_ratio == output_res_ratio: - letter_box /= pixel_aspect - elif input_res_ratio < output_res_ratio: - letter_box /= scale_factor_by_width - else: - letter_box /= scale_factor_by_height - - scale_filter = "scale={}x{}:flags=lanczos".format( - output_width, output_height + if letter_box_enabled: + filters.extend([ + "scale={}x{}:flags=lanczos".format( + output_width, output_height + ), + "setsar=1" + ]) + filters.extend( + self.get_letterbox_filters( + letter_box_def, + input_res_ratio, + output_res_ratio, + pixel_aspect, + scale_factor_by_width, + scale_factor_by_height + ) ) - top_box = ( - "drawbox=0:0:iw:round((ih-(iw*(1/{})))/2):t=fill:c=black" - ).format(letter_box) - - bottom_box = ( - "drawbox=0:ih-round((ih-(iw*(1/{0})))/2)" - ":iw:round((ih-(iw*(1/{0})))/2):t=fill:c=black" - ).format(letter_box) - - # Add letter box filters - filters.extend([scale_filter, "setsar=1", top_box, bottom_box]) - # scaling none square pixels and 1920 width if ( input_height != output_height diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index f8d4cf78fa..61db35ba79 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -45,7 +45,25 @@ ] }, "width": 0, - "height": 0 + "height": 0, + "letter_box": { + "enabled": false, + "ratio": 0.0, + "state": "letterbox", + "fill_color": [ + 0, + 0, + 0, + 255 + ], + "line_thickness": 0, + "line_color": [ + 255, + 0, + 0, + 255 + ] + } } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 3c079a130d..1bd028ac79 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -203,6 +203,69 @@ "default": 0, "minimum": 0, "maximum": 100000 + }, + { + "key": "letter_box", + "label": "Letter box", + "type": "dict", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled", + "default": false + }, + { + "key": "ratio", + "label": "Ratio", + "type": "number", + "decimal": 4, + "default": 0, + "minimum": 0, + "maximum": 10000 + }, + { + "key": "state", + "label": "Type", + "type": "enum", + "enum_items": [ + { + "letterbox": "Letterbox" + }, + { + "pillar": "Pillar" + } + ] + }, + { + "type": "schema_template", + "name": "template_rgba_color", + "template_data": [ + { + "label": "Fill Color", + "name": "fill_color" + } + ] + }, + { + "key": "line_thickness", + "label": "Line Thickness", + "type": "number", + "minimum": 0, + "maximum": 1000 + }, + { + "type": "schema_template", + "name": "template_rgba_color", + "template_data": [ + { + "label": "Line Color", + "name": "line_color" + } + ] + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_rgba_color.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_rgba_color.json new file mode 100644 index 0000000000..ffe530175a --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_rgba_color.json @@ -0,0 +1,33 @@ +[ + { + "type": "list-strict", + "key": "{name}", + "label": "{label}", + "object_types": [ + { + "label": "R", + "type": "number", + "minimum": 0, + "maximum": 255 + }, + { + "label": "G", + "type": "number", + "minimum": 0, + "maximum": 255 + }, + { + "label": "B", + "type": "number", + "minimum": 0, + "maximum": 255 + }, + { + "label": "A", + "type": "number", + "minimum": 0, + "maximum": 255 + } + ] + } +]