"
+
+ "Source "
+ "{source}"
+ ).format(**data))
+
+ def contextMenuEvent(self, event):
+ """Context menu with additional actions"""
+ menu = self.createStandardContextMenu()
+
+ # Add additional actions when any text so we can assume
+ # the version is set.
+ if self.toPlainText().strip():
+
+ menu.addSeparator()
+ action = QtWidgets.QAction("Copy source path to clipboard",
+ menu)
+ action.triggered.connect(self.on_copy_source)
+ menu.addAction(action)
+
+ action = QtWidgets.QAction("Copy raw data to clipboard",
+ menu)
+ action.triggered.connect(self.on_copy_raw)
+ menu.addAction(action)
+
+ menu.exec_(event.globalPos())
+ del menu
+
+ def on_copy_source(self):
+ """Copy formatted source path to clipboard"""
+ source = self.data.get("source", None)
+ if not source:
+ return
+
+ path = source.format(root=api.registered_root())
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(path)
+
+ def on_copy_raw(self):
+ """Copy raw version data to clipboard
+
+ The data is string formatted with `pprint.pformat`.
+
+ """
+ raw = self.data.get("raw", None)
+ if not raw:
+ return
+
+ raw_text = pprint.pformat(raw)
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(raw_text)
+
+
+class ThumbnailWidget(QtWidgets.QLabel):
+
+ aspect_ratio = (16, 9)
+ max_width = 300
+
+ def __init__(self, dbcon, parent=None):
+ super(ThumbnailWidget, self).__init__(parent)
+ self.dbcon = dbcon
+
+ self.current_thumb_id = None
+ self.current_thumbnail = None
+
+ self.setAlignment(QtCore.Qt.AlignCenter)
+
+ # TODO get res path much better way
+ default_pix_path = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "images",
+ "default_thumbnail.png"
+ )
+ self.default_pix = QtGui.QPixmap(default_pix_path)
+
+ def height(self):
+ width = self.width()
+ asp_w, asp_h = self.aspect_ratio
+
+ return (width / asp_w) * asp_h
+
+ def width(self):
+ width = super(ThumbnailWidget, self).width()
+ if width > self.max_width:
+ width = self.max_width
+ return width
+
+ def set_pixmap(self, pixmap=None):
+ if not pixmap:
+ pixmap = self.default_pix
+ self.current_thumb_id = None
+
+ self.current_thumbnail = pixmap
+
+ pixmap = self.scale_pixmap(pixmap)
+ self.setPixmap(pixmap)
+
+ def resizeEvent(self, _event):
+ if not self.current_thumbnail:
+ return
+ cur_pix = self.scale_pixmap(self.current_thumbnail)
+ self.setPixmap(cur_pix)
+
+ def scale_pixmap(self, pixmap):
+ return pixmap.scaled(
+ self.width(), self.height(), QtCore.Qt.KeepAspectRatio
+ )
+
+ def set_thumbnail(self, entity=None):
+ if not entity:
+ self.set_pixmap()
+ return
+
+ if isinstance(entity, (list, tuple)):
+ if len(entity) == 1:
+ entity = entity[0]
+ else:
+ self.set_pixmap()
+ return
+
+ thumbnail_id = entity.get("data", {}).get("thumbnail_id")
+ if thumbnail_id == self.current_thumb_id:
+ if self.current_thumbnail is None:
+ self.set_pixmap()
+ return
+
+ self.current_thumb_id = thumbnail_id
+ if not thumbnail_id:
+ self.set_pixmap()
+ return
+
+ thumbnail_ent = self.dbcon.find_one(
+ {"type": "thumbnail", "_id": thumbnail_id}
+ )
+ if not thumbnail_ent:
+ return
+
+ thumbnail_bin = pipeline.get_thumbnail_binary(
+ thumbnail_ent, "thumbnail", self.dbcon
+ )
+ if not thumbnail_bin:
+ self.set_pixmap()
+ return
+
+ thumbnail = QtGui.QPixmap()
+ thumbnail.loadFromData(thumbnail_bin)
+
+ self.set_pixmap(thumbnail)
+
+
+class VersionWidget(QtWidgets.QWidget):
+ """A Widget that display information about a specific version"""
+ def __init__(self, dbcon, parent=None):
+ super(VersionWidget, self).__init__(parent=parent)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ label = QtWidgets.QLabel("Version", self)
+ data = VersionTextEdit(dbcon, self)
+ data.setReadOnly(True)
+
+ layout.addWidget(label)
+ layout.addWidget(data)
+
+ self.data = data
+
+ def set_version(self, version_doc):
+ self.data.set_version(version_doc)
+
+
+class FamilyListWidget(QtWidgets.QListWidget):
+ """A Widget that lists all available families"""
+
+ NameRole = QtCore.Qt.UserRole + 1
+ active_changed = QtCore.Signal(list)
+
+ def __init__(self, dbcon, family_config_cache, parent=None):
+ super(FamilyListWidget, self).__init__(parent=parent)
+
+ self.family_config_cache = family_config_cache
+ self.dbcon = dbcon
+
+ multi_select = QtWidgets.QAbstractItemView.ExtendedSelection
+ self.setSelectionMode(multi_select)
+ self.setAlternatingRowColors(True)
+ # Enable RMB menu
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.show_right_mouse_menu)
+
+ self.itemChanged.connect(self._on_item_changed)
+
+ def refresh(self):
+ """Refresh the listed families.
+
+ This gets all unique families and adds them as checkable items to
+ the list.
+
+ """
+
+ families = []
+ if self.dbcon.Session.get("AVALON_PROJECT"):
+ result = list(self.dbcon.aggregate([
+ {"$match": {
+ "type": "subset"
+ }},
+ {"$project": {
+ "family": {"$arrayElemAt": ["$data.families", 0]}
+ }},
+ {"$group": {
+ "_id": "family_group",
+ "families": {"$addToSet": "$family"}
+ }}
+ ]))
+ if result:
+ families = result[0]["families"]
+
+ # Rebuild list
+ self.blockSignals(True)
+ self.clear()
+ for name in sorted(families):
+ family = self.family_config_cache.family_config(name)
+ if family.get("hideFilter"):
+ continue
+
+ label = family.get("label", name)
+ icon = family.get("icon", None)
+
+ # TODO: This should be more managable by the artist
+ # Temporarily implement support for a default state in the project
+ # configuration
+ state = family.get("state", True)
+ state = QtCore.Qt.Checked if state else QtCore.Qt.Unchecked
+
+ item = QtWidgets.QListWidgetItem(parent=self)
+ item.setText(label)
+ item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
+ item.setData(self.NameRole, name)
+ item.setCheckState(state)
+
+ if icon:
+ item.setIcon(icon)
+
+ self.addItem(item)
+ self.blockSignals(False)
+
+ self.active_changed.emit(self.get_filters())
+
+ def get_filters(self):
+ """Return the checked family items"""
+
+ items = [self.item(i) for i in
+ range(self.count())]
+
+ return [item.data(self.NameRole) for item in items if
+ item.checkState() == QtCore.Qt.Checked]
+
+ def _on_item_changed(self):
+ self.active_changed.emit(self.get_filters())
+
+ def _set_checkstate_all(self, state):
+ _state = QtCore.Qt.Checked if state is True else QtCore.Qt.Unchecked
+ self.blockSignals(True)
+ for i in range(self.count()):
+ item = self.item(i)
+ item.setCheckState(_state)
+ self.blockSignals(False)
+ self.active_changed.emit(self.get_filters())
+
+ def show_right_mouse_menu(self, pos):
+ """Build RMB menu under mouse at current position (within widget)"""
+
+ # Get mouse position
+ globalpos = self.viewport().mapToGlobal(pos)
+
+ menu = QtWidgets.QMenu(self)
+
+ # Add enable all action
+ state_checked = QtWidgets.QAction(menu, text="Enable All")
+ state_checked.triggered.connect(
+ lambda: self._set_checkstate_all(True))
+ # Add disable all action
+ state_unchecked = QtWidgets.QAction(menu, text="Disable All")
+ state_unchecked.triggered.connect(
+ lambda: self._set_checkstate_all(False))
+
+ menu.addAction(state_checked)
+ menu.addAction(state_unchecked)
+
+ menu.exec_(globalpos)
+
+
+class RepresentationWidget(QtWidgets.QWidget):
+ load_started = QtCore.Signal()
+ load_ended = QtCore.Signal()
+
+ default_widths = (
+ ("name", 120),
+ ("subset", 125),
+ ("asset", 125),
+ ("active_site", 85),
+ ("remote_site", 85)
+ )
+
+ commands = {'active': 'Download', 'remote': 'Upload'}
+
+ def __init__(self, dbcon, tool_name=None, parent=None):
+ super(RepresentationWidget, self).__init__(parent=parent)
+ self.dbcon = dbcon
+ self.tool_name = tool_name
+
+ headers = [item[0] for item in self.default_widths]
+
+ model = RepresentationModel(self.dbcon, headers, [])
+
+ proxy_model = RepresentationSortProxyModel(self)
+ proxy_model.setSourceModel(model)
+
+ label = QtWidgets.QLabel("Representations", self)
+
+ tree_view = DeselectableTreeView()
+ tree_view.setModel(proxy_model)
+ tree_view.setAllColumnsShowFocus(True)
+ tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ tree_view.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection)
+ tree_view.setSortingEnabled(True)
+ tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder)
+ tree_view.setAlternatingRowColors(True)
+ tree_view.setIndentation(20)
+ tree_view.setStyleSheet("""
+ QTreeView::item{
+ padding: 5px 1px;
+ border: 0px;
+ }
+ """)
+ tree_view.collapseAll()
+
+ for column_name, width in self.default_widths:
+ idx = model.Columns.index(column_name)
+ tree_view.setColumnWidth(idx, width)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(label)
+ layout.addWidget(tree_view)
+
+ # self.itemChanged.connect(self._on_item_changed)
+ tree_view.customContextMenuRequested.connect(self.on_context_menu)
+
+ self.tree_view = tree_view
+ self.model = model
+ self.proxy_model = proxy_model
+
+ self.sync_server_enabled = False
+ actual_project = dbcon.Session["AVALON_PROJECT"]
+ self.on_project_change(actual_project)
+
+ self.model.refresh()
+
+ def on_project_change(self, project_name):
+ """
+ Called on each project change in parent widget.
+
+ Checks if Sync Server is enabled for a project, pushes changes to
+ model.
+ """
+ enabled = False
+ if project_name:
+ self.model.reset_sync_server(project_name)
+ if self.model.sync_server:
+ enabled_proj = self.model.sync_server.get_enabled_projects()
+ enabled = project_name in enabled_proj
+
+ self.sync_server_enabled = enabled
+ lib.change_visibility(self.model, self.tree_view,
+ "active_site", enabled)
+ lib.change_visibility(self.model, self.tree_view,
+ "remote_site", enabled)
+
+ def _repre_contexts_for_loaders_filter(self, items):
+ repre_ids = []
+ for item in items:
+ repre_ids.append(item["_id"])
+
+ repre_docs = list(self.dbcon.find(
+ {
+ "type": "representation",
+ "_id": {"$in": repre_ids}
+ },
+ {
+ "name": 1,
+ "parent": 1
+ }
+ ))
+ version_ids = [
+ repre_doc["parent"]
+ for repre_doc in repre_docs
+ ]
+ version_docs = self.dbcon.find({
+ "_id": {"$in": version_ids}
+ })
+
+ version_docs_by_id = {}
+ version_docs_by_subset_id = collections.defaultdict(list)
+ for version_doc in version_docs:
+ version_id = version_doc["_id"]
+ subset_id = version_doc["parent"]
+ version_docs_by_id[version_id] = version_doc
+ version_docs_by_subset_id[subset_id].append(version_doc)
+
+ subset_docs = list(self.dbcon.find(
+ {
+ "_id": {"$in": list(version_docs_by_subset_id.keys())},
+ "type": "subset"
+ },
+ {
+ "schema": 1,
+ "data.families": 1
+ }
+ ))
+ subset_docs_by_id = {
+ subset_doc["_id"]: subset_doc
+ for subset_doc in subset_docs
+ }
+ repre_context_by_id = {}
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+
+ version_doc = version_docs_by_id[version_id]
+ repre_context_by_id[repre_doc["_id"]] = {
+ "representation": repre_doc,
+ "version": version_doc,
+ "subset": subset_docs_by_id[version_doc["parent"]]
+ }
+ return repre_context_by_id
+
+ def on_context_menu(self, point):
+ """Shows menu with loader actions on Right-click.
+
+ Registered actions are filtered by selection and help of
+ `loaders_from_representation` from avalon api. Intersection of actions
+ is shown when more subset is selected. When there are not available
+ actions for selected subsets then special action is shown (works as
+ info message to user): "*No compatible loaders for your selection"
+
+ """
+ point_index = self.tree_view.indexAt(point)
+ if not point_index.isValid():
+ return
+
+ # Get selected subsets without groups
+ selection = self.tree_view.selectionModel()
+ rows = selection.selectedRows(column=0)
+
+ items = lib.get_selected_items(rows, self.model.ItemRole)
+
+ selected_side = self._get_selected_side(point_index, rows)
+
+ # Get all representation->loader combinations available for the
+ # index under the cursor, so we can list the user the options.
+ available_loaders = api.discover(api.Loader)
+
+ filtered_loaders = []
+ for loader in available_loaders:
+ # Skip subset loaders
+ if api.SubsetLoader in inspect.getmro(loader):
+ continue
+
+ if (
+ tools_lib.is_sync_loader(loader)
+ and not self.sync_server_enabled
+ ):
+ continue
+
+ filtered_loaders.append(loader)
+
+ if self.tool_name:
+ filtered_loaders = lib.remove_tool_name_from_loaders(
+ filtered_loaders, self.tool_name
+ )
+
+ loaders = list()
+ already_added_loaders = set()
+ label_already_in_menu = set()
+
+ repre_context_by_id = (
+ self._repre_contexts_for_loaders_filter(items)
+ )
+
+ for item in items:
+ repre_context = repre_context_by_id[item["_id"]]
+ for loader in pipeline.loaders_from_repre_context(
+ filtered_loaders,
+ repre_context
+ ):
+ if tools_lib.is_sync_loader(loader):
+ both_unavailable = (
+ item["active_site_progress"] <= 0
+ and item["remote_site_progress"] <= 0
+ )
+ if both_unavailable:
+ continue
+
+ for selected_side in self.commands.keys():
+ item = item.copy()
+ item["custom_label"] = None
+ label = None
+ selected_site_progress = item.get(
+ "{}_site_progress".format(selected_side), -1)
+
+ # only remove if actually present
+ if tools_lib.is_remove_site_loader(loader):
+ label = "Remove {}".format(selected_side)
+ if selected_site_progress < 1:
+ continue
+
+ if tools_lib.is_add_site_loader(loader):
+ label = self.commands[selected_side]
+ if selected_site_progress >= 0:
+ label = 'Re-{} {}'.format(label, selected_side)
+
+ if not label:
+ continue
+
+ item["selected_side"] = selected_side
+ item["custom_label"] = label
+
+ if label not in label_already_in_menu:
+ loaders.append((item, loader))
+ already_added_loaders.add(loader)
+ label_already_in_menu.add(label)
+
+ else:
+ item = item.copy()
+ item["custom_label"] = None
+
+ if loader not in already_added_loaders:
+ loaders.append((item, loader))
+ already_added_loaders.add(loader)
+
+ loaders = lib.sort_loaders(loaders)
+
+ menu = OptionalMenu(self)
+ if not loaders:
+ action = lib.get_no_loader_action(menu)
+ menu.addAction(action)
+ else:
+ repre_contexts = pipeline.get_repres_contexts(
+ repre_context_by_id.keys(), self.dbcon)
+ menu = lib.add_representation_loaders_to_menu(loaders, menu,
+ repre_contexts)
+
+ self._process_action(items, menu, point)
+
+ def _process_action(self, items, menu, point):
+ """
+ Show the context action menu and process selected
+
+ Args:
+ items(dict): menu items
+ menu(OptionalMenu)
+ point(PointIndex)
+ """
+ global_point = self.tree_view.mapToGlobal(point)
+ action = menu.exec_(global_point)
+
+ if not action or not action.data():
+ return
+
+ self.load_started.emit()
+
+ # Find the representation name and loader to trigger
+ action_representation, loader = action.data()
+ repre_ids = []
+ data_by_repre_id = {}
+ selected_side = action_representation.get("selected_side")
+
+ for item in items:
+ if tools_lib.is_sync_loader(loader):
+ site_name = "{}_site_name".format(selected_side)
+ data = {
+ "_id": item.get("_id"),
+ "site_name": item.get(site_name),
+ "project_name": self.dbcon.Session["AVALON_PROJECT"]
+ }
+
+ if not data["site_name"]:
+ continue
+
+ data_by_repre_id[data["_id"]] = data
+
+ repre_ids.append(item.get("_id"))
+
+ repre_contexts = pipeline.get_repres_contexts(repre_ids,
+ self.dbcon)
+ options = lib.get_options(action, loader, self,
+ list(repre_contexts.values()))
+
+ errors = _load_representations_by_loader(
+ loader, repre_contexts,
+ options=options, data_by_repre_id=data_by_repre_id)
+
+ self.model.refresh()
+
+ self.load_ended.emit()
+
+ if errors:
+ box = LoadErrorMessageBox(errors)
+ box.show()
+
+ def _get_optional_labels(self, loaders, selected_side):
+ """Each loader could have specific label
+
+ Args:
+ loaders (tuple of dict, dict): (item, loader)
+ selected_side(string): active or remote
+
+ Returns:
+ (dict) {loader: string}
+ """
+ optional_labels = {}
+ if selected_side:
+ if selected_side == 'active':
+ txt = "Localize"
+ else:
+ txt = "Sync to Remote"
+ optional_labels = {loader: txt for _, loader in loaders
+ if tools_lib.is_sync_loader(loader)}
+ return optional_labels
+
+ def _get_selected_side(self, point_index, rows):
+ """Returns active/remote label according to column in 'point_index'"""
+ selected_side = None
+ if self.sync_server_enabled:
+ if rows:
+ source_index = self.proxy_model.mapToSource(point_index)
+ selected_side = self.model.data(source_index,
+ self.model.SiteSideRole)
+ return selected_side
+
+ def set_version_ids(self, version_ids):
+ self.model.set_version_ids(version_ids)
+
+ def _set_download(self):
+ pass
+
+ def change_visibility(self, column_name, visible):
+ """
+ Hides or shows particular 'column_name'.
+
+ "asset" and "subset" columns should be visible only in multiselect
+ """
+ lib.change_visibility(self.model, self.tree_view, column_name, visible)
+
+
+def _load_representations_by_loader(loader, repre_contexts,
+ options,
+ data_by_repre_id=None):
+ """Loops through list of repre_contexts and loads them with one loader
+
+ Args:
+ loader (cls of api.Loader) - not initialized yet
+ repre_contexts (dicts) - full info about selected representations
+ (containing repre_doc, version_doc, subset_doc, project info)
+ options (dict) - qargparse arguments to fill OptionDialog
+ data_by_repre_id (dict) - additional data applicable on top of
+ options to provide dynamic values
+ """
+ error_info = []
+
+ if options is None: # not load when cancelled
+ return
+
+ for repre_context in repre_contexts.values():
+ try:
+ if data_by_repre_id:
+ _id = repre_context["representation"]["_id"]
+ data = data_by_repre_id.get(_id)
+ options.update(data)
+ pipeline.load_with_repre_context(
+ loader,
+ repre_context,
+ options=options
+ )
+ except pipeline.IncompatibleLoaderError as exc:
+ print(exc)
+ error_info.append((
+ "Incompatible Loader",
+ None,
+ repre_context["representation"]["name"],
+ repre_context["subset"]["name"],
+ repre_context["version"]["name"]
+ ))
+
+ except Exception as exc:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ ))
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ repre_context["representation"]["name"],
+ repre_context["subset"]["name"],
+ repre_context["version"]["name"]
+ ))
+ return error_info
+
+
+def _load_subsets_by_loader(loader, subset_contexts, options,
+ subset_version_docs=None):
+ """
+ Triggers load with SubsetLoader type of loaders
+
+ Args:
+ loader (SubsetLoder):
+ subset_contexts (list):
+ options (dict):
+ subset_version_docs (dict): {subset_id: version_doc}
+ """
+ error_info = []
+
+ if options is None: # not load when cancelled
+ return
+
+ if loader.is_multiple_contexts_compatible:
+ subset_names = []
+ for context in subset_contexts:
+ subset_name = context.get("subset", {}).get("name") or "N/A"
+ subset_names.append(subset_name)
+
+ context["version"] = subset_version_docs[context["subset"]["_id"]]
+ try:
+ pipeline.load_with_subset_contexts(
+ loader,
+ subset_contexts,
+ options=options
+ )
+ except Exception as exc:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(
+ traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ )
+ )
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ None,
+ ", ".join(subset_names),
+ None
+ ))
+ else:
+ for subset_context in subset_contexts:
+ subset_name = subset_context.get("subset", {}).get("name") or "N/A"
+
+ version_doc = subset_version_docs[subset_context["subset"]["_id"]]
+ subset_context["version"] = version_doc
+ try:
+ pipeline.load_with_subset_context(
+ loader,
+ subset_context,
+ options=options
+ )
+ except Exception as exc:
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "\n".join(
+ traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ )
+ )
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ None,
+ subset_name,
+ None
+ ))
+
+ return error_info
diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py
index 7c71f4b451..4a23649ef3 100644
--- a/openpype/tools/project_manager/project_manager/window.py
+++ b/openpype/tools/project_manager/project_manager/window.py
@@ -29,7 +29,7 @@ class ProjectManagerWindow(QtWidgets.QWidget):
self._user_passed = False
self.setWindowTitle("OpenPype Project Manager")
- self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath()))
+ self.setWindowIcon(QtGui.QIcon(resources.get_openpype_icon_filepath()))
# Top part of window
top_part_widget = QtWidgets.QWidget(self)
diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py
index 8235cf8642..ab6b27bdaf 100644
--- a/openpype/tools/settings/settings/base.py
+++ b/openpype/tools/settings/settings/base.py
@@ -3,6 +3,7 @@ import json
from Qt import QtWidgets, QtGui, QtCore
from openpype.tools.settings import CHILD_OFFSET
from .widgets import ExpandingWidget
+from .lib import create_deffered_value_change_timer
class BaseWidget(QtWidgets.QWidget):
@@ -329,6 +330,20 @@ class BaseWidget(QtWidgets.QWidget):
class InputWidget(BaseWidget):
+ def __init__(self, *args, **kwargs):
+ super(InputWidget, self).__init__(*args, **kwargs)
+
+ # Input widgets have always timer available (but may not be used).
+ self._value_change_timer = create_deffered_value_change_timer(
+ self._on_value_change_timer
+ )
+
+ def start_value_timer(self):
+ self._value_change_timer.start()
+
+ def _on_value_change_timer(self):
+ pass
+
def create_ui(self):
if self.entity.use_label_wrap:
label = None
diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py
index c420a8cdc5..be2264340b 100644
--- a/openpype/tools/settings/settings/categories.py
+++ b/openpype/tools/settings/settings/categories.py
@@ -609,14 +609,23 @@ class ProjectWidget(SettingsCategoryWidget):
self.project_list_widget.refresh()
def _on_reset_crash(self):
- self.project_list_widget.setEnabled(False)
+ self._set_enabled_project_list(False)
super(ProjectWidget, self)._on_reset_crash()
def _on_reset_success(self):
- if not self.project_list_widget.isEnabled():
- self.project_list_widget.setEnabled(True)
+ self._set_enabled_project_list(True)
super(ProjectWidget, self)._on_reset_success()
+ def _set_enabled_project_list(self, enabled):
+ if (
+ enabled
+ and self.modify_defaults_checkbox
+ and self.modify_defaults_checkbox.isChecked()
+ ):
+ enabled = False
+ if self.project_list_widget.isEnabled() != enabled:
+ self.project_list_widget.setEnabled(enabled)
+
def _create_root_entity(self):
self.entity = ProjectSettings(change_state=False)
self.entity.on_change_callbacks.append(self._on_entity_change)
@@ -637,7 +646,8 @@ class ProjectWidget(SettingsCategoryWidget):
if self.modify_defaults_checkbox:
self.modify_defaults_checkbox.setEnabled(True)
- self.project_list_widget.setEnabled(True)
+
+ self._set_enabled_project_list(True)
except DefaultsNotDefined:
if not self.modify_defaults_checkbox:
@@ -646,7 +656,7 @@ class ProjectWidget(SettingsCategoryWidget):
self.entity.set_defaults_state()
self.modify_defaults_checkbox.setChecked(True)
self.modify_defaults_checkbox.setEnabled(False)
- self.project_list_widget.setEnabled(False)
+ self._set_enabled_project_list(False)
except StudioDefaultsNotDefined:
self.select_default_project()
@@ -666,8 +676,10 @@ class ProjectWidget(SettingsCategoryWidget):
def _on_modify_defaults(self):
if self.modify_defaults_checkbox.isChecked():
+ self._set_enabled_project_list(False)
if not self.entity.is_in_defaults_state():
self.reset()
else:
+ self._set_enabled_project_list(True)
if not self.entity.is_in_studio_state():
self.reset()
diff --git a/openpype/tools/settings/settings/dict_mutable_widget.py b/openpype/tools/settings/settings/dict_mutable_widget.py
index ba86fe82dd..cfb9d4a4b1 100644
--- a/openpype/tools/settings/settings/dict_mutable_widget.py
+++ b/openpype/tools/settings/settings/dict_mutable_widget.py
@@ -3,6 +3,7 @@ from uuid import uuid4
from Qt import QtWidgets, QtCore, QtGui
from .base import BaseWidget
+from .lib import create_deffered_value_change_timer
from .widgets import (
ExpandingWidget,
IconButton
@@ -284,6 +285,10 @@ class ModifiableDictItem(QtWidgets.QWidget):
self.confirm_btn = None
+ self._key_change_timer = create_deffered_value_change_timer(
+ self._on_timeout
+ )
+
if collapsible_key:
self.create_collapsible_ui()
else:
@@ -516,6 +521,10 @@ class ModifiableDictItem(QtWidgets.QWidget):
if self.ignore_input_changes:
return
+ self._key_change_timer.start()
+
+ def _on_timeout(self):
+ key = self.key_value()
is_key_duplicated = self.entity_widget.validate_key_duplication(
self.temp_key, key, self
)
diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py
index da74c2adc5..a28bee8d36 100644
--- a/openpype/tools/settings/settings/item_widgets.py
+++ b/openpype/tools/settings/settings/item_widgets.py
@@ -400,7 +400,9 @@ class TextWidget(InputWidget):
def _on_value_change(self):
if self.ignore_input_changes:
return
+ self.start_value_timer()
+ def _on_value_change_timer(self):
self.entity.set(self.input_value())
@@ -474,6 +476,9 @@ class NumberWidget(InputWidget):
if self.ignore_input_changes:
return
+ self.start_value_timer()
+
+ def _on_value_change_timer(self):
value = self.input_field.value()
if self._slider_widget is not None and not self._ignore_input_change:
self._ignore_slider_change = True
@@ -571,7 +576,9 @@ class RawJsonWidget(InputWidget):
def _on_value_change(self):
if self.ignore_input_changes:
return
+ self.start_value_timer()
+ def _on_value_change_timer(self):
self._is_invalid = self.input_field.has_invalid_value()
if not self.is_invalid:
self.entity.set(self.input_field.json_value())
@@ -786,4 +793,7 @@ class PathInputWidget(InputWidget):
def _on_value_change(self):
if self.ignore_input_changes:
return
+ self.start_value_timer()
+
+ def _on_value_change_timer(self):
self.entity.set(self.input_value())
diff --git a/openpype/tools/settings/settings/lib.py b/openpype/tools/settings/settings/lib.py
new file mode 100644
index 0000000000..577aaa5671
--- /dev/null
+++ b/openpype/tools/settings/settings/lib.py
@@ -0,0 +1,18 @@
+from Qt import QtCore
+
+# Offset of value change trigger in ms
+VALUE_CHANGE_OFFSET_MS = 300
+
+
+def create_deffered_value_change_timer(callback):
+ """Deffer value change callback.
+
+ UI won't trigger all callbacks on each value change but after predefined
+ time. Timer is reset on each start so callback is triggered after user
+ finish editing.
+ """
+ timer = QtCore.QTimer()
+ timer.setSingleShot(True)
+ timer.setInterval(VALUE_CHANGE_OFFSET_MS)
+ timer.timeout.connect(callback)
+ return timer
diff --git a/openpype/tools/settings/settings/style/__init__.py b/openpype/tools/settings/settings/style/__init__.py
index 5a57642ee1..f1d9829a04 100644
--- a/openpype/tools/settings/settings/style/__init__.py
+++ b/openpype/tools/settings/settings/style/__init__.py
@@ -10,4 +10,4 @@ def load_stylesheet():
def app_icon_path():
- return resources.pype_icon_filepath()
+ return resources.get_openpype_icon_filepath()
diff --git a/openpype/tools/settings/settings/style/style.css b/openpype/tools/settings/settings/style/style.css
index d9d85a481e..b77b575204 100644
--- a/openpype/tools/settings/settings/style/style.css
+++ b/openpype/tools/settings/settings/style/style.css
@@ -146,6 +146,15 @@ QSlider::handle:vertical {
border: 1px solid #464b54;
background: #21252B;
}
+
+#ProjectListWidget QListView:disabled {
+ background: #282C34;
+}
+
+#ProjectListWidget QListView::item:disabled {
+ color: #4e5254;
+}
+
#ProjectListWidget QLabel {
background: transparent;
font-weight: bold;
@@ -249,8 +258,6 @@ QTabBar::tab:!selected:hover {
background: #333840;
}
-
-
QTabBar::tab:first:selected {
margin-left: 0;
}
@@ -405,12 +412,15 @@ QHeaderView::section {
font-weight: bold;
}
-QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed {
+QAbstractItemView::item:pressed {
background: #78879b;
color: #FFFFFF;
}
-QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active {
+QAbstractItemView::item:selected:active {
+ background: #3d8ec9;
+}
+QAbstractItemView::item:selected:!active {
background: #3d8ec9;
}
diff --git a/openpype/tools/standalonepublish/app.py b/openpype/tools/standalonepublish/app.py
index 81a53c52b8..2ce757f773 100644
--- a/openpype/tools/standalonepublish/app.py
+++ b/openpype/tools/standalonepublish/app.py
@@ -231,7 +231,7 @@ def main():
qt_app = QtWidgets.QApplication([])
# app.setQuitOnLastWindowClosed(False)
qt_app.setStyleSheet(style.load_stylesheet())
- icon = QtGui.QIcon(resources.pype_icon_filepath())
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
qt_app.setWindowIcon(icon)
def signal_handler(sig, frame):
diff --git a/openpype/tools/tray/pype_info_widget.py b/openpype/tools/tray/pype_info_widget.py
index 2965463c37..2ca625f307 100644
--- a/openpype/tools/tray/pype_info_widget.py
+++ b/openpype/tools/tray/pype_info_widget.py
@@ -214,7 +214,7 @@ class PypeInfoWidget(QtWidgets.QWidget):
self.setStyleSheet(style.load_stylesheet())
- icon = QtGui.QIcon(resources.pype_icon_filepath())
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowTitle("OpenPype info")
diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py
index ed66f1a80f..35b254513f 100644
--- a/openpype/tools/tray/pype_tray.py
+++ b/openpype/tools/tray/pype_tray.py
@@ -200,7 +200,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
doubleclick_time_ms = 100
def __init__(self, parent):
- icon = QtGui.QIcon(resources.pype_icon_filepath())
+ icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
super(SystemTrayIcon, self).__init__(icon, parent)
@@ -308,7 +308,7 @@ class PypeTrayApplication(QtWidgets.QApplication):
splash_widget.hide()
def set_splash(self):
- splash_pix = QtGui.QPixmap(resources.pype_splash_filepath())
+ splash_pix = QtGui.QPixmap(resources.get_openpype_splash_filepath())
splash = QtWidgets.QSplashScreen(splash_pix)
splash.setMask(splash_pix.mask())
splash.setEnabled(False)
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py
new file mode 100644
index 0000000000..1827bc7e9b
--- /dev/null
+++ b/openpype/tools/utils/delegates.py
@@ -0,0 +1,449 @@
+import time
+from datetime import datetime
+import logging
+import numbers
+
+import Qt
+from Qt import QtWidgets, QtGui, QtCore
+
+from avalon.lib import HeroVersionType
+from .models import (
+ AssetModel,
+ TreeModel
+)
+from . import lib
+
+if Qt.__binding__ == "PySide":
+ from PySide.QtGui import QStyleOptionViewItemV4
+elif Qt.__binding__ == "PyQt4":
+ from PyQt4.QtGui import QStyleOptionViewItemV4
+
+log = logging.getLogger(__name__)
+
+
+class AssetDelegate(QtWidgets.QItemDelegate):
+ bar_height = 3
+
+ def sizeHint(self, option, index):
+ result = super(AssetDelegate, self).sizeHint(option, index)
+ height = result.height()
+ result.setHeight(height + self.bar_height)
+
+ return result
+
+ def paint(self, painter, option, index):
+ # Qt4 compat
+ if Qt.__binding__ in ("PySide", "PyQt4"):
+ option = QStyleOptionViewItemV4(option)
+
+ painter.save()
+
+ item_rect = QtCore.QRect(option.rect)
+ item_rect.setHeight(option.rect.height() - self.bar_height)
+
+ subset_colors = index.data(AssetModel.subsetColorsRole)
+ subset_colors_width = 0
+ if subset_colors:
+ subset_colors_width = option.rect.width() / len(subset_colors)
+
+ subset_rects = []
+ counter = 0
+ for subset_c in subset_colors:
+ new_color = None
+ new_rect = None
+ if subset_c:
+ new_color = QtGui.QColor(*subset_c)
+
+ new_rect = QtCore.QRect(
+ option.rect.left() + (counter * subset_colors_width),
+ option.rect.top() + (
+ option.rect.height() - self.bar_height
+ ),
+ subset_colors_width,
+ self.bar_height
+ )
+ subset_rects.append((new_color, new_rect))
+ counter += 1
+
+ # Background
+ bg_color = QtGui.QColor(60, 60, 60)
+ if option.state & QtWidgets.QStyle.State_Selected:
+ if len(subset_colors) == 0:
+ item_rect.setTop(item_rect.top() + (self.bar_height / 2))
+ if option.state & QtWidgets.QStyle.State_MouseOver:
+ bg_color.setRgb(70, 70, 70)
+ else:
+ item_rect.setTop(item_rect.top() + (self.bar_height / 2))
+ if option.state & QtWidgets.QStyle.State_MouseOver:
+ bg_color.setAlpha(100)
+ else:
+ bg_color.setAlpha(0)
+
+ # When not needed to do a rounded corners (easier and without
+ # painter restore):
+ # painter.fillRect(
+ # item_rect,
+ # QtGui.QBrush(bg_color)
+ # )
+ pen = painter.pen()
+ pen.setStyle(QtCore.Qt.NoPen)
+ pen.setWidth(0)
+ painter.setPen(pen)
+ painter.setBrush(QtGui.QBrush(bg_color))
+ painter.drawRoundedRect(option.rect, 3, 3)
+
+ if option.state & QtWidgets.QStyle.State_Selected:
+ for color, subset_rect in subset_rects:
+ if not color or not subset_rect:
+ continue
+ painter.fillRect(subset_rect, QtGui.QBrush(color))
+
+ painter.restore()
+ painter.save()
+
+ # Icon
+ icon_index = index.model().index(
+ index.row(), index.column(), index.parent()
+ )
+ # - Default icon_rect if not icon
+ icon_rect = QtCore.QRect(
+ item_rect.left(),
+ item_rect.top(),
+ # To make sure it's same size all the time
+ option.rect.height() - self.bar_height,
+ option.rect.height() - self.bar_height
+ )
+ icon = index.model().data(icon_index, QtCore.Qt.DecorationRole)
+
+ if icon:
+ mode = QtGui.QIcon.Normal
+ if not (option.state & QtWidgets.QStyle.State_Enabled):
+ mode = QtGui.QIcon.Disabled
+ elif option.state & QtWidgets.QStyle.State_Selected:
+ mode = QtGui.QIcon.Selected
+
+ if isinstance(icon, QtGui.QPixmap):
+ icon = QtGui.QIcon(icon)
+ option.decorationSize = icon.size() / icon.devicePixelRatio()
+
+ elif isinstance(icon, QtGui.QColor):
+ pixmap = QtGui.QPixmap(option.decorationSize)
+ pixmap.fill(icon)
+ icon = QtGui.QIcon(pixmap)
+
+ elif isinstance(icon, QtGui.QImage):
+ icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon))
+ option.decorationSize = icon.size() / icon.devicePixelRatio()
+
+ elif isinstance(icon, QtGui.QIcon):
+ state = QtGui.QIcon.Off
+ if option.state & QtWidgets.QStyle.State_Open:
+ state = QtGui.QIcon.On
+ actualSize = option.icon.actualSize(
+ option.decorationSize, mode, state
+ )
+ option.decorationSize = QtCore.QSize(
+ min(option.decorationSize.width(), actualSize.width()),
+ min(option.decorationSize.height(), actualSize.height())
+ )
+
+ state = QtGui.QIcon.Off
+ if option.state & QtWidgets.QStyle.State_Open:
+ state = QtGui.QIcon.On
+
+ icon.paint(
+ painter, icon_rect,
+ QtCore.Qt.AlignLeft, mode, state
+ )
+
+ # Text
+ text_rect = QtCore.QRect(
+ icon_rect.left() + icon_rect.width() + 2,
+ item_rect.top(),
+ item_rect.width(),
+ item_rect.height()
+ )
+
+ painter.drawText(
+ text_rect, QtCore.Qt.AlignVCenter,
+ index.data(QtCore.Qt.DisplayRole)
+ )
+
+ painter.restore()
+
+
+class VersionDelegate(QtWidgets.QStyledItemDelegate):
+ """A delegate that display version integer formatted as version string."""
+
+ version_changed = QtCore.Signal()
+ first_run = False
+ lock = False
+
+ def __init__(self, dbcon, *args, **kwargs):
+ self.dbcon = dbcon
+ super(VersionDelegate, self).__init__(*args, **kwargs)
+
+ def displayText(self, value, locale):
+ if isinstance(value, HeroVersionType):
+ return lib.format_version(value, True)
+ assert isinstance(value, numbers.Integral), (
+ "Version is not integer. \"{}\" {}".format(value, str(type(value)))
+ )
+ return lib.format_version(value)
+
+ def paint(self, painter, option, index):
+ fg_color = index.data(QtCore.Qt.ForegroundRole)
+ if fg_color:
+ if isinstance(fg_color, QtGui.QBrush):
+ fg_color = fg_color.color()
+ elif isinstance(fg_color, QtGui.QColor):
+ pass
+ else:
+ fg_color = None
+
+ if not fg_color:
+ return super(VersionDelegate, self).paint(painter, option, index)
+
+ if option.widget:
+ style = option.widget.style()
+ else:
+ style = QtWidgets.QApplication.style()
+
+ style.drawControl(
+ style.CE_ItemViewItem, option, painter, option.widget
+ )
+
+ painter.save()
+
+ text = self.displayText(
+ index.data(QtCore.Qt.DisplayRole), option.locale
+ )
+ pen = painter.pen()
+ pen.setColor(fg_color)
+ painter.setPen(pen)
+
+ text_rect = style.subElementRect(style.SE_ItemViewItemText, option)
+ text_margin = style.proxy().pixelMetric(
+ style.PM_FocusFrameHMargin, option, option.widget
+ ) + 1
+
+ painter.drawText(
+ text_rect.adjusted(text_margin, 0, - text_margin, 0),
+ option.displayAlignment,
+ text
+ )
+
+ painter.restore()
+
+ def createEditor(self, parent, option, index):
+ item = index.data(TreeModel.ItemRole)
+ if item.get("isGroup") or item.get("isMerged"):
+ return
+
+ editor = QtWidgets.QComboBox(parent)
+
+ def commit_data():
+ if not self.first_run:
+ self.commitData.emit(editor) # Update model data
+ self.version_changed.emit() # Display model data
+ editor.currentIndexChanged.connect(commit_data)
+
+ self.first_run = True
+ self.lock = False
+
+ return editor
+
+ def setEditorData(self, editor, index):
+ if self.lock:
+ # Only set editor data once per delegation
+ return
+
+ editor.clear()
+
+ # Current value of the index
+ item = index.data(TreeModel.ItemRole)
+ value = index.data(QtCore.Qt.DisplayRole)
+ if item["version_document"]["type"] != "hero_version":
+ assert isinstance(value, numbers.Integral), (
+ "Version is not integer"
+ )
+
+ # Add all available versions to the editor
+ parent_id = item["version_document"]["parent"]
+ version_docs = list(self.dbcon.find(
+ {
+ "type": "version",
+ "parent": parent_id
+ },
+ sort=[("name", 1)]
+ ))
+
+ hero_version_doc = self.dbcon.find_one(
+ {
+ "type": "hero_version",
+ "parent": parent_id
+ }, {
+ "name": 1,
+ "data.tags": 1,
+ "version_id": 1
+ }
+ )
+
+ doc_for_hero_version = None
+
+ selected = None
+ items = []
+ for version_doc in version_docs:
+ version_tags = version_doc["data"].get("tags") or []
+ if "deleted" in version_tags:
+ continue
+
+ if (
+ hero_version_doc
+ and doc_for_hero_version is None
+ and hero_version_doc["version_id"] == version_doc["_id"]
+ ):
+ doc_for_hero_version = version_doc
+
+ label = lib.format_version(version_doc["name"])
+ item = QtGui.QStandardItem(label)
+ item.setData(version_doc, QtCore.Qt.UserRole)
+ items.append(item)
+
+ if version_doc["name"] == value:
+ selected = item
+
+ if hero_version_doc and doc_for_hero_version:
+ version_name = doc_for_hero_version["name"]
+ label = lib.format_version(version_name, True)
+ if isinstance(value, HeroVersionType):
+ index = len(version_docs)
+ hero_version_doc["name"] = HeroVersionType(version_name)
+
+ item = QtGui.QStandardItem(label)
+ item.setData(hero_version_doc, QtCore.Qt.UserRole)
+ items.append(item)
+
+ # Reverse items so latest versions be upper
+ items = list(reversed(items))
+ for item in items:
+ editor.model().appendRow(item)
+
+ index = 0
+ if selected:
+ index = selected.row()
+
+ # Will trigger index-change signal
+ editor.setCurrentIndex(index)
+ self.first_run = False
+ self.lock = True
+
+ def setModelData(self, editor, model, index):
+ """Apply the integer version back in the model"""
+ version = editor.itemData(editor.currentIndex())
+ model.setData(index, version["name"])
+
+
+def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"):
+ """Parse datetime to readable timestamp
+
+ Within first ten seconds:
+ - "just now",
+ Within first minute ago:
+ - "%S seconds ago"
+ Within one hour ago:
+ - "%M minutes ago".
+ Within one day ago:
+ - "%H:%M hours ago"
+ Else:
+ "%Y-%m-%d %H:%M:%S"
+
+ """
+
+ assert isinstance(t, datetime)
+ if now is None:
+ now = datetime.now()
+ assert isinstance(now, datetime)
+ diff = now - t
+
+ second_diff = diff.seconds
+ day_diff = diff.days
+
+ # future (consider as just now)
+ if day_diff < 0:
+ return "just now"
+
+ # history
+ if day_diff == 0:
+ if second_diff < 10:
+ return "just now"
+ if second_diff < 60:
+ return str(second_diff) + " seconds ago"
+ if second_diff < 120:
+ return "a minute ago"
+ if second_diff < 3600:
+ return str(second_diff // 60) + " minutes ago"
+ if second_diff < 86400:
+ minutes = (second_diff % 3600) // 60
+ hours = second_diff // 3600
+ return "{0}:{1:02d} hours ago".format(hours, minutes)
+
+ return t.strftime(strftime)
+
+
+def pretty_timestamp(t, now=None):
+ """Parse timestamp to user readable format
+
+ >>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z")
+ 'just now'
+
+ >>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z")
+ '2:01 hours ago'
+
+ Args:
+ t (str): The time string to parse.
+ now (str, optional)
+
+ Returns:
+ str: human readable "recent" date.
+
+ """
+
+ if now is not None:
+ try:
+ now = time.strptime(now, "%Y%m%dT%H%M%SZ")
+ now = datetime.fromtimestamp(time.mktime(now))
+ except ValueError as e:
+ log.warning("Can't parse 'now' time format: {0} {1}".format(t, e))
+ return None
+
+ if isinstance(t, float):
+ dt = datetime.fromtimestamp(t)
+ else:
+ # Parse the time format as if it is `str` result from
+ # `pyblish.lib.time()` which usually is stored in Avalon database.
+ try:
+ t = time.strptime(t, "%Y%m%dT%H%M%SZ")
+ except ValueError as e:
+ log.warning("Can't parse time format: {0} {1}".format(t, e))
+ return None
+ dt = datetime.fromtimestamp(time.mktime(t))
+
+ # prettify
+ return pretty_date(dt, now=now)
+
+
+class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
+ """A delegate that displays a timestamp as a pretty date.
+
+ This displays dates like `pretty_date`.
+
+ """
+
+ def displayText(self, value, locale):
+
+ if value is None:
+ # Ignore None value
+ return
+
+ return pretty_timestamp(value)
diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py
new file mode 100644
index 0000000000..e83f663b2e
--- /dev/null
+++ b/openpype/tools/utils/lib.py
@@ -0,0 +1,622 @@
+import os
+import sys
+import contextlib
+import collections
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from avalon import io, api, style
+from avalon.vendor import qtawesome
+
+self = sys.modules[__name__]
+self._jobs = dict()
+
+
+class SharedObjects:
+ # Variable for family cache in global context
+ # QUESTION is this safe? More than one tool can refresh at the same time.
+ family_cache = None
+
+
+def global_family_cache():
+ if SharedObjects.family_cache is None:
+ SharedObjects.family_cache = FamilyConfigCache(io)
+ return SharedObjects.family_cache
+
+
+def format_version(value, hero_version=False):
+ """Formats integer to displayable version name"""
+ label = "v{0:03d}".format(value)
+ if not hero_version:
+ return label
+ return "[{}]".format(label)
+
+
+@contextlib.contextmanager
+def application():
+ app = QtWidgets.QApplication.instance()
+
+ if not app:
+ print("Starting new QApplication..")
+ app = QtWidgets.QApplication(sys.argv)
+ yield app
+ app.exec_()
+ else:
+ print("Using existing QApplication..")
+ yield app
+
+
+def defer(delay, func):
+ """Append artificial delay to `func`
+
+ This aids in keeping the GUI responsive, but complicates logic
+ when producing tests. To combat this, the environment variable ensures
+ that every operation is synchonous.
+
+ Arguments:
+ delay (float): Delay multiplier; default 1, 0 means no delay
+ func (callable): Any callable
+
+ """
+
+ delay *= float(os.getenv("PYBLISH_DELAY", 1))
+ if delay > 0:
+ return QtCore.QTimer.singleShot(delay, func)
+ else:
+ return func()
+
+
+def schedule(func, time, channel="default"):
+ """Run `func` at a later `time` in a dedicated `channel`
+
+ Given an arbitrary function, call this function after a given
+ timeout. It will ensure that only one "job" is running within
+ the given channel at any one time and cancel any currently
+ running job if a new job is submitted before the timeout.
+
+ """
+
+ try:
+ self._jobs[channel].stop()
+ except (AttributeError, KeyError, RuntimeError):
+ pass
+
+ timer = QtCore.QTimer()
+ timer.setSingleShot(True)
+ timer.timeout.connect(func)
+ timer.start(time)
+
+ self._jobs[channel] = timer
+
+
+@contextlib.contextmanager
+def dummy():
+ """Dummy context manager
+
+ Usage:
+ >> with some_context() if False else dummy():
+ .. pass
+
+ """
+
+ yield
+
+
+def iter_model_rows(model, column, include_root=False):
+ """Iterate over all row indices in a model"""
+ indices = [QtCore.QModelIndex()] # start iteration at root
+
+ for index in indices:
+ # Add children to the iterations
+ child_rows = model.rowCount(index)
+ for child_row in range(child_rows):
+ child_index = model.index(child_row, column, index)
+ indices.append(child_index)
+
+ if not include_root and not index.isValid():
+ continue
+
+ yield index
+
+
+@contextlib.contextmanager
+def preserve_states(tree_view,
+ column=0,
+ role=None,
+ preserve_expanded=True,
+ preserve_selection=True,
+ expanded_role=QtCore.Qt.DisplayRole,
+ selection_role=QtCore.Qt.DisplayRole):
+ """Preserves row selection in QTreeView by column's data role.
+ This function is created to maintain the selection status of
+ the model items. When refresh is triggered the items which are expanded
+ will stay expanded and vise versa.
+ tree_view (QWidgets.QTreeView): the tree view nested in the application
+ column (int): the column to retrieve the data from
+ role (int): the role which dictates what will be returned
+ Returns:
+ None
+ """
+ # When `role` is set then override both expanded and selection roles
+ if role:
+ expanded_role = role
+ selection_role = role
+
+ model = tree_view.model()
+ selection_model = tree_view.selectionModel()
+ flags = selection_model.Select | selection_model.Rows
+
+ expanded = set()
+
+ if preserve_expanded:
+ for index in iter_model_rows(
+ model, column=column, include_root=False
+ ):
+ if tree_view.isExpanded(index):
+ value = index.data(expanded_role)
+ expanded.add(value)
+
+ selected = None
+
+ if preserve_selection:
+ selected_rows = selection_model.selectedRows()
+ if selected_rows:
+ selected = set(row.data(selection_role) for row in selected_rows)
+
+ try:
+ yield
+ finally:
+ if expanded:
+ for index in iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ value = index.data(expanded_role)
+ is_expanded = value in expanded
+ # skip if new index was created meanwhile
+ if is_expanded is None:
+ continue
+ tree_view.setExpanded(index, is_expanded)
+
+ if selected:
+ # Go through all indices, select the ones with similar data
+ for index in iter_model_rows(
+ model, column=column, include_root=False
+ ):
+ value = index.data(selection_role)
+ state = value in selected
+ if state:
+ tree_view.scrollTo(index) # Ensure item is visible
+ selection_model.select(index, flags)
+
+
+@contextlib.contextmanager
+def preserve_expanded_rows(tree_view, column=0, role=None):
+ """Preserves expanded row in QTreeView by column's data role.
+
+ This function is created to maintain the expand vs collapse status of
+ the model items. When refresh is triggered the items which are expanded
+ will stay expanded and vise versa.
+
+ Arguments:
+ tree_view (QWidgets.QTreeView): the tree view which is
+ nested in the application
+ column (int): the column to retrieve the data from
+ role (int): the role which dictates what will be returned
+
+ Returns:
+ None
+
+ """
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+ model = tree_view.model()
+
+ expanded = set()
+
+ for index in iter_model_rows(model, column=column, include_root=False):
+ if tree_view.isExpanded(index):
+ value = index.data(role)
+ expanded.add(value)
+
+ try:
+ yield
+ finally:
+ if not expanded:
+ return
+
+ for index in iter_model_rows(model, column=column, include_root=False):
+ value = index.data(role)
+ state = value in expanded
+ if state:
+ tree_view.expand(index)
+ else:
+ tree_view.collapse(index)
+
+
+@contextlib.contextmanager
+def preserve_selection(tree_view, column=0, role=None, current_index=True):
+ """Preserves row selection in QTreeView by column's data role.
+
+ This function is created to maintain the selection status of
+ the model items. When refresh is triggered the items which are expanded
+ will stay expanded and vise versa.
+
+ tree_view (QWidgets.QTreeView): the tree view nested in the application
+ column (int): the column to retrieve the data from
+ role (int): the role which dictates what will be returned
+
+ Returns:
+ None
+
+ """
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+ model = tree_view.model()
+ selection_model = tree_view.selectionModel()
+ flags = selection_model.Select | selection_model.Rows
+
+ if current_index:
+ current_index_value = tree_view.currentIndex().data(role)
+ else:
+ current_index_value = None
+
+ selected_rows = selection_model.selectedRows()
+ if not selected_rows:
+ yield
+ return
+
+ selected = set(row.data(role) for row in selected_rows)
+ try:
+ yield
+ finally:
+ if not selected:
+ return
+
+ # Go through all indices, select the ones with similar data
+ for index in iter_model_rows(model, column=column, include_root=False):
+ value = index.data(role)
+ state = value in selected
+ if state:
+ tree_view.scrollTo(index) # Ensure item is visible
+ selection_model.select(index, flags)
+
+ if current_index_value and value == current_index_value:
+ selection_model.setCurrentIndex(
+ index, selection_model.NoUpdate
+ )
+
+
+class FamilyConfigCache:
+ default_color = "#0091B2"
+ _default_icon = None
+ _default_item = None
+
+ def __init__(self, dbcon):
+ self.dbcon = dbcon
+ self.family_configs = {}
+
+ @classmethod
+ def default_icon(cls):
+ if cls._default_icon is None:
+ cls._default_icon = qtawesome.icon(
+ "fa.folder", color=cls.default_color
+ )
+ return cls._default_icon
+
+ @classmethod
+ def default_item(cls):
+ if cls._default_item is None:
+ cls._default_item = {"icon": cls.default_icon()}
+ return cls._default_item
+
+ def family_config(self, family_name):
+ """Get value from config with fallback to default"""
+ return self.family_configs.get(family_name, self.default_item())
+
+ def refresh(self):
+ """Get the family configurations from the database
+
+ The configuration must be stored on the project under `config`.
+ For example:
+
+ {"config": {
+ "families": [
+ {"name": "avalon.camera", label: "Camera", "icon": "photo"},
+ {"name": "avalon.anim", label: "Animation", "icon": "male"},
+ ]
+ }}
+
+ It is possible to override the default behavior and set specific
+ families checked. For example we only want the families imagesequence
+ and camera to be visible in the Loader.
+
+ # This will turn every item off
+ api.data["familyStateDefault"] = False
+
+ # Only allow the imagesequence and camera
+ api.data["familyStateToggled"] = ["imagesequence", "camera"]
+
+ """
+
+ self.family_configs.clear()
+
+ families = []
+
+ # Update the icons from the project configuration
+ project_name = self.dbcon.Session.get("AVALON_PROJECT")
+ if project_name:
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ projection={"config.families": True}
+ )
+
+ if not project_doc:
+ print((
+ "Project \"{}\" not found!"
+ " Can't refresh family icons cache."
+ ).format(project_name))
+ else:
+ families = project_doc["config"].get("families") or []
+
+ # Check if any family state are being overwritten by the configuration
+ default_state = api.data.get("familiesStateDefault", True)
+ toggled = set(api.data.get("familiesStateToggled") or [])
+
+ # Replace icons with a Qt icon we can use in the user interfaces
+ for family in families:
+ name = family["name"]
+ # Set family icon
+ icon = family.get("icon", None)
+ if icon:
+ family["icon"] = qtawesome.icon(
+ "fa.{}".format(icon),
+ color=self.default_color
+ )
+ else:
+ family["icon"] = self.default_icon()
+
+ # Update state
+ if name in toggled:
+ state = True
+ else:
+ state = default_state
+ family["state"] = state
+
+ self.family_configs[name] = family
+
+ return self.family_configs
+
+
+class GroupsConfig:
+ # Subset group item's default icon and order
+ _default_group_config = None
+
+ def __init__(self, dbcon):
+ self.dbcon = dbcon
+ self.groups = {}
+
+ @classmethod
+ def default_group_config(cls):
+ if cls._default_group_config is None:
+ cls._default_group_config = {
+ "icon": qtawesome.icon(
+ "fa.object-group",
+ color=style.colors.default
+ ),
+ "order": 0
+ }
+ return cls._default_group_config
+
+ def refresh(self):
+ """Get subset group configurations from the database
+
+ The 'group' configuration must be stored in the project `config` field.
+ See schema `config-1.0.json`
+
+ """
+ # Clear cached groups
+ self.groups.clear()
+
+ group_configs = []
+ project_name = self.dbcon.Session.get("AVALON_PROJECT")
+ if project_name:
+ # Get pre-defined group name and apperance from project config
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ projection={"config.groups": True}
+ )
+
+ if project_doc:
+ group_configs = project_doc["config"].get("groups") or []
+ else:
+ print("Project not found! \"{}\"".format(project_name))
+
+ # Build pre-defined group configs
+ for config in group_configs:
+ name = config["name"]
+ icon = "fa." + config.get("icon", "object-group")
+ color = config.get("color", style.colors.default)
+ order = float(config.get("order", 0))
+
+ self.groups[name] = {
+ "icon": qtawesome.icon(icon, color=color),
+ "order": order
+ }
+
+ return self.groups
+
+ def ordered_groups(self, group_names):
+ # default order zero included
+ _orders = set([0])
+ for config in self.groups.values():
+ _orders.add(config["order"])
+
+ # Remap order to list index
+ orders = sorted(_orders)
+
+ _groups = list()
+ for name in group_names:
+ # Get group config
+ config = self.groups.get(name) or self.default_group_config()
+ # Base order
+ remapped_order = orders.index(config["order"])
+
+ data = {
+ "name": name,
+ "icon": config["icon"],
+ "_order": remapped_order,
+ }
+
+ _groups.append(data)
+
+ # Sort by tuple (base_order, name)
+ # If there are multiple groups in same order, will sorted by name.
+ ordered_groups = sorted(
+ _groups, key=lambda _group: (_group.pop("_order"), _group["name"])
+ )
+
+ total = len(ordered_groups)
+ order_temp = "%0{}d".format(len(str(total)))
+
+ # Update sorted order to config
+ for index, group_data in enumerate(ordered_groups):
+ order = index
+ inverse_order = total - index
+
+ # Format orders into fixed length string for groups sorting
+ group_data["order"] = order_temp % order
+ group_data["inverseOrder"] = order_temp % inverse_order
+
+ return ordered_groups
+
+ def active_groups(self, asset_ids, include_predefined=True):
+ """Collect all active groups from each subset"""
+ # Collect groups from subsets
+ group_names = set(
+ self.dbcon.distinct(
+ "data.subsetGroup",
+ {"type": "subset", "parent": {"$in": asset_ids}}
+ )
+ )
+ if include_predefined:
+ # Ensure all predefined group configs will be included
+ group_names.update(self.groups.keys())
+
+ return self.ordered_groups(group_names)
+
+ def split_subsets_for_groups(self, subset_docs, grouping):
+ """Collect all active groups from each subset"""
+ subset_docs_without_group = collections.defaultdict(list)
+ subset_docs_by_group = collections.defaultdict(dict)
+ for subset_doc in subset_docs:
+ subset_name = subset_doc["name"]
+ if grouping:
+ group_name = subset_doc["data"].get("subsetGroup")
+ if group_name:
+ if subset_name not in subset_docs_by_group[group_name]:
+ subset_docs_by_group[group_name][subset_name] = []
+
+ subset_docs_by_group[group_name][subset_name].append(
+ subset_doc
+ )
+ continue
+
+ subset_docs_without_group[subset_name].append(subset_doc)
+
+ ordered_groups = self.ordered_groups(subset_docs_by_group.keys())
+
+ return ordered_groups, subset_docs_without_group, subset_docs_by_group
+
+
+def create_qthread(func, *args, **kwargs):
+ class Thread(QtCore.QThread):
+ def run(self):
+ func(*args, **kwargs)
+ return Thread()
+
+
+def get_repre_icons():
+ try:
+ from openpype_modules import sync_server
+ except Exception:
+ # Backwards compatibility
+ from openpype.modules import sync_server
+
+ resource_path = os.path.join(
+ os.path.dirname(sync_server.sync_server_module.__file__),
+ "providers", "resources"
+ )
+ icons = {}
+ # TODO get from sync module
+ for provider in ['studio', 'local_drive', 'gdrive']:
+ pix_url = "{}/{}.png".format(resource_path, provider)
+ icons[provider] = QtGui.QIcon(pix_url)
+
+ return icons
+
+
+def get_progress_for_repre(doc, active_site, remote_site):
+ """
+ Calculates average progress for representation.
+
+ If site has created_dt >> fully available >> progress == 1
+
+ Could be calculated in aggregate if it would be too slow
+ Args:
+ doc(dict): representation dict
+ Returns:
+ (dict) with active and remote sites progress
+ {'studio': 1.0, 'gdrive': -1} - gdrive site is not present
+ -1 is used to highlight the site should be added
+ {'studio': 1.0, 'gdrive': 0.0} - gdrive site is present, not
+ uploaded yet
+ """
+ progress = {active_site: -1,
+ remote_site: -1}
+ if not doc:
+ return progress
+
+ files = {active_site: 0, remote_site: 0}
+ doc_files = doc.get("files") or []
+ for doc_file in doc_files:
+ if not isinstance(doc_file, dict):
+ continue
+
+ sites = doc_file.get("sites") or []
+ for site in sites:
+ if (
+ # Pype 2 compatibility
+ not isinstance(site, dict)
+ # Check if site name is one of progress sites
+ or site["name"] not in progress
+ ):
+ continue
+
+ files[site["name"]] += 1
+ norm_progress = max(progress[site["name"]], 0)
+ if site.get("created_dt"):
+ progress[site["name"]] = norm_progress + 1
+ elif site.get("progress"):
+ progress[site["name"]] = norm_progress + site["progress"]
+ else: # site exists, might be failed, do not add again
+ progress[site["name"]] = 0
+
+ # for example 13 fully avail. files out of 26 >> 13/26 = 0.5
+ avg_progress = {}
+ avg_progress[active_site] = \
+ progress[active_site] / max(files[active_site], 1)
+ avg_progress[remote_site] = \
+ progress[remote_site] / max(files[remote_site], 1)
+ return avg_progress
+
+
+def is_sync_loader(loader):
+ return is_remove_site_loader(loader) or is_add_site_loader(loader)
+
+
+def is_remove_site_loader(loader):
+ return hasattr(loader, "remove_site_on_representation")
+
+
+def is_add_site_loader(loader):
+ return hasattr(loader, "add_site_to_representation")
diff --git a/openpype/tools/utils/models.py b/openpype/tools/utils/models.py
new file mode 100644
index 0000000000..c5e1ce1b12
--- /dev/null
+++ b/openpype/tools/utils/models.py
@@ -0,0 +1,500 @@
+import re
+import time
+import logging
+import collections
+
+import Qt
+from Qt import QtCore, QtGui
+from avalon.vendor import qtawesome
+from avalon import style, io
+from . import lib
+
+log = logging.getLogger(__name__)
+
+
+class TreeModel(QtCore.QAbstractItemModel):
+
+ Columns = list()
+ ItemRole = QtCore.Qt.UserRole + 1
+ item_class = None
+
+ def __init__(self, parent=None):
+ super(TreeModel, self).__init__(parent)
+ self._root_item = self.ItemClass()
+
+ @property
+ def ItemClass(self):
+ if self.item_class is not None:
+ return self.item_class
+ return Item
+
+ def rowCount(self, parent=None):
+ if parent is None or not parent.isValid():
+ parent_item = self._root_item
+ else:
+ parent_item = parent.internalPointer()
+ return parent_item.childCount()
+
+ def columnCount(self, parent):
+ return len(self.Columns)
+
+ def data(self, index, role):
+ if not index.isValid():
+ return None
+
+ if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
+ item = index.internalPointer()
+ column = index.column()
+
+ key = self.Columns[column]
+ return item.get(key, None)
+
+ if role == self.ItemRole:
+ return index.internalPointer()
+
+ def setData(self, index, value, role=QtCore.Qt.EditRole):
+ """Change the data on the items.
+
+ Returns:
+ bool: Whether the edit was successful
+ """
+
+ if index.isValid():
+ if role == QtCore.Qt.EditRole:
+
+ item = index.internalPointer()
+ column = index.column()
+ key = self.Columns[column]
+ item[key] = value
+
+ # passing `list()` for PyQt5 (see PYSIDE-462)
+ if Qt.__binding__ in ("PyQt4", "PySide"):
+ self.dataChanged.emit(index, index)
+ else:
+ self.dataChanged.emit(index, index, [role])
+
+ # must return true if successful
+ return True
+
+ return False
+
+ def setColumns(self, keys):
+ assert isinstance(keys, (list, tuple))
+ self.Columns = keys
+
+ def headerData(self, section, orientation, role):
+
+ if role == QtCore.Qt.DisplayRole:
+ if section < len(self.Columns):
+ return self.Columns[section]
+
+ super(TreeModel, self).headerData(section, orientation, role)
+
+ def flags(self, index):
+ flags = QtCore.Qt.ItemIsEnabled
+
+ item = index.internalPointer()
+ if item.get("enabled", True):
+ flags |= QtCore.Qt.ItemIsSelectable
+
+ return flags
+
+ def parent(self, index):
+
+ item = index.internalPointer()
+ parent_item = item.parent()
+
+ # If it has no parents we return invalid
+ if parent_item == self._root_item or not parent_item:
+ return QtCore.QModelIndex()
+
+ return self.createIndex(parent_item.row(), 0, parent_item)
+
+ def index(self, row, column, parent=None):
+ """Return index for row/column under parent"""
+
+ if parent is None or not parent.isValid():
+ parent_item = self._root_item
+ else:
+ parent_item = parent.internalPointer()
+
+ child_item = parent_item.child(row)
+ if child_item:
+ return self.createIndex(row, column, child_item)
+ else:
+ return QtCore.QModelIndex()
+
+ def add_child(self, item, parent=None):
+ if parent is None:
+ parent = self._root_item
+
+ parent.add_child(item)
+
+ def column_name(self, column):
+ """Return column key by index"""
+
+ if column < len(self.Columns):
+ return self.Columns[column]
+
+ def clear(self):
+ self.beginResetModel()
+ self._root_item = self.ItemClass()
+ self.endResetModel()
+
+
+class Item(dict):
+ """An item that can be represented in a tree view using `TreeModel`.
+
+ The item can store data just like a regular dictionary.
+
+ >>> data = {"name": "John", "score": 10}
+ >>> item = Item(data)
+ >>> assert item["name"] == "John"
+
+ """
+
+ def __init__(self, data=None):
+ super(Item, self).__init__()
+
+ self._children = list()
+ self._parent = None
+
+ if data is not None:
+ assert isinstance(data, dict)
+ self.update(data)
+
+ def childCount(self):
+ return len(self._children)
+
+ def child(self, row):
+
+ if row >= len(self._children):
+ log.warning("Invalid row as child: {0}".format(row))
+ return
+
+ return self._children[row]
+
+ def children(self):
+ return self._children
+
+ def parent(self):
+ return self._parent
+
+ def row(self):
+ """
+ Returns:
+ int: Index of this item under parent"""
+ if self._parent is not None:
+ siblings = self.parent().children()
+ return siblings.index(self)
+ return -1
+
+ def add_child(self, child):
+ """Add a child to this item"""
+ child._parent = self
+ self._children.append(child)
+
+
+class AssetModel(TreeModel):
+ """A model listing assets in the silo in the active project.
+
+ The assets are displayed in a treeview, they are visually parented by
+ a `visualParent` field in the database containing an `_id` to a parent
+ asset.
+
+ """
+
+ Columns = ["label"]
+ Name = 0
+ Deprecated = 2
+ ObjectId = 3
+
+ DocumentRole = QtCore.Qt.UserRole + 2
+ ObjectIdRole = QtCore.Qt.UserRole + 3
+ subsetColorsRole = QtCore.Qt.UserRole + 4
+
+ doc_fetched = QtCore.Signal(bool)
+ refreshed = QtCore.Signal(bool)
+
+ # Asset document projection
+ asset_projection = {
+ "type": 1,
+ "schema": 1,
+ "name": 1,
+ "silo": 1,
+ "data.visualParent": 1,
+ "data.label": 1,
+ "data.tags": 1,
+ "data.icon": 1,
+ "data.color": 1,
+ "data.deprecated": 1
+ }
+
+ def __init__(self, dbcon=None, parent=None, asset_projection=None):
+ super(AssetModel, self).__init__(parent=parent)
+ if dbcon is None:
+ dbcon = io
+ self.dbcon = dbcon
+ self.asset_colors = {}
+
+ # Projections for Mongo queries
+ # - let ability to modify them if used in tools that require more than
+ # defaults
+ if asset_projection:
+ self.asset_projection = asset_projection
+
+ self.asset_projection = asset_projection
+
+ self._doc_fetching_thread = None
+ self._doc_fetching_stop = False
+ self._doc_payload = {}
+
+ self.doc_fetched.connect(self.on_doc_fetched)
+
+ self.refresh()
+
+ def _add_hierarchy(self, assets, parent=None, silos=None):
+ """Add the assets that are related to the parent as children items.
+
+ This method does *not* query the database. These instead are queried
+ in a single batch upfront as an optimization to reduce database
+ queries. Resulting in up to 10x speed increase.
+
+ Args:
+ assets (dict): All assets in the currently active silo stored
+ by key/value
+
+ Returns:
+ None
+
+ """
+ # Reset colors
+ self.asset_colors = {}
+
+ if silos:
+ # WARNING: Silo item "_id" is set to silo value
+ # mainly because GUI issue with perserve selection and expanded row
+ # and because of easier hierarchy parenting (in "assets")
+ for silo in silos:
+ item = Item({
+ "_id": silo,
+ "name": silo,
+ "label": silo,
+ "type": "silo"
+ })
+ self.add_child(item, parent=parent)
+ self._add_hierarchy(assets, parent=item)
+
+ parent_id = parent["_id"] if parent else None
+ current_assets = assets.get(parent_id, list())
+
+ for asset in current_assets:
+ # get label from data, otherwise use name
+ data = asset.get("data", {})
+ label = data.get("label", asset["name"])
+ tags = data.get("tags", [])
+
+ # store for the asset for optimization
+ deprecated = "deprecated" in tags
+
+ item = Item({
+ "_id": asset["_id"],
+ "name": asset["name"],
+ "label": label,
+ "type": asset["type"],
+ "tags": ", ".join(tags),
+ "deprecated": deprecated,
+ "_document": asset
+ })
+ self.add_child(item, parent=parent)
+
+ # Add asset's children recursively if it has children
+ if asset["_id"] in assets:
+ self._add_hierarchy(assets, parent=item)
+
+ self.asset_colors[asset["_id"]] = []
+
+ def on_doc_fetched(self, was_stopped):
+ if was_stopped:
+ self.stop_fetch_thread()
+ return
+
+ self.beginResetModel()
+
+ assets_by_parent = self._doc_payload.get("assets_by_parent")
+ silos = self._doc_payload.get("silos")
+ if assets_by_parent is not None:
+ # Build the hierarchical tree items recursively
+ self._add_hierarchy(
+ assets_by_parent,
+ parent=None,
+ silos=silos
+ )
+
+ self.endResetModel()
+
+ has_content = bool(assets_by_parent) or bool(silos)
+ self.refreshed.emit(has_content)
+
+ self.stop_fetch_thread()
+
+ def fetch(self):
+ self._doc_payload = self._fetch() or {}
+ # Emit doc fetched only if was not stopped
+ self.doc_fetched.emit(self._doc_fetching_stop)
+
+ def _fetch(self):
+ if not self.dbcon.Session.get("AVALON_PROJECT"):
+ return
+
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ {"_id": True}
+ )
+ if not project_doc:
+ return
+
+ # Get all assets sorted by name
+ db_assets = self.dbcon.find(
+ {"type": "asset"},
+ self.asset_projection
+ ).sort("name", 1)
+
+ # Group the assets by their visual parent's id
+ assets_by_parent = collections.defaultdict(list)
+ for asset in db_assets:
+ if self._doc_fetching_stop:
+ return
+ parent_id = asset.get("data", {}).get("visualParent")
+ assets_by_parent[parent_id].append(asset)
+
+ return {
+ "assets_by_parent": assets_by_parent,
+ "silos": None
+ }
+
+ def stop_fetch_thread(self):
+ if self._doc_fetching_thread is not None:
+ self._doc_fetching_stop = True
+ while self._doc_fetching_thread.isRunning():
+ time.sleep(0.001)
+ self._doc_fetching_thread = None
+
+ def refresh(self, force=False):
+ """Refresh the data for the model."""
+ # Skip fetch if there is already other thread fetching documents
+ if self._doc_fetching_thread is not None:
+ if not force:
+ return
+ self.stop_fetch_thread()
+
+ # Clear model items
+ self.clear()
+
+ # Fetch documents from mongo
+ # Restart payload
+ self._doc_payload = {}
+ self._doc_fetching_stop = False
+ self._doc_fetching_thread = lib.create_qthread(self.fetch)
+ self._doc_fetching_thread.start()
+
+ def flags(self, index):
+ return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+
+ def setData(self, index, value, role=QtCore.Qt.EditRole):
+ if not index.isValid():
+ return False
+
+ if role == self.subsetColorsRole:
+ asset_id = index.data(self.ObjectIdRole)
+ self.asset_colors[asset_id] = value
+
+ if Qt.__binding__ in ("PyQt4", "PySide"):
+ self.dataChanged.emit(index, index)
+ else:
+ self.dataChanged.emit(index, index, [role])
+
+ return True
+
+ return super(AssetModel, self).setData(index, value, role)
+
+ def data(self, index, role):
+ if not index.isValid():
+ return
+
+ item = index.internalPointer()
+ if role == QtCore.Qt.DecorationRole:
+ column = index.column()
+ if column == self.Name:
+ # Allow a custom icon and custom icon color to be defined
+ data = item.get("_document", {}).get("data", {})
+ icon = data.get("icon", None)
+ if icon is None and item.get("type") == "silo":
+ icon = "database"
+ color = data.get("color", style.colors.default)
+
+ if icon is None:
+ # Use default icons if no custom one is specified.
+ # If it has children show a full folder, otherwise
+ # show an open folder
+ has_children = self.rowCount(index) > 0
+ icon = "folder" if has_children else "folder-o"
+
+ # Make the color darker when the asset is deprecated
+ if item.get("deprecated", False):
+ color = QtGui.QColor(color).darker(250)
+
+ try:
+ key = "fa.{0}".format(icon) # font-awesome key
+ icon = qtawesome.icon(key, color=color)
+ return icon
+ except Exception as exception:
+ # Log an error message instead of erroring out completely
+ # when the icon couldn't be created (e.g. invalid name)
+ log.error(exception)
+
+ return
+
+ if role == QtCore.Qt.ForegroundRole: # font color
+ if "deprecated" in item.get("tags", []):
+ return QtGui.QColor(style.colors.light).darker(250)
+
+ if role == self.ObjectIdRole:
+ return item.get("_id", None)
+
+ if role == self.DocumentRole:
+ return item.get("_document", None)
+
+ if role == self.subsetColorsRole:
+ asset_id = item.get("_id", None)
+ return self.asset_colors.get(asset_id) or []
+
+ return super(AssetModel, self).data(index, role)
+
+
+class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
+ """Filters to the regex if any of the children matches allow parent"""
+ def filterAcceptsRow(self, row, parent):
+ regex = self.filterRegExp()
+ if not regex.isEmpty():
+ pattern = regex.pattern()
+ model = self.sourceModel()
+ source_index = model.index(row, self.filterKeyColumn(), parent)
+ if source_index.isValid():
+ # Check current index itself
+ key = model.data(source_index, self.filterRole())
+ if re.search(pattern, key, re.IGNORECASE):
+ return True
+
+ # Check children
+ rows = model.rowCount(source_index)
+ for i in range(rows):
+ if self.filterAcceptsRow(i, source_index):
+ return True
+
+ # Otherwise filter it
+ return False
+
+ return super(
+ RecursiveSortFilterProxyModel, self
+ ).filterAcceptsRow(row, parent)
diff --git a/openpype/tools/utils/views.py b/openpype/tools/utils/views.py
new file mode 100644
index 0000000000..bed5655647
--- /dev/null
+++ b/openpype/tools/utils/views.py
@@ -0,0 +1,86 @@
+import os
+from avalon import style
+from Qt import QtWidgets, QtCore, QtGui, QtSvg
+
+
+class DeselectableTreeView(QtWidgets.QTreeView):
+ """A tree view that deselects on clicking on an empty area in the view"""
+
+ def mousePressEvent(self, event):
+
+ index = self.indexAt(event.pos())
+ if not index.isValid():
+ # clear the selection
+ self.clearSelection()
+ # clear the current index
+ self.setCurrentIndex(QtCore.QModelIndex())
+
+ QtWidgets.QTreeView.mousePressEvent(self, event)
+
+
+class TreeViewSpinner(QtWidgets.QTreeView):
+ size = 160
+
+ def __init__(self, parent=None):
+ super(TreeViewSpinner, self).__init__(parent=parent)
+
+ loading_image_path = os.path.join(
+ os.path.dirname(os.path.abspath(style.__file__)),
+ "svg",
+ "spinner-200.svg"
+ )
+ self.spinner = QtSvg.QSvgRenderer(loading_image_path)
+
+ self.is_loading = False
+ self.is_empty = True
+
+ def paint_loading(self, event):
+ rect = event.rect()
+ rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
+ rect.moveTo(
+ rect.x() + rect.width() / 2 - self.size / 2,
+ rect.y() + rect.height() / 2 - self.size / 2
+ )
+ rect.setSize(QtCore.QSizeF(self.size, self.size))
+ painter = QtGui.QPainter(self.viewport())
+ self.spinner.render(painter, rect)
+
+ def paint_empty(self, event):
+ painter = QtGui.QPainter(self.viewport())
+ rect = event.rect()
+ rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
+ qtext_opt = QtGui.QTextOption(
+ QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter
+ )
+ painter.drawText(rect, "No Data", qtext_opt)
+
+ def paintEvent(self, event):
+ if self.is_loading:
+ self.paint_loading(event)
+ elif self.is_empty:
+ self.paint_empty(event)
+ else:
+ super(TreeViewSpinner, self).paintEvent(event)
+
+
+class AssetsView(TreeViewSpinner, DeselectableTreeView):
+ """Item view.
+ This implements a context menu.
+ """
+
+ def __init__(self):
+ super(AssetsView, self).__init__()
+ self.setIndentation(15)
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ self.setHeaderHidden(True)
+
+ def mousePressEvent(self, event):
+ index = self.indexAt(event.pos())
+ if not index.isValid():
+ modifiers = QtWidgets.QApplication.keyboardModifiers()
+ if modifiers == QtCore.Qt.ShiftModifier:
+ return
+ elif modifiers == QtCore.Qt.ControlModifier:
+ return
+
+ super(AssetsView, self).mousePressEvent(event)
diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py
new file mode 100644
index 0000000000..b9b542c123
--- /dev/null
+++ b/openpype/tools/utils/widgets.py
@@ -0,0 +1,499 @@
+import logging
+import time
+
+from . import lib
+
+from Qt import QtWidgets, QtCore, QtGui
+from avalon.vendor import qtawesome, qargparse
+
+from avalon import style
+
+from .models import AssetModel, RecursiveSortFilterProxyModel
+from .views import AssetsView
+from .delegates import AssetDelegate
+
+log = logging.getLogger(__name__)
+
+
+class AssetWidget(QtWidgets.QWidget):
+ """A Widget to display a tree of assets with filter
+
+ To list the assets of the active project:
+ >>> # widget = AssetWidget()
+ >>> # widget.refresh()
+ >>> # widget.show()
+
+ """
+
+ refresh_triggered = QtCore.Signal() # on model refresh
+ refreshed = QtCore.Signal()
+ selection_changed = QtCore.Signal() # on view selection change
+ current_changed = QtCore.Signal() # on view current index change
+
+ def __init__(self, dbcon, multiselection=False, parent=None):
+ super(AssetWidget, self).__init__(parent=parent)
+
+ self.dbcon = dbcon
+
+ self.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(4)
+
+ # Tree View
+ model = AssetModel(dbcon=self.dbcon, parent=self)
+ proxy = RecursiveSortFilterProxyModel()
+ proxy.setSourceModel(model)
+ proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ view = AssetsView()
+ view.setModel(proxy)
+ if multiselection:
+ asset_delegate = AssetDelegate()
+ view.setSelectionMode(view.ExtendedSelection)
+ view.setItemDelegate(asset_delegate)
+
+ # Header
+ header = QtWidgets.QHBoxLayout()
+
+ icon = qtawesome.icon("fa.arrow-down", color=style.colors.light)
+ set_current_asset_btn = QtWidgets.QPushButton(icon, "")
+ set_current_asset_btn.setToolTip("Go to Asset from current Session")
+ # Hide by default
+ set_current_asset_btn.setVisible(False)
+
+ icon = qtawesome.icon("fa.refresh", color=style.colors.light)
+ refresh = QtWidgets.QPushButton(icon, "")
+ refresh.setToolTip("Refresh items")
+
+ filter = QtWidgets.QLineEdit()
+ filter.textChanged.connect(proxy.setFilterFixedString)
+ filter.setPlaceholderText("Filter assets..")
+
+ header.addWidget(filter)
+ header.addWidget(set_current_asset_btn)
+ header.addWidget(refresh)
+
+ # Layout
+ layout.addLayout(header)
+ layout.addWidget(view)
+
+ # Signals/Slots
+ selection = view.selectionModel()
+ selection.selectionChanged.connect(self.selection_changed)
+ selection.currentChanged.connect(self.current_changed)
+ refresh.clicked.connect(self.refresh)
+ set_current_asset_btn.clicked.connect(self.set_current_session_asset)
+
+ self.set_current_asset_btn = set_current_asset_btn
+ self.model = model
+ self.proxy = proxy
+ self.view = view
+
+ self.model_selection = {}
+
+ def set_current_asset_btn_visibility(self, visible=None):
+ """Hide set current asset button.
+
+ Not all tools support using of current context asset.
+ """
+ if visible is None:
+ visible = not self.set_current_asset_btn.isVisible()
+ self.set_current_asset_btn.setVisible(visible)
+
+ def _refresh_model(self):
+ # Store selection
+ self._store_model_selection()
+ time_start = time.time()
+
+ self.set_loading_state(
+ loading=True,
+ empty=True
+ )
+
+ def on_refreshed(has_item):
+ self.set_loading_state(loading=False, empty=not has_item)
+ self._restore_model_selection()
+ self.model.refreshed.disconnect()
+ self.refreshed.emit()
+ print("Duration: %.3fs" % (time.time() - time_start))
+
+ # Connect to signal
+ self.model.refreshed.connect(on_refreshed)
+ # Trigger signal before refresh is called
+ self.refresh_triggered.emit()
+ # Refresh model
+ self.model.refresh()
+
+ def refresh(self):
+ self._refresh_model()
+
+ def get_active_asset(self):
+ """Return the asset item of the current selection."""
+ current = self.view.currentIndex()
+ return current.data(self.model.ItemRole)
+
+ def get_active_asset_document(self):
+ """Return the asset document of the current selection."""
+ current = self.view.currentIndex()
+ return current.data(self.model.DocumentRole)
+
+ def get_active_index(self):
+ return self.view.currentIndex()
+
+ def get_selected_assets(self):
+ """Return the documents of selected assets."""
+ selection = self.view.selectionModel()
+ rows = selection.selectedRows()
+ assets = [row.data(self.model.DocumentRole) for row in rows]
+
+ # NOTE: skip None object assumed they are silo (backwards comp.)
+ return [asset for asset in assets if asset]
+
+ def select_assets(self, assets, expand=True, key="name"):
+ """Select assets by item key.
+
+ Args:
+ assets (list): List of asset values that can be found under
+ specified `key`
+ expand (bool): Whether to also expand to the asset in the view
+ key (string): Key that specifies where to look for `assets` values
+
+ Returns:
+ None
+
+ Default `key` is "name" in that case `assets` should contain single
+ asset name or list of asset names. (It is good idea to use "_id" key
+ instead of name in that case `assets` must contain `ObjectId` object/s)
+ It is expected that each value in `assets` will be found only once.
+ If the filters according to the `key` and `assets` correspond to
+ the more asset, only the first found will be selected.
+
+ """
+
+ if not isinstance(assets, (tuple, list)):
+ assets = [assets]
+
+ # convert to list - tuple cant be modified
+ assets = set(assets)
+
+ # Clear selection
+ selection_model = self.view.selectionModel()
+ selection_model.clearSelection()
+
+ # Select
+ mode = selection_model.Select | selection_model.Rows
+ for index in lib.iter_model_rows(
+ self.proxy, column=0, include_root=False
+ ):
+ # stop iteration if there are no assets to process
+ if not assets:
+ break
+
+ value = index.data(self.model.ItemRole).get(key)
+ if value not in assets:
+ continue
+
+ # Remove processed asset
+ assets.discard(value)
+
+ selection_model.select(index, mode)
+ if expand:
+ # Expand parent index
+ self.view.expand(self.proxy.parent(index))
+
+ # Set the currently active index
+ self.view.setCurrentIndex(index)
+
+ def set_loading_state(self, loading, empty):
+ if self.view.is_loading != loading:
+ if loading:
+ self.view.spinner.repaintNeeded.connect(
+ self.view.viewport().update
+ )
+ else:
+ self.view.spinner.repaintNeeded.disconnect()
+
+ self.view.is_loading = loading
+ self.view.is_empty = empty
+
+ def _store_model_selection(self):
+ index = self.view.currentIndex()
+ current = None
+ if index and index.isValid():
+ current = index.data(self.model.ObjectIdRole)
+
+ expanded = set()
+ model = self.view.model()
+ for index in lib.iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ if self.view.isExpanded(index):
+ value = index.data(self.model.ObjectIdRole)
+ expanded.add(value)
+
+ selection_model = self.view.selectionModel()
+
+ selected = None
+ selected_rows = selection_model.selectedRows()
+ if selected_rows:
+ selected = set(
+ row.data(self.model.ObjectIdRole)
+ for row in selected_rows
+ )
+
+ self.model_selection = {
+ "expanded": expanded,
+ "selected": selected,
+ "current": current
+ }
+
+ def _restore_model_selection(self):
+ model = self.view.model()
+ not_set = object()
+ expanded = self.model_selection.pop("expanded", not_set)
+ selected = self.model_selection.pop("selected", not_set)
+ current = self.model_selection.pop("current", not_set)
+
+ if (
+ expanded is not_set
+ or selected is not_set
+ or current is not_set
+ ):
+ return
+
+ if expanded:
+ for index in lib.iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ is_expanded = index.data(self.model.ObjectIdRole) in expanded
+ self.view.setExpanded(index, is_expanded)
+
+ if not selected and not current:
+ self.set_current_session_asset()
+ return
+
+ current_index = None
+ selected_indexes = []
+ # Go through all indices, select the ones with similar data
+ for index in lib.iter_model_rows(
+ model, column=0, include_root=False
+ ):
+ object_id = index.data(self.model.ObjectIdRole)
+ if object_id in selected:
+ selected_indexes.append(index)
+
+ if not current_index and object_id == current:
+ current_index = index
+
+ if current_index:
+ self.view.setCurrentIndex(current_index)
+
+ if not selected_indexes:
+ return
+ selection_model = self.view.selectionModel()
+ flags = selection_model.Select | selection_model.Rows
+ for index in selected_indexes:
+ # Ensure item is visible
+ self.view.scrollTo(index)
+ selection_model.select(index, flags)
+
+ def set_current_session_asset(self):
+ asset_name = self.dbcon.Session.get("AVALON_ASSET")
+ if asset_name:
+ self.select_assets([asset_name])
+
+
+class OptionalMenu(QtWidgets.QMenu):
+ """A subclass of `QtWidgets.QMenu` to work with `OptionalAction`
+
+ This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and
+ `leaveEvent` to provide better action hightlighting and triggering for
+ actions that were instances of `QtWidgets.QWidgetAction`.
+
+ """
+
+ def mouseReleaseEvent(self, event):
+ """Emit option clicked signal if mouse released on it"""
+ active = self.actionAt(event.pos())
+ if active and active.use_option:
+ option = active.widget.option
+ if option.is_hovered(event.globalPos()):
+ option.clicked.emit()
+ super(OptionalMenu, self).mouseReleaseEvent(event)
+
+ def mouseMoveEvent(self, event):
+ """Add highlight to active action"""
+ active = self.actionAt(event.pos())
+ for action in self.actions():
+ action.set_highlight(action is active, event.globalPos())
+ super(OptionalMenu, self).mouseMoveEvent(event)
+
+ def leaveEvent(self, event):
+ """Remove highlight from all actions"""
+ for action in self.actions():
+ action.set_highlight(False)
+ super(OptionalMenu, self).leaveEvent(event)
+
+
+class OptionalAction(QtWidgets.QWidgetAction):
+ """Menu action with option box
+
+ A menu action like Maya's menu item with option box, implemented by
+ subclassing `QtWidgets.QWidgetAction`.
+
+ """
+
+ def __init__(self, label, icon, use_option, parent):
+ super(OptionalAction, self).__init__(parent)
+ self.label = label
+ self.icon = icon
+ self.use_option = use_option
+ self.option_tip = ""
+ self.optioned = False
+
+ def createWidget(self, parent):
+ widget = OptionalActionWidget(self.label, parent)
+ self.widget = widget
+
+ if self.icon:
+ widget.setIcon(self.icon)
+
+ if self.use_option:
+ widget.option.clicked.connect(self.on_option)
+ widget.option.setToolTip(self.option_tip)
+ else:
+ widget.option.setVisible(False)
+
+ return widget
+
+ def set_option_tip(self, options):
+ sep = "\n\n"
+ mak = (lambda opt: opt["name"] + " :\n " + opt["help"])
+ self.option_tip = sep.join(mak(opt) for opt in options)
+
+ def on_option(self):
+ self.optioned = True
+
+ def set_highlight(self, state, global_pos=None):
+ body = self.widget.body
+ option = self.widget.option
+
+ role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window
+ body.setBackgroundRole(role)
+ body.setAutoFillBackground(state)
+
+ if not self.use_option:
+ return
+
+ state = option.is_hovered(global_pos)
+ role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window
+ option.setBackgroundRole(role)
+ option.setAutoFillBackground(state)
+
+
+class OptionalActionWidget(QtWidgets.QWidget):
+ """Main widget class for `OptionalAction`"""
+
+ def __init__(self, label, parent=None):
+ super(OptionalActionWidget, self).__init__(parent)
+
+ body = QtWidgets.QWidget()
+ body.setStyleSheet("background: transparent;")
+
+ icon = QtWidgets.QLabel()
+ label = QtWidgets.QLabel(label)
+ option = OptionBox(body)
+
+ icon.setFixedSize(24, 16)
+ option.setFixedSize(30, 30)
+
+ layout = QtWidgets.QHBoxLayout(body)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ layout.addWidget(icon)
+ layout.addWidget(label)
+ layout.addSpacing(6)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(6, 1, 2, 1)
+ layout.setSpacing(0)
+ layout.addWidget(body)
+ layout.addWidget(option)
+
+ body.setMouseTracking(True)
+ label.setMouseTracking(True)
+ option.setMouseTracking(True)
+ self.setMouseTracking(True)
+ self.setFixedHeight(32)
+
+ self.icon = icon
+ self.label = label
+ self.option = option
+ self.body = body
+
+ # (NOTE) For removing ugly QLable shadow FX when highlighted in Nuke.
+ # See https://stackoverflow.com/q/52838690/4145300
+ label.setStyle(QtWidgets.QStyleFactory.create("Plastique"))
+
+ def setIcon(self, icon):
+ pixmap = icon.pixmap(16, 16)
+ self.icon.setPixmap(pixmap)
+
+
+class OptionBox(QtWidgets.QLabel):
+ """Option box widget class for `OptionalActionWidget`"""
+
+ clicked = QtCore.Signal()
+
+ def __init__(self, parent):
+ super(OptionBox, self).__init__(parent)
+
+ self.setAlignment(QtCore.Qt.AlignCenter)
+
+ icon = qtawesome.icon("fa.sticky-note-o", color="#c6c6c6")
+ pixmap = icon.pixmap(18, 18)
+ self.setPixmap(pixmap)
+
+ self.setStyleSheet("background: transparent;")
+
+ def is_hovered(self, global_pos):
+ if global_pos is None:
+ return False
+ pos = self.mapFromGlobal(global_pos)
+ return self.rect().contains(pos)
+
+
+class OptionDialog(QtWidgets.QDialog):
+ """Option dialog shown by option box"""
+
+ def __init__(self, parent=None):
+ super(OptionDialog, self).__init__(parent)
+ self.setModal(True)
+ self._options = dict()
+
+ def create(self, options):
+ parser = qargparse.QArgumentParser(arguments=options)
+
+ decision = QtWidgets.QWidget()
+ accept = QtWidgets.QPushButton("Accept")
+ cancel = QtWidgets.QPushButton("Cancel")
+
+ layout = QtWidgets.QHBoxLayout(decision)
+ layout.addWidget(accept)
+ layout.addWidget(cancel)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(parser)
+ layout.addWidget(decision)
+
+ accept.clicked.connect(self.accept)
+ cancel.clicked.connect(self.reject)
+ parser.changed.connect(self.on_changed)
+
+ def on_changed(self, argument):
+ self._options[argument["name"]] = argument.read()
+
+ def parse(self):
+ return self._options.copy()
diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py
index 3d2633f8dc..6fff0d0278 100644
--- a/openpype/tools/workfiles/app.py
+++ b/openpype/tools/workfiles/app.py
@@ -376,6 +376,9 @@ class TasksWidget(QtWidgets.QWidget):
task (str): Name of the task to select.
"""
+ task_view_model = self._tasks_view.model()
+ if not task_view_model:
+ return
# Clear selection
selection_model = self._tasks_view.selectionModel()
@@ -383,8 +386,8 @@ class TasksWidget(QtWidgets.QWidget):
# Select the task
mode = selection_model.Select | selection_model.Rows
- for row in range(self._tasks_model.rowCount()):
- index = self._tasks_model.index(row, 0)
+ for row in range(task_view_model.rowCount()):
+ index = task_view_model.index(row, 0)
name = index.data(TASK_NAME_ROLE)
if name == task_name:
selection_model.select(index, mode)
diff --git a/openpype/version.py b/openpype/version.py
index 17bd0ff892..f8ed9c7c2f 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.4.0-nightly.4"
+__version__ = "3.5.0-nightly.1"
diff --git a/repos/avalon-core b/repos/avalon-core
index b3e4959778..1e94241ffe 160000
--- a/repos/avalon-core
+++ b/repos/avalon-core
@@ -1 +1 @@
-Subproject commit b3e49597786c931c13bca207769727d5fc56d5f6
+Subproject commit 1e94241ffe2dd7ce65ca66b08e452ffc03180235
diff --git a/tools/ci_tools.py b/tools/ci_tools.py
index 3c1aaae991..69f5158bb3 100644
--- a/tools/ci_tools.py
+++ b/tools/ci_tools.py
@@ -3,7 +3,31 @@ import sys
from semver import VersionInfo
from git import Repo
from optparse import OptionParser
+from github import Github
+import os
+def get_release_type_github(Log, github_token):
+ # print(Log)
+ minor_labels = ["type: feature", "type: deprecated"]
+ patch_labels = ["type: enhancement", "type: bug"]
+
+ g = Github(github_token)
+ repo = g.get_repo("pypeclub/OpenPype")
+
+ for line in Log.splitlines():
+ print(line)
+ match = re.search("pull request #(\d+)", line)
+ if match:
+ pr_number = match.group(1)
+ pr = repo.get_pull(int(pr_number))
+ for label in pr.labels:
+ print(label.name)
+ if label.name in minor_labels:
+ return ("minor")
+ elif label.name in patch_labels:
+ return("patch")
+ return None
+
def remove_prefix(text, prefix):
return text[text.startswith(prefix) and len(prefix):]
@@ -36,7 +60,7 @@ def get_log_since_tag(version):
def release_type(log):
regex_minor = ["feature/", "(feat)"]
- regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/"]
+ regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/", "update"]
for reg in regex_minor:
if re.search(reg, log):
return "minor"
@@ -69,7 +93,7 @@ def bump_file_versions(version):
file_regex_replace(filename, regex, pyproject_version)
-def calculate_next_nightly(token="nightly"):
+def calculate_next_nightly(type="nightly", github_token=None):
last_prerelease, last_pre_tag = get_last_version("CI")
last_pre_v = VersionInfo.parse(last_prerelease)
last_pre_v_finalized = last_pre_v.finalize_version()
@@ -78,7 +102,10 @@ def calculate_next_nightly(token="nightly"):
last_release, last_release_tag = get_last_version("release")
last_release_v = VersionInfo.parse(last_release)
- bump_type = release_type(get_log_since_tag(last_release))
+ bump_type = get_release_type_github(
+ get_log_since_tag(last_release_tag),
+ github_token
+ )
if not bump_type:
return None
@@ -86,10 +113,10 @@ def calculate_next_nightly(token="nightly"):
# print(next_release_v)
if next_release_v > last_pre_v_finalized:
- next_tag = next_release_v.bump_prerelease(token=token).__str__()
+ next_tag = next_release_v.bump_prerelease(token=type).__str__()
return next_tag
elif next_release_v == last_pre_v_finalized:
- next_tag = last_pre_v.bump_prerelease(token=token).__str__()
+ next_tag = last_pre_v.bump_prerelease(token=type).__str__()
return next_tag
def finalize_latest_nightly():
@@ -125,30 +152,36 @@ def main():
help="finalize latest prerelease to a release")
parser.add_option("-p", "--prerelease",
dest="prerelease", action="store",
- help="define prerelease token")
+ help="define prerelease type")
parser.add_option("-f", "--finalize",
dest="finalize", action="store",
- help="define prerelease token")
+ help="define prerelease type")
parser.add_option("-v", "--version",
dest="version", action="store",
help="work with explicit version")
parser.add_option("-l", "--lastversion",
dest="lastversion", action="store",
help="work with explicit version")
+ parser.add_option("-g", "--github_token",
+ dest="github_token", action="store",
+ help="github token")
(options, args) = parser.parse_args()
if options.bump:
- last_CI, last_CI_tag = get_last_version("CI")
last_release, last_release_tag = get_last_version("release")
- bump_type_CI = release_type(get_log_since_tag(last_CI_tag))
- bump_type_release = release_type(get_log_since_tag(last_release_tag))
- if bump_type_CI is None or bump_type_release is None:
+ bump_type_release = get_release_type_github(
+ get_log_since_tag(last_release_tag),
+ options.github_token
+ )
+ if bump_type_release is None:
print("skip")
+ else:
+ print(bump_type_release)
if options.nightly:
- next_tag_v = calculate_next_nightly()
+ next_tag_v = calculate_next_nightly(github_token=options.github_token)
print(next_tag_v)
bump_file_versions(next_tag_v)
diff --git a/website/docs/artist_hosts_nuke_tut.md b/website/docs/artist_hosts_nuke_tut.md
new file mode 100644
index 0000000000..4d116bd958
--- /dev/null
+++ b/website/docs/artist_hosts_nuke_tut.md
@@ -0,0 +1,337 @@
+---
+id: artist_hosts_nuke_tut
+title: Nuke
+sidebar_label: Nuke
+---
+
+:::note
+OpenPype supports Nuke version **`11.0`** and above.
+:::
+
+## OpenPype global tools
+
+- [Set Context](artist_tools.md#set-context)
+- [Work Files](artist_tools.md#workfiles)
+- [Create](artist_tools.md#creator)
+- [Load](artist_tools.md#loader)
+- [Manage (Inventory)](artist_tools.md#inventory)
+- [Publish](artist_tools.md#publisher)
+- [Library Loader](artist_tools.md#library-loader)
+
+## Nuke specific tools
+
+
+
+
+### Set Frame Ranges
+
+Use this feature in case you are not sure the frame range is correct.
+
+##### Result
+
+- setting Frame Range in script settings
+- setting Frame Range in viewers (timeline)
+
+
+
+
+
+
+
+
+
+
+1. limiting to Frame Range without handles
+2. **Input** handle on start
+3. **Output** handle on end
+
+
+
+
+### Set Resolution
+
+
+
+
+
+This menu item will set correct resolution format for you defined by your production.
+
+##### Result
+
+- creates new item in formats with project name
+- sets the new format as used
+
+
+
+This menu item will set correct Colorspace definitions for you. All has to be configured by your production (Project coordinator).
+
+##### Result
+
+- set Colorspace in your script settings
+- set preview LUT to your viewers
+- set correct colorspace to all discovered Read nodes (following expression set in settings)
+
+
+
+It is usually enough if you once per while use this option just to make yourself sure the workfile is having set correct properties.
+
+##### Result
+
+- set Frame Ranges
+- set Colorspace
+- set Resolution
+
+
+
+
+
+
+
+
+
+### Build Workfile
+
+
+
+
+This tool will append all available subsets into an actual node graph. It will look into database and get all last [versions](artist_concepts.md#version) of available [subsets](artist_concepts.md#subset).
+
+
+##### Result
+
+- adds all last versions of subsets (rendered image sequences) as read nodes
+- ~~adds publishable write node as `renderMain` subset~~
+
+
+
+
+
+
+
+
+
+## Nuke QuickStart
+
+This QuickStart is short introduction to what OpenPype can do for you. It attempts to make an overview for compositing artists, and simplifies processes that are better described in specific parts of the documentation.
+
+### Launch Nuke - Shot and Task Context
+OpenPype has to know what shot and task you are working on. You need to run Nuke in context of the task, using Ftrack Action or OpenPype Launcher to select the task and run Nuke.
+
+
+
+
+:::tip Admin Tip - Nuke version
+You can [configure](admin_settings_project_anatomy.md#Attributes) which DCC version(s) will be available for current project in **Studio Settings β Project β Anatomy β Attributes β Applications**
+:::
+
+### Nuke Initial setup
+Nuke OpenPype menu shows the current context
+
+
+
+Launching Nuke with context stops your timer, and starts the clock on the shot and task you picked.
+
+Openpype makes initial setup for your Nuke script. It is the same as running [Apply All Settings](artist_hosts_nuke.md#apply-all-settings) from the OpenPype menu.
+
+- Reads frame range and resolution from Avalon database, sets it in Nuke Project Settings,
+Creates Viewer node, sets itβs range and indicates handles by In and Out points.
+
+- Reads Color settings from the project configuration, and sets it in Nuke Project Settings and Viewer.
+
+- Sets project directory in the Nuke Project Settings to the Nuke Script Directory
+
+:::tip Tip - Project Settings
+After Nuke starts it will automatically **Apply All Settings** for you. If you are sure the settings are wrong just contact your supervisor and he will set them correctly for you in project database.
+:::
+
+### Save Nuke script β the Work File
+Use OpenPype - Work files menu to create a new Nuke script. Openpype offers you the preconfigured naming.
+
+
+The Next Available Version checks the work folder for already used versions and offers the lowest unused version number automatically.
+
+Subversion can be used to distinguish or name versions. For example used to add shortened artist name.
+
+More about [workfiles](artist_tools#workfiles).
+
+
+:::tip Admin Tips
+- **Workfile Naming**
+
+ - The [workfile naming](admin_settings_project_anatomy#templates) is configured in anatomy, see **Studio Settings β Project β Anatomy β Templates β Work**
+
+- **Open Workfile**
+
+ - You can [configure](project_settings/settings_project_nuke#create-first-workfile) Nuke to automatically open the last version, or create a file on startup. See **Studio Settings β Project β Global β Tools β Workfiles**
+
+- **Nuke Color Settings**
+
+ - [Color setting](project_settings/settings_project_nuke) for Nuke can be found in **Studio Settings β Project β Anatomy β Color Management and Output Formats β Nuke**
+:::
+
+### Load plate
+Use Load from OpenPype menu to load any plates or renders available.
+
+
+
+Pick the plate asset, right click and choose Load Image Sequence to create a Read node in Nuke.
+
+Note that the Read node created by OpenPype is green. Green color indicates the highest version of asset is loaded. Asset versions could be easily changed by [Manage](#managing-versions). Lower versions will be highlighted by orange color on the read node.
+
+
+
+More about [Asset loader](artist_tools#loader).
+
+### Create Write Node
+To create OpenPype managed Write node, select the Read node you just created, from OpenPype menu, pick Create.
+In the Instance Creator, pick Create Write Render, and Create.
+
+
+
+This will create a Group with a Write node inside.
+
+
+
+:::tip Admin Tip - Configuring write node
+You can configure write node parameters in **Studio Settings β Project β Anatomy β Color Management and Output Formats β Nuke β Nodes**
+:::
+
+#### What Nuke Publish Does
+From Artist perspective, Nuke publish gathers all the stuff found in the Nuke script with Publish checkbox set to on, exports stuff and raises the Nuke script (workfile) version.
+
+The Pyblish dialog shows the progress of the process.
+
+The left column of the dialog shows what will be published. Typically it is one or more renders or prerenders, plus work file.
+
+
+
+The right column shows the publish steps
+
+##### Publish steps
+1. Gathers all the stuff found in the Nuke script with Publish checkbox set to on
+2. Collects all the info (from the script, databaseβ¦)
+3. Validates components to be published (checks render range and resolution...)
+4. Extracts data from the script
+ - generates thumbnail
+ - creates review(s) like h264
+ - adds burnins to review(s)
+ - Copies and renames components like render(s), review(s), Nuke script... to publish folder
+5. Integrates components (writes to database, sends preview of the render to Ftrack ...
+6. Increments Nuke script version, cleans up the render directory
+
+Gathering all the info and validating usually takes just a few seconds. Creating reviews for long, high resolution shots can however take significant amount of time when publishing locally.
+
+##### Pyblish Note and Intent
+
+
+Artist can add Note and Intent before firing the publish button. The Note and Intent is ment for easy communication between artist and supervisor. After publish, Note and Intent can be seen in Ftrack notes.
+
+##### Pyblish Checkbox
+
+
+
+Pyblish Dialog tries to pack a lot of info in a small area. One of the more tricky parts is that it uses non-standard checkboxes. Some squares can be turned on and off by the artist, some are mandatory.
+
+If you run the publish and decide to not publish the Nuke script, you can turn it off right in the Pyblish dialog by clicking on the checkbox. If you decide to render and publish the shot in lower resolution to speed up the turnaround, you have to turn off the Write Resolution validator. If you want to use an older version of the asset (older version of the plate...), you have to turn off the Validate containers, and so on.
+
+More info about [Using Pyblish](artist_tools#publisher)
+
+:::tip Admin Tip - Configuring validators
+You can configure Nuke validators like Output Resolution in **Studio Settings β Project β Nuke β Publish plugins**
+:::
+
+### Review
+
+
+When you turn the review checkbox on in your OpenPype write node, here is what happens:
+- OpenPype uses the current Nuke script to
+ - Load the render
+ - Optionally apply LUT
+ - Render Prores 4444 with the same resolution as your render
+- Use Ffmpeg to convert the Prores to whatever review(s) you defined
+- Use Ffmpeg to add (optional) burnin to the review(s) from previous step
+
+Creating reviews is a part of the publishing process. If you choose to do a local publish or to use existing frames, review will be processed also on the artist's machine.
+If you choose to publish on the farm, you will render and do reviews on the farm.
+
+So far there is no option for using existing frames (from your local / check render) and just do the review on the farm.
+
+More info about [configuring reviews](pype2/admin_presets_plugins#extractreview).
+
+:::tip Admin Tip - Configuring Reviews
+You can configure reviewsin **Studio Settings β Project β Global β Publish plugins β ExtractReview / ExtractBurnin**
+Reviews can be configured separately for each host, task, or family. For example Maya can produce different review to Nuke, animation task can have different burnin then modelling, and plate can have different review then model.
+:::
+
+### Render and Publish
+
+
+
+Letβs say you want to render and publish the shot right now, with only a Read and Write node. You need to decide if you want to render, check the render and then publish it, or you want to execute the render and publish in one go.
+
+If you wish to check your render before publishing, you can use your local machine or your farm to render the write node as you would do without OpenPype, load and check your render (OpenPype Write has a convenience button for that), and if happy, use publish with Use existing frames option selected in the write node to generate the review on your local machine.
+
+If you want to render and publish on the farm in one go, run publish with On farm option selected in the write node to render and make the review on farm.
+
+
+
+### Version-less Render
+
+
+
+OpenPype is configured so your render file names have no version number until the render is fully finished and published. The main advantage is that you can keep the render from the previous version and re-render only part of the shot. With care, this is handy.
+
+Main disadvantage of this approach is that you can render only one version of your shot at one time. Otherwise you risk to partially overwrite your shot render before publishing copies and renames the rendered files to the properly versioned publish folder.
+
+When making quick farm publishes, like making two versions with different color correction, care must be taken to let the first job (first version) completely finish before the second version starts rendering.
+
+### Managing Versions
+
+
+
+OpenPype checks all the assets loaded to Nuke on script open. All out of date assets are colored orange, up to date assets are colored green.
+
+Use Manage to switch versions for loaded assets.
+
+## Troubleshooting
+
+### Fixing Validate Containers
+
+
+
+If your Pyblish dialog fails on Validate Containers, you might have an old asset loaded. Use OpenPype - Manage... to switch the asset(s) to the latest version.
+
+### Fixing Validate Version
+If your Pyblish dialog fails on Validate Version, you might be trying to publish already published version. Rise your version in the OpenPype WorkFiles SaveAs.
+
+Or maybe you accidentaly copied write node from different shot to your current one. Check the write publishes on the left side of the Pyblish dialog. Typically you publish only one write. Locate and delete the stray write from other shot.
\ No newline at end of file
diff --git a/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png b/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png
new file mode 100644
index 0000000000..92e1b4dad7
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AnatomyAppsVersions.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png b/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png
new file mode 100644
index 0000000000..f7f807a94f
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AssetLoadOutOfDate.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_AssetLoader.png b/website/docs/assets/nuke_tut/nuke_AssetLoader.png
new file mode 100644
index 0000000000..e52abdc428
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_AssetLoader.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_Context.png b/website/docs/assets/nuke_tut/nuke_Context.png
new file mode 100644
index 0000000000..65bb288764
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Context.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_Create.png b/website/docs/assets/nuke_tut/nuke_Create.png
new file mode 100644
index 0000000000..2c843c05df
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Create.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_Creator.png b/website/docs/assets/nuke_tut/nuke_Creator.png
new file mode 100644
index 0000000000..454777574a
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Creator.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_Load.png b/website/docs/assets/nuke_tut/nuke_Load.png
new file mode 100644
index 0000000000..2a345dc69f
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Load.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_ManageVersion.png b/website/docs/assets/nuke_tut/nuke_ManageVersion.png
new file mode 100644
index 0000000000..c9f2091347
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_ManageVersion.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_NukeColor.png b/website/docs/assets/nuke_tut/nuke_NukeColor.png
new file mode 100644
index 0000000000..5c4f9a15e0
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_NukeColor.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_Publish.png b/website/docs/assets/nuke_tut/nuke_Publish.png
new file mode 100644
index 0000000000..b53b6cc06c
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_Publish.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png b/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png
new file mode 100644
index 0000000000..2c5d59c9d5
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishCheckBox.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png b/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png
new file mode 100644
index 0000000000..e98a4b9553
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishDialogNuke.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png b/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png
new file mode 100644
index 0000000000..3519ecc22d
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_PyblishDialogNukeNoteIntent.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png b/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png
new file mode 100644
index 0000000000..4c4c8977a0
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RenderLocalFarm.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png
new file mode 100644
index 0000000000..75faaec572
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png
new file mode 100644
index 0000000000..27fec32ae4
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeFtrackAction_p3.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png
new file mode 100644
index 0000000000..a42ee6d7b9
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png
new file mode 100644
index 0000000000..2a36cad380
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_RunNukeLauncher_p2.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_ValidateContainers.png b/website/docs/assets/nuke_tut/nuke_ValidateContainers.png
new file mode 100644
index 0000000000..78e0f2edd7
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_ValidateContainers.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png b/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png
new file mode 100644
index 0000000000..115a321285
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkFileNamingAnatomy.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png b/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png
new file mode 100644
index 0000000000..661f44632a
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkFileSaveAs.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png b/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png
new file mode 100644
index 0000000000..450589ee3a
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WorkfileOnStartup.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WriteNode.png b/website/docs/assets/nuke_tut/nuke_WriteNode.png
new file mode 100644
index 0000000000..5ce3e81aab
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNode.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png b/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png
new file mode 100644
index 0000000000..b283593d6a
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNodeCreated.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png b/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png
new file mode 100644
index 0000000000..68651cdd6c
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteNodeReview.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_WriteSettings.png b/website/docs/assets/nuke_tut/nuke_WriteSettings.png
new file mode 100644
index 0000000000..cf00adbee6
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_WriteSettings.png differ
diff --git a/website/docs/assets/nuke_tut/nuke_versionless.png b/website/docs/assets/nuke_tut/nuke_versionless.png
new file mode 100644
index 0000000000..fbb98c55e2
Binary files /dev/null and b/website/docs/assets/nuke_tut/nuke_versionless.png differ
diff --git a/website/sidebars.js b/website/sidebars.js
index 3a4b933b9a..38e4206b84 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -18,7 +18,7 @@ module.exports = {
label: "Integrations",
items: [
"artist_hosts_hiero",
- "artist_hosts_nuke",
+ "artist_hosts_nuke_tut",
"artist_hosts_maya",
"artist_hosts_blender",
"artist_hosts_harmony",