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 '' in filename: diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index e266c39714..1e805afba7 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -3,6 +3,7 @@ import re import json import copy import tempfile +import clique import openpype import openpype.api @@ -114,8 +115,30 @@ class ExtractBurnin(openpype.api.Extractor): # Prepare burnin options profile_options = copy.deepcopy(self.default_options) for key, value in (self.options or {}).items(): - if value is not None: - profile_options[key] = value + if value is None: + continue + + if key == "bg_color" and len(value) == 4: + bg_red, bg_green, bg_blue, bg_alpha = value + bg_color_hex = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( + bg_red, bg_green, bg_blue + ) + bg_color_alpha = float(bg_alpha) / 255 + profile_options["bg_opacity"] = bg_color_alpha + profile_options["bg_color"] = bg_color_hex + continue + + elif key == "font_color" and len(value) == 4: + fg_red, fg_green, fg_blue, fg_alpha = value + fg_color_hex = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( + fg_red, fg_green, fg_blue + ) + fg_color_alpha = float(fg_alpha) / 255 + profile_options["opacity"] = fg_color_alpha + profile_options["font_color"] = fg_color_hex + continue + + profile_options[key] = value # Prepare global burnin values from presets profile_burnins = {} @@ -247,7 +270,9 @@ class ExtractBurnin(openpype.api.Extractor): "output": temp_data["full_output_path"], "burnin_data": burnin_data, "options": burnin_options, - "values": burnin_values + "values": burnin_values, + "full_input_path": temp_data["full_input_paths"][0], + "first_frame": temp_data["first_frame"] } self.log.debug( @@ -461,32 +486,47 @@ class ExtractBurnin(openpype.api.Extractor): None: This is processing method. """ # TODO we should find better way to know if input is sequence - is_sequence = ( - "sequence" in new_repre["tags"] - and isinstance(new_repre["files"], (tuple, list)) - ) + input_filenames = new_repre["files"] + is_sequence = False + if isinstance(input_filenames, (tuple, list)): + if len(input_filenames) > 1: + is_sequence = True + + # Sequence must have defined first frame + # - not used if input is not a sequence + first_frame = None if is_sequence: - input_filename = new_repre["sequence_file"] - else: - input_filename = new_repre["files"] + collections, _ = clique.assemble(input_filenames) + if not collections: + is_sequence = False + else: + input_filename = new_repre["sequence_file"] + collection = collections[0] + indexes = list(collection.indexes) + padding = len(str(max(indexes))) + head = collection.format("{head}") + tail = collection.format("{tail}") + output_filename = "{}%{:0>2}d{}{}".format( + head, padding, filename_suffix, tail + ) + repre_files = [] + for idx in indexes: + repre_files.append(output_filename % idx) - filepart_start, ext = os.path.splitext(input_filename) - dir_path, basename = os.path.split(filepart_start) + first_frame = min(indexes) - if is_sequence: - # NOTE modified to keep name when multiple dots are in name - basename_parts = basename.split(".") - frame_part = basename_parts.pop(-1) + if not is_sequence: + input_filename = input_filenames + if isinstance(input_filename, (tuple, list)): + input_filename = input_filename[0] - basename_start = ".".join(basename_parts) + filename_suffix - new_basename = ".".join((basename_start, frame_part)) - output_filename = new_basename + ext - - else: + filepart_start, ext = os.path.splitext(input_filename) + dir_path, basename = os.path.split(filepart_start) output_filename = basename + filename_suffix + ext + if dir_path: + output_filename = os.path.join(dir_path, output_filename) - if dir_path: - output_filename = os.path.join(dir_path, output_filename) + repre_files = output_filename stagingdir = new_repre["stagingDir"] full_input_path = os.path.join( @@ -498,6 +538,9 @@ class ExtractBurnin(openpype.api.Extractor): temp_data["full_input_path"] = full_input_path temp_data["full_output_path"] = full_output_path + temp_data["first_frame"] = first_frame + + new_repre["files"] = repre_files self.log.debug("full_input_path: {}".format(full_input_path)) self.log.debug("full_output_path: {}".format(full_output_path)) @@ -505,17 +548,16 @@ class ExtractBurnin(openpype.api.Extractor): # Prepare full paths to input files and filenames for reprensetation full_input_paths = [] if is_sequence: - repre_files = [] - for frame_index in range(1, temp_data["duration"] + 1): - repre_files.append(output_filename % frame_index) - full_input_paths.append(full_input_path % frame_index) + for filename in input_filenames: + filepath = os.path.join( + os.path.normpath(stagingdir), filename + ).replace("\\", "/") + full_input_paths.append(filepath) else: full_input_paths.append(full_input_path) - repre_files = output_filename temp_data["full_input_paths"] = full_input_paths - new_repre["files"] = repre_files def prepare_repre_data(self, instance, repre, burnin_data, temp_data): """Prepare data for representation. diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 00b671199d..f341ba197f 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -333,10 +333,24 @@ class ExtractReview(pyblish.api.InstancePlugin): # Get FFmpeg arguments from profile presets out_def_ffmpeg_args = output_def.get("ffmpeg_args") or {} - ffmpeg_input_args = out_def_ffmpeg_args.get("input") or [] - ffmpeg_output_args = out_def_ffmpeg_args.get("output") or [] - ffmpeg_video_filters = out_def_ffmpeg_args.get("video_filters") or [] - ffmpeg_audio_filters = out_def_ffmpeg_args.get("audio_filters") or [] + _ffmpeg_input_args = out_def_ffmpeg_args.get("input") or [] + _ffmpeg_output_args = out_def_ffmpeg_args.get("output") or [] + _ffmpeg_video_filters = out_def_ffmpeg_args.get("video_filters") or [] + _ffmpeg_audio_filters = out_def_ffmpeg_args.get("audio_filters") or [] + + # Cleanup empty strings + ffmpeg_input_args = [ + value for value in _ffmpeg_input_args if value.strip() + ] + ffmpeg_output_args = [ + value for value in _ffmpeg_output_args if value.strip() + ] + ffmpeg_video_filters = [ + value for value in _ffmpeg_video_filters if value.strip() + ] + ffmpeg_audio_filters = [ + value for value in _ffmpeg_audio_filters if value.strip() + ] if isinstance(new_repre['files'], list): input_files_urls = [os.path.join(new_repre["stagingDir"], f) for f diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index a2d97429d3..981cca82dc 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -119,26 +119,3 @@ class PypeCommands: def validate_jsons(self): pass - @staticmethod - def generate_zip(out_path: str): - """Generate zip file from current sources. - - Args: - out_path (str): Path to generated zip file. - - """ - from igniter import bootstrap_repos - - # create zip file - bs = bootstrap_repos.BootstrapRepos() - if out_path: - out_path = Path(out_path) - bs.data_dir = out_path.parent - - print(f">>> Creating zip in {bs.data_dir} ...") - repo_file = bs.create_version_from_live_code() - if not repo_file: - print("!!! Error while creating zip file.") - exit(1) - - print(f">>> Created {repo_file}") diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 5e2a22f1b5..6dcf00e97c 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -14,7 +14,7 @@ ffprobe_path = openpype.lib.get_ffmpeg_tool_path("ffprobe") FFMPEG = ( - '"{}" -i "%(input)s" %(filters)s %(args)s%(output)s' + '"{}"%(input_args)s -i "%(input)s" %(filters)s %(args)s%(output)s' ).format(ffmpeg_path) FFPROBE = ( @@ -121,10 +121,18 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): 'font_size': 42 } - def __init__(self, source, streams=None, options_init=None): + def __init__( + self, source, streams=None, options_init=None, first_frame=None + ): if not streams: streams = _streams(source) + input_args = [] + if first_frame: + input_args.append("-start_number {}".format(first_frame)) + + self.input_args = input_args + super().__init__(source, streams) if options_init: @@ -289,7 +297,12 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if self.filter_string: filters = '-vf "{}"'.format(self.filter_string) + input_args = "" + if self.input_args: + input_args = " {}".format(" ".join(self.input_args)) + return (FFMPEG % { + 'input_args': input_args, 'input': self.source, 'output': output, 'args': '%s ' % args if args else '', @@ -370,7 +383,8 @@ def example(input_path, output_path): def burnins_from_data( input_path, output_path, data, - codec_data=None, options=None, burnin_values=None, overwrite=True + codec_data=None, options=None, burnin_values=None, overwrite=True, + full_input_path=None, first_frame=None ): """This method adds burnins to video/image file based on presets setting. @@ -427,8 +441,11 @@ def burnins_from_data( "shot": "sh0010" } """ + streams = None + if full_input_path: + streams = _streams(full_input_path) - burnin = ModifiedBurnins(input_path, options_init=options) + burnin = ModifiedBurnins(input_path, streams, options, first_frame) frame_start = data.get("frame_start") frame_end = data.get("frame_end") @@ -591,6 +608,8 @@ if __name__ == "__main__": in_data["burnin_data"], codec_data=in_data.get("codec"), options=in_data.get("options"), - burnin_values=in_data.get("values") + burnin_values=in_data.get("values"), + full_input_path=in_data.get("full_input_path"), + first_frame=in_data.get("first_frame") ) print("* Burnin script has finished") diff --git a/openpype/settings/defaults/project_anatomy/attributes.json b/openpype/settings/defaults/project_anatomy/attributes.json index e2c97cb052..387e12bcea 100644 --- a/openpype/settings/defaults/project_anatomy/attributes.json +++ b/openpype/settings/defaults/project_anatomy/attributes.json @@ -20,7 +20,7 @@ "harmony/20", "photoshop/2021", "aftereffects/2021", - "unreal/4-24" + "unreal/4-26" ], "tools_env": [] } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 905ba68d60..03f3e19a64 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -21,8 +21,8 @@ "secondary_pool": "", "group": "", "department": "", - "limit_groups": {}, - "use_gpu": true + "use_gpu": true, + "limit_groups": {} }, "HarmonySubmitDeadline": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 61db35ba79..712ec22cfc 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -73,8 +73,18 @@ "enabled": true, "options": { "font_size": 42, - "opacity": 1.0, - "bg_opacity": 0.5, + "font_color": [ + 255, + 255, + 255, + 255 + ], + "bg_color": [ + 0, + 0, + 0, + 127 + ], "x_offset": 5, "y_offset": 5, "bg_padding": 5 diff --git a/openpype/settings/defaults/project_settings/hiero.json b/openpype/settings/defaults/project_settings/hiero.json index a8d6472c47..44b27fc16f 100644 --- a/openpype/settings/defaults/project_settings/hiero.json +++ b/openpype/settings/defaults/project_settings/hiero.json @@ -21,10 +21,20 @@ "LoadClip": { "enabled": true, "families": [ - "render2d", "source", "plate", "render", "review" + "render2d", + "source", + "plate", + "render", + "review" ], "representations": [ - "exr", "dpx", "jpg", "jpeg", "png", "h264", "mov" + "exr", + "dpx", + "jpg", + "jpeg", + "png", + "h264", + "mov" ], "clip_name_template": "{asset}_{subset}_{representation}" } diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index f99f048bdf..63d6da4633 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -210,11 +210,11 @@ "environment": {} }, "__dynamic_keys_labels__": { + "13-0": "13.0 (Testing only)", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2", - "13-0": "13.0 (Testing only)" + "11-2": "11.2" } } }, @@ -354,11 +354,11 @@ "environment": {} }, "__dynamic_keys_labels__": { + "13-0": "13.0 (Testing only)", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2", - "13-0": "13.0 (Testing only)" + "11-2": "11.2" } } }, @@ -496,11 +496,11 @@ "environment": {} }, "__dynamic_keys_labels__": { + "13-0": "13.0 (Testing only)", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2", - "13-0": "13.0 (Testing only)" + "11-2": "11.2" } } }, @@ -640,11 +640,11 @@ "environment": {} }, "__dynamic_keys_labels__": { + "13-0": "13.0 (Testing only)", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2", - "13-0": "13.0 (Testing only)" + "11-2": "11.2" } } }, diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index 2568e8b6a8..d03fedf3c9 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -1,12 +1,10 @@ { "studio_name": "Studio name", "studio_code": "stu", + "admin_password": "", "environment": { - "OPENPYPE_OCIO_CONFIG": "{STUDIO_SOFT}/OpenColorIO-Configs", "__environment_keys__": { - "global": [ - "OPENPYPE_OCIO_CONFIG" - ] + "global": [] } }, "openpype_path": { diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index b5c42e1da0..3e73fa8aa6 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -457,27 +457,18 @@ class BaseItemEntity(BaseEntity): pass @property - def can_discard_changes(self): - """Result defines if `discard_changes` will be processed. - - Also can be used as validation before the method is called. - """ + def _can_discard_changes(self): + """Defines if `discard_changes` will be processed.""" return self.has_unsaved_changes @property - def can_add_to_studio_default(self): - """Result defines if `add_to_studio_default` will be processed. - - Also can be used as validation before the method is called. - """ + def _can_add_to_studio_default(self): + """Defines if `add_to_studio_default` will be processed.""" if self._override_state is not OverrideState.STUDIO: return False - if self.is_dynamic_item or self.is_in_dynamic_item: - return False - # Skip if entity is under group - if self.group_item: + if self.group_item is not None: return False # Skip if is group and any children is already marked with studio @@ -487,36 +478,24 @@ class BaseItemEntity(BaseEntity): return True @property - def can_remove_from_studio_default(self): - """Result defines if `remove_from_studio_default` can be triggered. - - This can be also used as validation before the method is called. - """ + def _can_remove_from_studio_default(self): + """Defines if `remove_from_studio_default` can be processed.""" if self._override_state is not OverrideState.STUDIO: return False - if self.is_dynamic_item or self.is_in_dynamic_item: - return False - if not self.has_studio_override: return False return True @property - def can_add_to_project_override(self): - """Result defines if `add_to_project_override` can be triggered. - - Also can be used as validation before the method is called. - """ - if self.is_dynamic_item or self.is_in_dynamic_item: - return False - + def _can_add_to_project_override(self): + """Defines if `add_to_project_override` can be processed.""" # Show only when project overrides are set if self._override_state is not OverrideState.PROJECT: return False # Do not show on items under group item - if self.group_item: + if self.group_item is not None: return False # Skip if already is marked to save project overrides @@ -525,14 +504,8 @@ class BaseItemEntity(BaseEntity): return True @property - def can_remove_from_project_override(self): - """Result defines if `remove_from_project_override` can be triggered. - - This can be also used as validation before the method is called. - """ - if self.is_dynamic_item or self.is_in_dynamic_item: - return False - + def _can_remove_from_project_override(self): + """Defines if `remove_from_project_override` can be processed.""" if self._override_state is not OverrideState.PROJECT: return False @@ -544,6 +517,54 @@ class BaseItemEntity(BaseEntity): return False return True + @property + def can_trigger_discard_changes(self): + """Defines if can trigger `discard_changes`. + + Also can be used as validation before the method is called. + """ + return self._can_discard_changes + + @property + def can_trigger_add_to_studio_default(self): + """Defines if can trigger `add_to_studio_default`. + + Also can be used as validation before the method is called. + """ + if self.is_dynamic_item or self.is_in_dynamic_item: + return False + return self._can_add_to_studio_default + + @property + def can_trigger_remove_from_studio_default(self): + """Defines if can trigger `remove_from_studio_default`. + + Also can be used as validation before the method is called. + """ + if self.is_dynamic_item or self.is_in_dynamic_item: + return False + return self._can_remove_from_studio_default + + @property + def can_trigger_add_to_project_override(self): + """Defines if can trigger `add_to_project_override`. + + Also can be used as validation before the method is called. + """ + if self.is_dynamic_item or self.is_in_dynamic_item: + return False + return self._can_add_to_project_override + + @property + def can_trigger_remove_from_project_override(self): + """Defines if can trigger `remove_from_project_override`. + + Also can be used as validation before the method is called. + """ + if self.is_dynamic_item or self.is_in_dynamic_item: + return False + return self._can_remove_from_project_override + def discard_changes(self, on_change_trigger=None): """Discard changes on entity and it's children. @@ -568,7 +589,7 @@ class BaseItemEntity(BaseEntity): """ initialized = False if on_change_trigger is None: - if not self.can_discard_changes: + if not self.can_trigger_discard_changes: return initialized = True @@ -588,7 +609,7 @@ class BaseItemEntity(BaseEntity): def add_to_studio_default(self, on_change_trigger=None): initialized = False if on_change_trigger is None: - if not self.can_add_to_studio_default: + if not self.can_trigger_add_to_studio_default: return initialized = True @@ -625,7 +646,7 @@ class BaseItemEntity(BaseEntity): """ initialized = False if on_change_trigger is None: - if not self.can_remove_from_studio_default: + if not self.can_trigger_remove_from_studio_default: return initialized = True @@ -649,7 +670,7 @@ class BaseItemEntity(BaseEntity): def add_to_project_override(self, on_change_trigger=None): initialized = False if on_change_trigger is None: - if not self.can_add_to_project_override: + if not self.can_trigger_add_to_project_override: return initialized = True @@ -689,7 +710,7 @@ class BaseItemEntity(BaseEntity): initialized = False if on_change_trigger is None: - if not self.can_remove_from_project_override: + if not self.can_trigger_remove_from_project_override: return initialized = True on_change_trigger = [] @@ -775,7 +796,8 @@ class ItemEntity(BaseItemEntity): # Group item reference if self.parent.is_group: self.group_item = self.parent - elif self.parent.group_item: + + elif self.parent.group_item is not None: self.group_item = self.parent.group_item self.key = self.schema_data.get("key") diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index d5563f80d6..052bbda4d0 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -353,6 +353,20 @@ class DictImmutableKeysEntity(ItemEntity): for key in METADATA_KEYS: if key in value: metadata[key] = value.pop(key) + + old_metadata = metadata.get(M_OVERRIDEN_KEY) + if old_metadata: + old_metadata_set = set(old_metadata) + new_metadata = [] + for key in self.non_gui_children.keys(): + if key in old_metadata: + new_metadata.append(key) + old_metadata_set.remove(key) + + for key in old_metadata_set: + new_metadata.append(key) + metadata[M_OVERRIDEN_KEY] = new_metadata + return value, metadata def update_default_value(self, value): @@ -458,6 +472,9 @@ class DictImmutableKeysEntity(ItemEntity): for child_obj in self.non_gui_children.values(): child_obj.add_to_studio_default(on_change_trigger) self._ignore_child_changes = False + + self._update_current_metadata() + self.parent.on_child_change(self) def _remove_from_studio_default(self, on_change_trigger): @@ -471,6 +488,9 @@ class DictImmutableKeysEntity(ItemEntity): for child_obj in self.non_gui_children.values(): child_obj.add_to_project_override(_on_change_trigger) self._ignore_child_changes = False + + self._update_current_metadata() + self.parent.on_child_change(self) def _remove_from_project_override(self, on_change_trigger): diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 7ba44ed0df..ef0124c0f0 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -222,7 +222,7 @@ class DictMutableKeysEntity(EndpointEntity): if self.value_is_env_group: self.item_schema["env_group_key"] = "" - if not self.group_item: + if self.group_item is None: self.is_group = True def schema_validations(self): @@ -251,8 +251,18 @@ class DictMutableKeysEntity(EndpointEntity): ) raise EntitySchemaError(self, reason) - for child_obj in self.children_by_key.values(): - child_obj.schema_validations() + # Validate object type schema + child_validated = False + for child_entity in self.children_by_key.values(): + child_entity.schema_validations() + child_validated = True + break + + if not child_validated: + key = "__tmp__" + tmp_child = self._add_key(key) + tmp_child.schema_validations() + self.children_by_key.pop(key) def get_child_path(self, child_obj): result_key = None @@ -277,21 +287,24 @@ class DictMutableKeysEntity(EndpointEntity): self.on_change() - def _metadata_for_current_state(self): + def _get_metadata_for_state(self, state): if ( - self._override_state is OverrideState.PROJECT + state is OverrideState.PROJECT and self._project_override_value is not NOT_SET ): return self._project_override_metadata if ( - self._override_state >= OverrideState.STUDIO + state >= OverrideState.STUDIO and self._studio_override_value is not NOT_SET ): return self._studio_override_metadata return self._default_metadata + def _metadata_for_current_state(self): + return self._get_metadata_for_state(self._override_state) + def set_override_state(self, state): # Trigger override state change of root if is not same if self.root_item.override_state is not state: @@ -519,6 +532,9 @@ class DictMutableKeysEntity(EndpointEntity): self.had_project_override = value is not NOT_SET def _discard_changes(self, on_change_trigger): + if not self._can_discard_changes: + return + self.set_override_state(self._override_state) on_change_trigger.append(self.on_change) @@ -527,6 +543,9 @@ class DictMutableKeysEntity(EndpointEntity): self.on_change() def _remove_from_studio_default(self, on_change_trigger): + if not self._can_remove_from_studio_default: + return + value = self._default_value if value is NOT_SET: value = self.value_on_not_set @@ -536,13 +555,23 @@ class DictMutableKeysEntity(EndpointEntity): # Simulate `clear` method without triggering value change for key in tuple(self.children_by_key.keys()): - child_obj = self.children_by_key.pop(key) + self.children_by_key.pop(key) + + metadata = self._get_metadata_for_state(OverrideState.DEFAULTS) + metadata_labels = metadata.get(M_DYNAMIC_KEY_LABEL) or {} + children_label_by_id = {} # Create new children for _key, _value in new_value.items(): - child_obj = self._add_key(_key) - child_obj.update_default_value(_value) - child_obj.set_override_state(self._override_state) + child_entity = self._add_key(_key) + child_entity.update_default_value(_value) + label = metadata_labels.get(_key) + if label: + children_label_by_id[child_entity.id] = label + + child_entity.set_override_state(self._override_state) + + self.children_label_by_id = children_label_by_id self._ignore_child_changes = False @@ -555,10 +584,7 @@ class DictMutableKeysEntity(EndpointEntity): self.on_change() def _remove_from_project_override(self, on_change_trigger): - if self._override_state is not OverrideState.PROJECT: - return - - if not self.has_project_override: + if not self._can_remove_from_project_override: return if self._has_studio_override: @@ -574,15 +600,26 @@ class DictMutableKeysEntity(EndpointEntity): # Simulate `clear` method without triggering value change for key in tuple(self.children_by_key.keys()): - child_obj = self.children_by_key.pop(key) + self.children_by_key.pop(key) + + metadata = self._get_metadata_for_state(OverrideState.STUDIO) + metadata_labels = metadata.get(M_DYNAMIC_KEY_LABEL) or {} + children_label_by_id = {} # Create new children for _key, _value in new_value.items(): - child_obj = self._add_key(_key) - child_obj.update_default_value(_value) + child_entity = self._add_key(_key) + child_entity.update_default_value(_value) if self._has_studio_override: - child_obj.update_studio_value(_value) - child_obj.set_override_state(self._override_state) + child_entity.update_studio_value(_value) + + label = metadata_labels.get(_key) + if label: + children_label_by_id[child_entity.id] = label + + child_entity.set_override_state(self._override_state) + + self.children_label_by_id = children_label_by_id self._ignore_child_changes = False diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index e897576d43..9278cfd9b0 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -32,7 +32,7 @@ class EndpointEntity(ItemEntity): super(EndpointEntity, self).__init__(*args, **kwargs) if ( - not (self.group_item or self.is_group) + not (self.group_item is not None or self.is_group) and not (self.is_dynamic_item or self.is_in_dynamic_item) ): self.is_group = True @@ -251,6 +251,9 @@ class InputEntity(EndpointEntity): self._current_value = copy.deepcopy(value) def _discard_changes(self, on_change_trigger=None): + if not self._can_discard_changes: + return + self._value_is_modified = False if self._override_state >= OverrideState.PROJECT: self._has_project_override = self.had_project_override @@ -286,6 +289,9 @@ class InputEntity(EndpointEntity): self.on_change() def _remove_from_studio_default(self, on_change_trigger): + if not self._can_remove_from_studio_default: + return + value = self._default_value if value is NOT_SET: value = self.value_on_not_set @@ -301,10 +307,7 @@ class InputEntity(EndpointEntity): self.on_change() def _remove_from_project_override(self, on_change_trigger): - if self._override_state is not OverrideState.PROJECT: - return - - if not self._has_project_override: + if not self._can_remove_from_project_override: return self._has_project_override = False diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index 56e7d1c7b2..fc92c28db9 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -49,7 +49,7 @@ class PathEntity(ItemEntity): return self.child_obj.items() def _item_initalization(self): - if not self.group_item and not self.is_group: + if self.group_item is None and not self.is_group: self.is_group = True self.multiplatform = self.schema_data.get("multiplatform", False) @@ -199,7 +199,7 @@ class ListStrictEntity(ItemEntity): # GUI attribute self.is_horizontal = self.schema_data.get("horizontal", True) - if not self.group_item and not self.is_group: + if self.group_item is None and not self.is_group: self.is_group = True def schema_validations(self): @@ -453,4 +453,5 @@ class ListStrictEntity(ItemEntity): def reset_callbacks(self): super(ListStrictEntity, self).reset_callbacks() - self.child_obj.reset_callbacks() + for child_obj in self.children: + child_obj.reset_callbacks() diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index c6155b78f8..a57468fff7 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -59,8 +59,7 @@ class ListEntity(EndpointEntity): ) def append(self, item): - child_obj = self._add_new_item() - child_obj.set_override_state(self._override_state) + child_obj = self.add_new_item(trigger_change=False) child_obj.set(item) self.on_change() @@ -92,8 +91,7 @@ class ListEntity(EndpointEntity): raise ValueError("ListEntity.remove(x): x not in ListEntity") def insert(self, idx, item): - child_obj = self._add_new_item(idx) - child_obj.set_override_state(self._override_state) + child_obj = self.add_new_item(idx, trigger_change=False) child_obj.set(item) self.on_change() @@ -105,10 +103,16 @@ class ListEntity(EndpointEntity): self.children.insert(idx, child_obj) return child_obj - def add_new_item(self, idx=None): + def add_new_item(self, idx=None, trigger_change=True): child_obj = self._add_new_item(idx) child_obj.set_override_state(self._override_state) - self.on_change() + if self._override_state is OverrideState.STUDIO: + child_obj.add_to_studio_default([]) + elif self._override_state is OverrideState.PROJECT: + child_obj.add_to_project_default([]) + + if trigger_change: + self.on_change() return child_obj def swap_items(self, item_1, item_2): @@ -144,7 +148,7 @@ class ListEntity(EndpointEntity): item_schema = {"type": item_schema} self.item_schema = item_schema - if not self.group_item: + if self.group_item is None: self.is_group = True # Value that was set on set_override_state @@ -167,8 +171,18 @@ class ListEntity(EndpointEntity): ) raise EntitySchemaError(self, reason) - for child_obj in self.children: - child_obj.schema_validations() + # Validate object type schema + child_validated = False + for child_entity in self.children: + child_entity.schema_validations() + child_validated = True + break + + if not child_validated: + idx = 0 + tmp_child = self._add_new_item(idx) + tmp_child.schema_validations() + self.children.pop(idx) def get_child_path(self, child_obj): result_idx = None @@ -343,7 +357,7 @@ class ListEntity(EndpointEntity): return output def _discard_changes(self, on_change_trigger): - if self._override_state is OverrideState.NOT_DEFINED: + if not self._can_discard_changes: return not_set = object() @@ -405,7 +419,7 @@ class ListEntity(EndpointEntity): self.on_change() def _remove_from_studio_default(self, on_change_trigger): - if self._override_state is not OverrideState.STUDIO: + if not self._can_remove_from_studio_default: return value = self._default_value @@ -433,10 +447,7 @@ class ListEntity(EndpointEntity): self.on_change() def _remove_from_project_override(self, on_change_trigger): - if self._override_state is not OverrideState.PROJECT: - return - - if not self.has_project_override: + if not self._can_remove_from_project_override: return if self._has_studio_override: diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index edd5c18f51..3c589f9492 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -247,8 +247,7 @@ "label": "Used in plugins", "object_type": { "type": "text", - "key": "pluginClass", - "label": "Plugin Class" + "key": "pluginClass" } }, { @@ -295,8 +294,7 @@ "label": "Used in plugins", "object_type": { "type": "text", - "key": "pluginClass", - "label": "Plugin Class" + "key": "pluginClass" } }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 1bd028ac79..62de311024 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -301,20 +301,24 @@ "minimum": 0 }, { - "type": "number", - "key": "opacity", - "label": "Font opacity", - "decimal": 2, - "maximum": 1, - "minimum": 0 + "type": "schema_template", + "name": "template_rgba_color", + "template_data": [ + { + "label": "Font Color", + "name": "font_color" + } + ] }, { - "type": "number", - "key": "bg_opacity", - "label": "Background opacity", - "decimal": 2, - "maximum": 1, - "minimum": 0 + "type": "schema_template", + "name": "template_rgba_color", + "template_data": [ + { + "label": "Background Color", + "name": "bg_color" + } + ] }, { "type": "number", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_publish_gui_filter.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_publish_gui_filter.json index 1539bd0738..f27ca9586f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_publish_gui_filter.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_publish_gui_filter.json @@ -4,7 +4,6 @@ "key": "filters", "label": "Publish GUI Filters", "object_type": { - "type": "raw-json", - "label": "Plugins" + "type": "raw-json" } } diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json index e6e7381e9f..e72b1fac5b 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_blender.json @@ -28,7 +28,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_djv.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_djv.json index a95cedf7c3..229ca42b04 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_djv.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_djv.json @@ -28,7 +28,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_houdini.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_houdini.json index 22a5b2e737..ee88d90e8e 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_houdini.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_houdini.json @@ -28,7 +28,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_maya.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_maya.json index 7c33671fa7..9a81eda88d 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_maya.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_maya.json @@ -28,7 +28,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_shell.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_shell.json index e344f98594..fcbb415b12 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_shell.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_shell.json @@ -24,7 +24,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json index eac09be113..3c13336eb1 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_tvpaint.json @@ -28,7 +28,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json index c5096197d6..1752899533 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_unreal.json @@ -28,7 +28,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/template_nuke.json b/openpype/settings/entities/schemas/system_schema/host_settings/template_nuke.json index 3f25c7d72f..0055d0b191 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/template_nuke.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/template_nuke.json @@ -29,7 +29,6 @@ "object_type": { "type": "dict", "collapsible": true, - "checkbox_key": "enabled", "children": [ { "type": "schema_template", diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index fd650b4a1e..568ccad5b9 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -18,6 +18,18 @@ { "type": "splitter" }, + { + "type": "label", + "label": "This is NOT a securely stored password!. It only acts as a simple barrier to stop users from accessing studio wide settings." + }, + { + "type": "text", + "key": "admin_password", + "label": "Admin password" + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index b3e1b1b1e1..65ec7291d3 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -12,7 +12,8 @@ from .constants import ( SYSTEM_SETTINGS_KEY, PROJECT_SETTINGS_KEY, PROJECT_ANATOMY_KEY, - LOCAL_SETTING_KEY + LOCAL_SETTING_KEY, + M_OVERRIDEN_KEY ) from .lib import load_json_file @@ -167,6 +168,7 @@ class CacheValues: class MongoSettingsHandler(SettingsHandler): """Settings handler that use mongo for storing and loading of settings.""" + global_general_keys = ("openpype_path", "admin_password") def __init__(self): # Get mongo connection @@ -225,13 +227,106 @@ class MongoSettingsHandler(SettingsHandler): self._prepare_project_settings_keys() return self._attribute_keys - def _prepare_global_settings(self, data): + def _extract_global_settings(self, data): + """Extract global settings data from system settings overrides. + + This is now limited to "general" key in system settings which must be + set as group in schemas. + + Returns: + dict: Global settings extracted from system settings data. + """ output = {} - # Add "openpype_path" key to global settings if is set - if "general" in data and "openpype_path" in data["general"]: - output["openpype_path"] = data["general"]["openpype_path"] + if "general" not in data: + return output + + general_data = data["general"] + + # Add predefined keys to global settings if are set + for key in self.global_general_keys: + if key not in general_data: + continue + # Pop key from values + output[key] = general_data.pop(key) + # Pop key from overriden metadata + if ( + M_OVERRIDEN_KEY in general_data + and key in general_data[M_OVERRIDEN_KEY] + ): + general_data[M_OVERRIDEN_KEY].remove(key) return output + def _apply_global_settings( + self, system_settings_document, globals_document + ): + """Apply global settings data to system settings. + + Applification is skipped if document with global settings is not + available or does not have set data in. + + System settings document is "faked" like it exists if global document + has set values. + + Args: + system_settings_document (dict): System settings document from + MongoDB. + globals_document (dict): Global settings document from MongoDB. + + Returns: + Merged document which has applied global settings data. + """ + # Skip if globals document is not available + if ( + not globals_document + or "data" not in globals_document + or not globals_document["data"] + ): + return system_settings_document + + globals_data = globals_document["data"] + # Check if data contain any key from predefined keys + any_key_found = False + if globals_data: + for key in self.global_general_keys: + if key in globals_data: + any_key_found = True + break + + # Skip if any key from predefined key was not found in globals + if not any_key_found: + return system_settings_document + + # "Fake" system settings document if document does not exist + # - global settings document may exist but system settings not yet + if not system_settings_document: + system_settings_document = {} + + if "data" in system_settings_document: + system_settings_data = system_settings_document["data"] + else: + system_settings_data = {} + system_settings_document["data"] = system_settings_data + + if "general" in system_settings_data: + system_general = system_settings_data["general"] + else: + system_general = {} + system_settings_data["general"] = system_general + + overriden_keys = system_general.get(M_OVERRIDEN_KEY) or [] + for key in self.global_general_keys: + if key not in globals_data: + continue + + system_general[key] = globals_data[key] + if key not in overriden_keys: + overriden_keys.append(key) + + if overriden_keys: + system_general[M_OVERRIDEN_KEY] = overriden_keys + + return system_settings_document + def save_studio_settings(self, data): """Save studio overrides of system settings. @@ -243,23 +338,29 @@ class MongoSettingsHandler(SettingsHandler): Args: data(dict): Data of studio overrides with override metadata. """ - # Store system settings + # Update cache self.system_settings_cache.update_data(data) + + # Get copy of just updated cache + system_settings_data = self.system_settings_cache.data_copy() + + # Extract global settings from system settings + global_settings = self._extract_global_settings( + system_settings_data + ) + + # Store system settings self.collection.replace_one( { "type": SYSTEM_SETTINGS_KEY }, { "type": SYSTEM_SETTINGS_KEY, - "data": self.system_settings_cache.data + "data": system_settings_data }, upsert=True ) - # Get global settings from system settings - global_settings = self._prepare_global_settings( - self.system_settings_cache.data - ) # Store global settings self.collection.replace_one( { @@ -418,11 +519,27 @@ class MongoSettingsHandler(SettingsHandler): def get_studio_system_settings_overrides(self): """Studio overrides of system settings.""" if self.system_settings_cache.is_outdated: - document = self.collection.find_one({ - "type": SYSTEM_SETTINGS_KEY + system_settings_document = None + globals_document = None + docs = self.collection.find({ + # Use `$or` as system settings may have more filters in future + "$or": [ + {"type": GLOBAL_SETTINGS_KEY}, + {"type": SYSTEM_SETTINGS_KEY}, + ] }) + for doc in docs: + doc_type = doc["type"] + if doc_type == GLOBAL_SETTINGS_KEY: + globals_document = doc + elif doc_type == SYSTEM_SETTINGS_KEY: + system_settings_document = doc - self.system_settings_cache.update_from_document(document) + merged_document = self._apply_global_settings( + system_settings_document, globals_document + ) + + self.system_settings_cache.update_from_document(merged_document) return self.system_settings_cache.data_copy() def _get_project_settings_overrides(self, project_name): diff --git a/openpype/tools/settings/__init__.py b/openpype/tools/settings/__init__.py index 3f47d1c2c3..547f4097a2 100644 --- a/openpype/tools/settings/__init__.py +++ b/openpype/tools/settings/__init__.py @@ -1,5 +1,7 @@ import sys from Qt import QtWidgets, QtGui +from .lib import is_password_required +from .widgets import PasswordDialog from .local_settings import LocalSettingsWindow from .settings import ( style, @@ -24,13 +26,14 @@ def main(user_role=None): widget = MainWidget(user_role) widget.show() - widget.reset() sys.exit(app.exec_()) __all__ = ( + "is_password_required", "style", + "PasswordDialog", "MainWidget", "ProjectListWidget", "LocalSettingsWindow", diff --git a/openpype/tools/settings/lib.py b/openpype/tools/settings/lib.py new file mode 100644 index 0000000000..0317941021 --- /dev/null +++ b/openpype/tools/settings/lib.py @@ -0,0 +1,16 @@ +def is_password_required(): + from openpype.settings import ( + get_system_settings, + get_local_settings + ) + + system_settings = get_system_settings() + password = system_settings["general"].get("admin_password") + if not password: + return False + + local_settings = get_local_settings() + is_admin = local_settings.get("general", {}).get("is_admin", False) + if is_admin: + return False + return True diff --git a/openpype/tools/settings/local_settings/general_widget.py b/openpype/tools/settings/local_settings/general_widget.py index 78bc53fdd2..d01c16ff82 100644 --- a/openpype/tools/settings/local_settings/general_widget.py +++ b/openpype/tools/settings/local_settings/general_widget.py @@ -1,28 +1,77 @@ import getpass -from Qt import QtWidgets +from Qt import QtWidgets, QtCore +from openpype.tools.settings import ( + is_password_required, + PasswordDialog +) class LocalGeneralWidgets(QtWidgets.QWidget): def __init__(self, parent): super(LocalGeneralWidgets, self).__init__(parent) + self._loading_local_settings = False + username_input = QtWidgets.QLineEdit(self) username_input.setPlaceholderText(getpass.getuser()) + is_admin_input = QtWidgets.QCheckBox(self) + layout = QtWidgets.QFormLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addRow("OpenPype Username", username_input) + layout.addRow("Admin permissions", is_admin_input) + + is_admin_input.stateChanged.connect(self._on_admin_check_change) self.username_input = username_input + self.is_admin_input = is_admin_input def update_local_settings(self, value): + self._loading_local_settings = True + username = "" + is_admin = False if value: username = value.get("username", username) + is_admin = value.get("is_admin", is_admin) + self.username_input.setText(username) + if self.is_admin_input.isChecked() != is_admin: + # Use state as `stateChanged` is connected to callbacks + if is_admin: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + self.is_admin_input.setCheckState(state) + + self._loading_local_settings = False + + def _on_admin_check_change(self): + if self._loading_local_settings: + return + + if not self.is_admin_input.isChecked(): + return + + if not is_password_required(): + return + + dialog = PasswordDialog(self, False) + dialog.setModal(True) + dialog.exec_() + result = dialog.result() + if self.is_admin_input.isChecked() != result: + # Use state as `stateChanged` is connected to callbacks + if result: + state = QtCore.Qt.Checked + else: + state = QtCore.Qt.Unchecked + self.is_admin_input.setCheckState(state) + def settings_value(self): # Add changed # If these have changed then @@ -30,6 +79,8 @@ class LocalGeneralWidgets(QtWidgets.QWidget): username = self.username_input.text() if username: output["username"] = username - # Do not return output yet since we don't have mechanism to save or - # load these data through api calls + + is_admin = self.is_admin_input.isChecked() + if is_admin: + output["is_admin"] = is_admin return output diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py index b6ca56d348..e117e7fa5d 100644 --- a/openpype/tools/settings/local_settings/window.py +++ b/openpype/tools/settings/local_settings/window.py @@ -156,6 +156,8 @@ class LocalSettingsWindow(QtWidgets.QWidget): def __init__(self, parent=None): super(LocalSettingsWindow, self).__init__(parent) + self._reset_on_show = True + self.resize(1000, 600) self.setWindowTitle("OpenPype Local settings") @@ -193,9 +195,14 @@ class LocalSettingsWindow(QtWidgets.QWidget): self.reset_btn = reset_btn self.save_btn = save_btn - self.reset() + def showEvent(self, event): + super(LocalSettingsWindow, self).showEvent(event) + if self._reset_on_show: + self.reset() def reset(self): + if self._reset_on_show: + self._reset_on_show = False value = get_local_settings() self.settings_widget.update_local_settings(value) diff --git a/openpype/tools/settings/resources/__init__.py b/openpype/tools/settings/resources/__init__.py new file mode 100644 index 0000000000..83ce1a286f --- /dev/null +++ b/openpype/tools/settings/resources/__init__.py @@ -0,0 +1,8 @@ +import os + + +RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def get_resource(*args): + return os.path.normpath(os.path.join(RESOURCES_DIR, *args)) diff --git a/openpype/tools/settings/resources/images/eye.png b/openpype/tools/settings/resources/images/eye.png new file mode 100644 index 0000000000..5a683e2974 Binary files /dev/null and b/openpype/tools/settings/resources/images/eye.png differ diff --git a/openpype/tools/settings/settings/widgets/base.py b/openpype/tools/settings/settings/widgets/base.py index 4010b8ab20..3d633a100e 100644 --- a/openpype/tools/settings/settings/widgets/base.py +++ b/openpype/tools/settings/settings/widgets/base.py @@ -71,7 +71,7 @@ class BaseWidget(QtWidgets.QWidget): def _discard_changes_action(self, menu, actions_mapping): # TODO use better condition as unsaved changes may be caused due to # changes in schema. - if not self.entity.can_discard_changes: + if not self.entity.can_trigger_discard_changes: return def discard_changes(): @@ -86,7 +86,7 @@ class BaseWidget(QtWidgets.QWidget): def _add_to_studio_default(self, menu, actions_mapping): """Set values as studio overrides.""" # Skip if not in studio overrides - if not self.entity.can_add_to_studio_default: + if not self.entity.can_trigger_add_to_studio_default: return action = QtWidgets.QAction("Add to studio default") @@ -94,7 +94,7 @@ class BaseWidget(QtWidgets.QWidget): menu.addAction(action) def _remove_from_studio_default_action(self, menu, actions_mapping): - if not self.entity.can_remove_from_studio_default: + if not self.entity.can_trigger_remove_from_studio_default: return def remove_from_studio_default(): @@ -106,7 +106,7 @@ class BaseWidget(QtWidgets.QWidget): menu.addAction(action) def _add_to_project_override_action(self, menu, actions_mapping): - if not self.entity.can_add_to_project_override: + if not self.entity.can_trigger_add_to_project_override: return action = QtWidgets.QAction("Add to project project override") @@ -114,7 +114,7 @@ class BaseWidget(QtWidgets.QWidget): menu.addAction(action) def _remove_from_project_override_action(self, menu, actions_mapping): - if not self.entity.can_remove_from_project_override: + if not self.entity.can_trigger_remove_from_project_override: return def remove_from_project_override(): diff --git a/openpype/tools/settings/settings/widgets/categories.py b/openpype/tools/settings/settings/widgets/categories.py index e4832c989a..ab28620fc5 100644 --- a/openpype/tools/settings/settings/widgets/categories.py +++ b/openpype/tools/settings/settings/widgets/categories.py @@ -318,9 +318,14 @@ class SettingsCategoryWidget(QtWidgets.QWidget): "`create_root_entity` method not implemented" ) + def _on_reset_start(self): + return + def reset(self): self.set_state(CategoryState.Working) + self._on_reset_start() + self.input_fields = [] while self.content_layout.count() != 0: @@ -485,7 +490,6 @@ class ProjectWidget(SettingsCategoryWidget): def ui_tweaks(self): project_list_widget = ProjectListWidget(self) - project_list_widget.refresh() self.main_layout.insertWidget(0, project_list_widget, 0) @@ -501,6 +505,9 @@ class ProjectWidget(SettingsCategoryWidget): if self is saved_tab_widget: return + def _on_reset_start(self): + self.project_list_widget.refresh() + def _on_reset_crash(self): self.project_list_widget.setEnabled(False) super(ProjectWidget, self)._on_reset_crash() diff --git a/openpype/tools/settings/settings/widgets/dict_mutable_widget.py b/openpype/tools/settings/settings/widgets/dict_mutable_widget.py index 9bea89c0d6..ff4905c480 100644 --- a/openpype/tools/settings/settings/widgets/dict_mutable_widget.py +++ b/openpype/tools/settings/settings/widgets/dict_mutable_widget.py @@ -36,6 +36,7 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): super(ModifiableDictEmptyItem, self).__init__(parent) self.entity_widget = entity_widget self.collapsible_key = entity_widget.entity.collapsible_key + self.ignore_input_changes = entity_widget.ignore_input_changes self.is_duplicated = False self.key_is_valid = False @@ -101,6 +102,10 @@ class ModifiableDictEmptyItem(QtWidgets.QWidget): def _on_key_change(self): key = self.key_input.text() self.key_is_valid = KEY_REGEX.match(key) + + if self.ignore_input_changes: + return + self.is_duplicated = self.entity_widget.is_key_duplicated(key) key_input_state = "" # Collapsible key and empty key are not invalid @@ -355,6 +360,7 @@ class ModifiableDictItem(QtWidgets.QWidget): def set_label(self, label): if self.key_label_input and label is not None: self.key_label_input.setText(label) + self.update_key_label() def set_as_required(self, key): self.key_input.setText(key) @@ -386,6 +392,9 @@ class ModifiableDictItem(QtWidgets.QWidget): self.set_edit_mode(False) def _on_key_label_change(self): + if self.ignore_input_changes: + return + label = self.key_label_value() self.entity_widget.change_label(label, self) self.update_key_label() @@ -393,6 +402,10 @@ class ModifiableDictItem(QtWidgets.QWidget): def _on_key_change(self): key = self.key_value() self.key_is_valid = KEY_REGEX.match(key) + + if self.ignore_input_changes: + return + is_key_duplicated = self.entity_widget.validate_key_duplication( self.temp_key, key, self ) @@ -422,7 +435,7 @@ class ModifiableDictItem(QtWidgets.QWidget): self.wrapper_widget.label_widget.setText(label) def on_add_clicked(self): - widget = self.entity_widget.add_new_key(None, None, self) + widget = self.entity_widget.add_new_key(None, None) widget.key_input.setFocus(True) def on_edit_pressed(self): @@ -621,7 +634,7 @@ class DictMutableKeysWidget(BaseWidget): # TODO implement pass - def add_new_key(self, key, label=None, after_widget=None): + def add_new_key(self, key, label=None): uuid_key = None entity_key = key if not key: @@ -641,7 +654,7 @@ class DictMutableKeysWidget(BaseWidget): # Backup solution (for testing) if input_field is None: - input_field = self.add_widget_for_child(child_entity, after_widget) + input_field = self.add_widget_for_child(child_entity) if key: # Happens when created from collapsible key items where key @@ -719,29 +732,16 @@ class DictMutableKeysWidget(BaseWidget): return self.entity.set_child_label(entity, label) - def add_widget_for_child( - self, child_entity, after_widget=None, first=False - ): - if first: - new_widget_index = 0 - else: - new_widget_index = len(self.input_fields) - - if self.input_fields and not first: - if not after_widget: - after_widget = self.input_fields[-1] - - for idx in range(self.content_layout.count()): - item = self.content_layout.itemAt(idx) - if item.widget() is after_widget: - new_widget_index = idx + 1 - break - + def add_widget_for_child(self, child_entity): input_field = ModifiableDictItem( self.entity.collapsible_key, child_entity, self ) self.input_fields.append(input_field) + + new_widget_index = self.content_layout.count() - 1 + self.content_layout.insertWidget(new_widget_index, input_field) + return input_field def remove_row(self, widget): @@ -810,21 +810,15 @@ class DictMutableKeysWidget(BaseWidget): for key, child_entity in self.entity.items(): found = False - previous_input = None for input_field in self.input_fields: - if input_field.entity is not child_entity: - previous_input = input_field - else: + if input_field.entity is child_entity: found = True break if not found: changed = True - args = [previous_input] - if previous_input is None: - args.append(True) - _input_field = self.add_widget_for_child(child_entity, *args) + _input_field = self.add_widget_for_child(child_entity) _input_field.origin_key = key _input_field.set_key(key) if self.entity.collapsible_key: @@ -855,9 +849,8 @@ class DictMutableKeysWidget(BaseWidget): if keys_order: last_required = keys_order[-1] for key in self.entity.keys(): - if key in keys_order: - continue - keys_order.append(key) + if key not in keys_order: + keys_order.append(key) for key in keys_order: child_entity = self.entity[key] diff --git a/openpype/tools/settings/settings/widgets/widgets.py b/openpype/tools/settings/settings/widgets/widgets.py index aa79cc4b62..249b4e305d 100644 --- a/openpype/tools/settings/settings/widgets/widgets.py +++ b/openpype/tools/settings/settings/widgets/widgets.py @@ -661,8 +661,14 @@ class ProjectListWidget(QtWidgets.QWidget): self.current_project = None if self.dbcon: - for project_name in self.dbcon.database.collection_names(): - items.append(project_name) + database = self.dbcon.database + for project_name in database.collection_names(): + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if project_doc: + items.append(project_doc["name"]) for item in items: model.appendRow(QtGui.QStandardItem(item)) diff --git a/openpype/tools/settings/settings/widgets/window.py b/openpype/tools/settings/settings/widgets/window.py index 96275facff..495f909e51 100644 --- a/openpype/tools/settings/settings/widgets/window.py +++ b/openpype/tools/settings/settings/widgets/window.py @@ -1,4 +1,4 @@ -from Qt import QtWidgets, QtGui +from Qt import QtWidgets, QtGui, QtCore from .categories import ( CategoryState, SystemWidget, @@ -7,6 +7,11 @@ from .categories import ( from .widgets import ShadowWidget from .. import style +from openpype.tools.settings import ( + is_password_required, + PasswordDialog +) + class MainWidget(QtWidgets.QWidget): widget_width = 1000 @@ -14,6 +19,12 @@ class MainWidget(QtWidgets.QWidget): def __init__(self, user_role, parent=None): super(MainWidget, self).__init__(parent) + + self._user_passed = False + self._reset_on_show = True + + self._password_dialog = None + self.setObjectName("MainWidget") self.setWindowTitle("OpenPype Settings") @@ -44,6 +55,7 @@ class MainWidget(QtWidgets.QWidget): self.setLayout(layout) self._shadow_widget = ShadowWidget("Working...", self) + self._shadow_widget.setVisible(False) for tab_widget in tab_widgets: tab_widget.saved.connect(self._on_tab_save) @@ -75,6 +87,48 @@ class MainWidget(QtWidgets.QWidget): if app: app.processEvents() + def showEvent(self, event): + super(MainWidget, self).showEvent(event) + if self._reset_on_show: + self.reset() + + def _show_password_dialog(self): + if self._password_dialog: + self._password_dialog.open() + + def _on_password_dialog_close(self, password_passed): + # Store result for future settings reset + self._user_passed = password_passed + # Remove reference to password dialog + self._password_dialog = None + if password_passed: + self.reset() + else: + self.close() + def reset(self): + if self._password_dialog: + return + + if not self._user_passed: + self._user_passed = not is_password_required() + + self._on_state_change() + + if not self._user_passed: + # Avoid doubled dialog + dialog = PasswordDialog(self) + dialog.setModal(True) + dialog.finished.connect(self._on_password_dialog_close) + + self._password_dialog = dialog + + QtCore.QTimer.singleShot(100, self._show_password_dialog) + + return + + if self._reset_on_show: + self._reset_on_show = False + for tab_widget in self.tab_widgets: tab_widget.reset() diff --git a/openpype/tools/settings/widgets.py b/openpype/tools/settings/widgets.py new file mode 100644 index 0000000000..e2662f350f --- /dev/null +++ b/openpype/tools/settings/widgets.py @@ -0,0 +1,164 @@ +from Qt import QtWidgets, QtCore, QtGui + +from .resources import get_resource + +from openpype.api import get_system_settings +from openpype.settings.lib import ( + get_local_settings, + save_local_settings +) + + +class PressHoverButton(QtWidgets.QPushButton): + _mouse_pressed = False + _mouse_hovered = False + change_state = QtCore.Signal(bool) + + def mousePressEvent(self, event): + self._mouse_pressed = True + self._mouse_hovered = True + self.change_state.emit(self._mouse_hovered) + super(PressHoverButton, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + self._mouse_pressed = False + self._mouse_hovered = False + self.change_state.emit(self._mouse_hovered) + super(PressHoverButton, self).mouseReleaseEvent(event) + + def mouseMoveEvent(self, event): + mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) + under_mouse = self.rect().contains(mouse_pos) + if under_mouse != self._mouse_hovered: + self._mouse_hovered = under_mouse + self.change_state.emit(self._mouse_hovered) + + super(PressHoverButton, self).mouseMoveEvent(event) + + +class PasswordDialog(QtWidgets.QDialog): + """Stupidly simple dialog to compare password from general settings.""" + finished = QtCore.Signal(bool) + + def __init__(self, parent=None, allow_remember=True): + super(PasswordDialog, self).__init__(parent) + + self.setWindowTitle("Settings Password") + self.resize(300, 120) + + system_settings = get_system_settings() + + self._expected_result = ( + system_settings["general"].get("admin_password") + ) + self._final_result = None + self._allow_remember = allow_remember + + # Password input + password_widget = QtWidgets.QWidget(self) + + password_label = QtWidgets.QLabel("Password:", password_widget) + + password_input = QtWidgets.QLineEdit(password_widget) + password_input.setEchoMode(QtWidgets.QLineEdit.Password) + + show_password_icon_path = get_resource("images", "eye.png") + show_password_icon = QtGui.QIcon(show_password_icon_path) + show_password_btn = PressHoverButton(password_widget) + show_password_btn.setIcon(show_password_icon) + show_password_btn.setStyleSheet(( + "border: none;padding:0.1em;" + )) + show_password_btn.setFocusPolicy(QtCore.Qt.ClickFocus) + + password_layout = QtWidgets.QHBoxLayout(password_widget) + password_layout.setContentsMargins(0, 0, 0, 0) + password_layout.addWidget(password_label) + password_layout.addWidget(password_input) + password_layout.addWidget(show_password_btn) + + message_label = QtWidgets.QLabel("", self) + + # Buttons + buttons_widget = QtWidgets.QWidget(self) + + remember_checkbox = QtWidgets.QCheckBox("Remember", buttons_widget) + remember_checkbox.setVisible(allow_remember) + remember_checkbox.setStyleSheet(( + "spacing: 0.5em;" + )) + + ok_btn = QtWidgets.QPushButton("Ok", buttons_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", buttons_widget) + + buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(remember_checkbox) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn) + buttons_layout.addWidget(cancel_btn) + + # Main layout + layout = QtWidgets.QVBoxLayout(self) + layout.addSpacing(10) + layout.addWidget(password_widget, 0) + layout.addWidget(message_label, 0) + layout.addStretch(1) + layout.addWidget(buttons_widget, 0) + + ok_btn.clicked.connect(self._on_ok_click) + cancel_btn.clicked.connect(self._on_cancel_click) + show_password_btn.change_state.connect(self._on_show_password) + + self.password_input = password_input + self.remember_checkbox = remember_checkbox + self.message_label = message_label + + def remember_password(self): + if not self._allow_remember: + return False + return self.remember_checkbox.isChecked() + + def result(self): + if self._final_result is None: + return False + return self._final_result == self._expected_result + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self._on_ok_click() + return event.accept() + super(PasswordDialog, self).keyPressEvent(event) + + def closeEvent(self, event): + super(PasswordDialog, self).closeEvent(event) + self.finished.emit(self.result()) + + def _on_ok_click(self): + input_value = self.password_input.text() + if input_value != self._expected_result: + self.message_label.setText("Invalid password. Try it again...") + self.password_input.setFocus() + return + + if self.remember_password(): + local_settings = get_local_settings() + if "general" not in local_settings: + local_settings["general"] = {} + + local_settings["general"]["is_admin"] = True + + save_local_settings(local_settings) + + self._final_result = input_value + self.close() + + def _on_show_password(self, show_password): + if show_password: + echo_mode = QtWidgets.QLineEdit.Normal + else: + echo_mode = QtWidgets.QLineEdit.Password + self.password_input.setEchoMode(echo_mode) + + def _on_cancel_click(self): + self.close() diff --git a/openpype/version.py b/openpype/version.py index dedf799055..25f9e08d80 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.0.0-beta2" +__version__ = "3.0.0-rc2" diff --git a/pyproject.toml b/pyproject.toml index 88c977cd99..c874db34f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.0.0-beta2" +version = "3.0.0-rc2" description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/repos/avalon-core b/repos/avalon-core index 807e8577a0..cfd4191e36 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 807e8577a0268580a2934ba38889911adad26eb1 +Subproject commit cfd4191e364b47de7364096f45d9d9d9a901692a diff --git a/start.py b/start.py index 05069862bf..c24e20f63f 100644 --- a/start.py +++ b/start.py @@ -289,6 +289,10 @@ def _process_arguments() -> tuple: if return_code not in [2, 3]: sys.exit(return_code) + idx = sys.argv.index("igniter") + sys.argv.pop(idx) + sys.argv.insert(idx, "tray") + return use_version, use_staging @@ -330,11 +334,13 @@ def _determine_mongodb() -> str: if result == 0: raise RuntimeError("MongoDB URL was not defined") - try: - openpype_mongo = bootstrap.secure_registry.get_item( - "openPypeMongo") - except ValueError: - raise RuntimeError("Missing MongoDB url") + openpype_mongo = os.getenv("OPENPYPE_MONGO") + if not openpype_mongo: + try: + openpype_mongo = bootstrap.secure_registry.get_item( + "openPypeMongo") + except ValueError: + raise RuntimeError("Missing MongoDB url") return openpype_mongo diff --git a/tools/build.ps1 b/tools/build.ps1 index 5283ee4754..1fd01191af 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -70,8 +70,6 @@ function Install-Poetry() { Write-Host ">>> " -NoNewline -ForegroundColor Green Write-Host "Installing Poetry ... " (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python - - # add it to PATH - $env:PATH = "$($env:PATH);$($env:USERPROFILE)\.poetry\bin" } $art = @" @@ -93,6 +91,14 @@ $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root $version_file = Get-Content -Path "$($openpype_root)\openpype\version.py" @@ -126,47 +132,20 @@ Write-Host "Making sure submodules are up-to-date ..." git submodule update --init --recursive Write-Host ">>> " -NoNewline -ForegroundColor green -Write-Host "Building OpenPype [ " -NoNewline -ForegroundColor white +Write-Host "OpenPype [ " -NoNewline -ForegroundColor white Write-host $openpype_version -NoNewline -ForegroundColor green -Write-Host " ] ..." -ForegroundColor white - -Write-Host ">>> " -NoNewline -ForegroundColor green -Write-Host "Detecting host Python ... " -NoNewline -if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - Write-Host "!!! Python not detected" -ForegroundColor red - Exit-WithCode 1 -} -$version_command = @" -import sys -print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) -"@ - -$p = & python -c $version_command -$env:PYTHON_VERSION = $p -$m = $p -match '(\d+)\.(\d+)' -if(-not $m) { - Write-Host "!!! Cannot determine version" -ForegroundColor red - Exit-WithCode 1 -} -# We are supporting python 3.6 and up -if(($matches[1] -lt 3) -or ($matches[2] -lt 7)) { - Write-Host "FAILED Version [ $p ] is old and unsupported" -ForegroundColor red - Exit-WithCode 1 -} -Write-Host "OK [ $p ]" -ForegroundColor green - +Write-Host " ]" -ForegroundColor white Write-Host ">>> " -NoNewline -ForegroundColor Green Write-Host "Reading Poetry ... " -NoNewline -if (-not (Test-Path -PathType Container -Path "$($env:USERPROFILE)\.poetry\bin")) { +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { Write-Host "NOT FOUND" -ForegroundColor Yellow - Install-Poetry - - Write-Host "INSTALLED" -ForegroundColor Cyan + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" } else { Write-Host "OK" -ForegroundColor Green } -$env:PATH = "$($env:PATH);$($env:USERPROFILE)\.poetry\bin" Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Cleaning cache files ... " -NoNewline diff --git a/tools/build.sh b/tools/build.sh index d0593a2b2f..62aecd8ee1 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -124,7 +124,6 @@ install_poetry () { echo -e "${BIGreen}>>>${RST} Installing Poetry ..." command -v curl >/dev/null 2>&1 || { echo -e "${BIRed}!!!${RST}${BIYellow} Missing ${RST}${BIBlue}curl${BIYellow} command.${RST}"; return 1; } curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python3 - - export PATH="$PATH:$HOME/.poetry/bin" } # Main @@ -141,6 +140,14 @@ main () { version_command="import os;exec(open(os.path.join('$openpype_root', 'openpype', 'version.py')).read());print(__version__);" openpype_version="$(python3 <<< ${version_command})" + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + echo -e "${BIYellow}---${RST} Cleaning build directory ..." rm -rf "$openpype_root/build" && mkdir "$openpype_root/build" > /dev/null @@ -149,12 +156,12 @@ main () { clean_pyc echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" - if [ -f "$HOME/.poetry/bin/poetry" ]; then + if [ -f "$POETRY_HOME/bin/poetry" ]; then echo -e "${BIGreen}OK${RST}" - export PATH="$PATH:$HOME/.poetry/bin" else echo -e "${BIYellow}NOT FOUND${RST}" - install_poetry || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } fi echo -e "${BIGreen}>>>${RST} Making sure submodules are up-to-date ..." diff --git a/tools/build_win_installer.ps1 b/tools/build_win_installer.ps1 new file mode 100644 index 0000000000..4a4d011258 --- /dev/null +++ b/tools/build_win_installer.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + Helper script to build OpenPype. + +.DESCRIPTION + This script will detect Python installation, and build OpenPype to `build` + directory using existing virtual environment created by Poetry (or + by running `/tools/create_venv.ps1`). It will then shuffle dependencies in + build folder to optimize for different Python versions (2/3) in Python host. + +.EXAMPLE + +PS> .\build.ps1 + +#> + +function Start-Progress { + param([ScriptBlock]$code) + $scroll = "/-\|/-\|" + $idx = 0 + $job = Invoke-Command -ComputerName $env:ComputerName -ScriptBlock { $code } -AsJob + + $origpos = $host.UI.RawUI.CursorPosition + + # $origpos.Y -= 1 + + while (($job.State -eq "Running") -and ($job.State -ne "NotStarted")) + { + $host.UI.RawUI.CursorPosition = $origpos + Write-Host $scroll[$idx] -NoNewline + $idx++ + if ($idx -ge $scroll.Length) + { + $idx = 0 + } + Start-Sleep -Milliseconds 100 + } + # It's over - clear the activity indicator. + $host.UI.RawUI.CursorPosition = $origpos + Write-Host ' ' + <# + .SYNOPSIS + Display spinner for running job + .PARAMETER code + Job to display spinner for + #> +} + + +function Exit-WithCode($exitcode) { + # Only exit this host process if it's a child of another PowerShell parent process... + $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId + $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name + if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } + + exit $exitcode +} + +function Show-PSWarning() { + if ($PSVersionTable.PSVersion.Major -lt 7) { + Write-Host "!!! " -NoNewline -ForegroundColor Red + Write-Host "You are using old version of PowerShell. $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" + Write-Host "Please update to at least 7.0 - " -NoNewline -ForegroundColor Gray + Write-Host "https://github.com/PowerShell/PowerShell/releases" -ForegroundColor White + Exit-WithCode 1 + } +} + +function Install-Poetry() { + Write-Host ">>> " -NoNewline -ForegroundColor Green + Write-Host "Installing Poetry ... " + (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python - + # add it to PATH + $env:PATH = "$($env:PATH);$($env:USERPROFILE)\.poetry\bin" +} + +$art = @" + +▒█▀▀▀█ █▀▀█ █▀▀ █▀▀▄ ▒█▀▀█ █░░█ █▀▀█ █▀▀ ▀█▀ ▀█▀ ▀█▀ +▒█░░▒█ █░░█ █▀▀ █░░█ ▒█▄▄█ █▄▄█ █░░█ █▀▀ ▒█░ ▒█░ ▒█░ +▒█▄▄▄█ █▀▀▀ ▀▀▀ ▀░░▀ ▒█░░░ ▄▄▄█ █▀▀▀ ▀▀▀ ▄█▄ ▄█▄ ▄█▄ + .---= [ by Pype Club ] =---. + https://openpype.io + +"@ + +Write-Host $art -ForegroundColor DarkGreen + +# Enable if PS 7.x is needed. +# Show-PSWarning + +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$openpype_root = (Get-Item $script_dir).parent.FullName + +Set-Location -Path $openpype_root + +$version_file = Get-Content -Path "$($openpype_root)\openpype\version.py" +$result = [regex]::Matches($version_file, '__version__ = "(?\d+\.\d+.\d+.*)"') +$openpype_version = $result[0].Groups['version'].Value +if (-not $openpype_version) { + Write-Host "!!! " -ForegroundColor yellow -NoNewline + Write-Host "Cannot determine OpenPype version." + Exit-WithCode 1 +} +$env:BUILD_VERSION = $openpype_version + +iscc + +Write-Host ">>> " -NoNewline -ForegroundColor green +Write-Host "Creating OpenPype installer ... " -ForegroundColor white + +$build_dir_command = @" +import sys +from distutils.util import get_platform +print('exe.{}-{}'.format(get_platform(), sys.version[0:3])) +"@ + +$build_dir = & python -c $build_dir_command +Write-Host "Build directory ... ${build_dir}" -ForegroundColor white +$env:BUILD_DIR = $build_dir + +if (Get-Command iscc -errorAction SilentlyContinue -ErrorVariable ProcessError) +{ + iscc "$openpype_root\inno_setup.iss" +}else { + Write-Host "!!! Cannot find Inno Setup command" -ForegroundColor red + Write-Host "!!! You can download it at https://jrsoftware.org/" -ForegroundColor red + Exit-WithCode 1 +} + + +Write-Host ">>> " -NoNewline -ForegroundColor green +Write-Host "restoring current directory" +Set-Location -Path $current_dir + +Write-Host "*** " -NoNewline -ForegroundColor Cyan +Write-Host "All done. You will find OpenPype installer in " -NoNewLine +Write-Host "'.\build'" -NoNewline -ForegroundColor Green +Write-Host " directory." diff --git a/tools/create_env.ps1 b/tools/create_env.ps1 index e72e98e04b..c806fc5e49 100644 --- a/tools/create_env.ps1 +++ b/tools/create_env.ps1 @@ -43,9 +43,10 @@ function Show-PSWarning() { function Install-Poetry() { Write-Host ">>> " -NoNewline -ForegroundColor Green Write-Host "Installing Poetry ... " + $env:POETRY_HOME="$openpype_root\.poetry" (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py -UseBasicParsing).Content | python - # add it to PATH - $env:PATH = "$($env:PATH);$($env:USERPROFILE)\.poetry\bin" + $env:PATH = "$($env:PATH);$openpype_root\.poetry\bin" } @@ -84,6 +85,12 @@ $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root $art = @" @@ -95,8 +102,9 @@ $art = @" https://openpype.io "@ - -Write-Host $art -ForegroundColor DarkGreen +if (-not (Test-Path 'env:_INSIDE_OPENPYPE_TOOL')) { + Write-Host $art -ForegroundColor DarkGreen +} # Enable if PS 7.x is needed. # Show-PSWarning @@ -118,7 +126,7 @@ Test-Python Write-Host ">>> " -NoNewline -ForegroundColor Green Write-Host "Reading Poetry ... " -NoNewline -if (-not (Test-Path -PathType Container -Path "$($env:USERPROFILE)\.poetry\bin")) { +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { Write-Host "NOT FOUND" -ForegroundColor Yellow Install-Poetry Write-Host "INSTALLED" -ForegroundColor Cyan diff --git a/tools/create_env.sh b/tools/create_env.sh index 04414ddea5..76597bc30e 100755 --- a/tools/create_env.sh +++ b/tools/create_env.sh @@ -136,19 +136,27 @@ realpath () { main () { # Main - echo -e "${BGreen}" - art - echo -e "${RST}" + if [[ -z $_inside_openpype_tool ]]; then + echo -e "${BGreen}" + art + echo -e "${RST}" + fi detect_python || return 1 # Directories openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + + pushd "$openpype_root" > /dev/null || return > /dev/null echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" - if [ -f "$HOME/.poetry/bin/poetry" ]; then + if [ -f "$POETRY_HOME/bin/poetry" ]; then echo -e "${BIGreen}OK${RST}" - export PATH="$PATH:$HOME/.poetry/bin" else echo -e "${BIYellow}NOT FOUND${RST}" install_poetry || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } diff --git a/tools/create_zip.ps1 b/tools/create_zip.ps1 index d18806c40b..4ccfd949fe 100644 --- a/tools/create_zip.ps1 +++ b/tools/create_zip.ps1 @@ -37,6 +37,15 @@ function Show-PSWarning() { $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName + +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root $art = @" @@ -63,35 +72,27 @@ if (-not $openpype_version) { Exit-WithCode 1 } -Write-Host ">>> " -NoNewline -ForegroundColor green -Write-Host "Detecting host Python ... " -NoNewline -if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - Write-Host "!!! Python not detected" -ForegroundColor red - Exit-WithCode 1 +Write-Host ">>> " -NoNewline -ForegroundColor Green +Write-Host "Reading Poetry ... " -NoNewline +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { + Write-Host "NOT FOUND" -ForegroundColor Yellow + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" +} else { + Write-Host "OK" -ForegroundColor Green } -$version_command = @' -import sys -print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) -'@ -$p = & python -c $version_command -$env:PYTHON_VERSION = $p -$m = $p -match '(\d+)\.(\d+)' -if(-not $m) { - Write-Host "!!! Cannot determine version" -ForegroundColor red - Exit-WithCode 1 -} -# We are supporting python 3.6 and up -if(($matches[1] -lt 3) -or ($matches[2] -lt 7)) { - Write-Host "FAILED Version [ $p ] is old and unsupported" -ForegroundColor red - Exit-WithCode 1 -} -Write-Host "OK [ $p ]" -ForegroundColor green +Write-Host ">>> " -NoNewline -ForegroundColor green +Write-Host "Cleaning cache files ... " -NoNewline +Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse | Remove-Item -Force -Recurse +Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Generating zip from current sources ..." -Write-Host "... " -NoNewline -ForegroundColor Magenta -Write-Host "arguments: " -NoNewline -ForegroundColor Gray -Write-Host $ARGS -ForegroundColor White -& poetry run python "$($openpype_root)\start.py" generate-zip $ARGS -Set-Location -Path $current_dir \ No newline at end of file +$env:PYTHONPATH="$($openpype_root);$($env:PYTHONPATH)" +$env:OPENPYPE_ROOT="$($openpype_root)" +& poetry run python "$($openpype_root)\tools\create_zip.py" $ARGS +Set-Location -Path $current_dir diff --git a/tools/create_zip.py b/tools/create_zip.py new file mode 100644 index 0000000000..32a4d27e8b --- /dev/null +++ b/tools/create_zip.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""Create OpenPype version from live sources.""" +from igniter import bootstrap_repos +import click +import enlighten +import blessed +from pathlib2 import Path + + +term = blessed.Terminal() +manager = enlighten.get_manager() +last_increment = 0 + + +@click.group(invoke_without_command=True) +@click.option("--path", required=False, + help="path where to put version", + type=click.Path(exists=True)) +def main(path): + # create zip file + + progress_bar = enlighten.Counter( + total=100, desc="OpenPype ZIP", units="%", color="green") + + def progress(inc: int): + """Progress handler.""" + global last_increment + progress_bar.update(incr=inc - last_increment) + last_increment = inc + + bs = bootstrap_repos.BootstrapRepos(progress_callback=progress) + if path: + out_path = Path(path) + bs.data_dir = out_path.parent + + _print(f"Creating zip in {bs.data_dir} ...") + repo_file = bs.create_version_from_live_code() + if not repo_file: + _print("Error while creating zip file.", 1) + exit(1) + + _print(f"Created {repo_file}") + + +def _print(msg: str, message_type: int = 0) -> None: + """Print message to console. + + Args: + msg (str): message to print + message_type (int): type of message (0 info, 1 error, 2 note) + + """ + if message_type == 0: + header = term.aquamarine3(">>> ") + elif message_type == 1: + header = term.orangered2("!!! ") + elif message_type == 2: + header = term.tan1("... ") + else: + header = term.darkolivegreen3("--- ") + + print("{}{}".format(header, msg)) + + +if __name__ == "__main__": + main() diff --git a/tools/create_zip.sh b/tools/create_zip.sh index 6e7f792f1d..030039aa92 100755 --- a/tools/create_zip.sh +++ b/tools/create_zip.sh @@ -120,10 +120,30 @@ main () { # Directories openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + pushd "$openpype_root" > /dev/null || return > /dev/null + echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" + if [ -f "$POETRY_HOME/bin/poetry" ]; then + echo -e "${BIGreen}OK${RST}" + else + echo -e "${BIYellow}NOT FOUND${RST}" + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + fi + echo -e "${BIGreen}>>>${RST} Generating zip from current sources ..." - poetry run python3 "$openpype_root/start.py" generate-zip "$@" + PYTHONPATH="$openpype_root:$PYTHONPATH" + OPENPYPE_ROOT="$openpype_root" + poetry run python3 "$openpype_root/tools/create_zip.py" "$@" } main "$@" diff --git a/tools/fetch_thirdparty_libs.ps1 b/tools/fetch_thirdparty_libs.ps1 index d1b914fac2..23f0b50c7a 100644 --- a/tools/fetch_thirdparty_libs.ps1 +++ b/tools/fetch_thirdparty_libs.ps1 @@ -14,7 +14,28 @@ PS> .\fetch_thirdparty_libs.ps1 $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName + +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root + +Write-Host ">>> " -NoNewline -ForegroundColor Green +Write-Host "Reading Poetry ... " -NoNewline +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { + Write-Host "NOT FOUND" -ForegroundColor Yellow + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" +} else { + Write-Host "OK" -ForegroundColor Green +} + & poetry run python "$($openpype_root)\tools\fetch_thirdparty_libs.py" Set-Location -Path $current_dir diff --git a/tools/fetch_thirdparty_libs.sh b/tools/fetch_thirdparty_libs.sh index e305b4b3e4..3875541d57 100755 --- a/tools/fetch_thirdparty_libs.sh +++ b/tools/fetch_thirdparty_libs.sh @@ -116,14 +116,31 @@ main () { echo -e "${BGreen}" art echo -e "${RST}" - detect_python || return 1 # Directories - pype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) - pushd "$pype_root" > /dev/null || return > /dev/null + openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + + echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" + if [ -f "$POETRY_HOME/bin/poetry" ]; then + echo -e "${BIGreen}OK${RST}" + else + echo -e "${BIYellow}NOT FOUND${RST}" + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + fi + + pushd "$openpype_root" > /dev/null || return > /dev/null echo -e "${BIGreen}>>>${RST} Running Pype tool ..." - poetry run python3 "$pype_root/tools/fetch_thirdparty_libs.py" + poetry run python3 "$openpype_root/tools/fetch_thirdparty_libs.py" } main \ No newline at end of file diff --git a/tools/make_docs.ps1 b/tools/make_docs.ps1 index aa526bbdc9..f0ccaae004 100644 --- a/tools/make_docs.ps1 +++ b/tools/make_docs.ps1 @@ -16,6 +16,15 @@ PS> .\make_docs.ps1 $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName + +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root @@ -32,6 +41,17 @@ $art = @" Write-Host $art -ForegroundColor DarkGreen +Write-Host ">>> " -NoNewline -ForegroundColor Green +Write-Host "Reading Poetry ... " -NoNewline +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { + Write-Host "NOT FOUND" -ForegroundColor Yellow + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" +} else { + Write-Host "OK" -ForegroundColor Green +} + Write-Host "This will not overwrite existing source rst files, only scan and add new." Set-Location -Path $openpype_root Write-Host ">>> " -NoNewline -ForegroundColor green diff --git a/tools/make_docs.sh b/tools/make_docs.sh index 2ac12d3d95..edd29a4c6c 100755 --- a/tools/make_docs.sh +++ b/tools/make_docs.sh @@ -71,6 +71,24 @@ main () { # Directories openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + + echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" + if [ -f "$POETRY_HOME/bin/poetry" ]; then + echo -e "${BIGreen}OK${RST}" + else + echo -e "${BIYellow}NOT FOUND${RST}" + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + fi + pushd "$openpype_root" > /dev/null || return > /dev/null echo -e "${BIGreen}>>>${RST} Running apidoc ..." diff --git a/tools/run_documentation.ps1 b/tools/run_documentation.ps1 new file mode 100644 index 0000000000..1be3709642 --- /dev/null +++ b/tools/run_documentation.ps1 @@ -0,0 +1,32 @@ +<# +.SYNOPSIS + Helper script to run mongodb. + +.DESCRIPTION + This script will detect mongodb, add it to the PATH and launch it on specified port and db location. + +.EXAMPLE + +PS> .\run_mongo.ps1 + +#> + +$art = @" + +▒█▀▀▀█ █▀▀█ █▀▀ █▀▀▄ ▒█▀▀█ █░░█ █▀▀█ █▀▀ ▀█▀ ▀█▀ ▀█▀ +▒█░░▒█ █░░█ █▀▀ █░░█ ▒█▄▄█ █▄▄█ █░░█ █▀▀ ▒█░ ▒█░ ▒█░ +▒█▄▄▄█ █▀▀▀ ▀▀▀ ▀░░▀ ▒█░░░ ▄▄▄█ █▀▀▀ ▀▀▀ ▄█▄ ▄█▄ ▄█▄ + .---= [ by Pype Club ] =---. + https://openpype.io + +"@ + +Write-Host $art -ForegroundColor DarkGreen + +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$openpype_root = (Get-Item $script_dir).parent.FullName + +cd $openpype_root/website + +yarn run start + diff --git a/tools/run_settings.ps1 b/tools/run_settings.ps1 index 3f99de4b4e..7477e546b3 100644 --- a/tools/run_settings.ps1 +++ b/tools/run_settings.ps1 @@ -14,6 +14,27 @@ PS> .\run_settings.ps1 $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName + +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root + +Write-Host ">>> " -NoNewline -ForegroundColor Green +Write-Host "Reading Poetry ... " -NoNewline +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { + Write-Host "NOT FOUND" -ForegroundColor Yellow + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" +} else { + Write-Host "OK" -ForegroundColor Green +} + & poetry run python "$($openpype_root)\start.py" settings --dev Set-Location -Path $current_dir \ No newline at end of file diff --git a/tools/run_settings.sh b/tools/run_settings.sh index 0c8a951d7c..2e1dfc744f 100755 --- a/tools/run_settings.sh +++ b/tools/run_settings.sh @@ -50,35 +50,6 @@ BICyan='\033[1;96m' # Cyan BIWhite='\033[1;97m' # White -############################################################################## -# Detect required version of python -# Globals: -# colors -# PYTHON -# Arguments: -# None -# Returns: -# None -############################################################################### -detect_python () { - echo -e "${BIGreen}>>>${RST} Using python \c" - local version_command="import sys;print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1]))" - local python_version="$(python3 <<< ${version_command})" - oIFS="$IFS" - IFS=. - set -- $python_version - IFS="$oIFS" - if [ "$1" -ge "3" ] && [ "$2" -ge "6" ] ; then - if [ "$2" -gt "7" ] ; then - echo -e "${BIWhite}[${RST} ${BIRed}$1.$2 ${BIWhite}]${RST} - ${BIRed}FAILED${RST} ${BIYellow}Version is new and unsupported, use${RST} ${BIPurple}3.7.x${RST}"; return 1; - else - echo -e "${BIWhite}[${RST} ${BIGreen}$1.$2${RST} ${BIWhite}]${RST}" - fi - else - command -v python3 >/dev/null 2>&1 || { echo -e "${BIRed}$1.$2$ - ${BIRed}FAILED${RST} ${BIYellow}Version is old and unsupported${RST}"; return 1; } - fi -} - ############################################################################## # Clean pyc files in specified directory # Globals: @@ -90,7 +61,7 @@ detect_python () { ############################################################################### clean_pyc () { local path - path=$oepnpype_root + path=$openpype_root echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete echo -e "${BIGreen}DONE${RST}" @@ -114,12 +85,29 @@ main () { echo -e "${BGreen}" art echo -e "${RST}" - detect_python || return 1 # Directories openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + pushd "$openpype_root" > /dev/null || return > /dev/null + echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" + if [ -f "$POETRY_HOME/bin/poetry" ]; then + echo -e "${BIGreen}OK${RST}" + else + echo -e "${BIYellow}NOT FOUND${RST}" + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + fi + echo -e "${BIGreen}>>>${RST} Generating zip from current sources ..." poetry run python3 "$openpype_root/start.py" settings --dev } diff --git a/tools/run_tests.ps1 b/tools/run_tests.ps1 index 5070591c02..15161adabe 100644 --- a/tools/run_tests.ps1 +++ b/tools/run_tests.ps1 @@ -49,6 +49,14 @@ $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root $version_file = Get-Content -Path "$($openpype_root)\openpype\version.py" @@ -61,34 +69,20 @@ if (-not $openpype_version) { } Write-Host ">>> " -NoNewline -ForegroundColor green -Write-Host "Building OpenPype [ " -NoNewline -ForegroundColor white +Write-Host "OpenPype [ " -NoNewline -ForegroundColor white Write-host $openpype_version -NoNewline -ForegroundColor green Write-Host " ] ..." -ForegroundColor white -Write-Host ">>> " -NoNewline -ForegroundColor green -Write-Host "Detecting host Python ... " -NoNewline -if (-not (Get-Command "python" -ErrorAction SilentlyContinue)) { - Write-Host "!!! Python not detected" -ForegroundColor red - Exit-WithCode 1 +Write-Host ">>> " -NoNewline -ForegroundColor Green +Write-Host "Reading Poetry ... " -NoNewline +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { + Write-Host "NOT FOUND" -ForegroundColor Yellow + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" +} else { + Write-Host "OK" -ForegroundColor Green } -$version_command = @" -import sys -print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1])) -"@ - -$p = & python -c $version_command -$env:PYTHON_VERSION = $p -$m = $p -match '(\d+)\.(\d+)' -if(-not $m) { - Write-Host "!!! Cannot determine version" -ForegroundColor red - Exit-WithCode 1 -} -# We are supporting python 3.6 and up -if(($matches[1] -lt 3) -or ($matches[2] -lt 7)) { - Write-Host "FAILED Version [ $p ] is old and unsupported" -ForegroundColor red - Exit-WithCode 1 -} -Write-Host "OK [ $p ]" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Cleaning cache files ... " -NoNewline diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 0af052ca01..a161f479b5 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -49,32 +49,6 @@ BIPurple='\033[1;95m' # Purple BICyan='\033[1;96m' # Cyan BIWhite='\033[1;97m' # White - -############################################################################## -# Detect required version of python -# Globals: -# colors -# PYTHON -# Arguments: -# None -# Returns: -# None -############################################################################### -detect_python () { - echo -e "${BIGreen}>>>${RST} Using python \c" - local version_command="import sys;print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1]))" - local python_version="$(python3 <<< ${version_command})" - oIFS="$IFS" - IFS=. - set -- $python_version - IFS="$oIFS" - if [ "$1" -ge "3" ] && [ "$2" -ge "6" ] ; then - echo -e "${BIWhite}[${RST} ${BIGreen}$1.$2${RST} ${BIWhite}]${RST}" - else - command -v python3 >/dev/null 2>&1 || { echo -e "${BIRed}FAILED${RST} ${BIYellow} Version [${RST}${BICyan}$1.$2${RST}]${BIYellow} is old and unsupported${RST}"; return 1; } - fi -} - ############################################################################## # Clean pyc files in specified directory # Globals: @@ -110,10 +84,27 @@ main () { echo -e "${BGreen}" art echo -e "${RST}" - detect_python || return 1 # Directories openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + + echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" + if [ -f "$POETRY_HOME/bin/poetry" ]; then + echo -e "${BIGreen}OK${RST}" + else + echo -e "${BIYellow}NOT FOUND${RST}" + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + fi + pushd "$openpype_root" || return > /dev/null echo -e "${BIGreen}>>>${RST} Testing OpenPype ..." diff --git a/tools/run_tray.ps1 b/tools/run_tray.ps1 index 9485584c6f..533a791836 100644 --- a/tools/run_tray.ps1 +++ b/tools/run_tray.ps1 @@ -13,7 +13,27 @@ PS> .\run_tray.ps1 $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent $openpype_root = (Get-Item $script_dir).parent.FullName + +$env:_INSIDE_OPENPYPE_TOOL = "1" + +# make sure Poetry is in PATH +if (-not (Test-Path 'env:POETRY_HOME')) { + $env:POETRY_HOME = "$openpype_root\.poetry" +} +$env:PATH = "$($env:PATH);$($env:POETRY_HOME)\bin" + Set-Location -Path $openpype_root +Write-Host ">>> " -NoNewline -ForegroundColor Green +Write-Host "Reading Poetry ... " -NoNewline +if (-not (Test-Path -PathType Container -Path "$openpype_root\.poetry\bin")) { + Write-Host "NOT FOUND" -ForegroundColor Yellow + Write-Host "*** " -NoNewline -ForegroundColor Yellow + Write-Host "We need to install Poetry create virtual env first ..." + & "$openpype_root\tools\create_env.ps1" +} else { + Write-Host "OK" -ForegroundColor Green +} + & poetry run python "$($openpype_root)\start.py" tray --debug Set-Location -Path $current_dir \ No newline at end of file diff --git a/tools/run_tray.sh b/tools/run_tray.sh index 8174f7e38a..7471d3ca5a 100755 --- a/tools/run_tray.sh +++ b/tools/run_tray.sh @@ -49,37 +49,6 @@ BIPurple='\033[1;95m' # Purple BICyan='\033[1;96m' # Cyan BIWhite='\033[1;97m' # White - -############################################################################## -# Detect required version of python -# Globals: -# colors -# PYTHON -# Arguments: -# None -# Returns: -# None -############################################################################### -detect_python () { - echo -e "${BIGreen}>>>${RST} Using python \c" - local version_command="import sys;print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1]))" - local python_version="$(python3 <<< ${version_command})" - oIFS="$IFS" - IFS=. - set -- $python_version - IFS="$oIFS" - if [ "$1" -ge "3" ] && [ "$2" -ge "6" ] ; then - if [ "$2" -gt "7" ] ; then - echo -e "${BIWhite}[${RST} ${BIRed}$1.$2 ${BIWhite}]${RST} - ${BIRed}FAILED${RST} ${BIYellow}Version is new and unsupported, use${RST} ${BIPurple}3.7.x${RST}"; return 1; - else - echo -e "${BIWhite}[${RST} ${BIGreen}$1.$2${RST} ${BIWhite}]${RST}" - fi - PYTHON="python3" - else - command -v python3 >/dev/null 2>&1 || { echo -e "${BIRed}$1.$2$ - ${BIRed}FAILED${RST} ${BIYellow}Version is old and unsupported${RST}"; return 1; } - fi -} - ############################################################################## # Clean pyc files in specified directory # Globals: @@ -119,6 +88,24 @@ main () { # Directories openpype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + + _inside_openpype_tool="1" + + # make sure Poetry is in PATH + if [[ -z $POETRY_HOME ]]; then + export POETRY_HOME="$openpype_root/.poetry" + fi + export PATH="$POETRY_HOME/bin:$PATH" + + echo -e "${BIGreen}>>>${RST} Reading Poetry ... \c" + if [ -f "$POETRY_HOME/bin/poetry" ]; then + echo -e "${BIGreen}OK${RST}" + else + echo -e "${BIYellow}NOT FOUND${RST}" + echo -e "${BIYellow}***${RST} We need to install Poetry and virtual env ..." + . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } + fi + pushd "$openpype_root" > /dev/null || return > /dev/null echo -e "${BIGreen}>>>${RST} Running OpenPype Tray with debug option ..." diff --git a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py index d1287dd213..5e64605271 100644 --- a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py +++ b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py @@ -60,7 +60,7 @@ def inject_openpype_environment(deadlinePlugin): with open(export_url) as fp: contents = json.load(fp) for key, value in contents.items(): - deadlinePlugin.SetEnvironmentVariable(key, value) + deadlinePlugin.SetProcessEnvironmentVariable(key, value) os.remove(export_url) @@ -162,4 +162,3 @@ def __main__(deadlinePlugin): inject_openpype_environment(deadlinePlugin) else: pype(deadlinePlugin) # backward compatibility with Pype2 - diff --git a/website/docs/admin_hosts_blender.md b/website/docs/admin_hosts_blender.md new file mode 100644 index 0000000000..0655e5341a --- /dev/null +++ b/website/docs/admin_hosts_blender.md @@ -0,0 +1,83 @@ +--- +id: admin_hosts_blender +title: Blender +sidebar_label: Blender +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Blender requirements +Blender integration requires to use **PySide2** module inside blender. Module is different for Blender versions and platforms so can't be bundled with OpenPype. + +### How to install + +:::info Permissions +This step requires Admin persmission. +::: + + + + + +Find python executable inside your Blender installation folder. It is usually located in **C:\\Program Files\\Blender Foundation\\Blender {version}\\{version}\\python\\bin\\python.exe** (This may differ in future blender version). + +Open Powershell or Command Prompt as Administrator and run commands below. + +*Replace `C:\Program Files\Blender Foundation\Blender 2.83\2.83\python\bin` with your path.* + +```bash +# Change directory to python executable directory. +> cd C:\Program Files\Blender Foundation\Blender 2.83\2.83\python\bin + +# Run pip install command. +> python -m pip install PySide2 +``` + + + + + +Procedure may differ based on Linux distribution and blender distribution. Some Blender distributions are using system Python in that case it is required to install PySide2 using pip to system python (Not tested). + +**These instructions are for Blender using bundled python.** + +Find python executable inside your blender application. + +:::note Find python executable in Blender +You can launch Blender and in "Scripting" section enter commands to console. +```bash +>>> import bpy +>>> print(bpy.app.binary_path_python) +'/path/to/python/executable' +``` +::: + +Open terminal and run pip install command below. + +*Replace `/usr/bin/blender/2.83/python/bin/python3.7m` with your path.* +```bash +> /usr/bin/blender/2.83/python/bin/python3.7m -m pip install PySide2 +``` + +:::warning No module named pip +If you get error `No module named pip` you'll have to do few steps first. Open new terminal and run the python executable from Blender (entering full path). +```bash +# Run Python executable +> /usr/bin/blender/2.83/python/bin/python3.7m +# Python process should start +>>> import ensurepip +>>> ensurepip.bootstrap() +``` +You can close new terminal. Run pip install command above again. Now should work as expected. +::: + + + + diff --git a/website/docs/admin_settings_project.md b/website/docs/admin_settings_project.md deleted file mode 100644 index a30c0f0082..0000000000 --- a/website/docs/admin_settings_project.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -id: admin_settings_project -title: Project Settings -sidebar_label: Project Settings ---- - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - - -PROJECT Settings \ No newline at end of file diff --git a/website/docs/artist_hosts_tvpaint.md b/website/docs/artist_hosts_tvpaint.md new file mode 100644 index 0000000000..19cb615158 --- /dev/null +++ b/website/docs/artist_hosts_tvpaint.md @@ -0,0 +1,208 @@ +--- +id: artist_hosts_tvpaint +title: TVPaint +sidebar_label: TVPaint +--- + +- [Work Files](artist_tools.md#workfiles) +- [Load](artist_tools.md#loader) +- [Create](artist_tools.md#creator) +- [Subset Manager](artist_tools.md#subset-manager) +- [Scene Inventory](artist_tools.md#scene-inventory) +- [Publish](artist_tools.md#publisher) +- [Library](artist_tools.md#library) + + +## Setup +When you launch TVPaint with OpenPype for the very first time it is necessary to do some additional steps. Right after the TVPaint launching a few system windows will pop up. + +![permission](assets/tvp_permission.png) + +Choose `Replace the file in the destination`. Then another window shows up. + +![permission2](assets/tvp_permission2.png) + +Click on `Continue`. + +After opening TVPaint go to the menu bar: `Windows → Plugins → OpenPype`. + +![pypewindow](assets/tvp_hidden_window.gif) + +Another TVPaint window pop up. Please press `Yes`. This window will be presented in every single TVPaint launching. Unfortunately, there is no other way how to workaround it. + +![writefile](assets/tvp_write_file.png) + +Now OpenPype Tools menu is in your TVPaint work area. + +![openpypetools](assets/tvp_openpype_menu.png) + +You can start your work. + +--- + +## Usage +In TVPaint you can find the Tools in OpenPype menu extension. The OpenPype Tools menu should be available in your work area. However, sometimes it happens that the Tools menu is hidden. You can display the extension panel by going to `Windows -> Plugins -> OpenPype`. + + +## Create +In TVPaint you can create and publish **[Reviews](#review)**, **[Render Passes](#render-pass)**, and **[Render Layers](#render-layer)**. + +You have the possibility to organize your layers by using `Color group`. + +On the bottom left corner of your timeline, you will note a `Color group` button. + +![colorgroups](assets/tvp_color_groups.png) + +It allows you to choose a group by checking one of the colors of the color list. + +![colorgroups](assets/tvp_color_groups2.png) + +The timeline's animation layer can be marked by the color you pick from your Color group. Layers in the timeline with the same color are gathered into a group represents one render layer. + +![timeline](assets/tvp_timeline_color.png) + +:::important +OpenPype specifically never tries to guess what you want to publish from the scene. Therefore, you have to tell OpenPype what you want to publish. There are three ways how to publish render from the scene. +::: + +When you want to publish `review` or `render layer` or `render pass`, open the `Creator` through the Tools menu `Create` button. + +### Review + +
+
+ +`Review` renders the whole file as is and sends the resulting QuickTime to Ftrack. + +To create reviewable quicktime of your animation: + +- select `Review` in the `Creator` +- press `Create` +- When you run [publish](#publish), file will be rendered and converted to quicktime.` + +
+
+ +![createreview](assets/tvp_create_review.png) + +
+
+ +### Render Layer + +
+
+ + +Render Layer bakes all the animation layers of one particular color group together. + +- Choose any amount of animation layers that need to be rendered together and assign them a color group. +- Select any layer of a particular color +- Go to `Creator` and choose `RenderLayer`. +- In the `Subset`, type in the name that the final published RenderLayer should have according to the naming convention in your studio. *(L10, BG, Hero, etc.)* +- Press `Create` +- When you run [publish](#publish), the whole color group will be rendered together and published as a single `RenderLayer` + +
+
+ +![createlayer](assets/tvp_create_layer.png) + +
+
+ + + + + +### Render Pass + +Render Passes are smaller individual elements of a Render Layer. A `character` render layer might +consist of multiple render passes such as `Line`, `Color` and `Shadow`. + + +
+
+Render Passes are specific because they have to belong to a particular layer. If you try to create a render pass and did not create any render layers before, an error message will pop up. + +When you want to create `RenderPass` +- choose one or several animation layers within one color group that you want to publish +- In the Creator, pick `RenderPass` +- Fill the `Subset` with the name of your pass, e.g. `Color`. +- Press `Create` + +
+
+ +![createpass](assets/tvp_create_pass.png) + +
+
+ +

+ +In this example, OpenPype will render selected animation layers within the given color group. E.i. the layers *L020_colour_fx*, *L020_colour_mouth*, and *L020_colour_eye* will be rendered as one pass belonging to the yellow RenderLayer. + +![renderpass](assets/tvp_timeline_color2.png) + + +:::note +You can check your RendrePasses and RenderLayers in [Subset Manager](#subset-manager) or you can start publishing. The publisher will show you a collection of all instances on the left side. +::: + + +--- + +## Publish + +
+
+ +Now that you have created the required instances, you can publish them via `Publish` tool. +- Click on `Publish` in OpenPype Tools menu. +- wait until all instances are collected. +- You can check on the left side whether all your instances have been created and are ready for publishing. +- Fill the comment on the bottom of the window. +- Press the `Play` button to publish + +
+
+ +![pyblish](assets/tvp_pyblish_render.png) + +
+
+ +Once the `Publisher` turns gets green your renders have been published. + +--- + +## Subset Manager +All created instances (render layers, passes, and reviews) will be shown as a simple list. If you don't want to publish some, right click on the item in the list and select `Remove instance`. + +![subsetmanager](assets/tvp_subset_manager.png) + +--- + +## Load +When you want to load existing published work you can reach the `Loader` through the OpenPype Tools `Load` button. + +The supported families for TVPaint are: + +- `render` +- `image` +- `background` +- `plate` + +To load a family item, right-click on the subset you want and import their representations, switch among the versions, delete older versions, copy files, etc. + +![Loader](assets/tvp_loader.gif) + +--- + +## Scene Inventory +Scene Inventory shows you everything that you have loaded into your scene using OpenPype. You can reach it through the extension's `Scene Inventory` button. + +![sceneinventory](assets/tvp_scene_inventory.png) + +You can switch to a previous version of the file or update it to the latest or delete items. diff --git a/website/docs/assets/tvp_asset_loader_actions.png b/website/docs/assets/tvp_asset_loader_actions.png new file mode 100644 index 0000000000..dbc3734e10 Binary files /dev/null and b/website/docs/assets/tvp_asset_loader_actions.png differ diff --git a/website/docs/assets/tvp_asset_loader_version.png b/website/docs/assets/tvp_asset_loader_version.png new file mode 100644 index 0000000000..b8052f3c84 Binary files /dev/null and b/website/docs/assets/tvp_asset_loader_version.png differ diff --git a/website/docs/assets/tvp_color_groups.png b/website/docs/assets/tvp_color_groups.png new file mode 100644 index 0000000000..558ff687c6 Binary files /dev/null and b/website/docs/assets/tvp_color_groups.png differ diff --git a/website/docs/assets/tvp_color_groups2.png b/website/docs/assets/tvp_color_groups2.png new file mode 100644 index 0000000000..7ab6775769 Binary files /dev/null and b/website/docs/assets/tvp_color_groups2.png differ diff --git a/website/docs/assets/tvp_create_layer.png b/website/docs/assets/tvp_create_layer.png new file mode 100644 index 0000000000..9d243da17a Binary files /dev/null and b/website/docs/assets/tvp_create_layer.png differ diff --git a/website/docs/assets/tvp_create_pass.png b/website/docs/assets/tvp_create_pass.png new file mode 100644 index 0000000000..7d226ea4b5 Binary files /dev/null and b/website/docs/assets/tvp_create_pass.png differ diff --git a/website/docs/assets/tvp_create_review.png b/website/docs/assets/tvp_create_review.png new file mode 100644 index 0000000000..d6e9f63428 Binary files /dev/null and b/website/docs/assets/tvp_create_review.png differ diff --git a/website/docs/assets/tvp_hidden_window.gif b/website/docs/assets/tvp_hidden_window.gif new file mode 100644 index 0000000000..b1adaa75b8 Binary files /dev/null and b/website/docs/assets/tvp_hidden_window.gif differ diff --git a/website/docs/assets/tvp_library.gif b/website/docs/assets/tvp_library.gif new file mode 100644 index 0000000000..14de86cdb5 Binary files /dev/null and b/website/docs/assets/tvp_library.gif differ diff --git a/website/docs/assets/tvp_loader.gif b/website/docs/assets/tvp_loader.gif new file mode 100644 index 0000000000..5775460372 Binary files /dev/null and b/website/docs/assets/tvp_loader.gif differ diff --git a/website/docs/assets/tvp_openpype_menu.png b/website/docs/assets/tvp_openpype_menu.png new file mode 100644 index 0000000000..cb5c2d4aac Binary files /dev/null and b/website/docs/assets/tvp_openpype_menu.png differ diff --git a/website/docs/assets/tvp_permission.png b/website/docs/assets/tvp_permission.png new file mode 100644 index 0000000000..e47d9841b6 Binary files /dev/null and b/website/docs/assets/tvp_permission.png differ diff --git a/website/docs/assets/tvp_permission2.png b/website/docs/assets/tvp_permission2.png new file mode 100644 index 0000000000..827e85db39 Binary files /dev/null and b/website/docs/assets/tvp_permission2.png differ diff --git a/website/docs/assets/tvp_pyblish.png b/website/docs/assets/tvp_pyblish.png new file mode 100644 index 0000000000..88423a3c75 Binary files /dev/null and b/website/docs/assets/tvp_pyblish.png differ diff --git a/website/docs/assets/tvp_pyblish_render.png b/website/docs/assets/tvp_pyblish_render.png new file mode 100644 index 0000000000..7d279c3aa0 Binary files /dev/null and b/website/docs/assets/tvp_pyblish_render.png differ diff --git a/website/docs/assets/tvp_render_pass.png b/website/docs/assets/tvp_render_pass.png new file mode 100644 index 0000000000..07790af7b2 Binary files /dev/null and b/website/docs/assets/tvp_render_pass.png differ diff --git a/website/docs/assets/tvp_scene_inventory.png b/website/docs/assets/tvp_scene_inventory.png new file mode 100644 index 0000000000..25c717b331 Binary files /dev/null and b/website/docs/assets/tvp_scene_inventory.png differ diff --git a/website/docs/assets/tvp_subset_manager.png b/website/docs/assets/tvp_subset_manager.png new file mode 100644 index 0000000000..1ffbead4ba Binary files /dev/null and b/website/docs/assets/tvp_subset_manager.png differ diff --git a/website/docs/assets/tvp_timeline_color.png b/website/docs/assets/tvp_timeline_color.png new file mode 100644 index 0000000000..6d00d4c8af Binary files /dev/null and b/website/docs/assets/tvp_timeline_color.png differ diff --git a/website/docs/assets/tvp_timeline_color2.png b/website/docs/assets/tvp_timeline_color2.png new file mode 100644 index 0000000000..e20e190471 Binary files /dev/null and b/website/docs/assets/tvp_timeline_color2.png differ diff --git a/website/docs/assets/tvp_write_file.png b/website/docs/assets/tvp_write_file.png new file mode 100644 index 0000000000..109e6badc9 Binary files /dev/null and b/website/docs/assets/tvp_write_file.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_letter_box.png b/website/docs/project_settings/assets/global_extract_review_letter_box.png new file mode 100644 index 0000000000..7cd9ecbdd6 Binary files /dev/null and b/website/docs/project_settings/assets/global_extract_review_letter_box.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png b/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png new file mode 100644 index 0000000000..9ad9c05f43 Binary files /dev/null and b/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_output_defs.png b/website/docs/project_settings/assets/global_extract_review_output_defs.png new file mode 100644 index 0000000000..0dc8329324 Binary files /dev/null and b/website/docs/project_settings/assets/global_extract_review_output_defs.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_profiles.png b/website/docs/project_settings/assets/global_extract_review_profiles.png new file mode 100644 index 0000000000..1b91786ff6 Binary files /dev/null and b/website/docs/project_settings/assets/global_extract_review_profiles.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md new file mode 100644 index 0000000000..a90e5caeef --- /dev/null +++ b/website/docs/project_settings/settings_project_global.md @@ -0,0 +1,69 @@ +--- +id: settings_project_global +title: Project Global Setting +sidebar_label: Global +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Project settings can have project specific values. Each new project is using studio values defined in **default** project but these values can be modified or overriden per project. + +:::warning Default studio values +Projects always use default project values unless they have [project override](../admin_settings#project-overrides) (orage colour). Any changes in default project may affect all existing projects. +::: + +## Publish plugins + +Publish plugins used across all integrations. + +### Extract Review +Plugin responsible for automatic FFmpeg conversion to variety of formats. + +Extract review is using profile filtering to be able render different outputs for different situations. + +**Profile filters** + +You can define multiple profiles for different contexts. Profile with filters matching the current context the most, is used. You can define profile without filters and use it as **default**. Only **one or none** profile will be processed per instance. + +All context filters are lists which may contain strings or Regular expressions (RegEx). +- **`hosts`** - Host from which publishing was triggered. `["maya", "nuke"]` +- **`families`** - Main family of processed instance. `["plate", "model"]` + +:::important Filtering +Filters are optional. In case when multiple profiles match current context, profile with higher number of matched filters has higher priority that profile without filters. +::: + +![global_extract_review_profiles](assets/global_extract_review_profiles.png) + +**Output Definitions** + + +Profile may generate multiple outputs from a single input. Each output must define unique name and output extension (use the extension without a dot e.g. **mp4**). All other settings of output definition are optional. + +![global_extract_review_output_defs](assets/global_extract_review_output_defs.png) +- **`Tags`** + Define what will happen to output. + +- **`FFmpeg arguments`** + These arguments are appended to ffmpeg arguments auto generated by publish plugin. Some of arguments are handled automatically like rescaling or letterboxes. + - **Video filters** additional FFmpeg filters that would be defined in `-filter:v` or `-vf` command line arguments. + - **Audio filters** additional FFmpeg filters that would be defined in `-filter:a` or `-af` command line arguments. + - **Input arguments** input definition arguments of video or image sequence - this setting has limitations as you have to know what is input. + - **Output arguments** other FFmpeg output arguments like codec definition. + +- **`Output width`** and **`Output height`** + - it is possible to rescale output to specified resolution and keep aspect ratio. + - If value is set to 0, source resolution will be used. + +- **`Letter Box`** + - **Enabled** - Enable letter boxes + - **Ratio** - Ratio of letter boxes + - **Type** - **Letterbox** (horizontal bars) or **Pillarbox** (vertical bars) + - **Fill color** - Fill color of boxes (RGBA: 0-255) + - **Line Thickness** - Line thickness on the edge of box (set to `0` to turn off) + - **Fill color** - Line color on the edge of box (RGBA: 0-255) + - **Example** + + ![global_extract_review_letter_box_settings](assets/global_extract_review_letter_box_settings.png) + ![global_extract_review_letter_box](assets/global_extract_review_letter_box.png) diff --git a/website/sidebars.js b/website/sidebars.js index 842d7a0a49..41611190fd 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -23,6 +23,7 @@ module.exports = { "artist_hosts_harmony", "artist_hosts_aftereffects", "artist_hosts_photoshop", + "artist_hosts_tvpaint", "artist_hosts_unreal", { type: "category", @@ -57,7 +58,13 @@ module.exports = { "admin_settings", "admin_settings_system", "admin_settings_project_anatomy", - "admin_settings_project", + { + type: "category", + label: "Project Settings", + items: [ + "project_settings/settings_project_global" + ], + }, ], }, { @@ -71,6 +78,13 @@ module.exports = { "module_clockify" ], }, + { + type: "category", + label: "Integrations", + items: [ + "admin_hosts_blender" + ], + }, { type: "category", label: "Releases",