diff --git a/.gitignore b/.gitignore index ebb47e55d2..26bf7cf65f 100644 --- a/.gitignore +++ b/.gitignore @@ -91,4 +91,9 @@ website/i18n/* website/debug.log -website/.docusaurus \ No newline at end of file +website/.docusaurus + +# Poetry +######## + +.poetry/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 364555f8b2..b70f3f98f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog + +## [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) (2021-04-30) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.0...2.17.1) + +**Enhancements:** + +- TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) +- PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) +- Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) +- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) + +**Fixed bugs:** + +- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) +- Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) + + +## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) (2021-04-20) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/3.0.0-beta2...2.17.0) + +**Enhancements:** + +- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) +- TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) +- TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) +- TV Paint: Validate mark in and out. [\#1298](https://github.com/pypeclub/OpenPype/pull/1298) +- Validate project settings [\#1297](https://github.com/pypeclub/OpenPype/pull/1297) +- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/OpenPype/pull/1234) +- Show error message in pyblish UI [\#1206](https://github.com/pypeclub/OpenPype/pull/1206) + +**Fixed bugs:** + +- Hiero: fixing source frame from correct object [\#1362](https://github.com/pypeclub/OpenPype/pull/1362) +- Nuke: fix colourspace, prerenders and nuke panes opening [\#1308](https://github.com/pypeclub/OpenPype/pull/1308) +- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/OpenPype/pull/1282) +- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/OpenPype/pull/1194) +- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/OpenPype/pull/1312) +- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/OpenPype/pull/1303) +- After Effects: remove orphaned instances [\#1275](https://github.com/pypeclub/OpenPype/pull/1275) +- Avalon schema names [\#1242](https://github.com/pypeclub/OpenPype/pull/1242) +- Handle duplication of Task name [\#1226](https://github.com/pypeclub/OpenPype/pull/1226) +- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/OpenPype/pull/1217) +- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/OpenPype/pull/1214) +- Bulk mov strict task [\#1204](https://github.com/pypeclub/OpenPype/pull/1204) +- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/OpenPype/pull/1202) +- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/OpenPype/pull/1199) +- Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/OpenPype/pull/1178) + + ## [2.16.1](https://github.com/pypeclub/pype/tree/2.16.1) (2021-04-13) [Full Changelog](https://github.com/pypeclub/pype/compare/2.16.0...2.16.1) diff --git a/igniter/Poppins/OFL.txt b/igniter/Poppins/OFL.txt new file mode 100644 index 0000000000..76df3b5656 --- /dev/null +++ b/igniter/Poppins/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/igniter/Poppins/Poppins-Black.ttf b/igniter/Poppins/Poppins-Black.ttf new file mode 100644 index 0000000000..a9520b78ac Binary files /dev/null and b/igniter/Poppins/Poppins-Black.ttf differ diff --git a/igniter/Poppins/Poppins-BlackItalic.ttf b/igniter/Poppins/Poppins-BlackItalic.ttf new file mode 100644 index 0000000000..ebfdd707e5 Binary files /dev/null and b/igniter/Poppins/Poppins-BlackItalic.ttf differ diff --git a/igniter/Poppins/Poppins-Bold.ttf b/igniter/Poppins/Poppins-Bold.ttf new file mode 100644 index 0000000000..b94d47f3af Binary files /dev/null and b/igniter/Poppins/Poppins-Bold.ttf differ diff --git a/igniter/Poppins/Poppins-BoldItalic.ttf b/igniter/Poppins/Poppins-BoldItalic.ttf new file mode 100644 index 0000000000..e2e64456c7 Binary files /dev/null and b/igniter/Poppins/Poppins-BoldItalic.ttf differ diff --git a/igniter/Poppins/Poppins-ExtraBold.ttf b/igniter/Poppins/Poppins-ExtraBold.ttf new file mode 100644 index 0000000000..8f008c3684 Binary files /dev/null and b/igniter/Poppins/Poppins-ExtraBold.ttf differ diff --git a/igniter/Poppins/Poppins-ExtraBoldItalic.ttf b/igniter/Poppins/Poppins-ExtraBoldItalic.ttf new file mode 100644 index 0000000000..b2a9bf557a Binary files /dev/null and b/igniter/Poppins/Poppins-ExtraBoldItalic.ttf differ diff --git a/igniter/Poppins/Poppins-ExtraLight.ttf b/igniter/Poppins/Poppins-ExtraLight.ttf new file mode 100644 index 0000000000..ee6238251f Binary files /dev/null and b/igniter/Poppins/Poppins-ExtraLight.ttf differ diff --git a/igniter/Poppins/Poppins-ExtraLightItalic.ttf b/igniter/Poppins/Poppins-ExtraLightItalic.ttf new file mode 100644 index 0000000000..e392492abd Binary files /dev/null and b/igniter/Poppins/Poppins-ExtraLightItalic.ttf differ diff --git a/igniter/Poppins/Poppins-Italic.ttf b/igniter/Poppins/Poppins-Italic.ttf new file mode 100644 index 0000000000..46203996d3 Binary files /dev/null and b/igniter/Poppins/Poppins-Italic.ttf differ diff --git a/igniter/Poppins/Poppins-Light.ttf b/igniter/Poppins/Poppins-Light.ttf new file mode 100644 index 0000000000..2ab022196b Binary files /dev/null and b/igniter/Poppins/Poppins-Light.ttf differ diff --git a/igniter/Poppins/Poppins-LightItalic.ttf b/igniter/Poppins/Poppins-LightItalic.ttf new file mode 100644 index 0000000000..6f9279daef Binary files /dev/null and b/igniter/Poppins/Poppins-LightItalic.ttf differ diff --git a/igniter/Poppins/Poppins-Medium.ttf b/igniter/Poppins/Poppins-Medium.ttf new file mode 100644 index 0000000000..e90e87ed69 Binary files /dev/null and b/igniter/Poppins/Poppins-Medium.ttf differ diff --git a/igniter/Poppins/Poppins-MediumItalic.ttf b/igniter/Poppins/Poppins-MediumItalic.ttf new file mode 100644 index 0000000000..d8a251c7c4 Binary files /dev/null and b/igniter/Poppins/Poppins-MediumItalic.ttf differ diff --git a/igniter/Poppins/Poppins-Regular.ttf b/igniter/Poppins/Poppins-Regular.ttf new file mode 100644 index 0000000000..be06e7fdca Binary files /dev/null and b/igniter/Poppins/Poppins-Regular.ttf differ diff --git a/igniter/Poppins/Poppins-SemiBold.ttf b/igniter/Poppins/Poppins-SemiBold.ttf new file mode 100644 index 0000000000..dabf7c242e Binary files /dev/null and b/igniter/Poppins/Poppins-SemiBold.ttf differ diff --git a/igniter/Poppins/Poppins-SemiBoldItalic.ttf b/igniter/Poppins/Poppins-SemiBoldItalic.ttf new file mode 100644 index 0000000000..29d5f7419b Binary files /dev/null and b/igniter/Poppins/Poppins-SemiBoldItalic.ttf differ diff --git a/igniter/Poppins/Poppins-Thin.ttf b/igniter/Poppins/Poppins-Thin.ttf new file mode 100644 index 0000000000..f5c0fdd531 Binary files /dev/null and b/igniter/Poppins/Poppins-Thin.ttf differ diff --git a/igniter/Poppins/Poppins-ThinItalic.ttf b/igniter/Poppins/Poppins-ThinItalic.ttf new file mode 100644 index 0000000000..b910089316 Binary files /dev/null and b/igniter/Poppins/Poppins-ThinItalic.ttf differ diff --git a/igniter/__init__.py b/igniter/__init__.py index c2442ad57f..20bf9be106 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -10,29 +10,22 @@ from .bootstrap_repos import BootstrapRepos from .version import __version__ as version -RESULT = 0 - - -def get_result(res: int): - """Sets result returned from dialog.""" - global RESULT - RESULT = res - - def open_dialog(): """Show Igniter dialog.""" - from Qt import QtWidgets + from Qt import QtWidgets, QtCore from .install_dialog import InstallDialog + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + app = QtWidgets.QApplication(sys.argv) d = InstallDialog() - d.finished.connect(get_result) d.open() - app.exec() - - return RESULT + app.exec_() + return d.result() __all__ = [ diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 754a2d2e25..8fbb580e8f 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -223,7 +223,7 @@ class BootstrapRepos: otherwise `None`. registry (OpenPypeSettingsRegistry): OpenPype registry object. zip_filter (list): List of files to exclude from zip - openpype_filter (list): list of top level directories not to + openpype_filter (list): list of top level directories to include in zip in OpenPype repository. """ @@ -246,7 +246,7 @@ class BootstrapRepos: self.registry = OpenPypeSettingsRegistry() self.zip_filter = [".pyc", "__pycache__"] self.openpype_filter = [ - "build", "docs", "tests", "tools", "venv", "coverage" + "openpype", "repos", "schema", "LICENSE" ] self._message = message @@ -423,18 +423,13 @@ class BootstrapRepos: """ frozen_root = Path(sys.executable).parent - # from frozen code we need igniter, openpype, schema vendor - openpype_list = self._filter_dir( - frozen_root / "openpype", self.zip_filter) - openpype_list += self._filter_dir( - frozen_root / "igniter", self.zip_filter) - openpype_list += self._filter_dir( - frozen_root / "repos", self.zip_filter) - openpype_list += self._filter_dir( - frozen_root / "schema", self.zip_filter) - openpype_list += self._filter_dir( - frozen_root / "vendor", self.zip_filter) - openpype_list.append(frozen_root / "LICENSE") + openpype_list = [] + for f in self.openpype_filter: + if (frozen_root / f).is_dir(): + openpype_list += self._filter_dir( + frozen_root / f, self.zip_filter) + else: + openpype_list.append(frozen_root / f) version = self.get_version(frozen_root) @@ -477,11 +472,16 @@ class BootstrapRepos: openpype_path (Path): Path to OpenPype sources. """ - openpype_list = [] - openpype_inc = 0 - # get filtered list of file in Pype repository - openpype_list = self._filter_dir(openpype_path, self.zip_filter) + # openpype_list = self._filter_dir(openpype_path, self.zip_filter) + openpype_list = [] + for f in self.openpype_filter: + if (openpype_path / f).is_dir(): + openpype_list += self._filter_dir( + openpype_path / f, self.zip_filter) + else: + openpype_list.append(openpype_path / f) + openpype_files = len(openpype_list) openpype_inc = 98.0 / float(openpype_files) @@ -506,7 +506,7 @@ class BootstrapRepos: except ValueError: pass - if is_inside: + if not is_inside: continue processed_path = file @@ -575,7 +575,7 @@ class BootstrapRepos: """ sys.path.insert(0, directory.as_posix()) - directory = directory / "repos" + directory /= "repos" if not directory.exists() and not directory.is_dir(): raise ValueError("directory is invalid") @@ -681,7 +681,7 @@ class BootstrapRepos: openpype_path = None # try to get OpenPype path from mongo. if location.startswith("mongodb"): - pype_path = get_openpype_path_from_db(location) + openpype_path = get_openpype_path_from_db(location) if not openpype_path: self._print("cannot find OPENPYPE_PATH in settings.") return None @@ -808,7 +808,7 @@ class BootstrapRepos: """Install OpenPype version to user data directory. Args: - oepnpype_version (OpenPypeVersion): OpenPype version to install. + openpype_version (OpenPypeVersion): OpenPype version to install. force (bool, optional): Force overwrite existing version. Returns: diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 27b2d1fe37..411fac1e96 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -2,14 +2,15 @@ """Show dialog for choosing central pype repository.""" import os import sys +import re +import collections from Qt import QtCore, QtGui, QtWidgets # noqa from Qt.QtGui import QValidator # noqa from Qt.QtCore import QTimer # noqa -from .install_thread import InstallThread, InstallResult +from .install_thread import InstallThread from .tools import ( - validate_path_string, validate_mongo_connection, get_openpype_path_from_db ) @@ -17,504 +18,484 @@ from .user_settings import OpenPypeSecureRegistry from .version import __version__ -class FocusHandlingLineEdit(QtWidgets.QLineEdit): - """Handling focus in/out on QLineEdit.""" - focusIn = QtCore.Signal() - focusOut = QtCore.Signal() +def load_stylesheet(): + stylesheet_path = os.path.join( + os.path.dirname(__file__), + "stylesheet.css" + ) + with open(stylesheet_path, "r") as file_stream: + stylesheet = file_stream.read() - def focusOutEvent(self, event): # noqa - """For emitting signal on focus out.""" - self.focusOut.emit() - super().focusOutEvent(event) + return stylesheet - def focusInEvent(self, event): # noqa - """For emitting signal on focus in.""" - self.focusIn.emit() - super().focusInEvent(event) + +class ButtonWithOptions(QtWidgets.QFrame): + option_clicked = QtCore.Signal(str) + + def __init__(self, commands, parent=None): + super(ButtonWithOptions, self).__init__(parent) + + self.setObjectName("ButtonWithOptions") + + options_btn = QtWidgets.QToolButton(self) + options_btn.setArrowType(QtCore.Qt.DownArrow) + options_btn.setIconSize(QtCore.QSize(12, 12)) + + default = None + default_label = None + options_menu = QtWidgets.QMenu(self) + for option, option_label in commands.items(): + if default is None: + default = option + default_label = option_label + continue + action = QtWidgets.QAction(option_label, options_menu) + action.setData(option) + options_menu.addAction(action) + + main_btn = QtWidgets.QPushButton(default_label, self) + main_btn.setFlat(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(1) + + main_layout.addWidget(main_btn, 1, QtCore.Qt.AlignVCenter) + main_layout.addWidget(options_btn, 0, QtCore.Qt.AlignVCenter) + + main_btn.clicked.connect(self._on_main_button) + options_btn.clicked.connect(self._on_options_click) + options_menu.triggered.connect(self._on_trigger) + + self.main_btn = main_btn + self.options_btn = options_btn + self.options_menu = options_menu + + options_btn.setEnabled(not options_menu.isEmpty()) + + self._default_value = default + + def resizeEvent(self, event): + super(ButtonWithOptions, self).resizeEvent(event) + self.options_btn.setFixedHeight(self.main_btn.height()) + + def _on_options_click(self): + pos = self.main_btn.rect().bottomLeft() + point = self.main_btn.mapToGlobal(pos) + self.options_menu.popup(point) + + def _on_trigger(self, action): + self.option_clicked.emit(action.data()) + + def _on_main_button(self): + self.option_clicked.emit(self._default_value) + + +class NiceProgressBar(QtWidgets.QProgressBar): + def __init__(self, parent=None): + super(NiceProgressBar, self).__init__(parent) + self._real_value = 0 + + def setValue(self, value): + self._real_value = value + if value != 0 and value < 11: + value = 11 + + super(NiceProgressBar, self).setValue(value) + + def value(self): + return self._real_value + + def text(self): + return "{} %".format(self._real_value) + + +class ConsoleWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(ConsoleWidget, self).__init__(parent) + + # style for normal and error console text + default_console_style = QtGui.QTextCharFormat() + error_console_style = QtGui.QTextCharFormat() + default_console_style.setForeground( + QtGui.QColor.fromRgb(72, 200, 150) + ) + error_console_style.setForeground( + QtGui.QColor.fromRgb(184, 54, 19) + ) + + label = QtWidgets.QLabel("Console:", self) + + console_output = QtWidgets.QPlainTextEdit(self) + console_output.setMinimumSize(QtCore.QSize(300, 200)) + console_output.setReadOnly(True) + console_output.setCurrentCharFormat(default_console_style) + console_output.setObjectName("Console") + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(label, 0) + main_layout.addWidget(console_output, 1) + + self.default_console_style = default_console_style + self.error_console_style = error_console_style + + self.label = label + self.console_output = console_output + + self.hide_console() + + def hide_console(self): + self.label.setVisible(False) + self.console_output.setVisible(False) + + self.updateGeometry() + + def show_console(self): + self.label.setVisible(True) + self.console_output.setVisible(True) + + self.updateGeometry() + + def update_console(self, msg: str, error: bool = False) -> None: + if not error: + self.console_output.setCurrentCharFormat( + self.default_console_style + ) + else: + self.console_output.setCurrentCharFormat( + self.error_console_style + ) + self.console_output.appendPlainText(msg) + + +class MongoUrlInput(QtWidgets.QLineEdit): + """Widget to input mongodb URL.""" + + def set_valid(self): + """Set valid state on mongo url input.""" + self.setProperty("state", "valid") + self.style().polish(self) + + def remove_state(self): + """Set invalid state on mongo url input.""" + self.setProperty("state", "") + self.style().polish(self) + + def set_invalid(self): + """Set invalid state on mongo url input.""" + self.setProperty("state", "invalid") + self.style().polish(self) class InstallDialog(QtWidgets.QDialog): """Main Igniter dialog window.""" - _size_w = 400 - _size_h = 600 - path = "" - _controls_disabled = False + + mongo_url_regex = re.compile(r"^(mongodb|mongodb\+srv)://.*?") + + _width = 500 + _height = 200 + commands = collections.OrderedDict([ + ("run", "Start"), + ("run_from_code", "Run from code") + ]) def __init__(self, parent=None): super(InstallDialog, self).__init__(parent) - self.secure_registry = OpenPypeSecureRegistry("mongodb") - self.mongo_url = "" + self.setWindowTitle( + f"OpenPype Igniter {__version__}" + ) + self.setWindowFlags( + QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + + current_dir = os.path.dirname(os.path.abspath(__file__)) + roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") + poppins_font_path = os.path.join(current_dir, "Poppins") + icon_path = os.path.join(current_dir, "openpype_icon.png") + + # Install roboto font + QtGui.QFontDatabase.addApplicationFont(roboto_font_path) + for filename in os.listdir(poppins_font_path): + if os.path.splitext(filename)[1] == ".ttf": + QtGui.QFontDatabase.addApplicationFont(filename) + + # Load logo + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + # Set logo as icon of window + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + secure_registry = OpenPypeSecureRegistry("mongodb") + mongo_url = "" try: - self.mongo_url = ( + mongo_url = ( os.getenv("OPENPYPE_MONGO", "") - or self.secure_registry.get_item("openPypeMongo") + or secure_registry.get_item("openPypeMongo") ) except ValueError: pass - self.setWindowTitle( - f"OpenPype Igniter {__version__} - OpenPype installation") - self._icon_path = os.path.join( - os.path.dirname(__file__), 'openpype_icon.png') - icon = QtGui.QIcon(self._icon_path) - self.setWindowIcon(icon) - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint - ) + self.mongo_url = mongo_url + self._pixmap_openpype_logo = pixmap_openpype_logo - self.setMinimumSize( - QtCore.QSize(self._size_w, self._size_h)) - self.setMaximumSize( - QtCore.QSize(self._size_w + 100, self._size_h + 500)) - - # style for normal console text - self.default_console_style = QtGui.QTextCharFormat() - # self.default_console_style.setFontPointSize(0.1) - self.default_console_style.setForeground( - QtGui.QColor.fromRgb(72, 200, 150)) - - # style for error console text - self.error_console_style = QtGui.QTextCharFormat() - # self.error_console_style.setFontPointSize(0.1) - self.error_console_style.setForeground( - QtGui.QColor.fromRgb(184, 54, 19)) - - QtGui.QFontDatabase.addApplicationFont( - os.path.join( - os.path.dirname(__file__), 'RobotoMono-Regular.ttf') - ) - self._openpype_run_ready = False + self._secure_registry = secure_registry + self._controls_disabled = False + self._install_thread = None + self.resize(QtCore.QSize(self._width, self._height)) self._init_ui() + # Set stylesheet + self.setStyleSheet(load_stylesheet()) + + # Trigger Mongo URL validation + self._mongo_input.setText(self.mongo_url) + def _init_ui(self): # basic visual style - dark background, light text - self.setStyleSheet(""" - color: rgb(200, 200, 200); - background-color: rgb(23, 23, 23); - """) - - main = QtWidgets.QVBoxLayout(self) # Main info # -------------------------------------------------------------------- - self.main_label = QtWidgets.QLabel( - """Welcome to OpenPype -
- We've detected OpenPype is not configured yet. But don't worry, - this is as easy as setting one or two things. -
- """) - self.main_label.setWordWrap(True) - self.main_label.setStyleSheet("color: rgb(200, 200, 200);") - - # OpenPype path info - # -------------------------------------------------------------------- - - self.openpype_path_label = QtWidgets.QLabel( - """This is Path to studio location where OpenPype versions - are stored. It will be pre-filled if your MongoDB connection is - already set and your studio defined this location. -
- Leave it empty if you want to install OpenPype version that - comes with this installation. -
-- If you want to just try OpenPype without installing, hit the - middle button that states "run without installation". -
- """ - ) - - self.openpype_path_label.setWordWrap(True) - self.openpype_path_label.setStyleSheet("color: rgb(150, 150, 150);") - - # Path/Url box | Select button - # -------------------------------------------------------------------- - - input_layout = QtWidgets.QHBoxLayout() - - input_layout.setContentsMargins(0, 10, 0, 10) - self.user_input = FocusHandlingLineEdit() - - self.user_input.setPlaceholderText("Path to OpenPype versions") - self.user_input.textChanged.connect(self._path_changed) - self.user_input.setStyleSheet( - ("color: rgb(233, 233, 233);" - "background-color: rgb(64, 64, 64);" - "padding: 0.5em;" - "border: 1px solid rgb(32, 32, 32);") - ) - - self.user_input.setValidator(PathValidator(self.user_input)) - - self._btn_select = QtWidgets.QPushButton("Select") - self._btn_select.setToolTip( - "Select OpenPype repository" - ) - self._btn_select.setStyleSheet( - ("color: rgb(64, 64, 64);" - "background-color: rgb(72, 200, 150);" - "padding: 0.5em;") - ) - self._btn_select.setMaximumSize(100, 140) - self._btn_select.clicked.connect(self._on_select_clicked) - - input_layout.addWidget(self.user_input) - input_layout.addWidget(self._btn_select) + main_label = QtWidgets.QLabel("Welcome to OpenPype", self) + main_label.setWordWrap(True) + main_label.setObjectName("MainLabel") # Mongo box | OK button # -------------------------------------------------------------------- - - self.mongo_label = QtWidgets.QLabel( - """Enter URL for running MongoDB instance:""" + mongo_input = MongoUrlInput(self) + mongo_input.setPlaceholderText( + "Enter your database Address. Example: mongodb://192.168.1.10:2707" ) - self.mongo_label.setWordWrap(True) - self.mongo_label.setStyleSheet("color: rgb(150, 150, 150);") + mongo_messages_widget = QtWidgets.QWidget(self) - class MongoWidget(QtWidgets.QWidget): - """Widget to input mongodb URL.""" - - def __init__(self, parent=None): - self._btn_mongo = None - super(MongoWidget, self).__init__(parent) - mongo_layout = QtWidgets.QHBoxLayout() - mongo_layout.setContentsMargins(0, 0, 0, 0) - self._mongo_input = FocusHandlingLineEdit() - self._mongo_input.setPlaceholderText("Mongo URL") - self._mongo_input.textChanged.connect(self._mongo_changed) - self._mongo_input.focusIn.connect(self._focus_in) - self._mongo_input.focusOut.connect(self._focus_out) - self._mongo_input.setValidator( - MongoValidator(self._mongo_input)) - self._mongo_input.setStyleSheet( - ("color: rgb(233, 233, 233);" - "background-color: rgb(64, 64, 64);" - "padding: 0.5em;" - "border: 1px solid rgb(32, 32, 32);") - ) - - mongo_layout.addWidget(self._mongo_input) - self.setLayout(mongo_layout) - - def _focus_out(self): - self.validate_url() - - def _focus_in(self): - self._mongo_input.setStyleSheet( - """ - background-color: rgb(32, 32, 19); - color: rgb(255, 190, 15); - padding: 0.5em; - border: 1px solid rgb(64, 64, 32); - """ - ) - - def _mongo_changed(self, mongo: str): - self.parent().mongo_url = mongo - - def get_mongo_url(self) -> str: - """Helper to get url from parent.""" - return self.parent().mongo_url - - def set_mongo_url(self, mongo: str): - """Helper to set url to parent. - - Args: - mongo (str): mongodb url string. - - """ - self._mongo_input.setText(mongo) - - def set_valid(self): - """Set valid state on mongo url input.""" - self._mongo_input.setStyleSheet( - """ - background-color: rgb(19, 19, 19); - color: rgb(64, 230, 132); - padding: 0.5em; - border: 1px solid rgb(32, 64, 32); - """ - ) - self.parent().install_button.setEnabled(True) - - def set_invalid(self): - """Set invalid state on mongo url input.""" - self._mongo_input.setStyleSheet( - """ - background-color: rgb(32, 19, 19); - color: rgb(255, 69, 0); - padding: 0.5em; - border: 1px solid rgb(64, 32, 32); - """ - ) - self.parent().install_button.setEnabled(False) - - def set_read_only(self, state: bool): - """Set input read-only.""" - self._mongo_input.setReadOnly(state) - - def validate_url(self) -> bool: - """Validate if entered url is ok. - - Returns: - True if url is valid monogo string. - - """ - if self.parent().mongo_url == "": - return False - - is_valid, reason_str = validate_mongo_connection( - self.parent().mongo_url - ) - if not is_valid: - self.set_invalid() - self.parent().update_console(f"!!! {reason_str}", True) - return False - else: - self.set_valid() - return True - - self._mongo = MongoWidget(self) - if self.mongo_url: - self._mongo.set_mongo_url(self.mongo_url) - - # Bottom button bar - # -------------------------------------------------------------------- - bottom_widget = QtWidgets.QWidget() - bottom_layout = QtWidgets.QHBoxLayout() - openpype_logo_label = QtWidgets.QLabel("openpype logo") - openpype_logo = QtGui.QPixmap(self._icon_path) - # openpype_logo.scaled( - # openpype_logo_label.width(), - # openpype_logo_label.height(), QtCore.Qt.KeepAspectRatio) - openpype_logo_label.setPixmap(openpype_logo) - openpype_logo_label.setContentsMargins(10, 0, 0, 10) - - # install button - - - - - - - - - - - - - - - - - - - - - - - - - - - - self.install_button = QtWidgets.QPushButton("Install") - self.install_button.setStyleSheet( - ("color: rgb(64, 64, 64);" - "background-color: rgb(72, 200, 150);" - "padding: 0.5em;") + mongo_connection_msg = QtWidgets.QLabel(mongo_messages_widget) + mongo_connection_msg.setVisible(True) + mongo_connection_msg.setTextInteractionFlags( + QtCore.Qt.TextSelectableByMouse ) - self.install_button.setMinimumSize(64, 24) - self.install_button.setToolTip("Install OpenPype") - self.install_button.clicked.connect(self._on_ok_clicked) - # run from current button - - - - - - - - - - - - - - - - - - - - - - - self.run_button = QtWidgets.QPushButton("Run without installation") - self.run_button.setStyleSheet( - ("color: rgb(64, 64, 64);" - "background-color: rgb(200, 164, 64);" - "padding: 0.5em;") - ) - self.run_button.setMinimumSize(64, 24) - self.run_button.setToolTip("Run without installing Pype") - self.run_button.clicked.connect(self._on_run_clicked) - - # install button - - - - - - - - - - - - - - - - - - - - - - - - - - - - self._exit_button = QtWidgets.QPushButton("Exit") - self._exit_button.setStyleSheet( - ("color: rgb(64, 64, 64);" - "background-color: rgb(128, 128, 128);" - "padding: 0.5em;") - ) - self._exit_button.setMinimumSize(64, 24) - self._exit_button.setToolTip("Exit") - self._exit_button.clicked.connect(self._on_exit_clicked) - - bottom_layout.setContentsMargins(0, 10, 10, 0) - bottom_layout.setAlignment(QtCore.Qt.AlignVCenter) - bottom_layout.addWidget(openpype_logo_label, 0, QtCore.Qt.AlignVCenter) - bottom_layout.addStretch(1) - bottom_layout.addWidget(self.install_button, 0, QtCore.Qt.AlignVCenter) - bottom_layout.addWidget(self.run_button, 0, QtCore.Qt.AlignVCenter) - bottom_layout.addWidget(self._exit_button, 0, QtCore.Qt.AlignVCenter) - - bottom_widget.setLayout(bottom_layout) - bottom_widget.setStyleSheet("background-color: rgb(32, 32, 32);") - - # Console label - # -------------------------------------------------------------------- - self._status_label = QtWidgets.QLabel("Console:") - self._status_label.setContentsMargins(0, 10, 0, 10) - self._status_label.setStyleSheet("color: rgb(61, 115, 97);") - - # Console - # -------------------------------------------------------------------- - self._status_box = QtWidgets.QPlainTextEdit() - self._status_box.setReadOnly(True) - self._status_box.setCurrentCharFormat(self.default_console_style) - self._status_box.setStyleSheet( - """QPlainTextEdit { - background-color: rgb(32, 32, 32); - color: rgb(72, 200, 150); - font-family: "Roboto Mono"; - font-size: 0.5em; - border: 1px solid rgb(48, 48, 48); - } - QScrollBar:vertical { - border: 1px solid rgb(61, 115, 97); - background: #000; - width:5px; - margin: 0px 0px 0px 0px; - } - QScrollBar::handle:vertical { - background: rgb(72, 200, 150); - min-height: 0px; - } - QScrollBar::sub-page:vertical { - background: rgb(31, 62, 50); - } - QScrollBar::add-page:vertical { - background: rgb(31, 62, 50); - } - QScrollBar::add-line:vertical { - background: rgb(72, 200, 150); - height: 0px; - subcontrol-position: bottom; - subcontrol-origin: margin; - } - QScrollBar::sub-line:vertical { - background: rgb(72, 200, 150); - height: 0 px; - subcontrol-position: top; - subcontrol-origin: margin; - } - """ - ) + mongo_messages_layout = QtWidgets.QVBoxLayout(mongo_messages_widget) + mongo_messages_layout.setContentsMargins(0, 0, 0, 0) + mongo_messages_layout.addWidget(mongo_connection_msg) # Progress bar # -------------------------------------------------------------------- - self._progress_bar = QtWidgets.QProgressBar() - self._progress_bar.setValue(0) - self._progress_bar.setAlignment(QtCore.Qt.AlignCenter) - self._progress_bar.setTextVisible(False) - # setting font and the size - self._progress_bar.setFont(QtGui.QFont('Arial', 7)) - self._progress_bar.setStyleSheet( - """QProgressBar:horizontal { - height: 5px; - border: 1px solid rgb(31, 62, 50); - color: rgb(72, 200, 150); - } - QProgressBar::chunk:horizontal { - background-color: rgb(72, 200, 150); - } - """ + progress_bar = NiceProgressBar(self) + progress_bar.setAlignment(QtCore.Qt.AlignCenter) + progress_bar.setTextVisible(False) + + # Console + # -------------------------------------------------------------------- + console_widget = ConsoleWidget(self) + + # Bottom button bar + # -------------------------------------------------------------------- + bottom_widget = QtWidgets.QWidget(self) + + btns_widget = QtWidgets.QWidget(bottom_widget) + + openpype_logo_label = QtWidgets.QLabel("openpype logo", bottom_widget) + openpype_logo_label.setPixmap(self._pixmap_openpype_logo) + + run_button = ButtonWithOptions( + self.commands, + btns_widget ) + run_button.setMinimumSize(64, 24) + run_button.setToolTip("Run OpenPype") + + # install button - - - - - - - - - - - - - - - - - - - - - - - - - - - + exit_button = QtWidgets.QPushButton("Exit", btns_widget) + exit_button.setObjectName("ExitBtn") + exit_button.setFlat(True) + exit_button.setMinimumSize(64, 24) + exit_button.setToolTip("Exit") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(run_button, 0) + btns_layout.addWidget(exit_button, 0) + + bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) + bottom_layout.setContentsMargins(0, 0, 0, 0) + bottom_layout.setAlignment(QtCore.Qt.AlignHCenter) + bottom_layout.addWidget(openpype_logo_label, 0) + bottom_layout.addStretch(1) + bottom_layout.addWidget(btns_widget, 0) + # add all to main - main.addWidget(self.main_label, 0) - main.addWidget(self.openpype_path_label, 0) - main.addLayout(input_layout, 0) - main.addWidget(self.mongo_label, 0) - main.addWidget(self._mongo, 0) + main = QtWidgets.QVBoxLayout(self) + main.addSpacing(15) + main.addWidget(main_label, 0) + main.addSpacing(15) + main.addWidget(mongo_input, 0) + main.addWidget(mongo_messages_widget, 0) - main.addWidget(self._status_label, 0) - main.addWidget(self._status_box, 1) + main.addWidget(progress_bar, 0) + main.addSpacing(15) + + main.addWidget(console_widget, 1) - main.addWidget(self._progress_bar, 0) main.addWidget(bottom_widget, 0) - self.setLayout(main) + run_button.option_clicked.connect(self._on_run_btn_click) + exit_button.clicked.connect(self._on_exit_clicked) + mongo_input.textChanged.connect(self._on_mongo_url_change) - # if mongo url is ok, try to get openpype path from there - if self._mongo.validate_url() and len(self.path) == 0: - self.path = get_openpype_path_from_db(self.mongo_url) - self.user_input.setText(self.path) + self._console_widget = console_widget - def _on_select_clicked(self): - """Show directory dialog.""" - options = QtWidgets.QFileDialog.Options() - options |= QtWidgets.QFileDialog.DontUseNativeDialog - options |= QtWidgets.QFileDialog.ShowDirsOnly + self.main_label = main_label - result = QtWidgets.QFileDialog.getExistingDirectory( - parent=self, - caption='Select path', - directory=os.getcwd(), - options=options) + self._mongo_input = mongo_input - if not result: + self._mongo_connection_msg = mongo_connection_msg + + self._run_button = run_button + self._exit_button = exit_button + self._progress_bar = progress_bar + + def _on_run_btn_click(self, option): + # Disable buttons + self._disable_buttons() + # Set progress to any value + self._update_progress(1) + self._progress_bar.repaint() + # Add label to show that is connecting to mongo + self.set_invalid_mongo_connection(self.mongo_url, True) + + # Process events to repaint changes + QtWidgets.QApplication.processEvents() + + if not self.validate_url(): + self._enable_buttons() + self._update_progress(0) + # Update any messages + self._mongo_input.setText(self.mongo_url) return - filename = QtCore.QDir.toNativeSeparators(result) - - if os.path.isdir(filename): - self.path = filename - self.user_input.setText(filename) - - def _on_run_clicked(self): - valid, reason = validate_mongo_connection( - self._mongo.get_mongo_url() - ) - if not valid: - self._mongo.set_invalid() - self.update_console(f"!!! {reason}", True) - return + if option == "run": + self._run_openpype() + elif option == "run_from_code": + self._run_openpype_from_code() else: - self._mongo.set_valid() + raise AssertionError("BUG: Unknown variant \"{}\"".format(option)) + + self._enable_buttons() + + def _run_openpype_from_code(self): + os.environ["OPENPYPE_MONGO"] = self.mongo_url + try: + self._secure_registry.set_item("openPypeMongo", self.mongo_url) + except ValueError: + print("Couldn't save Mongo URL to keyring") self.done(2) - def _on_ok_clicked(self): + def _run_openpype(self): """Start install process. This will once again validate entered path and mongo if ok, start working thread that will do actual job. """ - valid, reason = validate_mongo_connection( - self._mongo.get_mongo_url() - ) - if not valid: - self._mongo.set_invalid() - self.update_console(f"!!! {reason}", True) - return - else: - self._mongo.set_valid() - - if self._openpype_run_ready: - self.done(3) + # Check if install thread is not already running + if self._install_thread and self._install_thread.isRunning(): return - if self.path and len(self.path) > 0: - valid, reason = validate_path_string(self.path) + self._mongo_input.set_valid() - if not valid: - self.update_console(f"!!! {reason}", True) - return + install_thread = InstallThread(self) + install_thread.message.connect(self.update_console) + install_thread.progress.connect(self._update_progress) + install_thread.finished.connect(self._installation_finished) + install_thread.set_mongo(self.mongo_url) - self._disable_buttons() - self._install_thread = InstallThread( - self.install_result_callback_handler, self) - self._install_thread.message.connect(self.update_console) - self._install_thread.progress.connect(self._update_progress) - self._install_thread.finished.connect(self._enable_buttons) - self._install_thread.set_path(self.path) - self._install_thread.set_mongo(self._mongo.get_mongo_url()) - self._install_thread.start() + self._install_thread = install_thread - def install_result_callback_handler(self, result: InstallResult): - """Change button behaviour based on installation outcome.""" - status = result.status + install_thread.start() + + def _installation_finished(self): + status = self._install_thread.result() if status >= 0: - self.install_button.setText("Run installed OpenPype") - self._openpype_run_ready = True + self._update_progress(100) + QtWidgets.QApplication.processEvents() + self.done(3) + else: + self._show_console() def _update_progress(self, progress: int): self._progress_bar.setValue(progress) + text_visible = self._progress_bar.isTextVisible() + if progress == 0: + if text_visible: + self._progress_bar.setTextVisible(False) + elif not text_visible: + self._progress_bar.setTextVisible(True) def _on_exit_clicked(self): self.reject() - def _path_changed(self, path: str) -> str: - """Set path.""" - self.path = path - return path + def _on_mongo_url_change(self, new_value): + # Strip the value + new_value = new_value.strip() + # Store new mongo url to variable + self.mongo_url = new_value + + msg = None + # Change style of input + if not new_value: + self._mongo_input.remove_state() + elif not self.mongo_url_regex.match(new_value): + self._mongo_input.set_invalid() + msg = ( + "Mongo URL should start with" + " \"mongodb://\" or \"mongodb+srv://\"" + ) + else: + self._mongo_input.set_valid() + + self.set_invalid_mongo_url(msg) + + def validate_url(self): + """Validate if entered url is ok. + + Returns: + True if url is valid monogo string. + + """ + if self.mongo_url == "": + return False + + is_valid, reason_str = validate_mongo_connection(self.mongo_url) + if not is_valid: + self.set_invalid_mongo_connection(self.mongo_url) + self._mongo_input.set_invalid() + self.update_console(f"!!! {reason_str}", True) + return False + + self.set_invalid_mongo_connection(None) + self._mongo_input.set_valid() + return True + + def set_invalid_mongo_url(self, reason): + if reason is None: + self._mongo_connection_msg.setText("") + else: + self._mongo_connection_msg.setText("- {}".format(reason)) + + def set_invalid_mongo_connection(self, mongo_url, connecting=False): + if mongo_url is None: + self.set_invalid_mongo_url(mongo_url) + return + + if connecting: + msg = "Connecting to: {}".format(mongo_url) + else: + msg = "Can't connect to: {}".format(mongo_url) + + self.set_invalid_mongo_url(msg) def update_console(self, msg: str, error: bool = False) -> None: """Display message in console. @@ -523,26 +504,22 @@ class InstallDialog(QtWidgets.QDialog): msg (str): message. error (bool): if True, print it red. """ - if not error: - self._status_box.setCurrentCharFormat(self.default_console_style) - else: - self._status_box.setCurrentCharFormat(self.error_console_style) - self._status_box.appendPlainText(msg) + self._console_widget.update_console(msg, error) + + def _show_console(self): + self._console_widget.show_console() + self.updateGeometry() def _disable_buttons(self): """Disable buttons so user interaction doesn't interfere.""" - self._btn_select.setEnabled(False) - self.run_button.setEnabled(False) self._exit_button.setEnabled(False) - self.install_button.setEnabled(False) + self._run_button.setEnabled(False) self._controls_disabled = True def _enable_buttons(self): """Enable buttons after operation is complete.""" - self._btn_select.setEnabled(True) - self.run_button.setEnabled(True) self._exit_button.setEnabled(True) - self.install_button.setEnabled(True) + self._run_button.setEnabled(True) self._controls_disabled = False def closeEvent(self, event): # noqa @@ -552,212 +529,6 @@ class InstallDialog(QtWidgets.QDialog): return super(InstallDialog, self).closeEvent(event) -class MongoValidator(QValidator): - """Validate mongodb url for Qt widgets.""" - - def __init__(self, parent=None, intermediate=False): - self.parent = parent - self.intermediate = intermediate - self._validate_lock = False - self.timer = QTimer() - self.timer.timeout.connect(self._unlock_validator) - super().__init__(parent) - - def _unlock_validator(self): - self._validate_lock = False - - def _return_state( - self, state: QValidator.State, reason: str, mongo: str): - """Set stylesheets and actions on parent based on state. - - Warning: - This will always return `QValidator.State.Acceptable` as - anything different will stop input to `QLineEdit` - - """ - - if state == QValidator.State.Invalid: - self.parent.setToolTip(reason) - self.parent.setStyleSheet( - """ - background-color: rgb(32, 19, 19); - color: rgb(255, 69, 0); - padding: 0.5em; - border: 1px solid rgb(64, 32, 32); - """ - ) - elif state == QValidator.State.Intermediate and self.intermediate: - self.parent.setToolTip(reason) - self.parent.setStyleSheet( - """ - background-color: rgb(32, 32, 19); - color: rgb(255, 190, 15); - padding: 0.5em; - border: 1px solid rgb(64, 64, 32); - """ - ) - else: - self.parent.setToolTip(reason) - self.parent.setStyleSheet( - """ - background-color: rgb(19, 19, 19); - color: rgb(64, 230, 132); - padding: 0.5em; - border: 1px solid rgb(32, 64, 32); - """ - ) - - return QValidator.State.Acceptable, mongo, len(mongo) - - def validate(self, mongo: str, pos: int) -> (QValidator.State, str, int): # noqa - """Validate entered mongodb connection string. - - As url (it should start with `mongodb://` or - `mongodb+srv:// url schema. - - Args: - mongo (str): connection string url. - pos (int): current position. - - Returns: - (QValidator.State.Acceptable, str, int): - Indicate input state with color and always return - Acceptable state as we need to be able to edit input further. - - """ - if not mongo.startswith("mongodb"): - return self._return_state( - QValidator.State.Invalid, "need mongodb schema", mongo) - - return self._return_state( - QValidator.State.Intermediate, "", mongo) - - -class PathValidator(MongoValidator): - """Validate mongodb url for Qt widgets.""" - - def validate(self, path: str, pos: int) -> (QValidator.State, str, int): # noqa - """Validate path to be accepted by Igniter. - - Args: - path (str): path to OpenPype. - pos (int): current position. - - Returns: - (QValidator.State.Acceptable, str, int): - Indicate input state with color and always return - Acceptable state as we need to be able to edit input further. - - """ - # allow empty path as that will use current version coming with - # OpenPype Igniter - if len(path) == 0: - return self._return_state( - QValidator.State.Acceptable, "Use version with Igniter", path) - - if len(path) > 3: - valid, reason = validate_path_string(path) - if not valid: - return self._return_state( - QValidator.State.Invalid, reason, path) - else: - return self._return_state( - QValidator.State.Acceptable, reason, path) - - -class CollapsibleWidget(QtWidgets.QWidget): - """Collapsible widget to hide mongo url in necessary.""" - - def __init__(self, parent=None, title: str = "", animation: int = 300): - self._mainLayout = QtWidgets.QGridLayout(parent) - self._toggleButton = QtWidgets.QToolButton(parent) - self._headerLine = QtWidgets.QFrame(parent) - self._toggleAnimation = QtCore.QParallelAnimationGroup(parent) - self._contentArea = QtWidgets.QScrollArea(parent) - self._animation = animation - self._title = title - super(CollapsibleWidget, self).__init__(parent) - self._init_ui() - - def _init_ui(self): - self._toggleButton.setStyleSheet( - """QToolButton { - border: none; - } - """) - self._toggleButton.setToolButtonStyle( - QtCore.Qt.ToolButtonTextBesideIcon) - - self._toggleButton.setArrowType(QtCore.Qt.ArrowType.RightArrow) - self._toggleButton.setText(self._title) - self._toggleButton.setCheckable(True) - self._toggleButton.setChecked(False) - - self._headerLine.setFrameShape(QtWidgets.QFrame.HLine) - self._headerLine.setFrameShadow(QtWidgets.QFrame.Sunken) - self._headerLine.setSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Maximum) - - self._contentArea.setStyleSheet( - """QScrollArea { - background-color: rgb(32, 32, 32); - border: none; - } - """) - self._contentArea.setSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Fixed) - self._contentArea.setMaximumHeight(0) - self._contentArea.setMinimumHeight(0) - - self._toggleAnimation.addAnimation( - QtCore.QPropertyAnimation(self, b"minimumHeight")) - self._toggleAnimation.addAnimation( - QtCore.QPropertyAnimation(self, b"maximumHeight")) - self._toggleAnimation.addAnimation( - QtCore.QPropertyAnimation(self._contentArea, b"maximumHeight")) - - self._mainLayout.setVerticalSpacing(0) - self._mainLayout.setContentsMargins(0, 0, 0, 0) - - row = 0 - - self._mainLayout.addWidget( - self._toggleButton, row, 0, 1, 1, QtCore.Qt.AlignCenter) - self._mainLayout.addWidget( - self._headerLine, row, 2, 1, 1) - row += row - self._mainLayout.addWidget(self._contentArea, row, 0, 1, 3) - self.setLayout(self._mainLayout) - - self._toggleButton.toggled.connect(self._toggle_action) - - def _toggle_action(self, collapsed: bool): - arrow = QtCore.Qt.ArrowType.DownArrow if collapsed else QtCore.Qt.ArrowType.RightArrow # noqa: E501 - direction = QtCore.QAbstractAnimation.Forward if collapsed else QtCore.QAbstractAnimation.Backward # noqa: E501 - self._toggleButton.setArrowType(arrow) - self._toggleAnimation.setDirection(direction) - self._toggleAnimation.start() - - def setContentLayout(self, content_layout: QtWidgets.QLayout): # noqa - self._contentArea.setLayout(content_layout) - collapsed_height = \ - self.sizeHint().height() - self._contentArea.maximumHeight() - content_height = self._contentArea.sizeHint().height() - - for i in range(self._toggleAnimation.animationCount() - 1): - sec_anim = self._toggleAnimation.animationAt(i) - sec_anim.setDuration(self._animation) - sec_anim.setStartValue(collapsed_height) - sec_anim.setEndValue(collapsed_height + content_height) - - con_anim = self._toggleAnimation.animationAt( - self._toggleAnimation.animationCount() - 1) - - con_anim.setDuration(self._animation) - con_anim.setStartValue(0) - con_anim.setEndValue(collapsed_height + content_height) - - if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) d = InstallDialog() diff --git a/igniter/install_thread.py b/igniter/install_thread.py index df8b830209..383012b88b 100644 --- a/igniter/install_thread.py +++ b/igniter/install_thread.py @@ -17,12 +17,6 @@ from .bootstrap_repos import ( from .tools import validate_mongo_connection -class InstallResult(QObject): - """Used to pass results back.""" - def __init__(self, value): - self.status = value - - class InstallThread(QThread): """Install Worker thread. @@ -36,15 +30,22 @@ class InstallThread(QThread): """ progress = Signal(int) message = Signal((str, bool)) - finished = Signal(object) - def __init__(self, callback, parent=None,): + def __init__(self, parent=None,): self._mongo = None self._path = None - self.result_callback = callback + self._result = None QThread.__init__(self, parent) - self.finished.connect(callback) + + def result(self): + """Result of finished installation.""" + return self._result + + def _set_result(self, value): + if self._result is not None: + raise AssertionError("BUG: Result was set more than once!") + self._result = value def run(self): """Thread entry point. @@ -76,7 +77,7 @@ class InstallThread(QThread): except ValueError: self.message.emit( "!!! We need MongoDB URL to proceed.", True) - self.finished.emit(InstallResult(-1)) + self._set_result(-1) return else: self._mongo = os.getenv("OPENPYPE_MONGO") @@ -101,7 +102,7 @@ class InstallThread(QThread): self.message.emit("Skipping OpenPype install ...", False) if detected[-1].path.suffix.lower() == ".zip": bs.extract_openpype(detected[-1]) - self.finished.emit(InstallResult(0)) + self._set_result(0) return if OpenPypeVersion(version=local_version).get_main_version() == detected[-1].get_main_version(): # noqa @@ -110,7 +111,7 @@ class InstallThread(QThread): f"currently running {local_version}" ), False) self.message.emit("Skipping OpenPype install ...", False) - self.finished.emit(InstallResult(0)) + self._set_result(0) return self.message.emit(( @@ -126,13 +127,13 @@ class InstallThread(QThread): if not openpype_version: self.message.emit( f"!!! Install failed - {openpype_version}", True) - self.finished.emit(InstallResult(-1)) + self._set_result(-1) return self.message.emit(f"Using: {openpype_version}", False) bs.install_version(openpype_version) self.message.emit(f"Installed as {openpype_version}", False) self.progress.emit(100) - self.finished.emit(InstallResult(1)) + self._set_result(1) return else: self.message.emit("None detected.", False) @@ -144,7 +145,7 @@ class InstallThread(QThread): if not local_openpype: self.message.emit( f"!!! Install failed - {local_openpype}", True) - self.finished.emit(InstallResult(-1)) + self._set_result(-1) return try: @@ -154,11 +155,12 @@ class InstallThread(QThread): OpenPypeVersionIOError) as e: self.message.emit(f"Installed failed: ", True) self.message.emit(str(e), True) - self.finished.emit(InstallResult(-1)) + self._set_result(-1) return self.message.emit(f"Installed as {local_openpype}", False) self.progress.emit(100) + self._set_result(1) return else: # if we have mongo connection string, validate it, set it to @@ -167,7 +169,7 @@ class InstallThread(QThread): if not validate_mongo_connection(self._mongo): self.message.emit( f"!!! invalid mongo url {self._mongo}", True) - self.finished.emit(InstallResult(-1)) + self._set_result(-1) return bs.secure_registry.set_item("openPypeMongo", self._mongo) os.environ["OPENPYPE_MONGO"] = self._mongo @@ -177,11 +179,11 @@ class InstallThread(QThread): if not repo_file: self.message.emit("!!! Cannot install", True) - self.finished.emit(InstallResult(-1)) + self._set_result(-1) return self.progress.emit(100) - self.finished.emit(InstallResult(1)) + self._set_result(1) return def set_path(self, path: str) -> None: diff --git a/igniter/stylesheet.css b/igniter/stylesheet.css new file mode 100644 index 0000000000..8df2621d83 --- /dev/null +++ b/igniter/stylesheet.css @@ -0,0 +1,280 @@ +*{ + font-size: 10pt; + font-family: "Poppins"; +} + +QWidget { + color: #bfccd6; + background-color: #282C34; + border-radius: 0px; +} + +QMenu { + border: 1px solid #555555; + background-color: #21252B; +} + +QMenu::item { + padding: 5px 10px 5px 10px; + border-left: 5px solid #313741;; +} + +QMenu::item:selected { + border-left-color: rgb(84, 209, 178); + background-color: #222d37; +} + +QLineEdit, QPlainTextEdit { + border: 1px solid #464b54; + border-radius: 3px; + background-color: #21252B; + padding: 0.5em; +} + +QLineEdit[state="valid"] { + background-color: rgb(19, 19, 19); + color: rgb(64, 230, 132); + border-color: rgb(32, 64, 32); +} + +QLineEdit[state="invalid"] { + background-color: rgb(32, 19, 19); + color: rgb(255, 69, 0); + border-color: rgb(64, 32, 32); +} + +QLabel { + background: transparent; + color: #969b9e; +} + +QLabel:hover {color: #b8c1c5;} + +QPushButton { + border: 1px solid #aaaaaa; + border-radius: 3px; + padding: 5px; +} + +QPushButton:hover { + background-color: #333840; + border: 1px solid #fff; + color: #fff; +} + +QTableView { + border: 1px solid #444; + gridline-color: #6c6c6c; + background-color: #201F1F; + alternate-background-color:#21252B; +} + +QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { + background: #78879b; + color: #FFFFFF; +} + +QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { + background: #3d8ec9; +} + +QProgressBar { + border: 1px solid grey; + border-radius: 10px; + color: #222222; + font-weight: bold; +} +QProgressBar:horizontal { + height: 20px; +} + +QProgressBar::chunk { + border-radius: 10px; + background-color: qlineargradient( + x1: 0, + y1: 0.5, + x2: 1, + y2: 0.5, + stop: 0 rgb(72, 200, 150), + stop: 1 rgb(82, 172, 215) + ); +} + + +QScrollBar:horizontal { + height: 15px; + margin: 3px 15px 3px 15px; + border: 1px transparent #21252B; + border-radius: 4px; + background-color: #21252B; +} + +QScrollBar::handle:horizontal { + background-color: #4B5362; + min-width: 5px; + border-radius: 4px; +} + +QScrollBar::add-line:horizontal { + margin: 0px 3px 0px 3px; + border-image: url(:/qss_icons/rc/right_arrow_disabled.png); + width: 10px; + height: 10px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal { + margin: 0px 3px 0px 3px; + border-image: url(:/qss_icons/rc/left_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::add-line:horizontal:hover,QScrollBar::add-line:horizontal:on { + border-image: url(:/qss_icons/rc/right_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal:hover, QScrollBar::sub-line:horizontal:on { + border-image: url(:/qss_icons/rc/left_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal { + background: none; +} + +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: none; +} + +QScrollBar:vertical { + background-color: #21252B; + width: 15px; + margin: 15px 3px 15px 3px; + border: 1px transparent #21252B; + border-radius: 4px; +} + +QScrollBar::handle:vertical { + background-color: #4B5362; + min-height: 5px; + border-radius: 4px; +} + +QScrollBar::sub-line:vertical { + margin: 3px 0px 3px 0px; + border-image: url(:/qss_icons/rc/up_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +QScrollBar::add-line:vertical { + margin: 3px 0px 3px 0px; + border-image: url(:/qss_icons/rc/down_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical:hover,QScrollBar::sub-line:vertical:on { + + border-image: url(:/qss_icons/rc/up_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: top; + subcontrol-origin: margin; +} + + +QScrollBar::add-line:vertical:hover, QScrollBar::add-line:vertical:on { + border-image: url(:/qss_icons/rc/down_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical { + background: none; +} + + +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: none; +} + +#MainLabel { + color: rgb(200, 200, 200); + font-size: 12pt; +} + +#Console { + background-color: #21252B; + color: rgb(72, 200, 150); + font-family: "Roboto Mono"; + font-size: 8pt; +} + +#ExitBtn { + /* `border` must be set to background of flat button is painted .*/ + border: none; + color: rgb(39, 39, 39); + background-color: #828a97; + padding: 0.5em; + font-weight: 400; +} + +#ExitBtn:hover{ + background-color: #b2bece +} +#ExitBtn:disabled { + background-color: rgba(185, 185, 185, 31); + color: rgba(64, 64, 64, 63); +} + +#ButtonWithOptions QPushButton{ + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border: none; + background-color: rgb(84, 209, 178); + color: rgb(39, 39, 39); + font-weight: 400; + padding: 0.5em; +} +#ButtonWithOptions QPushButton:hover{ + background-color: rgb(85, 224, 189) +} +#ButtonWithOptions QPushButton:disabled { + background-color: rgba(72, 200, 150, 31); + color: rgba(64, 64, 64, 63); +} + +#ButtonWithOptions QToolButton{ + border: none; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + background-color: rgb(84, 209, 178); + color: rgb(39, 39, 39); +} +#ButtonWithOptions QToolButton:hover{ + background-color: rgb(85, 224, 189) +} +#ButtonWithOptions QToolButton:disabled { + background-color: rgba(72, 200, 150, 31); + color: rgba(64, 64, 64, 63); +} diff --git a/igniter/tools.py b/igniter/tools.py index 368e9a2b3d..529d535c25 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -14,7 +14,12 @@ from pathlib import Path import platform from pymongo import MongoClient -from pymongo.errors import ServerSelectionTimeoutError, InvalidURI +from pymongo.errors import ( + ServerSelectionTimeoutError, + InvalidURI, + ConfigurationError, + OperationFailure +) def decompose_url(url: str) -> Dict: @@ -115,30 +120,20 @@ def validate_mongo_connection(cnx: str) -> (bool, str): parsed = urlparse(cnx) if parsed.scheme not in ["mongodb", "mongodb+srv"]: return False, "Not mongodb schema" - # we have mongo connection string. Let's try if we can connect. - try: - components = decompose_url(cnx) - except RuntimeError: - return False, f"Invalid port specified." - - mongo_args = { - "host": compose_url(**components), - "serverSelectionTimeoutMS": 2000 - } - port = components.get("port") - if port is not None: - mongo_args["port"] = int(port) try: - client = MongoClient(cnx) + client = MongoClient( + cnx, + serverSelectionTimeoutMS=2000 + ) client.server_info() client.close() except ServerSelectionTimeoutError as e: return False, f"Cannot connect to server {cnx} - {e}" except ValueError: return False, f"Invalid port specified {parsed.port}" - except InvalidURI as e: - return False, str(e) + except (ConfigurationError, OperationFailure, InvalidURI) as exc: + return False, str(exc) else: return True, "Connection is successful" diff --git a/igniter/version.py b/igniter/version.py index 8c8ffdccb7..4f8f0907e9 100644 --- a/igniter/version.py +++ b/igniter/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- """Definition of Igniter version.""" -__version__ = "1.0.0-beta" +__version__ = "1.0.0-rc1" diff --git a/inno_setup.iss b/inno_setup.iss new file mode 100644 index 0000000000..ead9907955 --- /dev/null +++ b/inno_setup.iss @@ -0,0 +1,50 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + + +#define MyAppName "OpenPype" +#define Build GetEnv("BUILD_DIR") +#define AppVer GetEnv("BUILD_VERSION") + + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{B9E9DF6A-5BDA-42DD-9F35-C09D564C4D93} +AppName={#MyAppName} +AppVersion={#AppVer} +AppVerName={#MyAppName} version {#AppVer} +AppPublisher=Orbi Tools s.r.o +AppPublisherURL=http://pype.club +AppSupportURL=http://pype.club +AppUpdatesURL=http://pype.club +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +OutputBaseFilename={#MyAppName}-{#AppVer}-install +AllowCancelDuringInstall=yes +; Uncomment the following line to run in non administrative install mode (install for current user only.) +;PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +SetupIconFile=igniter\openpype.ico +OutputDir=build\ +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "build\{#build}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\openpype_gui.exe" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\openpype_gui.exe"; Tasks: desktopicon + +[Run] +Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent + diff --git a/openpype/__init__.py b/openpype/__init__.py index edd48a018d..f63d534e08 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -9,6 +9,7 @@ from .settings import get_project_settings from .lib import ( Anatomy, filter_pyblish_plugins, + set_plugin_attributes_from_settings, change_timer_to_current_context ) @@ -58,38 +59,8 @@ def patched_discover(superclass): # run original discover and get plugins plugins = _original_discover(superclass) - # determine host application to use for finding presets - if avalon.registered_host() is None: - return plugins - host = avalon.registered_host().__name__.split(".")[-1] + set_plugin_attributes_from_settings(plugins, superclass) - # map plugin superclass to preset json. Currenly suppoted is load and - # create (avalon.api.Loader and avalon.api.Creator) - plugin_type = "undefined" - if superclass.__name__.split(".")[-1] == "Loader": - plugin_type = "load" - elif superclass.__name__.split(".")[-1] == "Creator": - plugin_type = "create" - - print(">>> Finding presets for {}:{} ...".format(host, plugin_type)) - try: - settings = ( - get_project_settings(os.environ['AVALON_PROJECT']) - [host][plugin_type] - ) - except KeyError: - print("*** no presets found.") - else: - for plugin in plugins: - if plugin.__name__ in settings: - print(">>> We have preset for {}".format(plugin.__name__)) - for option, value in settings[plugin.__name__].items(): - if option == "enabled" and value is False: - setattr(plugin, "active", False) - print(" - is disabled by preset") - else: - setattr(plugin, option, value) - print(" - setting `{}`: `{}`".format(option, value)) return plugins diff --git a/openpype/cli.py b/openpype/cli.py index c6da88cbc1..9c49825721 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -224,17 +224,6 @@ def launch(app, project, asset, task, PypeCommands().run_application(app, project, asset, task, tools, arguments) -@main.command() -@click.option("-p", "--path", help="Path to zip file", default=None) -def generate_zip(path): - """Generate Pype zip from current sources. - - If PATH is not provided, it will create zip file in user data dir. - - """ - PypeCommands().generate_zip(path) - - @main.command( context_settings=dict( ignore_unknown_options=True, diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index 99636e8dda..e914c26435 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 - max(frame_start - handle_start, 0) + duration = (frame_end - frame_start + 1) + handle_start + handle_end entity_type = asset_data.get("entityType") scene_data = { diff --git a/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py b/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py new file mode 100644 index 0000000000..d12e7665bf --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py @@ -0,0 +1,59 @@ +import os +import pyblish.api +import openpype.api + + +class ExtractThumnail(openpype.api.Extractor): + """ + Extractor for track item's tumnails + """ + + label = "Extract Thumnail" + order = pyblish.api.ExtractorOrder + families = ["plate", "take"] + hosts = ["hiero"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + staging_dir = self.staging_dir(instance) + + self.create_thumbnail(staging_dir, instance) + + def create_thumbnail(self, staging_dir, instance): + track_item = instance.data["item"] + track_item_name = track_item.name() + + # frames + duration = track_item.sourceDuration() + frame_start = track_item.sourceIn() + self.log.debug( + "__ frame_start: `{}`, duration: `{}`".format( + frame_start, duration)) + + # get thumbnail frame from the middle + thumb_frame = int(frame_start + (duration / 2)) + + thumb_file = "{}thumbnail{}{}".format( + track_item_name, thumb_frame, ".png") + thumb_path = os.path.join(staging_dir, thumb_file) + + thumbnail = track_item.thumbnail(thumb_frame).save( + thumb_path, + format='png' + ) + self.log.debug( + "__ thumb_path: `{}`, frame: `{}`".format(thumbnail, thumb_frame)) + + self.log.info("Thumnail was generated to: {}".format(thumb_path)) + thumb_representation = { + 'files': thumb_file, + 'stagingDir': staging_dir, + 'name': "thumbnail", + 'thumbnail': True, + 'ext': "png" + } + instance.data["representations"].append( + thumb_representation) diff --git a/openpype/hosts/hiero/plugins/publish/extract_workfile.py b/openpype/hosts/hiero/plugins/publish/extract_workfile.py deleted file mode 100644 index e3d60465a2..0000000000 --- a/openpype/hosts/hiero/plugins/publish/extract_workfile.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import pyblish.api -import openpype.api -from openpype.hosts import resolve - - -class ExtractWorkfile(openpype.api.Extractor): - """ - Extractor export DRP workfile file representation - """ - - label = "Extract Workfile" - order = pyblish.api.ExtractorOrder - families = ["workfile"] - hosts = ["resolve"] - - def process(self, instance): - # create representation data - if "representations" not in instance.data: - instance.data["representations"] = [] - - name = instance.data["name"] - project = instance.context.data["activeProject"] - staging_dir = self.staging_dir(instance) - - resolve_workfile_ext = ".drp" - drp_file_name = name + resolve_workfile_ext - drp_file_path = os.path.normpath( - os.path.join(staging_dir, drp_file_name)) - - # write out the drp workfile - resolve.get_project_manager().ExportProject( - project.GetName(), drp_file_path) - - # create drp workfile representation - representation_drp = { - 'name': resolve_workfile_ext[1:], - 'ext': resolve_workfile_ext[1:], - 'files': drp_file_name, - "stagingDir": staging_dir, - } - - instance.data["representations"].append(representation_drp) - - # add sourcePath attribute to instance - if not instance.data.get("sourcePath"): - instance.data["sourcePath"] = drp_file_path - - self.log.info("Added Resolve file representation: {}".format( - representation_drp)) diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/version_up_workfile.py b/openpype/hosts/hiero/plugins/publish/integrate_version_up_workfile.py similarity index 90% rename from openpype/hosts/hiero/plugins/publish_old_workflow/version_up_workfile.py rename to openpype/hosts/hiero/plugins/publish/integrate_version_up_workfile.py index ae03513d78..934e7112fa 100644 --- a/openpype/hosts/hiero/plugins/publish_old_workflow/version_up_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/integrate_version_up_workfile.py @@ -2,7 +2,7 @@ from pyblish import api import openpype.api as pype -class VersionUpWorkfile(api.ContextPlugin): +class IntegrateVersionUpWorkfile(api.ContextPlugin): """Save as new workfile version""" order = api.IntegratorOrder + 10.1 diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index 22201cafe3..bc4ef7e150 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -1,10 +1,12 @@ +import os import pyblish.api import hiero.ui from openpype.hosts.hiero import api as phiero from avalon import api as avalon from pprint import pformat from openpype.hosts.hiero.otio import hiero_export - +from Qt.QtGui import QPixmap +import tempfile class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" @@ -23,12 +25,46 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): # adding otio timeline to context otio_timeline = hiero_export.create_otio_timeline() + # get workfile thumnail paths + tmp_staging = tempfile.mkdtemp(prefix="pyblish_tmp_") + thumbnail_name = "workfile_thumbnail.png" + thumbnail_path = os.path.join(tmp_staging, thumbnail_name) + + # search for all windows with name of actual sequence + _windows = [w for w in hiero.ui.windowManager().windows() + if active_timeline.name() in w.windowTitle()] + + # export window to thumb path + QPixmap.grabWidget(_windows[-1]).save(thumbnail_path, 'png') + + # thumbnail + thumb_representation = { + 'files': thumbnail_name, + 'stagingDir': tmp_staging, + 'name': "thumbnail", + 'thumbnail': True, + 'ext': "png" + } + + # get workfile paths + curent_file = project.path() + staging_dir, base_name = os.path.split(curent_file) + + # creating workfile representation + workfile_representation = { + 'name': 'hrox', + 'ext': 'hrox', + 'files': base_name, + "stagingDir": staging_dir, + } + instance_data = { "name": "{}_{}".format(asset, subset), "asset": asset, "subset": "{}{}".format(asset, subset.capitalize()), "item": project, - "family": "workfile" + "family": "workfile", + "representations": [workfile_representation, thumb_representation] } # create instance with workfile @@ -38,7 +74,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin): context_data = { "activeProject": project, "otioTimeline": otio_timeline, - "currentFile": project.path(), + "currentFile": curent_file, "fps": fps, } context.data.update(context_data) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index ae2d329a97..a83ff98c99 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -1872,7 +1872,7 @@ def set_context_settings(): # Set project fps fps = asset_data.get("fps", project_data.get("fps", 25)) - api.Session["AVALON_FPS"] = fps + api.Session["AVALON_FPS"] = str(fps) set_scene_fps(fps) # Set project resolution diff --git a/openpype/hosts/maya/plugins/publish/collect_remove_marked.py b/openpype/hosts/maya/plugins/publish/collect_remove_marked.py index a45c8e45a7..69e69f6630 100644 --- a/openpype/hosts/maya/plugins/publish/collect_remove_marked.py +++ b/openpype/hosts/maya/plugins/publish/collect_remove_marked.py @@ -2,14 +2,9 @@ import pyblish.api class CollectRemoveMarked(pyblish.api.ContextPlugin): - """Collect model data + """Remove marked data - Ensures always only a single frame is extracted (current frame). - - Note: - This is a workaround so that the `pype.model` family can use the - same pointcache extractor implementation as animation and pointcaches. - This always enforces the "current" frame to be published. + Remove instances that have 'remove' in their instance.data """ diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 75749a952e..647a46e240 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -358,9 +358,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): options["extendFrames"] = extend_frames options["overrideExistingFrame"] = override_frames - maya_render_plugin = "MayaPype" - if attributes.get("useMayaBatch", True): - maya_render_plugin = "MayaBatch" + maya_render_plugin = "MayaBatch" options["mayaRenderPlugin"] = maya_render_plugin diff --git a/openpype/hosts/maya/startup/userSetup.py b/openpype/hosts/maya/startup/userSetup.py index d556a89fa3..6d27c66882 100644 --- a/openpype/hosts/maya/startup/userSetup.py +++ b/openpype/hosts/maya/startup/userSetup.py @@ -10,7 +10,6 @@ print("starting OpenPype usersetup") settings = get_project_settings(os.environ['AVALON_PROJECT']) shelf_preset = settings['maya'].get('project_shelf') - if shelf_preset: project = os.environ["AVALON_PROJECT"] @@ -23,7 +22,7 @@ if shelf_preset: print(import_string) exec(import_string) -cmds.evalDeferred("mlib.shelf(name=shelf_preset['name'], iconPath=icon_path, preset=shelf_preset)") + cmds.evalDeferred("mlib.shelf(name=shelf_preset['name'], iconPath=icon_path, preset=shelf_preset)") print("finished OpenPype usersetup") diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py index 92726913af..8b8c5d0c10 100644 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ b/openpype/hosts/nuke/plugins/load/load_mov.py @@ -135,12 +135,14 @@ class LoadMov(api.Loader): read_name = self.node_name_template.format(**name_data) - # Create the Loader with the filename path set + read_node = nuke.createNode( + "Read", + "name {}".format(read_name) + ) + + # to avoid multiple undo steps for rest of process + # we will switch off undo-ing with viewer_update_and_undo_stop(): - read_node = nuke.createNode( - "Read", - "name {}".format(read_name) - ) read_node["file"].setValue(file) read_node["origfirst"].setValue(first) diff --git a/openpype/hosts/nuke/plugins/load/load_sequence.py b/openpype/hosts/nuke/plugins/load/load_sequence.py index df7aa55cd1..71f0b8c298 100644 --- a/openpype/hosts/nuke/plugins/load/load_sequence.py +++ b/openpype/hosts/nuke/plugins/load/load_sequence.py @@ -139,11 +139,15 @@ class LoadSequence(api.Loader): read_name = self.node_name_template.format(**name_data) # Create the Loader with the filename path set + + # TODO: it might be universal read to img/geo/camera + r = nuke.createNode( + "Read", + "name {}".format(read_name)) + + # to avoid multiple undo steps for rest of process + # we will switch off undo-ing with viewer_update_and_undo_stop(): - # TODO: it might be universal read to img/geo/camera - r = nuke.createNode( - "Read", - "name {}".format(read_name)) r["file"].setValue(file) # Set colorspace defined in version data diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_clear_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_clear_instances.py deleted file mode 100644 index 097e730251..0000000000 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_clear_instances.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -Optional: - instance.data["remove"] -> mareker for removing -""" -import pyblish.api - - -class CollectClearInstances(pyblish.api.InstancePlugin): - """Clear all marked instances""" - - order = pyblish.api.CollectorOrder + 0.4999 - label = "Clear Instances" - hosts = ["standalonepublisher"] - - def process(self, instance): - self.log.debug( - f"Instance: `{instance}` | " - f"families: `{instance.data['families']}`") - if instance.data.get("remove"): - self.log.info(f"Removing: {instance}") - instance.context.remove(instance) diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index cbc86f7b03..539cebe646 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -77,8 +77,9 @@ def set_context_settings(asset_doc=None): handle_start = handles handle_end = handles - frame_start -= int(handle_start) - frame_end += int(handle_end) + # Always start from 0 Mark In and set only Mark Out + mark_in = 0 + mark_out = mark_in + (frame_end - frame_start) + handle_start + handle_end - execute_george("tv_markin {} set".format(frame_start - 1)) - execute_george("tv_markout {} set".format(frame_end - 1)) + execute_george("tv_markin {} set".format(mark_in)) + execute_george("tv_markout {} set".format(mark_out)) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py new file mode 100644 index 0000000000..f291c363b8 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py @@ -0,0 +1,37 @@ +import pyblish.api + + +class CollectOutputFrameRange(pyblish.api.ContextPlugin): + """Collect frame start/end from context. + + When instances are collected context does not contain `frameStart` and + `frameEnd` keys yet. They are collected in global plugin + `CollectAvalonEntities`. + """ + label = "Collect output frame range" + order = pyblish.api.CollectorOrder + hosts = ["tvpaint"] + + def process(self, context): + for instance in context: + frame_start = instance.data.get("frameStart") + frame_end = instance.data.get("frameEnd") + if frame_start is not None and frame_end is not None: + self.log.debug( + "Instance {} already has set frames {}-{}".format( + str(instance), frame_start, frame_end + ) + ) + return + + frame_start = context.data.get("frameStart") + frame_end = context.data.get("frameEnd") + + instance.data["frameStart"] = frame_start + instance.data["frameEnd"] = frame_end + + self.log.info( + "Set frames {}-{} on instance {} ".format( + frame_start, frame_end, str(instance) + ) + ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index cc236734e5..27bd8e9ede 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -86,9 +86,6 @@ class CollectInstances(pyblish.api.ContextPlugin): instance.data["publish"] = any_visible - instance.data["frameStart"] = context.data["sceneMarkIn"] + 1 - instance.data["frameEnd"] = context.data["sceneMarkOut"] + 1 - self.log.debug("Created instance: {}\n{}".format( instance, json.dumps(instance.data, indent=4) )) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 0d125a1a50..007b5c41f1 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -1,8 +1,6 @@ import os import shutil -import time import tempfile -import multiprocessing import pyblish.api from avalon.tvpaint import lib @@ -45,10 +43,64 @@ class ExtractSequence(pyblish.api.Extractor): ) family_lowered = instance.data["family"].lower() - frame_start = instance.data["frameStart"] - frame_end = instance.data["frameEnd"] + mark_in = instance.context.data["sceneMarkIn"] + mark_out = instance.context.data["sceneMarkOut"] + # Frame start/end may be stored as float + frame_start = int(instance.data["frameStart"]) + frame_end = int(instance.data["frameEnd"]) - filename_template = self._get_filename_template(frame_end) + # Handles are not stored per instance but on Context + handle_start = instance.context.data["handleStart"] + handle_end = instance.context.data["handleEnd"] + + # --- Fallbacks ---------------------------------------------------- + # This is required if validations of ranges are ignored. + # - all of this code won't change processing if range to render + # match to range of expected output + + # Prepare output frames + output_frame_start = frame_start - handle_start + output_frame_end = frame_end + handle_end + + # Change output frame start to 0 if handles cause it's negative number + if output_frame_start < 0: + self.log.warning(( + "Frame start with handles has negative value." + " Changed to \"0\". Frames start: {}, Handle Start: {}" + ).format(frame_start, handle_start)) + output_frame_start = 0 + + # Check Marks range and output range + output_range = output_frame_end - output_frame_start + marks_range = mark_out - mark_in + + # Lower Mark Out if mark range is bigger than output + # - do not rendered not used frames + if output_range < marks_range: + new_mark_out = mark_out - (marks_range - output_range) + self.log.warning(( + "Lowering render range to {} frames. Changed Mark Out {} -> {}" + ).format(marks_range + 1, mark_out, new_mark_out)) + # Assign new mark out to variable + mark_out = new_mark_out + + # Lower output frame end so representation has right `frameEnd` value + elif output_range > marks_range: + new_output_frame_end = ( + output_frame_end - (output_range - marks_range) + ) + self.log.warning(( + "Lowering representation range to {} frames." + " Changed frame end {} -> {}" + ).format(output_range + 1, mark_out, new_mark_out)) + output_frame_end = new_output_frame_end + + # ------------------------------------------------------------------- + + filename_template = self._get_filename_template( + # Use the biggest number + max(mark_out, frame_end) + ) ext = os.path.splitext(filename_template)[1].replace(".", "") self.log.debug("Using file template \"{}\"".format(filename_template)) @@ -57,7 +109,9 @@ class ExtractSequence(pyblish.api.Extractor): output_dir = instance.data.get("stagingDir") if not output_dir: # Create temp folder if staging dir is not set - output_dir = tempfile.mkdtemp().replace("\\", "/") + output_dir = ( + tempfile.mkdtemp(prefix="tvpaint_render_") + ).replace("\\", "/") instance.data["stagingDir"] = output_dir self.log.debug( @@ -65,23 +119,36 @@ class ExtractSequence(pyblish.api.Extractor): ) if instance.data["family"] == "review": - repre_files, thumbnail_fullpath = self.render_review( - filename_template, output_dir, frame_start, frame_end + output_filenames, thumbnail_fullpath = self.render_review( + filename_template, output_dir, mark_in, mark_out ) else: # Render output - repre_files, thumbnail_fullpath = self.render( - filename_template, output_dir, frame_start, frame_end, + output_filenames, thumbnail_fullpath = self.render( + filename_template, output_dir, + mark_in, mark_out, filtered_layers ) + # Sequence of one frame + if not output_filenames: + self.log.warning("Extractor did not create any output.") + return + + repre_files = self._rename_output_files( + filename_template, output_dir, + mark_in, mark_out, + output_frame_start, output_frame_end + ) + # Fill tags and new families tags = [] if family_lowered in ("review", "renderlayer"): tags.append("review") # Sequence of one frame - if len(repre_files) == 1: + single_file = len(repre_files) == 1 + if single_file: repre_files = repre_files[0] new_repre = { @@ -89,10 +156,13 @@ class ExtractSequence(pyblish.api.Extractor): "ext": ext, "files": repre_files, "stagingDir": output_dir, - "frameStart": frame_start, - "frameEnd": frame_end, "tags": tags } + + if not single_file: + new_repre["frameStart"] = output_frame_start + new_repre["frameEnd"] = output_frame_end + self.log.debug("Creating new representation: {}".format(new_repre)) instance.data["representations"].append(new_repre) @@ -133,9 +203,45 @@ class ExtractSequence(pyblish.api.Extractor): return "{{frame:0>{}}}".format(frame_padding) + ".png" - def render_review( - self, filename_template, output_dir, frame_start, frame_end + def _rename_output_files( + self, filename_template, output_dir, + mark_in, mark_out, output_frame_start, output_frame_end ): + # Use differnet ranges based on Mark In and output Frame Start values + # - this is to make sure that filename renaming won't affect files that + # are not renamed yet + mark_start_is_less = bool(mark_in < output_frame_start) + if mark_start_is_less: + marks_range = range(mark_out, mark_in - 1, -1) + frames_range = range(output_frame_end, output_frame_start - 1, -1) + else: + # This is less possible situation as frame start will be in most + # cases higher than Mark In. + marks_range = range(mark_in, mark_out + 1) + frames_range = range(output_frame_start, output_frame_end + 1) + + repre_filepaths = [] + for mark, frame in zip(marks_range, frames_range): + new_filename = filename_template.format(frame=frame) + new_filepath = os.path.join(output_dir, new_filename) + + repre_filepaths.append(new_filepath) + + if mark != frame: + old_filename = filename_template.format(frame=mark) + old_filepath = os.path.join(output_dir, old_filename) + os.rename(old_filepath, new_filepath) + + # Reverse repre files order if output + if mark_start_is_less: + repre_filepaths = list(reversed(repre_filepaths)) + + return [ + os.path.basename(path) + for path in repre_filepaths + ] + + def render_review(self, filename_template, output_dir, mark_in, mark_out): """ Export images from TVPaint using `tv_savesequence` command. Args: @@ -144,8 +250,8 @@ class ExtractSequence(pyblish.api.Extractor): keyword argument `{frame}` or index argument (for same value). Extension in template must match `save_mode`. output_dir (str): Directory where files will be stored. - first_frame (int): Starting frame from which export will begin. - last_frame (int): On which frame export will end. + mark_in (int): Starting frame index from which export will begin. + mark_out (int): On which frame index export will end. Retruns: tuple: With 2 items first is list of filenames second is path to @@ -154,10 +260,8 @@ class ExtractSequence(pyblish.api.Extractor): self.log.debug("Preparing data for rendering.") first_frame_filepath = os.path.join( output_dir, - filename_template.format(frame=frame_start) + filename_template.format(frame=mark_in) ) - mark_in = frame_start - 1 - mark_out = frame_end - 1 george_script_lines = [ "tv_SaveMode \"PNG\"", @@ -170,13 +274,22 @@ class ExtractSequence(pyblish.api.Extractor): ] lib.execute_george_through_file("\n".join(george_script_lines)) - output = [] first_frame_filepath = None - for frame in range(frame_start, frame_end + 1): + output_filenames = [] + for frame in range(mark_in, mark_out + 1): filename = filename_template.format(frame=frame) - output.append(filename) + output_filenames.append(filename) + + filepath = os.path.join(output_dir, filename) + if not os.path.exists(filepath): + raise AssertionError( + "Output was not rendered. File was not found {}".format( + filepath + ) + ) + if first_frame_filepath is None: - first_frame_filepath = os.path.join(output_dir, filename) + first_frame_filepath = filepath thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") if first_frame_filepath and os.path.exists(first_frame_filepath): @@ -184,11 +297,10 @@ class ExtractSequence(pyblish.api.Extractor): thumbnail_obj = Image.new("RGB", source_img.size, (255, 255, 255)) thumbnail_obj.paste(source_img) thumbnail_obj.save(thumbnail_filepath) - return output, thumbnail_filepath - def render( - self, filename_template, output_dir, frame_start, frame_end, layers - ): + return output_filenames, thumbnail_filepath + + def render(self, filename_template, output_dir, mark_in, mark_out, layers): """ Export images from TVPaint. Args: @@ -197,8 +309,8 @@ class ExtractSequence(pyblish.api.Extractor): keyword argument `{frame}` or index argument (for same value). Extension in template must match `save_mode`. output_dir (str): Directory where files will be stored. - first_frame (int): Starting frame from which export will begin. - last_frame (int): On which frame export will end. + mark_in (int): Starting frame index from which export will begin. + mark_out (int): On which frame index export will end. layers (list): List of layers to be exported. Retruns: @@ -219,14 +331,11 @@ class ExtractSequence(pyblish.api.Extractor): # Sort layer positions in reverse order sorted_positions = list(reversed(sorted(layers_by_position.keys()))) if not sorted_positions: - return + return [], None self.log.debug("Collecting pre/post behavior of individual layers.") behavior_by_layer_id = lib.get_layers_pre_post_behavior(layer_ids) - mark_in_index = frame_start - 1 - mark_out_index = frame_end - 1 - tmp_filename_template = "pos_{pos}." + filename_template files_by_position = {} @@ -239,25 +348,47 @@ class ExtractSequence(pyblish.api.Extractor): tmp_filename_template, output_dir, behavior, - mark_in_index, - mark_out_index + mark_in, + mark_out ) - files_by_position[position] = files_by_frames + if files_by_frames: + files_by_position[position] = files_by_frames + else: + self.log.warning(( + "Skipped layer \"{}\". Probably out of Mark In/Out range." + ).format(layer["name"])) + + if not files_by_position: + layer_names = set(layer["name"] for layer in layers) + joined_names = ", ".join( + ["\"{}\"".format(name) for name in layer_names] + ) + self.log.warning( + "Layers {} do not have content in range {} - {}".format( + joined_names, mark_in, mark_out + ) + ) + return [], None output_filepaths = self._composite_files( files_by_position, - mark_in_index, - mark_out_index, + mark_in, + mark_out, filename_template, output_dir ) self._cleanup_tmp_files(files_by_position) - thumbnail_src_filepath = None - thumbnail_filepath = None - if output_filepaths: - thumbnail_src_filepath = tuple(sorted(output_filepaths))[0] + output_filenames = [ + os.path.basename(filepath) + for filepath in output_filepaths + ] + thumbnail_src_filepath = None + if output_filepaths: + thumbnail_src_filepath = output_filepaths[0] + + thumbnail_filepath = None if thumbnail_src_filepath and os.path.exists(thumbnail_src_filepath): source_img = Image.open(thumbnail_src_filepath) thumbnail_filepath = os.path.join(output_dir, "thumbnail.jpg") @@ -265,11 +396,7 @@ class ExtractSequence(pyblish.api.Extractor): thumbnail_obj.paste(source_img) thumbnail_obj.save(thumbnail_filepath) - repre_files = [ - os.path.basename(path) - for path in output_filepaths - ] - return repre_files, thumbnail_filepath + return output_filenames, thumbnail_filepath def _render_layer( self, @@ -283,6 +410,22 @@ class ExtractSequence(pyblish.api.Extractor): layer_id = layer["layer_id"] frame_start_index = layer["frame_start"] frame_end_index = layer["frame_end"] + + pre_behavior = behavior["pre"] + post_behavior = behavior["post"] + + # Check if layer is before mark in + if frame_end_index < mark_in_index: + # Skip layer if post behavior is "none" + if post_behavior == "none": + return {} + + # Check if layer is after mark out + elif frame_start_index > mark_out_index: + # Skip layer if pre behavior is "none" + if pre_behavior == "none": + return {} + exposure_frames = lib.get_exposure_frames( layer_id, frame_start_index, frame_end_index ) @@ -341,8 +484,6 @@ class ExtractSequence(pyblish.api.Extractor): self.log.debug("Filled frames {}".format(str(_debug_filled_frames))) # Fill frames by pre/post behavior of layer - pre_behavior = behavior["pre"] - post_behavior = behavior["post"] self.log.debug(( "Completing image sequence of layer by pre/post behavior." " PRE: {} | POST: {}" @@ -530,17 +671,12 @@ class ExtractSequence(pyblish.api.Extractor): filepath = position_data[frame_idx] images_by_frame[frame_idx].append(filepath) - process_count = os.cpu_count() - if process_count > 1: - process_count -= 1 - - processes = {} output_filepaths = [] missing_frame_paths = [] random_frame_path = None for frame_idx in sorted(images_by_frame.keys()): image_filepaths = images_by_frame[frame_idx] - output_filename = filename_template.format(frame=frame_idx + 1) + output_filename = filename_template.format(frame=frame_idx) output_filepath = os.path.join(output_dir, output_filename) output_filepaths.append(output_filepath) @@ -553,45 +689,15 @@ class ExtractSequence(pyblish.api.Extractor): if len(image_filepaths) == 1: os.rename(image_filepaths[0], output_filepath) - # Prepare process for compositing of images + # Composite images else: - processes[frame_idx] = multiprocessing.Process( - target=composite_images, - args=(image_filepaths, output_filepath) - ) + composite_images(image_filepaths, output_filepath) # Store path of random output image that will 100% exist after all # multiprocessing as mockup for missing frames if random_frame_path is None: random_frame_path = output_filepath - self.log.info( - "Running {} compositing processes - this mey take a while.".format( - len(processes) - ) - ) - # Wait until all compositing processes are done - running_processes = {} - while True: - for idx in tuple(running_processes.keys()): - process = running_processes[idx] - if not process.is_alive(): - running_processes.pop(idx).join() - - if processes and len(running_processes) != process_count: - indexes = list(processes.keys()) - for _ in range(process_count - len(running_processes)): - if not indexes: - break - idx = indexes.pop(0) - running_processes[idx] = processes.pop(idx) - running_processes[idx].start() - - if not running_processes and not processes: - break - - time.sleep(0.01) - self.log.debug( "Creating transparent images for frames without render {}.".format( str(missing_frame_paths) diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py index 73486d1005..e2ef81e4a4 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py @@ -14,37 +14,54 @@ class ValidateMarksRepair(pyblish.api.Action): def process(self, context, plugin): expected_data = ValidateMarks.get_expected_data(context) - expected_data["markIn"] -= 1 - expected_data["markOut"] -= 1 - - lib.execute_george("tv_markin {} set".format(expected_data["markIn"])) + lib.execute_george( + "tv_markin {} set".format(expected_data["markIn"]) + ) lib.execute_george( "tv_markout {} set".format(expected_data["markOut"]) ) class ValidateMarks(pyblish.api.ContextPlugin): - """Validate mark in and out are enabled.""" + """Validate mark in and out are enabled and it's duration. - label = "Validate Marks" + Mark In/Out does not have to match frameStart and frameEnd but duration is + important. + """ + + label = "Validate Mark In/Out" order = pyblish.api.ValidatorOrder optional = True actions = [ValidateMarksRepair] @staticmethod def get_expected_data(context): + scene_mark_in = context.data["sceneMarkIn"] + + # Data collected in `CollectAvalonEntities` + frame_end = context.data["frameEnd"] + frame_start = context.data["frameStart"] + handle_start = context.data["handleStart"] + handle_end = context.data["handleEnd"] + + # Calculate expeted Mark out (Mark In + duration - 1) + expected_mark_out = ( + scene_mark_in + + (frame_end - frame_start) + + handle_start + handle_end + ) return { - "markIn": int(context.data["frameStart"]), + "markIn": scene_mark_in, "markInState": True, - "markOut": int(context.data["frameEnd"]), + "markOut": expected_mark_out, "markOutState": True } def process(self, context): current_data = { - "markIn": context.data["sceneMarkIn"] + 1, + "markIn": context.data["sceneMarkIn"], "markInState": context.data["sceneMarkInState"], - "markOut": context.data["sceneMarkOut"] + 1, + "markOut": context.data["sceneMarkOut"], "markOutState": context.data["sceneMarkOutState"] } expected_data = self.get_expected_data(context) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 895d11601f..1df89dbb21 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -113,6 +113,7 @@ from .plugin_tools import ( TaskNotSetError, get_subset_name, filter_pyblish_plugins, + set_plugin_attributes_from_settings, source_hash, get_unique_layer_name, get_background_layers, @@ -207,6 +208,7 @@ __all__ = [ "TaskNotSetError", "get_subset_name", "filter_pyblish_plugins", + "set_plugin_attributes_from_settings", "source_hash", "get_unique_layer_name", "get_background_layers", diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 9a2d30d1a7..44c688456e 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -150,6 +150,95 @@ def filter_pyblish_plugins(plugins): setattr(plugin, option, value) +def set_plugin_attributes_from_settings( + plugins, superclass, host_name=None, project_name=None +): + """Change attribute values on Avalon plugins by project settings. + + This function should be used only in host context. Modify + behavior of plugins. + + Args: + plugins (list): Plugins discovered by origin avalon discover method. + superclass (object): Superclass of plugin type (e.g. Cretor, Loader). + host_name (str): Name of host for which plugins are loaded and from. + Value from environment `AVALON_APP` is used if not entered. + project_name (str): Name of project for which settings will be loaded. + Value from environment `AVALON_PROJECT` is used if not entered. + """ + + # determine host application to use for finding presets + if host_name is None: + host_name = os.environ.get("AVALON_APP") + + if project_name is None: + project_name = os.environ.get("AVALON_PROJECT") + + # map plugin superclass to preset json. Currenly suppoted is load and + # create (avalon.api.Loader and avalon.api.Creator) + plugin_type = None + if superclass.__name__.split(".")[-1] == "Loader": + plugin_type = "load" + elif superclass.__name__.split(".")[-1] == "Creator": + plugin_type = "create" + + if not host_name or not project_name or plugin_type is None: + msg = "Skipped attributes override from settings." + if not host_name: + msg += " Host name is not defined." + + if not project_name: + msg += " Project name is not defined." + + if plugin_type is None: + msg += " Plugin type is unsupported for class {}.".format( + superclass.__name__ + ) + + print(msg) + return + + print(">>> Finding presets for {}:{} ...".format(host_name, plugin_type)) + + project_settings = get_project_settings(project_name) + plugin_type_settings = ( + project_settings + .get(host_name, {}) + .get(plugin_type, {}) + ) + global_type_settings = ( + project_settings + .get("global", {}) + .get(plugin_type, {}) + ) + if not global_type_settings and not plugin_type_settings: + return + + for plugin in plugins: + plugin_name = plugin.__name__ + + plugin_settings = None + # Look for plugin settings in host specific settings + if plugin_name in plugin_type_settings: + plugin_settings = plugin_type_settings[plugin_name] + + # Look for plugin settings in global settings + elif plugin_name in global_type_settings: + plugin_settings = global_type_settings[plugin_name] + + if not plugin_settings: + continue + + print(">>> We have preset for {}".format(plugin_name)) + for option, value in plugin_settings.items(): + if option == "enabled" and value is False: + setattr(plugin, "active", False) + print(" - is disabled by preset") + else: + setattr(plugin, option, value) + print(" - setting `{}`: `{}`".format(option, value)) + + def source_hash(filepath, *args): """Generate simple identifier for a source file. This is used to identify whether a source file has previously been diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 0e92fb38bb..a5841f406c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -47,7 +47,7 @@ payload_skeleton_template = { "BatchName": None, # Top-level group name "Name": None, # Job name, as seen in Monitor "UserName": None, - "Plugin": "MayaPype", + "Plugin": "MayaBatch", "Frames": "{start}-{end}x{step}", "Comment": None, "Priority": 50, @@ -396,7 +396,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): step=int(self._instance.data["byFrameStep"])) self.payload_skeleton["JobInfo"]["Plugin"] = self._instance.data.get( - "mayaRenderPlugin", "MayaPype") + "mayaRenderPlugin", "MayaBatch") self.payload_skeleton["JobInfo"]["BatchName"] = filename # Job name, as seen in Monitor diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index af578de86b..ee139a500e 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -127,10 +127,28 @@ class FtrackModule( self, old_value, new_value, changes, new_value_metadata ): """Implementation of ISettingsChangeListener interface.""" + if not self.ftrack_url: + raise SaveWarningExc(( + "Ftrack URL is not set." + " Can't propagate changes to Ftrack server." + )) + + ftrack_changes = changes.get("modules", {}).get("ftrack", {}) + url_change_msg = None + if "ftrack_server" in ftrack_changes: + url_change_msg = ( + "Ftrack URL was changed." + " This change may need to restart OpenPype to take affect." + ) + try: session = self.create_ftrack_session() except Exception: self.log.warning("Couldn't create ftrack session.", exc_info=True) + + if url_change_msg: + raise SaveWarningExc(url_change_msg) + raise SaveWarningExc(( "Saving of attributes to ftrack wasn't successful," " try running Create/Update Avalon Attributes in ftrack." @@ -204,6 +222,9 @@ class FtrackModule( " Try running Create/Update Avalon Attributes in ftrack." ).format(", ".join(missing_attributes))) + if url_change_msg: + raise SaveWarningExc(url_change_msg) + def on_project_settings_save(self, *_args, **_kwargs): """Implementation of ISettingsChangeListener interface.""" # Ignore diff --git a/openpype/hosts/maya/plugins/publish/collect_ftrack_family.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py similarity index 64% rename from openpype/hosts/maya/plugins/publish/collect_ftrack_family.py rename to openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py index b2b66b1875..e6daed9a33 100644 --- a/openpype/hosts/maya/plugins/publish/collect_ftrack_family.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_family.py @@ -2,14 +2,9 @@ import pyblish.api class CollectFtrackFamilies(pyblish.api.InstancePlugin): - """Collect model data - - Ensures always only a single frame is extracted (current frame). - - Note: - This is a workaround so that the `pype.model` family can use the - same pointcache extractor implementation as animation and pointcaches. - This always enforces the "current" frame to be published. + """Collect family for ftrack publishing + + Add ftrack family to those instance that should be published to ftrack """ @@ -23,6 +18,7 @@ class CollectFtrackFamilies(pyblish.api.InstancePlugin): "rig", "camera" ] + hosts = ["maya"] def process(self, instance): diff --git a/openpype/modules/settings_action.py b/openpype/modules/settings_action.py index 3f7cb8c3ba..5651868f68 100644 --- a/openpype/modules/settings_action.py +++ b/openpype/modules/settings_action.py @@ -80,16 +80,20 @@ class SettingsAction(PypeModule, ITrayAction): # Store if was visible was_visible = self.settings_window.isVisible() + was_minimized = self.settings_window.isMinimized() # Show settings gui self.settings_window.show() + if was_minimized: + self.settings_window.showNormal() + # Pull window to the front. self.settings_window.raise_() self.settings_window.activateWindow() # Reset content if was not visible - if not was_visible: + if not was_visible and not was_minimized: self.settings_window.reset() diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index f1ea24f601..b67e5a6cfa 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -7,7 +7,7 @@ from .abstract_provider import AbstractProvider from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload from openpype.api import Logger from openpype.api import get_system_settings -from ..utils import time_function +from ..utils import time_function, ResumableError import time @@ -63,7 +63,14 @@ class GDriveHandler(AbstractProvider): return self.service = self._get_gd_service() - self.root = self._prepare_root_info() + try: + self.root = self._prepare_root_info() + except errors.HttpError: + log.warning("HttpError in sync loop, " + "trying next loop", + exc_info=True) + raise ResumableError + self._tree = tree self.active = True diff --git a/openpype/modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py index 58947e115d..01a5d50ba5 100644 --- a/openpype/modules/sync_server/providers/lib.py +++ b/openpype/modules/sync_server/providers/lib.py @@ -92,4 +92,4 @@ factory = ProviderFactory() # 7 denotes number of files that could be synced in single loop - learned by # trial and error factory.register_provider('gdrive', GDriveHandler, 7) -factory.register_provider('local_drive', LocalDriveHandler, 10) +factory.register_provider('local_drive', LocalDriveHandler, 50) diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index e97c0e8844..9b305a1b2e 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -8,7 +8,7 @@ from concurrent.futures._base import CancelledError from .providers import lib from openpype.lib import PypeLogger -from .utils import SyncStatus +from .utils import SyncStatus, ResumableError log = PypeLogger().get_logger("SyncServer") @@ -232,6 +232,7 @@ class SyncServerThread(threading.Thread): self.loop = None self.is_running = False self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=3) + self.timer = None def run(self): self.is_running = True @@ -266,8 +267,8 @@ class SyncServerThread(threading.Thread): Returns: """ - try: - while self.is_running and not self.module.is_paused(): + while self.is_running and not self.module.is_paused(): + try: import time start_time = None self.module.set_sync_project_settings() # clean cache @@ -384,17 +385,27 @@ class SyncServerThread(threading.Thread): duration = time.time() - start_time log.debug("One loop took {:.2f}s".format(duration)) - await asyncio.sleep(self.module.get_loop_delay(collection)) - except ConnectionResetError: - log.warning("ConnectionResetError in sync loop, trying next loop", - exc_info=True) - except CancelledError: - # just stopping server - pass - except Exception: - self.stop() - log.warning("Unhandled exception in sync loop, stopping server", - exc_info=True) + + delay = self.module.get_loop_delay(collection) + log.debug("Waiting for {} seconds to new loop".format(delay)) + self.timer = asyncio.create_task(self.run_timer(delay)) + await asyncio.gather(self.timer) + + except ConnectionResetError: + log.warning("ConnectionResetError in sync loop, " + "trying next loop", + exc_info=True) + except CancelledError: + # just stopping server + pass + except ResumableError: + log.warning("ResumableError in sync loop, " + "trying next loop", + exc_info=True) + except Exception: + self.stop() + log.warning("Unhandled except. in sync loop, stopping server", + exc_info=True) def stop(self): """Sets is_running flag to false, 'check_shutdown' shuts server down""" @@ -417,6 +428,17 @@ class SyncServerThread(threading.Thread): await asyncio.sleep(0.07) self.loop.stop() + async def run_timer(self, delay): + """Wait for 'delay' seconds to start next loop""" + await asyncio.sleep(delay) + + def reset_timer(self): + """Called when waiting for next loop should be skipped""" + log.debug("Resetting timer") + if self.timer: + self.timer.cancel() + self.timer = None + def _working_sites(self, collection): if self.module.is_project_paused(collection): log.debug("Both sites same, skipping") diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 59c3787789..5645cdfbec 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -83,6 +83,7 @@ class SyncServerModule(PypeModule, ITrayModule): DEFAULT_SITE = 'studio' LOCAL_SITE = 'local' LOG_PROGRESS_SEC = 5 # how often log progress to DB + DEFAULT_PRIORITY = 50 # higher is better, allowed range 1 - 1000 name = "sync_server" label = "Sync Queue" @@ -401,6 +402,24 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site + def reset_timer(self): + """ + Called when waiting for next loop should be skipped. + + In case of user's involvement (reset site), start that right away. + """ + self.sync_server_thread.reset_timer() + + def get_enabled_projects(self): + """Returns list of projects which have SyncServer enabled.""" + enabled_projects = [] + for project in self.connection.projects(): + project_name = project["name"] + project_settings = self.get_sync_project_setting(project_name) + if project_settings: + enabled_projects.append(project_name) + + return enabled_projects """ End of Public API """ def get_local_file_path(self, collection, site_name, file_path): @@ -413,7 +432,7 @@ class SyncServerModule(PypeModule, ITrayModule): return local_file_path def _get_remote_sites_from_settings(self, sync_settings): - if not self.enabled or not sync_settings['enabled']: + if not self.enabled or not sync_settings.get('enabled'): return [] remote_sites = [self.DEFAULT_SITE, self.LOCAL_SITE] @@ -424,7 +443,7 @@ class SyncServerModule(PypeModule, ITrayModule): def _get_enabled_sites_from_settings(self, sync_settings): sites = [self.DEFAULT_SITE] - if self.enabled and sync_settings['enabled']: + if self.enabled and sync_settings.get('enabled'): sites.append(self.LOCAL_SITE) return sites @@ -445,10 +464,16 @@ class SyncServerModule(PypeModule, ITrayModule): if not self.enabled: return + enabled_projects = self.get_enabled_projects() + if not enabled_projects: + self.enabled = False + return + self.lock = threading.Lock() try: self.sync_server_thread = SyncServerThread(self) + from .tray.app import SyncServerWindow self.widget = SyncServerWindow(self) except ValueError: @@ -639,7 +664,7 @@ class SyncServerModule(PypeModule, ITrayModule): self.connection.Session["AVALON_PROJECT"] = collection # retry_cnt - number of attempts to sync specific file before giving up retries_arr = self._get_retries_arr(collection) - query = { + match = { "type": "representation", "$or": [ {"$and": [ @@ -677,10 +702,47 @@ class SyncServerModule(PypeModule, ITrayModule): ]} ] } + + aggr = [ + {"$match": match}, + {'$unwind': '$files'}, + {'$addFields': { + 'order_remote': { + '$filter': {'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', remote_site]} + }}, + 'order_local': { + '$filter': {'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', active_site]} + }}, + }}, + {'$addFields': { + 'priority': { + '$cond': [ + {'$size': '$order_local.priority'}, + {'$first': '$order_local.priority'}, + {'$cond': [ + {'$size': '$order_remote.priority'}, + {'$first': '$order_remote.priority'}, + self.DEFAULT_PRIORITY]} + ] + }, + }}, + {'$group': { + '_id': '$_id', + # pass through context - same for representation + 'context': {'$addToSet': '$context'}, + 'data': {'$addToSet': '$data'}, + # pass through files as a list + 'files': {'$addToSet': '$files'}, + 'priority': {'$max': "$priority"}, + }}, + {"$sort": {'priority': -1, '_id': 1}}, + ] log.debug("active_site:{} - remote_site:{}".format(active_site, remote_site)) - log.debug("query: {}".format(query)) - representations = self.connection.find(query) + log.debug("query: {}".format(aggr)) + representations = self.connection.aggregate(aggr) return representations @@ -726,7 +788,7 @@ class SyncServerModule(PypeModule, ITrayModule): return SyncStatus.DO_NOTHING def update_db(self, collection, new_file_id, file, representation, - site, error=None, progress=None): + site, error=None, progress=None, priority=None): """ Update 'provider' portion of records in DB with success (file_id) or error (exception) @@ -740,12 +802,16 @@ class SyncServerModule(PypeModule, ITrayModule): site (string): label ('gdrive', 'S3') error (string): exception message progress (float): 0-1 of progress of upload/download + priority (int): 0-100 set priority Returns: None """ representation_id = representation.get("_id") - file_id = file.get("_id") + file_id = None + if file: + file_id = file.get("_id") + query = { "_id": representation_id } @@ -757,6 +823,8 @@ class SyncServerModule(PypeModule, ITrayModule): update["$unset"] = self._get_error_dict("", "", "") elif progress is not None: update["$set"] = self._get_progress_dict(progress) + elif priority is not None: + update["$set"] = self._get_priority_dict(priority, file_id) else: tries = self._get_tries_count(file, site) tries += 1 @@ -764,9 +832,10 @@ class SyncServerModule(PypeModule, ITrayModule): update["$set"] = self._get_error_dict(error, tries) arr_filter = [ - {'s.name': site}, - {'f._id': ObjectId(file_id)} + {'s.name': site} ] + if file_id: + arr_filter.append({'f._id': ObjectId(file_id)}) self.connection.database[collection].update_one( query, @@ -775,7 +844,7 @@ class SyncServerModule(PypeModule, ITrayModule): array_filters=arr_filter ) - if progress is not None: + if progress is not None or priority is not None: return status = 'failed' @@ -1169,6 +1238,21 @@ class SyncServerModule(PypeModule, ITrayModule): val = {"files.$[f].sites.$[s].progress": progress} return val + def _get_priority_dict(self, priority, file_id): + """ + Provide priority metadata to be stored in Db. + Used during upload/download for GUI to show. + Args: + priority: (int) - priority for file(s) + Returns: + (dictionary) + """ + if file_id: + str_key = "files.$[f].sites.$[s].priority" + else: + str_key = "files.$[].sites.$[s].priority" + return {str_key: int(priority)} + def _get_retries_arr(self, project_name): """ Returns array with allowed values in 'tries' field. If repre diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index d91ba76335..b3b6f0a6c3 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -78,15 +78,33 @@ class SyncServerWindow(QtWidgets.QDialog): layout.addWidget(footer) self.setLayout(body_layout) - self.setWindowTitle("Sync Server") + self.setWindowTitle("Sync Queue") self.projects.project_changed.connect( lambda: repres.table_view.model().set_project( self.projects.current_project)) self.pause_btn.clicked.connect(self._pause) + self.pause_btn.setAutoDefault(False) + self.pause_btn.setDefault(False) repres.message_generated.connect(self._update_message) + self.representationWidget = repres + + def showEvent(self, event): + self.representationWidget.model.set_project( + self.projects.current_project) + self._set_running(True) + super().showEvent(event) + + def closeEvent(self, event): + self._set_running(False) + super().closeEvent(event) + + def _set_running(self, running): + self.representationWidget.model.is_running = running + self.representationWidget.model.timer.setInterval(0) + def _pause(self): if self.sync_server.is_paused(): self.sync_server.unpause_server() diff --git a/openpype/modules/sync_server/tray/delegates.py b/openpype/modules/sync_server/tray/delegates.py new file mode 100644 index 0000000000..9316ec2c3e --- /dev/null +++ b/openpype/modules/sync_server/tray/delegates.py @@ -0,0 +1,116 @@ +import os +from Qt import QtCore, QtWidgets, QtGui + +from openpype.lib import PypeLogger +from openpype.modules.sync_server.tray import lib + +log = PypeLogger().get_logger("SyncServer") + + +class PriorityDelegate(QtWidgets.QStyledItemDelegate): + """Creates editable line edit to set priority on representation""" + def paint(self, painter, option, index): + super(PriorityDelegate, self).paint(painter, option, index) + + if option.widget.selectionModel().isSelected(index) or \ + option.state & QtWidgets.QStyle.State_MouseOver: + edit_icon = index.data(lib.EditIconRole) + if not edit_icon: + return + + state = QtGui.QIcon.On + mode = QtGui.QIcon.Selected + + icon_side = 16 + icon_rect = QtCore.QRect( + option.rect.left() + option.rect.width() - icon_side - 4, + option.rect.top() + ((option.rect.height() - icon_side) / 2), + icon_side, + icon_side + ) + + edit_icon.paint( + painter, icon_rect, + QtCore.Qt.AlignRight, mode, state + ) + + def createEditor(self, parent, option, index): + editor = PriorityLineEdit( + parent, + option.widget.selectionModel().selectedRows()) + editor.setFocus(True) + return editor + + def setModelData(self, editor, model, index): + for index in editor.selected_idxs: + try: + val = int(editor.text()) + except ValueError: + val = model.sync_server.DEFAULT_PRIORITY + model.set_priority_data(index, val) + + +class PriorityLineEdit(QtWidgets.QLineEdit): + """Special LineEdit to consume Enter and store selected indexes""" + def __init__(self, parent=None, selected_idxs=None): + self.selected_idxs = selected_idxs + super(PriorityLineEdit, self).__init__(parent) + + def keyPressEvent(self, event): + result = super(PriorityLineEdit, self).keyPressEvent(event) + if ( + event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter) + ): + return event.accept() + + return result + + +class ImageDelegate(QtWidgets.QStyledItemDelegate): + """ + Prints icon of site and progress of synchronization + """ + + def __init__(self, parent=None): + super(ImageDelegate, self).__init__(parent) + self.icons = {} + + def paint(self, painter, option, index): + super(ImageDelegate, self).paint(painter, option, index) + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + provider = index.data(lib.ProviderRole) + value = index.data(lib.ProgressRole) + date_value = index.data(lib.DateRole) + is_failed = index.data(lib.FailedRole) + + if not self.icons.get(provider): + resource_path = os.path.dirname(__file__) + resource_path = os.path.join(resource_path, "..", + "providers", "resources") + pix_url = "{}/{}.png".format(resource_path, provider) + pixmap = QtGui.QPixmap(pix_url) + self.icons[provider] = pixmap + else: + pixmap = self.icons[provider] + + padding = 10 + point = QtCore.QPoint(option.rect.x() + padding, + option.rect.y() + + (option.rect.height() - pixmap.height()) / 2) + painter.drawPixmap(point, pixmap) + + overlay_rect = option.rect.translated(0, 0) + overlay_rect.setHeight(overlay_rect.height() * (1.0 - float(value))) + painter.fillRect(overlay_rect, + QtGui.QBrush(QtGui.QColor(0, 0, 0, 100))) + text_rect = option.rect.translated(10, 0) + painter.drawText(text_rect, + QtCore.Qt.AlignCenter, + date_value) + + if is_failed: + overlay_rect = option.rect.translated(0, 0) + painter.fillRect(overlay_rect, + QtGui.QBrush(QtGui.QColor(255, 0, 0, 35))) diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index 04bd1f568e..c1f8eaf629 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -25,6 +25,7 @@ DateRole = QtCore.Qt.UserRole + 6 FailedRole = QtCore.Qt.UserRole + 8 HeaderNameRole = QtCore.Qt.UserRole + 10 FullItemRole = QtCore.Qt.UserRole + 12 +EditIconRole = QtCore.Qt.UserRole + 14 @six.add_metaclass(abc.ABCMeta) diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py index ffd81a1588..efef039b8b 100644 --- a/openpype/modules/sync_server/tray/models.py +++ b/openpype/modules/sync_server/tray/models.py @@ -6,8 +6,10 @@ 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 +from openpype.api import get_local_site_id from openpype.modules.sync_server.tray import lib @@ -41,6 +43,9 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): PAGE_SIZE = 20 # default page size to query for REFRESH_SEC = 5000 # in seconds, requery DB for new status + refresh_started = QtCore.Signal() + refresh_finished = QtCore.Signal() + @property def dbcon(self): """ @@ -60,6 +65,14 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): def column_filtering(self): return self._column_filtering + @property + def is_running(self): + return self._is_running + + @is_running.setter + def is_running(self, state): + self._is_running = state + def rowCount(self, _index): return len(self._data) @@ -78,7 +91,20 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): if orientation == Qt.Horizontal: return self.COLUMN_LABELS[section][0] # return name + @property + def can_edit(self): + """Returns true if some site is user local site, eg. could edit""" + return get_local_site_id() in (self.active_site, self.remote_site) + def get_column(self, index): + """ + Returns info about column + + Args: + index (QModelIndex) + Returns: + (tuple): (COLUMN_NAME: COLUMN_LABEL) + """ return self.COLUMN_LABELS[index] def get_header_index(self, value): @@ -108,8 +134,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): actually queried (scrolled a couple of times to list more than single page of records) """ - if self.sync_server.is_paused() or \ - self.sync_server.is_project_paused(self.project): + if self.is_editing or not self.is_running: return self.refresh_started.emit() self.beginResetModel() @@ -170,6 +195,8 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): Sort is happening on a DB side, model is reset, db queried again. + It remembers one last sort, adds it as secondary after new sort. + Args: index (int): column index order (int): 0| @@ -184,7 +211,17 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel): else: order = -1 - self.sort = {self.SORT_BY_COLUMN[index]: order, '_id': 1} + backup_sort = dict(self.sort) + + self.sort = {self.SORT_BY_COLUMN[index]: order} # reset + # add last one + for key, val in backup_sort.items(): + if key != '_id' and key != self.SORT_BY_COLUMN[index]: + self.sort[key] = val + break + # add default one + self.sort['_id'] = 1 + self.query = self.get_query() # import json # log.debug(json.dumps(self.query, indent=4).\ @@ -351,7 +388,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): "updated_dt_remote", # remote created_dt "files_count", # count of files "files_size", # file size of all files - "context.asset", # priority TODO + "priority", # priority "status" # status ] @@ -362,6 +399,8 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'representation': lib.MultiSelectFilter('representation') } + EDITABLE_COLUMNS = ["priority"] + refresh_started = QtCore.Signal() refresh_finished = QtCore.Signal() @@ -391,8 +430,8 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): status = attr.ib(default=None) path = attr.ib(default=None) - def __init__(self, sync_server, header, project=None): - super(SyncRepresentationSummaryModel, self).__init__() + def __init__(self, sync_server, header, project=None, parent=None): + super(SyncRepresentationSummaryModel, self).__init__(parent=parent) self._header = header self._data = [] self._project = project @@ -400,10 +439,13 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): self._total_records = 0 # how many documents query actually found self._word_filter = None self._column_filtering = {} + self._is_running = False + + self.edit_icon = qtawesome.icon("fa.edit", color="white") + self.is_editing = False self._word_filter = None - self._initialized = False if not self._project or self._project == lib.DUMMY_PROJECT: return @@ -460,12 +502,17 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): return item.status == lib.STATUS[2] and \ item.remote_progress < 1 - if role == Qt.DisplayRole: + if role in (Qt.DisplayRole, Qt.EditRole): # because of ImageDelegate if header_value in ['remote_site', 'local_site']: return "" return attr.asdict(item)[self._header[index.column()]] + + if role == lib.EditIconRole: + if self.can_edit and header_value in self.EDITABLE_COLUMNS: + return self.edit_icon + if role == Qt.UserRole: return item._id @@ -537,7 +584,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): avg_progress_remote, repre.get("files_count", 1), lib.pretty_size(repre.get("files_size", 0)), - 1, + repre.get("priority"), lib.STATUS[repre.get("status", -1)], files[0].get('path') ) @@ -656,6 +703,16 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): '$cond': [{'$size': "$order_local.paused"}, 1, 0]}, + 'priority': { + '$cond': [ + {'$size': '$order_local.priority'}, + {'$first': '$order_local.priority'}, + {'$cond': [ + {'$size': '$order_remote.priority'}, + {'$first': '$order_remote.priority'}, + self.sync_server.DEFAULT_PRIORITY]} + ] + }, }}, {'$group': { '_id': '$_id', @@ -678,7 +735,8 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'failed_local_tries': {'$sum': '$failed_local_tries'}, 'paused_remote': {'$sum': '$paused_remote'}, 'paused_local': {'$sum': '$paused_local'}, - 'updated_dt_local': {'$max': "$updated_dt_local"} + 'updated_dt_local': {'$max': "$updated_dt_local"}, + 'priority': {'$max': "$priority"}, }}, {"$project": self.projection} ] @@ -760,6 +818,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): 'updated_dt_local': 1, 'paused_remote': 1, 'paused_local': 1, + 'priority': 1, 'status': { '$switch': { 'branches': [ @@ -806,6 +865,35 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel): } } + def set_priority_data(self, index, value): + """ + Sets 'priority' flag and value on local site for selected reprs. + + Args: + index (QItemIndex): selected index from View + value (int): priority value + + Updates DB. + Potentially should allow set priority to any site when user + management is implemented. + """ + if not self.can_edit: + return + + repre_id = self.data(index, Qt.UserRole) + + representation = list(self.dbcon.find({"type": "representation", + "_id": repre_id})) + if representation: + self.sync_server.update_db(self.project, None, None, + representation.pop(), + get_local_site_id(), + priority=value) + self.is_editing = False + + # all other approaches messed up selection to 0th index + self.timer.setInterval(0) + class SyncRepresentationDetailModel(_SyncRepresentationModel): """ @@ -840,7 +928,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt "size", # remote progress - "size", # priority TODO + "priority", # priority "status" # status ] @@ -849,8 +937,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): 'file': lib.RegexTextFilter('file'), } - refresh_started = QtCore.Signal() - refresh_finished = QtCore.Signal() + EDITABLE_COLUMNS = ["priority"] @attr.s class SyncRepresentationDetail: @@ -886,8 +973,11 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): self._total_records = 0 # how many documents query actually found self._word_filter = None self._id = _id - self._initialized = False self._column_filtering = {} + self._is_running = False + + self.is_editing = False + self.edit_icon = qtawesome.icon("fa.edit", color="white") self.sync_server = sync_server # TODO think about admin mode @@ -940,11 +1030,17 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): return item.status == lib.STATUS[2] and \ item.remote_progress < 1 - if role == Qt.DisplayRole: + if role in (Qt.DisplayRole, Qt.EditRole): # because of ImageDelegate if header_value in ['remote_site', 'local_site']: return "" + return attr.asdict(item)[self._header[index.column()]] + + if role == lib.EditIconRole: + if self.can_edit and header_value in self.EDITABLE_COLUMNS: + return self.edit_icon + if role == Qt.UserRole: return item._id @@ -1014,7 +1110,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): local_progress, remote_progress, lib.pretty_size(file.get('size', 0)), - 1, + repre.get("priority"), lib.STATUS[repre.get("status", -1)], repre.get("tries"), '\n'.join(errors), @@ -1132,7 +1228,17 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): "$order_remote.tries", [] ]} - ]}} + ]}}, + 'priority': { + '$cond': [ + {'$size': '$order_local.priority'}, + {'$first': '$order_local.priority'}, + {'$cond': [ + {'$size': '$order_remote.priority'}, + {'$first': '$order_remote.priority'}, + self.sync_server.DEFAULT_PRIORITY]} + ] + }, }}, {"$project": self.projection} ] @@ -1198,6 +1304,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): 'failed_remote_error': 1, 'failed_local_error': 1, 'tries': 1, + 'priority': 1, 'status': { '$switch': { 'branches': [ @@ -1249,3 +1356,37 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel): }, 'data.path': 1 } + + def set_priority_data(self, index, value): + """ + Sets 'priority' flag and value on local site for selected reprs. + + Args: + index (QItemIndex): selected index from View + value (int): priority value + + Updates DB + """ + if not self.can_edit: + return + + file_id = self.data(index, Qt.UserRole) + + updated_file = None + # conversion from cursor to list + representations = list(self.dbcon.find({"type": "representation", + "_id": self._id})) + + representation = representations.pop() + for repre_file in representation["files"]: + if repre_file["_id"] == file_id: + updated_file = repre_file + break + + if representation and updated_file: + self.sync_server.update_db(self.project, None, updated_file, + representation, get_local_site_id(), + priority=value) + self.is_editing = False + # all other approaches messed up selection to 0th index + self.timer.setInterval(0) diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index 21236dc64a..e80f91e09f 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -23,6 +23,7 @@ from openpype.modules.sync_server.tray.models import ( ) from openpype.modules.sync_server.tray import lib +from openpype.modules.sync_server.tray import delegates log = PypeLogger().get_logger("SyncServer") @@ -94,16 +95,19 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) - menu = QtWidgets.QMenu() + menu = QtWidgets.QMenu(self) actions_mapping = {} - if self.sync_server.is_project_paused(self.project_name): - action = QtWidgets.QAction("Unpause") - actions_mapping[action] = self._unpause - else: - action = QtWidgets.QAction("Pause") - actions_mapping[action] = self._pause - menu.addAction(action) + can_edit = self.model.can_edit + + if can_edit: + if self.sync_server.is_project_paused(self.project_name): + action = QtWidgets.QAction("Unpause") + actions_mapping[action] = self._unpause + else: + action = QtWidgets.QAction("Pause") + actions_mapping[action] = self._pause + menu.addAction(action) if self.local_site == get_local_site_id(): action = QtWidgets.QAction("Clear local project") @@ -145,10 +149,10 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): def _selection_changed(self, _new_selected, _all_selected): idxs = self.selection_model.selectedRows() - self._selected_ids = [] + self._selected_ids = set() for index in idxs: - self._selected_ids.append(self.model.data(index, Qt.UserRole)) + self._selected_ids.add(self.model.data(index, Qt.UserRole)) def _set_selection(self): """ @@ -156,14 +160,14 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): Keep selection during model refresh. """ - existing_ids = [] + existing_ids = set() 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) + existing_ids.add(selected_id) self._selected_ids = existing_ids @@ -171,9 +175,17 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): """ Opens representation dialog with all files after doubleclick """ + # priority editing + if self.model.can_edit: + column_name = self.model.get_column(index.column()) + if column_name[0] in self.model.EDITABLE_COLUMNS: + self.model.is_editing = True + self.table_view.openPersistentEditor(index) + return + _id = self.model.data(index, Qt.UserRole) detail_window = SyncServerDetailWindow( - self.sync_server, _id, self.model.project) + self.sync_server, _id, self.model.project, parent=self) detail_window.exec() def _on_context_menu(self, point): @@ -189,13 +201,15 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): return if is_multi: - index = self.model.get_index(self._selected_ids[0]) + index = self.model.get_index(list(self._selected_ids)[0]) item = self.model.data(index, lib.FullItemRole) else: item = self.model.data(point_index, lib.FullItemRole) + can_edit = self.model.can_edit action_kwarg_map, actions_mapping, menu = self._prepare_menu(item, - is_multi) + is_multi, + can_edit) result = menu.exec_(QtGui.QCursor.pos()) if result: @@ -206,8 +220,8 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): self.model.refresh() - def _prepare_menu(self, item, is_multi): - menu = QtWidgets.QMenu() + def _prepare_menu(self, item, is_multi, can_edit): + menu = QtWidgets.QMenu(self) actions_mapping = {} action_kwarg_map = {} @@ -235,24 +249,30 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): self._get_action_kwargs(site) menu.addAction(action) - if remote_progress == 1.0 or is_multi: + if can_edit and (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: + if can_edit and (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(): + if can_edit and 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) + if can_edit: + action = QtWidgets.QAction("Change priority") + action_kwarg_map[action] = self._get_action_kwargs(active_site) + actions_mapping[action] = self._change_priority + menu.addAction(action) + # # temp for testing only !!! # action = QtWidgets.QAction("Download") # action_kwarg_map[action] = self._get_action_kwargs(active_site) @@ -316,6 +336,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): representation_id)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) + self.sync_server.reset_timer() def _remove_site(self, selected_ids=None, site_name=None): """ @@ -343,6 +364,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): self.model.refresh( load_records=self.model._rec_loaded) + self.sync_server.reset_timer() def _reset_site(self, selected_ids=None, site_name=None): """ @@ -368,6 +390,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): self.model.refresh( load_records=self.model._rec_loaded) + self.sync_server.reset_timer() def _open_in_explorer(self, selected_ids=None, site_name=None): log.debug("Open in Explorer {}:{}".format(selected_ids, site_name)) @@ -394,6 +417,16 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): except OSError: raise OSError('unsupported xdg-open call??') + def _change_priority(self, **kwargs): + """Open editor to change priority on first selected row""" + if self._selected_ids: + # get_index returns dummy index with column equals to 0 + index = self.model.get_index(list(self._selected_ids)[0]) + column_no = self.model.get_header_index("priority") # real column + real_index = self.model.index(index.row(), column_no) + self.model.is_editing = True + self.table_view.openPersistentEditor(real_index) + def _get_progress(self, item, site_name, opposite=False): """Returns progress value according to site (side)""" progress = {'local': item.local_progress, @@ -438,7 +471,7 @@ class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): self.sync_server = sync_server - self._selected_ids = [] # keep last selected _id + self._selected_ids = set() # keep last selected _id txt_filter = QtWidgets.QLineEdit() txt_filter.setPlaceholderText("Quick filter representations..") @@ -456,7 +489,8 @@ class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] - model = SyncRepresentationSummaryModel(sync_server, headers, project) + model = SyncRepresentationSummaryModel(sync_server, headers, project, + parent=self) table_view.setModel(model) table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) table_view.setSelectionMode( @@ -467,15 +501,20 @@ class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): -1, Qt.AscendingOrder) table_view.setAlternatingRowColors(True) table_view.verticalHeader().hide() + table_view.viewport().setAttribute(QtCore.Qt.WA_Hover, True) column = table_view.model().get_header_index("local_site") - delegate = ImageDelegate(self) + delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) column = table_view.model().get_header_index("remote_site") - delegate = ImageDelegate(self) + delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) + column = table_view.model().get_header_index("priority") + priority_delegate = delegates.PriorityDelegate(self) + table_view.setItemDelegateForColumn(column, priority_delegate) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) @@ -505,18 +544,19 @@ class SyncRepresentationSummaryWidget(_SyncRepresentationWidget): self.selection_model = self.table_view.selectionModel() self.selection_model.selectionChanged.connect(self._selection_changed) - def _prepare_menu(self, item, is_multi): + def _prepare_menu(self, item, is_multi, can_edit): action_kwarg_map, actions_mapping, menu = \ - super()._prepare_menu(item, is_multi) + super()._prepare_menu(item, is_multi, can_edit) - if item.status in [lib.STATUS[0], lib.STATUS[1]] or is_multi: + if can_edit and ( + 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 item.status == lib.STATUS[3] or is_multi: + if can_edit and (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} @@ -595,7 +635,7 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): self.sync_server = sync_server self.representation_id = _id - self._selected_ids = [] + self._selected_ids = set() self.txt_filter = QtWidgets.QLineEdit() self.txt_filter.setPlaceholderText("Quick filter representation..") @@ -613,6 +653,8 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): model = SyncRepresentationDetailModel(sync_server, headers, _id, project) + model.is_running = True + table_view.setModel(model) table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) table_view.setSelectionMode( @@ -625,13 +667,18 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): table_view.verticalHeader().hide() column = model.get_header_index("local_site") - delegate = ImageDelegate(self) + delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) column = model.get_header_index("remote_site") - delegate = ImageDelegate(self) + delegate = delegates.ImageDelegate(self) table_view.setItemDelegateForColumn(column, delegate) + if model.can_edit: + column = table_view.model().get_header_index("priority") + priority_delegate = delegates.PriorityDelegate(self) + table_view.setItemDelegateForColumn(column, priority_delegate) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) @@ -655,12 +702,25 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): self.txt_filter.textChanged.connect(lambda: model.set_word_filter( self.txt_filter.text())) + table_view.doubleClicked.connect(self._double_clicked) 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) + def _double_clicked(self, index): + """ + Opens representation dialog with all files after doubleclick + """ + # priority editing + if self.model.can_edit: + column_name = self.model.get_column(index.column()) + if column_name[0] in self.model.EDITABLE_COLUMNS: + self.model.is_editing = True + self.table_view.openPersistentEditor(index) + return + def _show_detail(self, selected_ids=None): """ Shows windows with error message for failed sync of a file. @@ -669,10 +729,10 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget): detail_window.exec() - def _prepare_menu(self, item, is_multi): + def _prepare_menu(self, item, is_multi, can_edit): """Adds view (and model) dependent actions to default ones""" action_kwarg_map, actions_mapping, menu = \ - super()._prepare_menu(item, is_multi) + super()._prepare_menu(item, is_multi, can_edit) if item.status == lib.STATUS[2] or is_multi: action = QtWidgets.QAction("Open error detail") @@ -775,72 +835,6 @@ class SyncRepresentationErrorWidget(QtWidgets.QWidget): layout.addWidget(text_area) -class ImageDelegate(QtWidgets.QStyledItemDelegate): - """ - Prints icon of site and progress of synchronization - """ - - def __init__(self, parent=None): - super(ImageDelegate, self).__init__(parent) - self.icons = {} - - def paint(self, painter, option, index): - super(ImageDelegate, self).paint(painter, option, index) - option = QtWidgets.QStyleOptionViewItem(option) - option.showDecorationSelected = True - - provider = index.data(lib.ProviderRole) - value = index.data(lib.ProgressRole) - date_value = index.data(lib.DateRole) - is_failed = index.data(lib.FailedRole) - - if not self.icons.get(provider): - resource_path = os.path.dirname(__file__) - resource_path = os.path.join(resource_path, "..", - "providers", "resources") - pix_url = "{}/{}.png".format(resource_path, provider) - pixmap = QtGui.QPixmap(pix_url) - self.icons[provider] = pixmap - else: - pixmap = self.icons[provider] - - padding = 10 - point = QtCore.QPoint(option.rect.x() + padding, - option.rect.y() + - (option.rect.height() - pixmap.height()) / 2) - painter.drawPixmap(point, pixmap) - - overlay_rect = option.rect.translated(0, 0) - overlay_rect.setHeight(overlay_rect.height() * (1.0 - float(value))) - painter.fillRect(overlay_rect, - QtGui.QBrush(QtGui.QColor(0, 0, 0, 100))) - text_rect = option.rect.translated(10, 0) - painter.drawText(text_rect, - QtCore.Qt.AlignCenter, - date_value) - - if is_failed: - overlay_rect = option.rect.translated(0, 0) - painter.fillRect(overlay_rect, - QtGui.QBrush(QtGui.QColor(255, 0, 0, 35))) - - -class TransparentWidget(QtWidgets.QWidget): - """Used for header cell for resizing to work properly""" - 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): """Reiplemented QHeaderView to contain clickable changeable button""" def __init__(self, parent=None): diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index 36f3444399..fa6e63b029 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -3,6 +3,11 @@ from openpype.api import Logger log = Logger().get_logger("SyncServer") +class ResumableError(Exception): + """Error which could be temporary, skip current loop, try next time""" + pass + + class SyncStatus: DO_NOTHING = 0 DO_UPLOAD = 1 diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py index 003c779836..1a52a59012 100644 --- a/openpype/plugins/publish/collect_current_pype_user.py +++ b/openpype/plugins/publish/collect_current_pype_user.py @@ -14,4 +14,4 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): def process(self, context): user = get_openpype_username() context.data["user"] = user - self.log.debug("Colected user \"{}\"".format(user)) + self.log.debug("Collected user \"{}\"".format(user)) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index f58bd0dd9d..669e6752f3 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -19,7 +19,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): if "unreal" in pyblish.api.registered_hosts(): return - assert context.data.get('currentFile'), "Cannot get curren file" + assert context.data.get('currentFile'), "Cannot get current file" filename = os.path.basename(context.data.get('currentFile')) if '