Merge branch 'develop' of https://github.com/ynput/ayon-core into bugfix/AY-5647_Enabled-toggle-is-ignored-in-RoyalRender

This commit is contained in:
Petr Kalis 2024-06-06 12:02:31 +02:00
commit 58dfd6507a
1159 changed files with 5178 additions and 4120 deletions

View file

@ -8,7 +8,6 @@ import inspect
import logging
import threading
import collections
from uuid import uuid4
from abc import ABCMeta, abstractmethod
@ -50,11 +49,24 @@ IGNORED_MODULES_IN_AYON = set()
# When addon was moved from ayon-core codebase
# - this is used to log the missing addon
MOVED_ADDON_MILESTONE_VERSIONS = {
"aftereffects": VersionInfo(0, 2, 0),
"applications": VersionInfo(0, 2, 0),
"blender": VersionInfo(0, 2, 0),
"celaction": VersionInfo(0, 2, 0),
"clockify": VersionInfo(0, 2, 0),
"flame": VersionInfo(0, 2, 0),
"fusion": VersionInfo(0, 2, 0),
"harmony": VersionInfo(0, 2, 0),
"hiero": VersionInfo(0, 2, 0),
"max": VersionInfo(0, 2, 0),
"photoshop": VersionInfo(0, 2, 0),
"traypublisher": VersionInfo(0, 2, 0),
"tvpaint": VersionInfo(0, 2, 0),
"maya": VersionInfo(0, 2, 0),
"nuke": VersionInfo(0, 2, 0),
"resolve": VersionInfo(0, 2, 0),
"substancepainter": VersionInfo(0, 2, 0),
"houdini": VersionInfo(0, 3, 0),
}
@ -543,6 +555,9 @@ class AYONAddon(object):
enabled = True
_id = None
# Temporary variable for 'version' property
_missing_version_warned = False
def __init__(self, manager, settings):
self.manager = manager
@ -573,6 +588,26 @@ class AYONAddon(object):
pass
@property
def version(self):
"""Addon version.
Todo:
Should be abstract property (required). Introduced in
ayon-core 0.3.3 .
Returns:
str: Addon version as semver compatible string.
"""
if not self.__class__._missing_version_warned:
self.__class__._missing_version_warned = True
print(
f"DEV WARNING: Addon '{self.name}' does not have"
f" defined version."
)
return "0.0.0"
def initialize(self, settings):
"""Initialization of addon attributes.
@ -688,6 +723,30 @@ class OpenPypeAddOn(OpenPypeModule):
enabled = True
class _AddonReportInfo:
def __init__(
self, class_name, name, version, report_value_by_label
):
self.class_name = class_name
self.name = name
self.version = version
self.report_value_by_label = report_value_by_label
@classmethod
def from_addon(cls, addon, report):
class_name = addon.__class__.__name__
report_value_by_label = {
label: reported.get(class_name)
for label, reported in report.items()
}
return cls(
addon.__class__.__name__,
addon.name,
addon.version,
report_value_by_label
)
class AddonsManager:
"""Manager of addons that helps to load and prepare them to work.
@ -864,10 +923,6 @@ class AddonsManager:
name_alias = getattr(addon, "openpype_alias", None)
if name_alias:
aliased_names.append((name_alias, addon))
enabled_str = "X"
if not addon.enabled:
enabled_str = " "
self.log.debug("[{}] {}".format(enabled_str, name))
now = time.time()
report[addon.__class__.__name__] = now - prev_start_time
@ -879,6 +934,13 @@ class AddonsManager:
exc_info=True
)
for addon_name in sorted(self._addons_by_name.keys()):
addon = self._addons_by_name[addon_name]
enabled_str = "X" if addon.enabled else " "
self.log.debug(
f"[{enabled_str}] {addon.name} ({addon.version})"
)
for item in aliased_names:
name_alias, addon = item
if name_alias not in self._addons_by_name:
@ -1167,39 +1229,55 @@ class AddonsManager:
available_col_names |= set(addon_names.keys())
# Prepare ordered dictionary for columns
cols = collections.OrderedDict()
# Add addon names to first columnt
cols["Addon name"] = list(sorted(
addon.__class__.__name__
addons_info = [
_AddonReportInfo.from_addon(addon, self._report)
for addon in self.addons
if addon.__class__.__name__ in available_col_names
))
]
addons_info.sort(key=lambda x: x.name)
addon_name_rows = [
addon_info.name
for addon_info in addons_info
]
addon_version_rows = [
addon_info.version
for addon_info in addons_info
]
# Add total key (as last addon)
cols["Addon name"].append(self._report_total_key)
addon_name_rows.append(self._report_total_key)
addon_version_rows.append(f"({len(addons_info)})")
cols = collections.OrderedDict()
# Add addon names to first columnt
cols["Addon name"] = addon_name_rows
cols["Version"] = addon_version_rows
# Add columns from report
total_by_addon = {
row: 0
for row in addon_name_rows
}
for label in self._report.keys():
cols[label] = []
total_addon_times = {}
for addon_name in cols["Addon name"]:
total_addon_times[addon_name] = 0
for label, reported in self._report.items():
for addon_name in cols["Addon name"]:
col_time = reported.get(addon_name)
if col_time is None:
cols[label].append("N/A")
rows = []
col_total = 0
for addon_info in addons_info:
value = addon_info.report_value_by_label.get(label)
if value is None:
rows.append("N/A")
continue
cols[label].append("{:.3f}".format(col_time))
total_addon_times[addon_name] += col_time
rows.append("{:.3f}".format(value))
total_by_addon[addon_info.name] += value
col_total += value
total_by_addon[self._report_total_key] += col_total
rows.append("{:.3f}".format(col_total))
cols[label] = rows
# Add to also total column that should sum the row
cols[self._report_total_key] = []
for addon_name in cols["Addon name"]:
cols[self._report_total_key].append(
"{:.3f}".format(total_addon_times[addon_name])
)
cols[self._report_total_key] = [
"{:.3f}".format(total_by_addon[addon_name])
for addon_name in cols["Addon name"]
]
# Prepare column widths and total row count
# - column width is by
@ -1326,7 +1404,7 @@ class TrayAddonsManager(AddonsManager):
self.doubleclick_callback = None
def add_doubleclick_callback(self, addon, callback):
"""Register doubleclick callbacks on tray icon.
"""Register double-click callbacks on tray icon.
Currently, there is no way how to determine which is launched. Name of
callback can be defined with `doubleclick_callback` attribute.

View file

@ -1,6 +0,0 @@
from .addon import BlenderAddon
__all__ = (
"BlenderAddon",
)

View file

@ -1,10 +0,0 @@
from .addon import (
HOST_DIR,
FlameAddon,
)
__all__ = (
"HOST_DIR",
"FlameAddon",
)

View file

@ -1,10 +0,0 @@
from .addon import (
HIERO_ROOT_DIR,
HieroAddon,
)
__all__ = (
"HIERO_ROOT_DIR",
"HieroAddon",
)

View file

@ -1,6 +0,0 @@
from .addon import ResolveAddon
__all__ = (
"ResolveAddon",
)

View file

@ -7,6 +7,8 @@ import six
from ayon_core.lib import Logger
from ayon_core.modules import AYONAddon, IPluginPaths
from .version import __version__
class DeadlineWebserviceError(Exception):
"""
@ -16,6 +18,7 @@ class DeadlineWebserviceError(Exception):
class DeadlineModule(AYONAddon, IPluginPaths):
name = "deadline"
version = __version__
def initialize(self, studio_settings):
# This module is always enabled

View file

@ -79,7 +79,7 @@ class FusionSubmitDeadline(
else:
context.data[key] = True
from ayon_core.hosts.fusion.api.lib import get_frame_path
from ayon_fusion.api.lib import get_frame_path
deadline_url = instance.data["deadline"]["url"]
assert deadline_url, "Requires Deadline Webservice URL"

View file

@ -39,8 +39,8 @@ from ayon_core.lib import (
EnumDef,
is_in_tests,
)
from ayon_core.hosts.maya.api.lib_rendersettings import RenderSettings
from ayon_core.hosts.maya.api.lib import get_attr_in_layer
from ayon_maya.api.lib_rendersettings import RenderSettings
from ayon_maya.api.lib import get_attr_in_layer
from openpype_modules.deadline import abstract_submit_deadline
from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo

View file

@ -1 +1 @@
__version__ = "0.1.10"
__version__ = "0.1.12"

View file

@ -1,6 +1,9 @@
from .version import __version__
from .addon import JobQueueAddon
__all__ = (
"__version__",
"JobQueueAddon",
)

View file

@ -44,9 +44,12 @@ import platform
from ayon_core.addon import AYONAddon, click_wrap
from ayon_core.settings import get_studio_settings
from .version import __version__
class JobQueueAddon(AYONAddon):
name = "job_queue"
version = __version__
def initialize(self, studio_settings):
addon_settings = studio_settings.get(self.name) or {}

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -7,6 +7,7 @@ from ayon_core.addon import AYONAddon, ITrayAction
class LauncherAction(AYONAddon, ITrayAction):
label = "Launcher"
name = "launcher_tool"
version = "1.0.0"
def initialize(self, settings):

View file

@ -3,6 +3,7 @@ from ayon_core.addon import AYONAddon, ITrayAddon
class LoaderAddon(AYONAddon, ITrayAddon):
name = "loader_tool"
version = "1.0.0"
def initialize(self, settings):
# Tray attributes

View file

@ -4,6 +4,7 @@ from ayon_core.addon import AYONAddon, ITrayAction
class PythonInterpreterAction(AYONAddon, ITrayAction):
label = "Console"
name = "python_interpreter"
version = "1.0.0"
admin_action = True
def initialize(self, settings):

View file

@ -1,6 +1,9 @@
from .version import __version__
from .addon import RoyalRenderAddon
__all__ = (
"__version__",
"RoyalRenderAddon",
)

View file

@ -4,10 +4,13 @@ import os
from ayon_core.addon import AYONAddon, IPluginPaths
from .version import __version__
class RoyalRenderAddon(AYONAddon, IPluginPaths):
"""Class providing basic Royal Render implementation logic."""
name = "royalrender"
version = __version__
def initialize(self, studio_settings):
# type: (dict) -> None

View file

@ -0,0 +1 @@
__version__ = "0.1.1"

View file

@ -1,7 +1,10 @@
from .version import __version__
from .timers_manager import (
TimersManager
)
__all__ = (
"__version__",
"TimersManager",
)

View file

@ -10,6 +10,7 @@ from ayon_core.addon import (
)
from ayon_core.lib.events import register_event_callback
from .version import __version__
from .exceptions import InvalidContextError
TIMER_MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
@ -96,6 +97,7 @@ class TimersManager(
See `ExampleTimersManagerConnector`.
"""
name = "timers_manager"
version = __version__
label = "Timers Service"
_required_methods = (

View file

@ -0,0 +1 @@
__version__ = "0.1.1"

View file

@ -1,8 +1,13 @@
from .version import __version__
from .structures import HostMsgAction
from .webserver_module import (
WebServerAddon
)
__all__ = (
"__version__",
"HostMsgAction",
"WebServerAddon",
)

View file

@ -9,22 +9,18 @@ from qtpy import QtWidgets
from ayon_core.addon import ITrayService
from ayon_core.tools.stdout_broker.window import ConsoleDialog
from .structures import HostMsgAction
log = logging.getLogger(__name__)
# Host listener icon type
class IconType:
IDLE = "idle"
RUNNING = "running"
FAILED = "failed"
class MsgAction:
CONNECTING = "connecting"
INITIALIZED = "initialized"
ADD = "add"
CLOSE = "close"
class HostListener:
def __init__(self, webserver, module):
self._window_per_id = {}
@ -96,22 +92,22 @@ class HostListener:
if msg.type == aiohttp.WSMsgType.TEXT:
host_name, action, text = self._parse_message(msg)
if action == MsgAction.CONNECTING:
if action == HostMsgAction.CONNECTING:
self._action_per_id[host_name] = None
# must be sent to main thread, or action wont trigger
self.module.execute_in_main_thread(
lambda: self._host_is_connecting(host_name, text))
elif action == MsgAction.CLOSE:
elif action == HostMsgAction.CLOSE:
# clean close
self._close(host_name)
await ws.close()
elif action == MsgAction.INITIALIZED:
elif action == HostMsgAction.INITIALIZED:
self.module.execute_in_main_thread(
# must be queued as _host_is_connecting might not
# be triggered/finished yet
lambda: self._set_host_icon(host_name,
IconType.RUNNING))
elif action == MsgAction.ADD:
elif action == HostMsgAction.ADD:
self.module.execute_in_main_thread(
lambda: self._add_text(host_name, text))
elif msg.type == aiohttp.WSMsgType.ERROR:

View file

@ -0,0 +1,6 @@
# Host listener message actions
class HostMsgAction:
CONNECTING = "connecting"
INITIALIZED = "initialized"
ADD = "add"
CLOSE = "close"

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -26,9 +26,12 @@ import socket
from ayon_core import resources
from ayon_core.addon import AYONAddon, ITrayService
from .version import __version__
class WebServerAddon(AYONAddon, ITrayService):
name = "webserver"
version = __version__
label = "WebServer"
webserver_url_env = "AYON_WEBSERVER_URL"

View file

@ -11,7 +11,12 @@ from pyblish.lib import MessageHandler
from ayon_core import AYON_CORE_ROOT
from ayon_core.host import HostBase
from ayon_core.lib import is_in_tests, initialize_ayon_connection, emit_event
from ayon_core.lib import (
is_in_tests,
initialize_ayon_connection,
emit_event,
version_up
)
from ayon_core.addon import load_addons, AddonsManager
from ayon_core.settings import get_project_settings
@ -21,6 +26,8 @@ from .template_data import get_template_data_with_names
from .workfile import (
get_workdir,
get_custom_workfile_template_by_string_context,
get_workfile_template_key_from_context,
get_last_workfile
)
from . import (
register_loader_plugin_path,
@ -579,3 +586,48 @@ def get_process_id():
if _process_id is None:
_process_id = str(uuid.uuid4())
return _process_id
def version_up_current_workfile():
"""Function to increment and save workfile
"""
host = registered_host()
if not host.has_unsaved_changes():
print("No unsaved changes, skipping file save..")
return
project_name = get_current_project_name()
folder_path = get_current_folder_path()
task_name = get_current_task_name()
host_name = get_current_host_name()
template_key = get_workfile_template_key_from_context(
project_name,
folder_path,
task_name,
host_name,
)
anatomy = Anatomy(project_name)
data = get_template_data_with_names(
project_name, folder_path, task_name, host_name
)
data["root"] = anatomy.roots
work_template = anatomy.get_template_item("work", template_key)
# Define saving file extension
extensions = host.get_workfile_extensions()
current_file = host.get_current_workfile()
if current_file:
extensions = [os.path.splitext(current_file)[-1]]
work_root = work_template["directory"].format_strict(data)
file_template = work_template["file"].template
last_workfile_path = get_last_workfile(
work_root, file_template, data, extensions, True
)
new_workfile_path = version_up(last_workfile_path)
if os.path.exists(new_workfile_path):
new_workfile_path = version_up(new_workfile_path)
host.save_workfile(new_workfile_path)

View file

@ -336,17 +336,16 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
settings_category = getattr(plugin, "settings_category", None)
if settings_category:
try:
return (
project_settings
[settings_category]
["publish"]
[plugin.__name__]
)
category_settings = project_settings[settings_category]
except KeyError:
log.warning((
"Couldn't find plugin '{}' settings"
" under settings category '{}'"
).format(plugin.__name__, settings_category))
"Couldn't find settings category '{}' in project settings"
).format(settings_category))
return {}
try:
return category_settings["publish"][plugin.__name__]
except KeyError:
return {}
# Use project settings based on a category name

View file

@ -3,9 +3,9 @@ import pyblish.api
from ayon_core.lib import filter_profiles
from ayon_core.pipeline.publish import (
PublishValidationError,
OptionalPyblishPluginMixin,
get_current_host_name,
OptionalPyblishPluginMixin
)
from ayon_core.pipeline import get_current_host_name
class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin):

View file

@ -1,12 +0,0 @@
Adobe webserver
---------------
Aiohttp (Asyncio) based websocket server used for communication with host
applications, currently only for Adobe (but could be used for any non python
DCC which has websocket client).
This webserver is started in spawned Python process that opens DCC during
its launch, waits for connection from DCC and handles communication going
forward. Server is closed before Python process is killed.
(Different from `ayon_core/modules/webserver` as that one is running in Tray,
this one is running in spawn Python process.)

View file

@ -172,12 +172,30 @@ class VersionItem:
def __gt__(self, other):
if not isinstance(other, VersionItem):
return False
if (
other.version == self.version
and self.is_hero
):
# Make sure hero versions are positive
version = abs(self.version)
other_version = abs(other.version)
# Hero version is greater than non-hero
if version == other_version:
return self.is_hero
return version > other_version
def __lt__(self, other):
if not isinstance(other, VersionItem):
return True
return other.version < self.version
# Make sure hero versions are positive
version = abs(self.version)
other_version = abs(other.version)
# Non-hero version is lesser than hero
if version == other_version:
return not self.is_hero
return version < other_version
def __ge__(self, other):
return self.__eq__(other) or self.__gt__(other)
def __le__(self, other):
return self.__eq__(other) or self.__lt__(other)
def to_data(self):
return {

View file

@ -348,10 +348,18 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
return set()
if not self._loaded_products_cache.is_valid:
if isinstance(self._host, ILoadHost):
containers = self._host.get_containers()
else:
containers = self._host.ls()
try:
if isinstance(self._host, ILoadHost):
containers = self._host.get_containers()
else:
containers = self._host.ls()
except BaseException:
self.log.error(
"Failed to collect loaded products.", exc_info=True
)
containers = []
repre_ids = set()
for container in containers:
repre_id = container.get("representation")

View file

@ -321,6 +321,8 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
"""
self._folders_proxy_model.setFilterFixedString(name)
if name:
self._folders_view.expandAll()
def set_merged_products_selection(self, items):
"""

View file

@ -6,9 +6,6 @@ from ayon_core.tools.utils.lib import format_version
from .products_model import (
PRODUCT_ID_ROLE,
VERSION_NAME_EDIT_ROLE,
VERSION_STATUS_NAME_ROLE,
VERSION_STATUS_SHORT_ROLE,
VERSION_STATUS_COLOR_ROLE,
VERSION_ID_ROLE,
PRODUCT_IN_SCENE_ROLE,
ACTIVE_SITE_ICON_ROLE,
@ -205,57 +202,6 @@ class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate):
option.palette.setBrush(QtGui.QPalette.Text, color)
class StatusDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
def paint(self, painter, option, index):
if option.widget:
style = option.widget.style()
else:
style = QtWidgets.QApplication.style()
style.drawControl(
QtWidgets.QCommonStyle.CE_ItemViewItem,
option,
painter,
option.widget
)
painter.save()
text_rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemText,
option
)
text_margin = style.proxy().pixelMetric(
QtWidgets.QCommonStyle.PM_FocusFrameHMargin,
option,
option.widget
) + 1
padded_text_rect = text_rect.adjusted(
text_margin, 0, - text_margin, 0
)
fm = QtGui.QFontMetrics(option.font)
text = index.data(VERSION_STATUS_NAME_ROLE)
if padded_text_rect.width() < fm.width(text):
text = index.data(VERSION_STATUS_SHORT_ROLE)
status_color = index.data(VERSION_STATUS_COLOR_ROLE)
fg_color = QtGui.QColor(status_color)
pen = painter.pen()
pen.setColor(fg_color)
painter.setPen(pen)
painter.drawText(
padded_text_rect,
option.displayAlignment,
text
)
painter.restore()
class SiteSyncDelegate(QtWidgets.QStyledItemDelegate):
"""Paints icons and downloaded representation ration for both sites."""

View file

@ -25,18 +25,19 @@ VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 18
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 19
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 20
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 21
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 22
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 23
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 24
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 25
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 27
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 28
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
class ProductsModel(QtGui.QStandardItemModel):
@ -198,7 +199,9 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item = self._product_items_by_id.get(product_id)
if product_item is None:
return None
return list(product_item.version_items.values())
product_items = list(product_item.version_items.values())
product_items.sort(reverse=True)
return product_items
if role == QtCore.Qt.EditRole:
return None

View file

@ -6,7 +6,7 @@ from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
)
from ayon_core.tools.utils.delegates import PrettyTimeDelegate
from ayon_core.tools.utils.delegates import PrettyTimeDelegate, StatusDelegate
from .products_model import (
ProductsModel,
@ -17,12 +17,15 @@ from .products_model import (
FOLDER_ID_ROLE,
PRODUCT_ID_ROLE,
VERSION_ID_ROLE,
VERSION_STATUS_NAME_ROLE,
VERSION_STATUS_SHORT_ROLE,
VERSION_STATUS_COLOR_ROLE,
VERSION_STATUS_ICON_ROLE,
VERSION_THUMBNAIL_ID_ROLE,
)
from .products_delegates import (
VersionDelegate,
LoadedInSceneDelegate,
StatusDelegate,
SiteSyncDelegate,
)
from .actions_utils import show_actions_menu
@ -131,7 +134,12 @@ class ProductsWidget(QtWidgets.QWidget):
version_delegate = VersionDelegate()
time_delegate = PrettyTimeDelegate()
status_delegate = StatusDelegate()
status_delegate = StatusDelegate(
VERSION_STATUS_NAME_ROLE,
VERSION_STATUS_SHORT_ROLE,
VERSION_STATUS_COLOR_ROLE,
VERSION_STATUS_ICON_ROLE,
)
in_scene_delegate = LoadedInSceneDelegate()
sitesync_delegate = SiteSyncDelegate()

View file

@ -1,14 +1,14 @@
import ayon_api
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.host import ILoadHost
from ayon_core.host import HostBase
from ayon_core.pipeline import (
registered_host,
get_current_context,
)
from ayon_core.tools.common_models import HierarchyModel
from ayon_core.tools.common_models import HierarchyModel, ProjectsModel
from .models import SiteSyncModel
from .models import SiteSyncModel, ContainersModel
class SceneInventoryController:
@ -28,11 +28,16 @@ class SceneInventoryController:
self._current_folder_id = None
self._current_folder_set = False
self._containers_model = ContainersModel(self)
self._sitesync_model = SiteSyncModel(self)
# Switch dialog requirements
self._hierarchy_model = HierarchyModel(self)
self._projects_model = ProjectsModel(self)
self._event_system = self._create_event_system()
def get_host(self) -> HostBase:
return self._host
def emit_event(self, topic, data=None, source=None):
if data is None:
data = {}
@ -47,6 +52,7 @@ class SceneInventoryController:
self._current_folder_id = None
self._current_folder_set = False
self._containers_model.reset()
self._sitesync_model.reset()
self._hierarchy_model.reset()
@ -80,13 +86,32 @@ class SceneInventoryController:
self._current_folder_set = True
return self._current_folder_id
def get_project_status_items(self):
project_name = self.get_current_project_name()
return self._projects_model.get_project_status_items(
project_name, None
)
# Containers methods
def get_containers(self):
host = self._host
if isinstance(host, ILoadHost):
return list(host.get_containers())
elif hasattr(host, "ls"):
return list(host.ls())
return []
return self._containers_model.get_containers()
def get_containers_by_item_ids(self, item_ids):
return self._containers_model.get_containers_by_item_ids(item_ids)
def get_container_items(self):
return self._containers_model.get_container_items()
def get_container_items_by_id(self, item_ids):
return self._containers_model.get_container_items_by_id(item_ids)
def get_representation_info_items(self, representation_ids):
return self._containers_model.get_representation_info_items(
representation_ids
)
def get_version_items(self, product_ids):
return self._containers_model.get_version_items(product_ids)
# Site Sync methods
def is_sitesync_enabled(self):

View file

@ -1,38 +1,10 @@
import numbers
import ayon_api
from ayon_core.pipeline import HeroVersionType
from ayon_core.tools.utils.models import TreeModel
from ayon_core.tools.utils.lib import format_version
from qtpy import QtWidgets, QtCore, QtGui
from .model import VERSION_LABEL_ROLE
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, controller, *args, **kwargs):
self._controller = controller
super(VersionDelegate, self).__init__(*args, **kwargs)
def get_project_name(self):
return self._controller.get_current_project_name()
def displayText(self, value, locale):
if isinstance(value, HeroVersionType):
return format_version(value)
if not isinstance(value, numbers.Integral):
# For cases where no version is resolved like NOT FOUND cases
# where a representation might not exist in current database
return
return format_version(value)
def paint(self, painter, option, index):
fg_color = index.data(QtCore.Qt.ForegroundRole)
if fg_color:
@ -44,7 +16,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
fg_color = None
if not fg_color:
return super(VersionDelegate, self).paint(painter, option, index)
return super().paint(painter, option, index)
if option.widget:
style = option.widget.style()
@ -60,9 +32,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
painter.save()
text = self.displayText(
index.data(QtCore.Qt.DisplayRole), option.locale
)
text = index.data(VERSION_LABEL_ROLE)
pen = painter.pen()
pen.setColor(fg_color)
painter.setPen(pen)
@ -82,77 +52,3 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
)
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)
project_name = self.get_project_name()
# Add all available versions to the editor
product_id = item["version_entity"]["productId"]
version_entities = list(sorted(
ayon_api.get_versions(
project_name, product_ids={product_id}, active=True
),
key=lambda item: abs(item["version"])
))
selected = None
items = []
is_hero_version = value < 0
for version_entity in version_entities:
version = version_entity["version"]
label = format_version(version)
item = QtGui.QStandardItem(label)
item.setData(version_entity, QtCore.Qt.UserRole)
items.append(item)
if (
version == value
or is_hero_version and version < 0
):
selected = item
# Reverse items so latest versions be upper
items.reverse()
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"])

View file

@ -1,57 +1,113 @@
import re
import logging
import uuid
from collections import defaultdict
import collections
import ayon_api
from qtpy import QtCore, QtGui
import qtawesome
from ayon_core.pipeline import (
get_current_project_name,
HeroVersionType,
)
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils.models import TreeModel, Item
from ayon_core.tools.utils.lib import format_version
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
NAME_COLOR_ROLE = QtCore.Qt.UserRole + 2
COUNT_ROLE = QtCore.Qt.UserRole + 3
IS_CONTAINER_ITEM_ROLE = QtCore.Qt.UserRole + 4
VERSION_IS_LATEST_ROLE = QtCore.Qt.UserRole + 5
VERSION_IS_HERO_ROLE = QtCore.Qt.UserRole + 6
VERSION_LABEL_ROLE = QtCore.Qt.UserRole + 7
VERSION_COLOR_ROLE = QtCore.Qt.UserRole + 8
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 9
STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 10
STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 11
STATUS_ICON_ROLE = QtCore.Qt.UserRole + 12
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 13
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 14
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 15
PRODUCT_GROUP_NAME_ROLE = QtCore.Qt.UserRole + 16
PRODUCT_GROUP_ICON_ROLE = QtCore.Qt.UserRole + 17
LOADER_NAME_ROLE = QtCore.Qt.UserRole + 18
OBJECT_NAME_ROLE = QtCore.Qt.UserRole + 19
ACTIVE_SITE_PROGRESS_ROLE = QtCore.Qt.UserRole + 20
REMOTE_SITE_PROGRESS_ROLE = QtCore.Qt.UserRole + 21
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 22
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23
# This value hold unique value of container that should be used to identify
# containers inbetween refresh.
ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24
def walk_hierarchy(node):
"""Recursively yield group node."""
for child in node.children():
if child.get("isGroupNode"):
yield child
for _child in walk_hierarchy(child):
yield _child
class InventoryModel(TreeModel):
class InventoryModel(QtGui.QStandardItemModel):
"""The model for the inventory"""
Columns = [
column_labels = [
"Name",
"version",
"count",
"productType",
"group",
"loader",
"objectName",
"active_site",
"remote_site",
"Version",
"Status",
"Count",
"Product type",
"Group",
"Loader",
"Object name",
"Active site",
"Remote site",
]
active_site_col = Columns.index("active_site")
remote_site_col = Columns.index("remote_site")
name_col = column_labels.index("Name")
version_col = column_labels.index("Version")
status_col = column_labels.index("Status")
count_col = column_labels.index("Count")
product_type_col = column_labels.index("Product type")
product_group_col = column_labels.index("Group")
loader_col = column_labels.index("Loader")
object_name_col = column_labels.index("Object name")
active_site_col = column_labels.index("Active site")
remote_site_col = column_labels.index("Remote site")
display_role_by_column = {
name_col: QtCore.Qt.DisplayRole,
version_col: VERSION_LABEL_ROLE,
status_col: STATUS_NAME_ROLE,
count_col: COUNT_ROLE,
product_type_col: PRODUCT_TYPE_ROLE,
product_group_col: PRODUCT_GROUP_NAME_ROLE,
loader_col: LOADER_NAME_ROLE,
object_name_col: OBJECT_NAME_ROLE,
active_site_col: ACTIVE_SITE_PROGRESS_ROLE,
remote_site_col: REMOTE_SITE_PROGRESS_ROLE,
}
decoration_role_by_column = {
name_col: QtCore.Qt.DecorationRole,
product_type_col: PRODUCT_TYPE_ICON_ROLE,
product_group_col: PRODUCT_GROUP_ICON_ROLE,
active_site_col: ACTIVE_SITE_ICON_ROLE,
remote_site_col: REMOTE_SITE_ICON_ROLE,
}
foreground_role_by_column = {
name_col: NAME_COLOR_ROLE,
version_col: VERSION_COLOR_ROLE,
status_col: STATUS_COLOR_ROLE
}
width_by_column = {
name_col: 250,
version_col: 55,
status_col: 100,
count_col: 55,
product_type_col: 150,
product_group_col: 120,
loader_col: 150,
}
OUTDATED_COLOR = QtGui.QColor(235, 30, 30)
CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30)
GRAYOUT_COLOR = QtGui.QColor(160, 160, 160)
UniqueRole = QtCore.Qt.UserRole + 2 # unique label role
def __init__(self, controller, parent=None):
super(InventoryModel, self).__init__(parent)
super().__init__(parent)
self.setColumnCount(len(self.column_labels))
for idx, label in enumerate(self.column_labels):
self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
self.log = logging.getLogger(self.__class__.__name__)
self._controller = controller
@ -60,103 +116,217 @@ class InventoryModel(TreeModel):
self._default_icon_color = get_default_entity_icon_color()
site_icons = self._controller.get_site_provider_icons()
self._site_icons = {
provider: get_qt_icon(icon_def)
for provider, icon_def in site_icons.items()
}
def outdated(self, item):
return item.get("isOutdated", True)
def refresh(self, selected=None):
"""Refresh the model"""
# for debugging or testing, injecting items from outside
container_items = self._controller.get_container_items()
self._clear_items()
items_by_repre_id = {}
for container_item in container_items:
# if (
# selected is not None
# and container_item.item_id not in selected
# ):
# continue
repre_id = container_item.representation_id
items = items_by_repre_id.setdefault(repre_id, [])
items.append(container_item)
repre_id = set(items_by_repre_id.keys())
repre_info_by_id = self._controller.get_representation_info_items(
repre_id
)
product_ids = {
repre_info.product_id
for repre_info in repre_info_by_id.values()
}
version_items_by_product_id = self._controller.get_version_items(
product_ids
)
# SiteSync addon information
progress_by_id = self._controller.get_representations_site_progress(
repre_id
)
sites_info = self._controller.get_sites_information()
site_icons = {
provider: get_qt_icon(icon_def)
for provider, icon_def in (
self._controller.get_site_provider_icons().items()
)
}
status_items_by_name = {
status_item.name: status_item
for status_item in self._controller.get_project_status_items()
}
group_item_icon = qtawesome.icon(
"fa.folder", color=self._default_icon_color
)
valid_item_icon = qtawesome.icon(
"fa.file-o", color=self._default_icon_color
)
invalid_item_icon = qtawesome.icon(
"fa.exclamation-circle", color=self._default_icon_color
)
group_icon = qtawesome.icon(
"fa.object-group", color=self._default_icon_color
)
product_type_icon = qtawesome.icon(
"fa.folder", color="#0091B2"
)
group_item_font = QtGui.QFont()
group_item_font.setBold(True)
active_site_icon = site_icons.get(sites_info["active_site_provider"])
remote_site_icon = site_icons.get(sites_info["remote_site_provider"])
root_item = self.invisibleRootItem()
group_items = []
for repre_id, container_items in items_by_repre_id.items():
repre_info = repre_info_by_id[repre_id]
version_label = "N/A"
version_color = None
is_latest = False
is_hero = False
status_name = None
status_color = None
status_short = None
if not repre_info.is_valid:
group_name = "< Entity N/A >"
item_icon = invalid_item_icon
else:
group_name = "{}_{}: ({})".format(
repre_info.folder_path.rsplit("/")[-1],
repre_info.product_name,
repre_info.representation_name
)
item_icon = valid_item_icon
version_items = (
version_items_by_product_id[repre_info.product_id]
)
version_item = version_items[repre_info.version_id]
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
is_latest = version_item.is_latest
if not is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status
status_item = status_items_by_name.get(status_name)
if status_item:
status_short = status_item.short
status_color = status_item.color
container_model_items = []
for container_item in container_items:
unique_name = (
repre_info.representation_name
+ container_item.object_name or "<none>"
)
item = QtGui.QStandardItem()
item.setColumnCount(root_item.columnCount())
item.setData(container_item.namespace, QtCore.Qt.DisplayRole)
item.setData(self.GRAYOUT_COLOR, NAME_COLOR_ROLE)
item.setData(self.GRAYOUT_COLOR, VERSION_COLOR_ROLE)
item.setData(item_icon, QtCore.Qt.DecorationRole)
item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
item.setData(container_item.item_id, ITEM_ID_ROLE)
item.setData(version_label, VERSION_LABEL_ROLE)
item.setData(container_item.loader_name, LOADER_NAME_ROLE)
item.setData(container_item.object_name, OBJECT_NAME_ROLE)
item.setData(True, IS_CONTAINER_ITEM_ROLE)
item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE)
container_model_items.append(item)
if not container_model_items:
continue
progress = progress_by_id[repre_id]
active_site_progress = "{}%".format(
max(progress["active_site"], 0) * 100
)
remote_site_progress = "{}%".format(
max(progress["remote_site"], 0) * 100
)
group_item = QtGui.QStandardItem()
group_item.setColumnCount(root_item.columnCount())
group_item.setData(group_name, QtCore.Qt.DisplayRole)
group_item.setData(group_name, ITEM_UNIQUE_NAME_ROLE)
group_item.setData(group_item_icon, QtCore.Qt.DecorationRole)
group_item.setData(group_item_font, QtCore.Qt.FontRole)
group_item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
group_item.setData(repre_info.product_type, PRODUCT_TYPE_ROLE)
group_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
group_item.setData(is_latest, VERSION_IS_LATEST_ROLE)
group_item.setData(is_hero, VERSION_IS_HERO_ROLE)
group_item.setData(version_label, VERSION_LABEL_ROLE)
group_item.setData(len(container_items), COUNT_ROLE)
group_item.setData(status_name, STATUS_NAME_ROLE)
group_item.setData(status_short, STATUS_SHORT_ROLE)
group_item.setData(status_color, STATUS_COLOR_ROLE)
group_item.setData(
active_site_progress, ACTIVE_SITE_PROGRESS_ROLE
)
group_item.setData(
remote_site_progress, REMOTE_SITE_PROGRESS_ROLE
)
group_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE)
group_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE)
group_item.setData(False, IS_CONTAINER_ITEM_ROLE)
if version_color is not None:
group_item.setData(version_color, VERSION_COLOR_ROLE)
if repre_info.product_group:
group_item.setData(
repre_info.product_group, PRODUCT_GROUP_NAME_ROLE
)
group_item.setData(group_icon, PRODUCT_GROUP_ICON_ROLE)
group_item.appendRows(container_model_items)
group_items.append(group_item)
if group_items:
root_item.appendRows(group_items)
def flags(self, index):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def data(self, index, role):
if not index.isValid():
return
item = index.internalPointer()
col = index.column()
if role == QtCore.Qt.DisplayRole:
role = self.display_role_by_column.get(col)
if role is None:
print(col, role)
return None
if role == QtCore.Qt.FontRole:
# Make top-level entries bold
if item.get("isGroupNode") or item.get("isNotSet"): # group-item
font = QtGui.QFont()
font.setBold(True)
return font
elif role == QtCore.Qt.DecorationRole:
role = self.decoration_role_by_column.get(col)
if role is None:
return None
if role == QtCore.Qt.ForegroundRole:
# Set the text color to the OUTDATED_COLOR when the
# collected version is not the same as the highest version
key = self.Columns[index.column()]
if key == "version": # version
if item.get("isGroupNode"): # group-item
if self.outdated(item):
return self.OUTDATED_COLOR
elif role == QtCore.Qt.ForegroundRole:
role = self.foreground_role_by_column.get(col)
if role is None:
return None
if self._hierarchy_view:
# If current group is not outdated, check if any
# outdated children.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR
else:
if col != 0:
index = self.index(index.row(), 0, index.parent())
if self._hierarchy_view:
# Although this is not a group item, we still need
# to distinguish which one contain outdated child.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR.darker(150)
return self.GRAYOUT_COLOR
if key == "Name" and not item.get("isGroupNode"):
return self.GRAYOUT_COLOR
# Add icons
if role == QtCore.Qt.DecorationRole:
if index.column() == 0:
# Override color
color = item.get("color", self._default_icon_color)
if item.get("isGroupNode"): # group-item
return qtawesome.icon("fa.folder", color=color)
if item.get("isNotSet"):
return qtawesome.icon("fa.exclamation-circle", color=color)
return qtawesome.icon("fa.file-o", color=color)
if index.column() == 3:
# Product type icon
return item.get("productTypeIcon", None)
column_name = self.Columns[index.column()]
if column_name == "group" and item.get("group"):
return qtawesome.icon("fa.object-group",
color=get_default_entity_icon_color())
if item.get("isGroupNode"):
if column_name == "active_site":
provider = item.get("active_site_provider")
return self._site_icons.get(provider)
if column_name == "remote_site":
provider = item.get("remote_site_provider")
return self._site_icons.get(provider)
if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"):
column_name = self.Columns[index.column()]
progress = None
if column_name == "active_site":
progress = item.get("active_site_progress", 0)
elif column_name == "remote_site":
progress = item.get("remote_site_progress", 0)
if progress is not None:
return "{}%".format(max(progress, 0) * 100)
if role == self.UniqueRole:
return item["representation"] + item.get("objectName", "<none>")
return super(InventoryModel, self).data(index, role)
return super().data(index, role)
def set_hierarchy_view(self, state):
"""Set whether to display products in hierarchy view."""
@ -165,299 +335,34 @@ class InventoryModel(TreeModel):
if state != self._hierarchy_view:
self._hierarchy_view = state
def refresh(self, selected=None, containers=None):
"""Refresh the model"""
# for debugging or testing, injecting items from outside
if containers is None:
containers = self._controller.get_containers()
self.clear()
if not selected or not self._hierarchy_view:
self._add_containers(containers)
return
# Filter by cherry-picked items
self._add_containers((
container
for container in containers
if container["objectName"] in selected
))
def _add_containers(self, containers, parent=None):
"""Add the items to the model.
The items should be formatted similar to `api.ls()` returns, an item
is then represented as:
{"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma,
full/filename/of/loaded/filename_v001.ma],
"nodetype" : "reference",
"node": "referenceNode1"}
Note: When performing an additional call to `add_items` it will *not*
group the new items with previously existing item groups of the
same type.
Args:
containers (generator): Container items.
parent (Item, optional): Set this item as parent for the added
items when provided. Defaults to the root of the model.
Returns:
node.Item: root node which has children added based on the data
"""
project_name = get_current_project_name()
self.beginResetModel()
# Group by representation
grouped = defaultdict(lambda: {"containers": list()})
for container in containers:
repre_id = container["representation"]
grouped[repre_id]["containers"].append(container)
(
repres_by_id,
versions_by_id,
products_by_id,
folders_by_id,
) = self._query_entities(project_name, set(grouped.keys()))
# Add to model
not_found = defaultdict(list)
not_found_ids = []
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
representation = repres_by_id.get(repre_id)
if not representation:
not_found["representation"].extend(group_containers)
not_found_ids.append(repre_id)
def get_outdated_item_ids(self, ignore_hero=True):
outdated_item_ids = []
root_item = self.invisibleRootItem()
for row in range(root_item.rowCount()):
group_item = root_item.child(row)
if group_item.data(VERSION_IS_LATEST_ROLE):
continue
version_entity = versions_by_id.get(representation["versionId"])
if not version_entity:
not_found["version"].extend(group_containers)
not_found_ids.append(repre_id)
if ignore_hero and group_item.data(VERSION_IS_HERO_ROLE):
continue
product_entity = products_by_id.get(version_entity["productId"])
if not product_entity:
not_found["product"].extend(group_containers)
not_found_ids.append(repre_id)
continue
for idx in range(group_item.rowCount()):
item = group_item.child(idx)
outdated_item_ids.append(item.data(ITEM_ID_ROLE))
return outdated_item_ids
folder_entity = folders_by_id.get(product_entity["folderId"])
if not folder_entity:
not_found["folder"].extend(group_containers)
not_found_ids.append(repre_id)
continue
group_dict.update({
"representation": representation,
"version": version_entity,
"product": product_entity,
"folder": folder_entity
})
for _repre_id in not_found_ids:
grouped.pop(_repre_id)
for where, group_containers in not_found.items():
# create the group header
group_node = Item()
name = "< NOT FOUND - {} >".format(where)
group_node["Name"] = name
group_node["representation"] = name
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = False
group_node["isNotSet"] = True
self.add_child(group_node, parent=parent)
for container in group_containers:
item_node = Item()
item_node.update(container)
item_node["Name"] = container.get("objectName", "NO NAME")
item_node["isNotFound"] = True
self.add_child(item_node, parent=group_node)
# TODO Use product icons
product_type_icon = qtawesome.icon(
"fa.folder", color="#0091B2"
)
# Prepare site sync specific data
progress_by_id = self._controller.get_representations_site_progress(
set(grouped.keys())
)
sites_info = self._controller.get_sites_information()
# Query the highest available version so the model can know
# whether current version is currently up-to-date.
highest_version_by_product_id = ayon_api.get_last_versions(
project_name,
product_ids={
group["version"]["productId"] for group in grouped.values()
},
fields={"productId", "version"}
)
# Map value to `version` key
highest_version_by_product_id = {
product_id: version["version"]
for product_id, version in highest_version_by_product_id.items()
}
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
repre_entity = group_dict["representation"]
version_entity = group_dict["version"]
folder_entity = group_dict["folder"]
product_entity = group_dict["product"]
product_type = product_entity["productType"]
# create the group header
group_node = Item()
group_node["Name"] = "{}_{}: ({})".format(
folder_entity["name"],
product_entity["name"],
repre_entity["name"]
)
group_node["representation"] = repre_id
# Detect hero version type
version = version_entity["version"]
if version < 0:
version = HeroVersionType(version)
group_node["version"] = version
# Check if the version is outdated.
# Hero versions are never considered to be outdated.
is_outdated = False
if not isinstance(version, HeroVersionType):
last_version = highest_version_by_product_id.get(
version_entity["productId"])
if last_version is not None:
is_outdated = version_entity["version"] != last_version
group_node["isOutdated"] = is_outdated
group_node["productType"] = product_type or ""
group_node["productTypeIcon"] = product_type_icon
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = True
group_node["group"] = product_entity["attrib"].get("productGroup")
# Site sync specific data
progress = progress_by_id[repre_id]
group_node.update(sites_info)
group_node["active_site_progress"] = progress["active_site"]
group_node["remote_site_progress"] = progress["remote_site"]
self.add_child(group_node, parent=parent)
for container in group_containers:
item_node = Item()
item_node.update(container)
# store the current version on the item
item_node["version"] = version_entity["version"]
item_node["version_entity"] = version_entity
# Remapping namespace to item name.
# Noted that the name key is capital "N", by doing this, we
# can view namespace in GUI without changing container data.
item_node["Name"] = container["namespace"]
self.add_child(item_node, parent=group_node)
self.endResetModel()
return self._root_item
def _query_entities(self, project_name, repre_ids):
"""Query entities for representations from containers.
Returns:
tuple[dict, dict, dict, dict]: Representation, version, product
and folder documents by id.
"""
repres_by_id = {}
versions_by_id = {}
products_by_id = {}
folders_by_id = {}
output = (
repres_by_id,
versions_by_id,
products_by_id,
folders_by_id,
)
filtered_repre_ids = set()
for repre_id in repre_ids:
# Filter out invalid representation ids
# NOTE: This is added because scenes from OpenPype did contain
# ObjectId from mongo.
try:
uuid.UUID(repre_id)
filtered_repre_ids.add(repre_id)
except ValueError:
continue
if not filtered_repre_ids:
return output
repre_entities = ayon_api.get_representations(project_name, repre_ids)
repres_by_id.update({
repre_entity["id"]: repre_entity
for repre_entity in repre_entities
})
version_ids = {
repre_entity["versionId"]
for repre_entity in repres_by_id.values()
}
if not version_ids:
return output
versions_by_id.update({
version_entity["id"]: version_entity
for version_entity in ayon_api.get_versions(
project_name, version_ids=version_ids
)
})
product_ids = {
version_entity["productId"]
for version_entity in versions_by_id.values()
}
if not product_ids:
return output
products_by_id.update({
product_entity["id"]: product_entity
for product_entity in ayon_api.get_products(
project_name, product_ids=product_ids
)
})
folder_ids = {
product_entity["folderId"]
for product_entity in products_by_id.values()
}
if not folder_ids:
return output
folders_by_id.update({
folder_entity["id"]: folder_entity
for folder_entity in ayon_api.get_folders(
project_name, folder_ids=folder_ids
)
})
return output
def _clear_items(self):
root_item = self.invisibleRootItem()
root_item.removeRows(0, root_item.rowCount())
class FilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filter model to where key column's value is in the filtered tags"""
def __init__(self, *args, **kwargs):
super(FilterProxyModel, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.setDynamicSortFilter(True)
self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
self._filter_outdated = False
self._hierarchy_view = False
@ -467,28 +372,23 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
# Always allow bottom entries (individual containers), since their
# parent group hidden if it wouldn't have been validated.
rows = model.rowCount(source_index)
if not rows:
if source_index.data(IS_CONTAINER_ITEM_ROLE):
return True
# Filter by regex
if hasattr(self, "filterRegExp"):
regex = self.filterRegExp()
else:
regex = self.filterRegularExpression()
pattern = regex.pattern()
if pattern:
pattern = re.escape(pattern)
if not self._matches(row, parent, pattern):
return False
if self._filter_outdated:
# When filtering to outdated we filter the up to date entries
# thus we "allow" them when they are outdated
if not self._is_outdated(row, parent):
if source_index.data(VERSION_IS_LATEST_ROLE):
return False
# Filter by regex
if hasattr(self, "filterRegularExpression"):
regex = self.filterRegularExpression()
else:
regex = self.filterRegExp()
if not self._matches(row, parent, regex.pattern()):
return False
return True
def set_filter_outdated(self, state):
@ -505,37 +405,6 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
if state != self._hierarchy_view:
self._hierarchy_view = state
def _is_outdated(self, row, parent):
"""Return whether row is outdated.
A row is considered outdated if `isOutdated` data is true or not set.
"""
def outdated(node):
return node.get("isOutdated", True)
index = self.sourceModel().index(row, self.filterKeyColumn(), parent)
# The scene contents are grouped by "representation", e.g. the same
# "representation" loaded twice is grouped under the same header.
# Since the version check filters these parent groups we skip that
# check for the individual children.
has_parent = index.parent().isValid()
if has_parent and not self._hierarchy_view:
return True
# Filter to those that have the different version numbers
node = index.internalPointer()
if outdated(node):
return True
if self._hierarchy_view:
for _node in walk_hierarchy(node):
if outdated(_node):
return True
return False
def _matches(self, row, parent, pattern):
"""Return whether row matches regex pattern.
@ -548,38 +417,31 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
bool
"""
if not pattern:
return True
flags = 0
if self.sortCaseSensitivity() == QtCore.Qt.CaseInsensitive:
flags = re.IGNORECASE
regex = re.compile(re.escape(pattern), flags=flags)
model = self.sourceModel()
column = self.filterKeyColumn()
role = self.filterRole()
def matches(row, parent, pattern):
matches_queue = collections.deque()
matches_queue.append((row, parent))
while matches_queue:
queue_item = matches_queue.popleft()
row, parent = queue_item
index = model.index(row, column, parent)
key = model.data(index, role)
if re.search(pattern, key, re.IGNORECASE):
value = model.data(index, role)
if regex.search(value):
return True
if matches(row, parent, pattern):
return True
for idx in range(model.rowCount(index)):
matches_queue.append((idx, index))
# Also allow if any of the children matches
source_index = model.index(row, column, parent)
rows = model.rowCount(source_index)
if any(
matches(idx, source_index, pattern)
for idx in range(rows)
):
return True
if not self._hierarchy_view:
return False
for idx in range(rows):
child_index = model.index(idx, column, source_index)
child_rows = model.rowCount(child_index)
return any(
self._matches(child_idx, child_index, pattern)
for child_idx in range(child_rows)
)
return True
return False

View file

@ -1,6 +1,8 @@
from .containers import ContainersModel
from .sitesync import SiteSyncModel
__all__ = (
"ContainersModel",
"SiteSyncModel",
)

View file

@ -0,0 +1,343 @@
import uuid
import collections
import ayon_api
from ayon_api.graphql import GraphQlQuery
from ayon_core.host import ILoadHost
# --- Implementation that should be in ayon-python-api ---
# The implementation is not available in all versions of ayon-python-api.
RepresentationHierarchy = collections.namedtuple(
"RepresentationHierarchy",
("folder", "product", "version", "representation")
)
def representations_parent_ids_qraphql_query():
query = GraphQlQuery("RepresentationsHierarchyQuery")
project_name_var = query.add_variable("projectName", "String!")
repre_ids_var = query.add_variable("representationIds", "[String!]")
project_field = query.add_field("project")
project_field.set_filter("name", project_name_var)
repres_field = project_field.add_field_with_edges("representations")
repres_field.add_field("id")
repres_field.add_field("name")
repres_field.set_filter("ids", repre_ids_var)
version_field = repres_field.add_field("version")
version_field.add_field("id")
product_field = version_field.add_field("product")
product_field.add_field("id")
product_field.add_field("name")
product_field.add_field("productType")
product_attrib_field = product_field.add_field("attrib")
product_attrib_field.add_field("productGroup")
folder_field = product_field.add_field("folder")
folder_field.add_field("id")
folder_field.add_field("path")
return query
def get_representations_hierarchy(project_name, representation_ids):
"""Find representations parents by representation id.
Representation parent entities up to project.
Args:
project_name (str): Project where to look for entities.
representation_ids (Iterable[str]): Representation ids.
Returns:
dict[str, RepresentationParents]: Parent entities by
representation id.
"""
if not representation_ids:
return {}
repre_ids = set(representation_ids)
output = {
repre_id: RepresentationHierarchy(None, None, None, None)
for repre_id in representation_ids
}
query = representations_parent_ids_qraphql_query()
query.set_variable_value("projectName", project_name)
query.set_variable_value("representationIds", list(repre_ids))
con = ayon_api.get_server_api_connection()
parsed_data = query.query(con)
for repre in parsed_data["project"]["representations"]:
repre_id = repre["id"]
version = repre.pop("version")
product = version.pop("product")
folder = product.pop("folder")
output[repre_id] = RepresentationHierarchy(
folder, product, version, repre
)
return output
# --- END of ayon-python-api implementation ---
class ContainerItem:
def __init__(
self,
representation_id,
loader_name,
namespace,
name,
object_name,
item_id
):
self.representation_id = representation_id
self.loader_name = loader_name
self.object_name = object_name
self.namespace = namespace
self.name = name
self.item_id = item_id
@classmethod
def from_container_data(cls, container):
return cls(
representation_id=container["representation"],
loader_name=container["loader"],
namespace=container["namespace"],
name=container["name"],
object_name=container["objectName"],
item_id=uuid.uuid4().hex,
)
class RepresentationInfo:
def __init__(
self,
folder_id,
folder_path,
product_id,
product_name,
product_type,
product_group,
version_id,
representation_name,
):
self.folder_id = folder_id
self.folder_path = folder_path
self.product_id = product_id
self.product_name = product_name
self.product_type = product_type
self.product_group = product_group
self.version_id = version_id
self.representation_name = representation_name
self._is_valid = None
@property
def is_valid(self):
if self._is_valid is None:
self._is_valid = (
self.folder_id is not None
and self.product_id is not None
and self.version_id is not None
and self.representation_name is not None
)
return self._is_valid
@classmethod
def new_invalid(cls):
return cls(None, None, None, None, None, None, None, None)
class VersionItem:
def __init__(self, version_id, product_id, version, status, is_latest):
self.version = version
self.version_id = version_id
self.product_id = product_id
self.version = version
self.status = status
self.is_latest = is_latest
@property
def is_hero(self):
return self.version < 0
@classmethod
def from_entity(cls, version_entity, is_latest):
return cls(
version_id=version_entity["id"],
product_id=version_entity["productId"],
version=version_entity["version"],
status=version_entity["status"],
is_latest=is_latest,
)
class ContainersModel:
def __init__(self, controller):
self._controller = controller
self._items_cache = None
self._containers_by_id = {}
self._container_items_by_id = {}
self._version_items_by_product_id = {}
self._repre_info_by_id = {}
def reset(self):
self._items_cache = None
self._containers_by_id = {}
self._container_items_by_id = {}
self._version_items_by_product_id = {}
self._repre_info_by_id = {}
def get_containers(self):
self._update_cache()
return list(self._containers_by_id.values())
def get_containers_by_item_ids(self, item_ids):
return {
item_id: self._containers_by_id.get(item_id)
for item_id in item_ids
}
def get_container_items(self):
self._update_cache()
return list(self._items_cache)
def get_container_items_by_id(self, item_ids):
return {
item_id: self._container_items_by_id.get(item_id)
for item_id in item_ids
}
def get_representation_info_items(self, representation_ids):
output = {}
missing_repre_ids = set()
for repre_id in representation_ids:
try:
uuid.UUID(repre_id)
except ValueError:
output[repre_id] = RepresentationInfo.new_invalid()
continue
repre_info = self._repre_info_by_id.get(repre_id)
if repre_info is None:
missing_repre_ids.add(repre_id)
else:
output[repre_id] = repre_info
if not missing_repre_ids:
return output
project_name = self._controller.get_current_project_name()
repre_hierarchy_by_id = get_representations_hierarchy(
project_name, missing_repre_ids
)
for repre_id, repre_hierarchy in repre_hierarchy_by_id.items():
kwargs = {
"folder_id": None,
"folder_path": None,
"product_id": None,
"product_name": None,
"product_type": None,
"product_group": None,
"version_id": None,
"representation_name": None,
}
folder = repre_hierarchy.folder
product = repre_hierarchy.product
version = repre_hierarchy.version
repre = repre_hierarchy.representation
if folder:
kwargs["folder_id"] = folder["id"]
kwargs["folder_path"] = folder["path"]
if product:
group = product["attrib"]["productGroup"]
kwargs["product_id"] = product["id"]
kwargs["product_name"] = product["name"]
kwargs["product_type"] = product["productType"]
kwargs["product_group"] = group
if version:
kwargs["version_id"] = version["id"]
if repre:
kwargs["representation_name"] = repre["name"]
repre_info = RepresentationInfo(**kwargs)
self._repre_info_by_id[repre_id] = repre_info
output[repre_id] = repre_info
return output
def get_version_items(self, product_ids):
if not product_ids:
return {}
missing_ids = {
product_id
for product_id in product_ids
if product_id not in self._version_items_by_product_id
}
if missing_ids:
def version_sorted(entity):
return entity["version"]
project_name = self._controller.get_current_project_name()
version_entities_by_product_id = {
product_id: []
for product_id in missing_ids
}
version_entities = list(ayon_api.get_versions(
project_name,
product_ids=missing_ids,
fields={"id", "version", "productId", "status"}
))
version_entities.sort(key=version_sorted)
for version_entity in version_entities:
product_id = version_entity["productId"]
version_entities_by_product_id[product_id].append(
version_entity
)
for product_id, version_entities in (
version_entities_by_product_id.items()
):
last_version = abs(version_entities[-1]["version"])
version_items_by_id = {
entity["id"]: VersionItem.from_entity(
entity, abs(entity["version"]) == last_version
)
for entity in version_entities
}
self._version_items_by_product_id[product_id] = (
version_items_by_id
)
return {
product_id: dict(self._version_items_by_product_id[product_id])
for product_id in product_ids
}
def _update_cache(self):
if self._items_cache is not None:
return
host = self._controller.get_host()
if isinstance(host, ILoadHost):
containers = list(host.get_containers())
elif hasattr(host, "ls"):
containers = list(host.ls())
else:
containers = []
container_items = []
containers_by_id = {}
container_items_by_id = {}
for container in containers:
item = ContainerItem.from_container_data(container)
containers_by_id[item.item_id] = container
container_items_by_id[item.item_id] = item
container_items.append(item)
self._containers_by_id = containers_by_id
self._container_items_by_id = container_items_by_id
self._items_cache = container_items

View file

@ -0,0 +1,216 @@
import uuid
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils.delegates import StatusDelegate
from .model import (
ITEM_ID_ROLE,
STATUS_NAME_ROLE,
STATUS_SHORT_ROLE,
STATUS_COLOR_ROLE,
STATUS_ICON_ROLE,
)
class VersionOption:
def __init__(
self,
version,
label,
status_name,
status_short,
status_color
):
self.version = version
self.label = label
self.status_name = status_name
self.status_short = status_short
self.status_color = status_color
class SelectVersionModel(QtGui.QStandardItemModel):
def data(self, index, role=None):
if role is None:
role = QtCore.Qt.DisplayRole
index = self.index(index.row(), 0, index.parent())
return super().data(index, role)
class SelectVersionComboBox(QtWidgets.QComboBox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
combo_model = SelectVersionModel(0, 2)
self.setModel(combo_model)
combo_view = QtWidgets.QTreeView(self)
combo_view.setHeaderHidden(True)
combo_view.setIndentation(0)
self.setView(combo_view)
header = combo_view.header()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
status_delegate = StatusDelegate(
STATUS_NAME_ROLE,
STATUS_SHORT_ROLE,
STATUS_COLOR_ROLE,
STATUS_ICON_ROLE,
)
combo_view.setItemDelegateForColumn(1, status_delegate)
self._combo_model = combo_model
self._combo_view = combo_view
self._status_delegate = status_delegate
self._items_by_id = {}
def paintEvent(self, event):
painter = QtWidgets.QStylePainter(self)
option = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(option)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option)
idx = self.currentIndex()
status_name = self.itemData(idx, STATUS_NAME_ROLE)
if status_name is None:
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option)
return
painter.save()
text_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
option,
QtWidgets.QStyle.SC_ComboBoxEditField
)
adj_rect = text_field_rect.adjusted(1, 0, -1, 0)
painter.drawText(
adj_rect,
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
option.currentText
)
metrics = QtGui.QFontMetrics(self.font())
text_width = metrics.width(option.currentText)
x_offset = text_width + 2
diff_width = adj_rect.width() - x_offset
if diff_width <= 0:
return
status_rect = adj_rect.adjusted(x_offset + 2, 0, 0, 0)
if diff_width < metrics.width(status_name):
status_name = self.itemData(idx, STATUS_SHORT_ROLE)
color = QtGui.QColor(self.itemData(idx, STATUS_COLOR_ROLE))
pen = painter.pen()
pen.setColor(color)
painter.setPen(pen)
painter.drawText(
status_rect,
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
status_name
)
def set_current_index(self, index):
model = self._combo_view.model()
if index > model.rowCount():
return
self.setCurrentIndex(index)
def get_item_by_id(self, item_id):
return self._items_by_id[item_id]
def set_versions(self, version_options):
self._items_by_id = {}
model = self._combo_model
root_item = model.invisibleRootItem()
root_item.removeRows(0, root_item.rowCount())
new_items = []
for version_option in version_options:
item_id = uuid.uuid4().hex
item = QtGui.QStandardItem(version_option.label)
item.setColumnCount(root_item.columnCount())
item.setData(
version_option.status_name, STATUS_NAME_ROLE
)
item.setData(
version_option.status_short, STATUS_SHORT_ROLE
)
item.setData(
version_option.status_color, STATUS_COLOR_ROLE
)
item.setData(item_id, ITEM_ID_ROLE)
new_items.append(item)
self._items_by_id[item_id] = version_option
if new_items:
root_item.appendRows(new_items)
class SelectVersionDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setWindowTitle("Select version")
label_widget = QtWidgets.QLabel("Set version number to", self)
versions_combobox = SelectVersionComboBox(self)
btns_widget = QtWidgets.QWidget(self)
confirm_btn = QtWidgets.QPushButton("OK", btns_widget)
cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(confirm_btn, 0)
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(label_widget, 0)
main_layout.addWidget(versions_combobox, 0)
main_layout.addWidget(btns_widget, 0)
confirm_btn.clicked.connect(self._on_confirm)
cancel_btn.clicked.connect(self._on_cancel)
self._selected_item = None
self._cancelled = False
self._versions_combobox = versions_combobox
def get_selected_item(self):
if self._cancelled:
return None
return self._selected_item
def set_versions(self, version_options):
self._versions_combobox.set_versions(version_options)
def select_index(self, index):
self._versions_combobox.set_current_index(index)
@classmethod
def ask_for_version(cls, version_options, index=None, parent=None):
dialog = cls(parent)
dialog.set_versions(version_options)
if index is not None:
dialog.select_index(index)
dialog.exec_()
return dialog.get_selected_item()
def _on_confirm(self):
self._cancelled = False
index = self._versions_combobox.currentIndex()
item_id = self._versions_combobox.itemData(index, ITEM_ID_ROLE)
self._selected_item = self._versions_combobox.get_item_by_id(item_id)
self.accept()
def _on_cancel(self):
self._cancelled = True
self.reject()

File diff suppressed because it is too large Load diff

View file

@ -2,17 +2,10 @@ from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
from ayon_core import style, resources
from ayon_core.tools.utils.lib import (
preserve_expanded_rows,
preserve_selection,
)
from ayon_core.tools.utils import PlaceholderLineEdit
from ayon_core.tools.sceneinventory import SceneInventoryController
from .delegates import VersionDelegate
from .model import (
InventoryModel,
FilterProxyModel
)
from .view import SceneInventoryView
@ -20,7 +13,7 @@ class SceneInventoryWindow(QtWidgets.QDialog):
"""Scene Inventory window"""
def __init__(self, controller=None, parent=None):
super(SceneInventoryWindow, self).__init__(parent)
super().__init__(parent)
if controller is None:
controller = SceneInventoryController()
@ -33,10 +26,9 @@ class SceneInventoryWindow(QtWidgets.QDialog):
self.resize(1100, 480)
# region control
filter_label = QtWidgets.QLabel("Search", self)
text_filter = QtWidgets.QLineEdit(self)
text_filter = PlaceholderLineEdit(self)
text_filter.setPlaceholderText("Filter by name...")
outdated_only_checkbox = QtWidgets.QCheckBox(
"Filter to outdated", self
@ -44,52 +36,30 @@ class SceneInventoryWindow(QtWidgets.QDialog):
outdated_only_checkbox.setToolTip("Show outdated files only")
outdated_only_checkbox.setChecked(False)
icon = qtawesome.icon("fa.arrow-up", color="white")
update_all_icon = qtawesome.icon("fa.arrow-up", color="white")
update_all_button = QtWidgets.QPushButton(self)
update_all_button.setToolTip("Update all outdated to latest version")
update_all_button.setIcon(icon)
update_all_button.setIcon(update_all_icon)
icon = qtawesome.icon("fa.refresh", color="white")
refresh_icon = qtawesome.icon("fa.refresh", color="white")
refresh_button = QtWidgets.QPushButton(self)
refresh_button.setToolTip("Refresh")
refresh_button.setIcon(icon)
refresh_button.setIcon(refresh_icon)
control_layout = QtWidgets.QHBoxLayout()
control_layout.addWidget(filter_label)
control_layout.addWidget(text_filter)
control_layout.addWidget(outdated_only_checkbox)
control_layout.addWidget(update_all_button)
control_layout.addWidget(refresh_button)
model = InventoryModel(controller)
proxy = FilterProxyModel()
proxy.setSourceModel(model)
proxy.setDynamicSortFilter(True)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
headers_widget = QtWidgets.QWidget(self)
headers_layout = QtWidgets.QHBoxLayout(headers_widget)
headers_layout.setContentsMargins(0, 0, 0, 0)
headers_layout.addWidget(filter_label, 0)
headers_layout.addWidget(text_filter, 1)
headers_layout.addWidget(outdated_only_checkbox, 0)
headers_layout.addWidget(update_all_button, 0)
headers_layout.addWidget(refresh_button, 0)
view = SceneInventoryView(controller, self)
view.setModel(proxy)
sync_enabled = controller.is_sitesync_enabled()
view.setColumnHidden(model.active_site_col, not sync_enabled)
view.setColumnHidden(model.remote_site_col, not sync_enabled)
# set some nice default widths for the view
view.setColumnWidth(0, 250) # name
view.setColumnWidth(1, 55) # version
view.setColumnWidth(2, 55) # count
view.setColumnWidth(3, 150) # product type
view.setColumnWidth(4, 120) # group
view.setColumnWidth(5, 150) # loader
# apply delegates
version_delegate = VersionDelegate(controller, self)
column = model.Columns.index("version")
view.setItemDelegateForColumn(column, version_delegate)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(control_layout)
layout.addWidget(view)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(headers_widget, 0)
main_layout.addWidget(view, 1)
show_timer = QtCore.QTimer()
show_timer.setInterval(0)
@ -114,12 +84,8 @@ class SceneInventoryWindow(QtWidgets.QDialog):
self._update_all_button = update_all_button
self._outdated_only_checkbox = outdated_only_checkbox
self._view = view
self._model = model
self._proxy = proxy
self._version_delegate = version_delegate
self._first_show = True
self._first_refresh = True
def showEvent(self, event):
super(SceneInventoryWindow, self).showEvent(event)
@ -139,29 +105,16 @@ class SceneInventoryWindow(QtWidgets.QDialog):
whilst trying to name an instance.
"""
pass
def _on_refresh_request(self):
"""Signal callback to trigger 'refresh' without any arguments."""
self.refresh()
def refresh(self, containers=None):
self._first_refresh = False
def refresh(self):
self._controller.reset()
with preserve_expanded_rows(
tree_view=self._view,
role=self._model.UniqueRole
):
with preserve_selection(
tree_view=self._view,
role=self._model.UniqueRole,
current_index=False
):
kwargs = {"containers": containers}
# TODO do not touch view's inner attribute
if self._view._hierarchy_view:
kwargs["selected"] = self._view._selected
self._model.refresh(**kwargs)
self._view.refresh()
def _on_show_timer(self):
if self._show_counter < 3:
@ -171,17 +124,13 @@ class SceneInventoryWindow(QtWidgets.QDialog):
self.refresh()
def _on_hierarchy_view_change(self, enabled):
self._proxy.set_hierarchy_view(enabled)
self._model.set_hierarchy_view(enabled)
self._view.set_hierarchy_view(enabled)
def _on_text_filter_change(self, text_filter):
if hasattr(self._proxy, "setFilterRegExp"):
self._proxy.setFilterRegExp(text_filter)
else:
self._proxy.setFilterRegularExpression(text_filter)
self._view.set_text_filter(text_filter)
def _on_outdated_state_change(self):
self._proxy.set_filter_outdated(
self._view.set_filter_outdated(
self._outdated_only_checkbox.isChecked()
)

View file

@ -0,0 +1,5 @@
from .broker import StdOutBroker
__all__ = (
"StdOutBroker",
)

View file

@ -1,173 +1,12 @@
import os
import sys
import threading
import collections
import websocket
import json
from datetime import datetime
import warnings
from .broker import StdOutBroker
from ayon_core.lib import Logger
from openpype_modules.webserver.host_console_listener import MsgAction
warnings.warn(
(
"Import of 'StdOutBroker' from 'ayon_core.tools.stdout_broker.app'"
" is deprecated. Please use 'ayon_core.tools.stdout_broker' instead."
),
DeprecationWarning
)
log = Logger.get_logger(__name__)
class StdOutBroker:
"""
Application showing console in Services tray for non python hosts
instead of cmd window.
"""
MAX_LINES = 10000
TIMER_TIMEOUT = 0.200
def __init__(self, host_name):
self.host_name = host_name
self.webserver_client = None
self.original_stdout_write = None
self.original_stderr_write = None
self.log_queue = collections.deque()
date_str = datetime.now().strftime("%d%m%Y%H%M%S")
self.host_id = "{}_{}".format(self.host_name, date_str)
self._std_available = False
self._is_running = False
self._catch_std_outputs()
self._timer = None
@property
def send_to_tray(self):
"""Checks if connected to tray and have access to logs."""
return self.webserver_client and self._std_available
def start(self):
"""Start app, create and start timer"""
if not self._std_available or self._is_running:
return
self._is_running = True
self._create_timer()
self._connect_to_tray()
def stop(self):
"""Disconnect from Tray, process last logs"""
if not self._is_running:
return
self._is_running = False
self._process_queue()
self._disconnect_from_tray()
def host_connected(self):
"""Send to Tray console that host is ready - icon change. """
log.info("Host {} connected".format(self.host_id))
payload = {
"host": self.host_id,
"action": MsgAction.INITIALIZED,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _create_timer(self):
timer = threading.Timer(self.TIMER_TIMEOUT, self._timer_callback)
timer.start()
self._timer = timer
def _timer_callback(self):
if not self._is_running:
return
self._process_queue()
self._create_timer()
def _connect_to_tray(self):
"""Connect to Tray webserver to pass console output. """
if not self._std_available: # not content to log
return
ws = websocket.WebSocket()
webserver_url = os.environ.get("AYON_WEBSERVER_URL")
if not webserver_url:
print("Unknown webserver url, cannot connect to pass log")
return
webserver_url = webserver_url.replace("http", "ws")
ws.connect("{}/ws/host_listener".format(webserver_url))
self.webserver_client = ws
payload = {
"host": self.host_id,
"action": MsgAction.CONNECTING,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _disconnect_from_tray(self):
"""Send to Tray that host is closing - remove from Services. """
print("Host {} closing".format(self.host_name))
if not self.webserver_client:
return
payload = {
"host": self.host_id,
"action": MsgAction.CLOSE,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
self.webserver_client.close()
def _catch_std_outputs(self):
"""Redirects standard out and error to own functions"""
if sys.stdout:
self.original_stdout_write = sys.stdout.write
sys.stdout.write = self._my_stdout_write
self._std_available = True
if sys.stderr:
self.original_stderr_write = sys.stderr.write
sys.stderr.write = self._my_stderr_write
self._std_available = True
def _my_stdout_write(self, text):
"""Appends outputted text to queue, keep writing to original stdout"""
if self.original_stdout_write is not None:
self.original_stdout_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _my_stderr_write(self, text):
"""Appends outputted text to queue, keep writing to original stderr"""
if self.original_stderr_write is not None:
self.original_stderr_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _process_queue(self):
"""Sends lines and purges queue"""
if not self.send_to_tray:
return
lines = tuple(self.log_queue)
self.log_queue.clear()
if lines:
payload = {
"host": self.host_id,
"action": MsgAction.ADD,
"text": "\n".join(lines)
}
self._send(payload)
def _send(self, payload):
"""Worker method to send to existing websocket connection."""
if not self.send_to_tray:
return
try:
self.webserver_client.send(json.dumps(payload))
except ConnectionResetError: # Tray closed
self._connect_to_tray()
__all__ = ("StdOutBroker", )

View file

@ -0,0 +1,174 @@
import os
import sys
import threading
import collections
import json
from datetime import datetime
import websocket
from ayon_core.lib import Logger
from ayon_core.modules.webserver import HostMsgAction
log = Logger.get_logger(__name__)
class StdOutBroker:
"""
Application showing console in Services tray for non python hosts
instead of cmd window.
"""
MAX_LINES = 10000
TIMER_TIMEOUT = 0.200
def __init__(self, host_name):
self.host_name = host_name
self.webserver_client = None
self.original_stdout_write = None
self.original_stderr_write = None
self.log_queue = collections.deque()
date_str = datetime.now().strftime("%d%m%Y%H%M%S")
self.host_id = "{}_{}".format(self.host_name, date_str)
self._std_available = False
self._is_running = False
self._catch_std_outputs()
self._timer = None
@property
def send_to_tray(self):
"""Checks if connected to tray and have access to logs."""
return self.webserver_client and self._std_available
def start(self):
"""Start app, create and start timer"""
if not self._std_available or self._is_running:
return
self._is_running = True
self._create_timer()
self._connect_to_tray()
def stop(self):
"""Disconnect from Tray, process last logs"""
if not self._is_running:
return
self._is_running = False
self._process_queue()
self._disconnect_from_tray()
def host_connected(self):
"""Send to Tray console that host is ready - icon change. """
log.info("Host {} connected".format(self.host_id))
payload = {
"host": self.host_id,
"action": HostMsgAction.INITIALIZED,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _create_timer(self):
timer = threading.Timer(self.TIMER_TIMEOUT, self._timer_callback)
timer.start()
self._timer = timer
def _timer_callback(self):
if not self._is_running:
return
self._process_queue()
self._create_timer()
def _connect_to_tray(self):
"""Connect to Tray webserver to pass console output. """
if not self._std_available: # not content to log
return
ws = websocket.WebSocket()
webserver_url = os.environ.get("AYON_WEBSERVER_URL")
if not webserver_url:
print("Unknown webserver url, cannot connect to pass log")
return
webserver_url = webserver_url.replace("http", "ws")
ws.connect("{}/ws/host_listener".format(webserver_url))
self.webserver_client = ws
payload = {
"host": self.host_id,
"action": HostMsgAction.CONNECTING,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _disconnect_from_tray(self):
"""Send to Tray that host is closing - remove from Services. """
print("Host {} closing".format(self.host_name))
if not self.webserver_client:
return
payload = {
"host": self.host_id,
"action": HostMsgAction.CLOSE,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
self.webserver_client.close()
def _catch_std_outputs(self):
"""Redirects standard out and error to own functions"""
if sys.stdout:
self.original_stdout_write = sys.stdout.write
sys.stdout.write = self._my_stdout_write
self._std_available = True
if sys.stderr:
self.original_stderr_write = sys.stderr.write
sys.stderr.write = self._my_stderr_write
self._std_available = True
def _my_stdout_write(self, text):
"""Appends outputted text to queue, keep writing to original stdout"""
if self.original_stdout_write is not None:
self.original_stdout_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _my_stderr_write(self, text):
"""Appends outputted text to queue, keep writing to original stderr"""
if self.original_stderr_write is not None:
self.original_stderr_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _process_queue(self):
"""Sends lines and purges queue"""
if not self.send_to_tray:
return
lines = tuple(self.log_queue)
self.log_queue.clear()
if lines:
payload = {
"host": self.host_id,
"action": HostMsgAction.ADD,
"text": "\n".join(lines)
}
self._send(payload)
def _send(self, payload):
"""Worker method to send to existing websocket connection."""
if not self.send_to_tray:
return
try:
self.webserver_client.send(json.dumps(payload))
except ConnectionResetError: # Tray closed
self._connect_to_tray()

View file

@ -447,8 +447,10 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
def initialize_addons(self):
self._initializing_addons = True
self.tray_man.initialize_addons()
self._initializing_addons = False
try:
self.tray_man.initialize_addons()
finally:
self._initializing_addons = False
def _click_timer_timeout(self):
self._click_timer.stop()

View file

@ -2,7 +2,7 @@ import time
from datetime import datetime
import logging
from qtpy import QtWidgets
from qtpy import QtWidgets, QtGui
log = logging.getLogger(__name__)
@ -106,3 +106,80 @@ class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
def displayText(self, value, locale):
if value is not None:
return pretty_timestamp(value)
class StatusDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
def __init__(
self,
status_name_role,
status_short_name_role,
status_color_role,
status_icon_role,
*args, **kwargs
):
super().__init__(*args, **kwargs)
self.status_name_role = status_name_role
self.status_short_name_role = status_short_name_role
self.status_color_role = status_color_role
self.status_icon_role = status_icon_role
def paint(self, painter, option, index):
if option.widget:
style = option.widget.style()
else:
style = QtWidgets.QApplication.style()
style.drawControl(
QtWidgets.QCommonStyle.CE_ItemViewItem,
option,
painter,
option.widget
)
painter.save()
text_rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemText,
option
)
text_margin = style.proxy().pixelMetric(
QtWidgets.QCommonStyle.PM_FocusFrameHMargin,
option,
option.widget
) + 1
padded_text_rect = text_rect.adjusted(
text_margin, 0, - text_margin, 0
)
fm = QtGui.QFontMetrics(option.font)
text = self._get_status_name(index)
if padded_text_rect.width() < fm.width(text):
text = self._get_status_short_name(index)
fg_color = self._get_status_color(index)
pen = painter.pen()
pen.setColor(fg_color)
painter.setPen(pen)
painter.drawText(
padded_text_rect,
option.displayAlignment,
text
)
painter.restore()
def _get_status_name(self, index):
return index.data(self.status_name_role)
def _get_status_short_name(self, index):
return index.data(self.status_short_name_role)
def _get_status_color(self, index):
return QtGui.QColor(index.data(self.status_color_role))
def _get_status_icon(self, index):
if self.status_icon_role is not None:
return index.data(self.status_icon_role)
return None

View file

@ -370,6 +370,8 @@ class FoldersWidget(QtWidgets.QWidget):
"""
self._folders_proxy_model.setFilterFixedString(name)
if name:
self._folders_view.expandAll()
def refresh(self):
"""Refresh folders model.

View file

@ -1,6 +1,7 @@
import os
import sys
import contextlib
import collections
from functools import partial
from qtpy import QtWidgets, QtCore, QtGui
@ -196,16 +197,16 @@ def get_openpype_qt_app():
return get_ayon_qt_app()
def iter_model_rows(model, column, include_root=False):
def iter_model_rows(model, column=0, include_root=False):
"""Iterate over all row indices in a model"""
indices = [QtCore.QModelIndex()] # start iteration at root
for index in indices:
indexes_queue = collections.deque()
# start iteration at root
indexes_queue.append(QtCore.QModelIndex())
while indexes_queue:
index = indexes_queue.popleft()
# 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)
for child_row in range(model.rowCount(index)):
indexes_queue.append(model.index(child_row, column, index))
if not include_root and not index.isValid():
continue

View file

@ -79,12 +79,12 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
exclude = [
"client/ayon_core/hosts/unreal/integration/*",
"client/ayon_core/hosts/aftereffects/api/extension/js/libs/*",
"client/ayon_core/hosts/hiero/api/startup/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/CelAction/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/HarmonyAYON/*",
"client/ayon_core/modules/click_wrap.py",
"client/ayon_core/scripts/slates/__init__.py"
"client/ayon_core/scripts/slates/__init__.py",
"server_addon/hiero/client/ayon_hiero/api/startup/*",
"server_addon/aftereffects/client/ayon_aftereffects/api/extension/js/libs/*"
]
[tool.ruff.lint.per-file-ignores]
@ -108,6 +108,10 @@ line-ending = "auto"
# Ignore words that are not in the dictionary.
ignore-words-list = "ayon,ynput,parms,parm,hda,developpement,ue"
# Ignore lines that contain this regex. This is hack for missing inline ignore.
# Remove with next codespell release (>2.2.6)
ignore-regex = ".*codespell:ignore.*"
skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/extension/js/libs/*"
count = true
quiet-level = 3

View file

@ -798,7 +798,7 @@ class PublishPuginsModel(BaseSettingsModel):
)
ValidateOutdatedContainers: PluginStateByHostModel = SettingsField(
default_factory=PluginStateByHostModel,
title="Validate Containers"
title="Validate Outdated Containers"
)
ValidateIntent: ValidateIntentModel = SettingsField(
default_factory=ValidateIntentModel,

View file

@ -118,6 +118,15 @@ class WorkfilesLockProfile(BaseSettingsModel):
enabled: bool = SettingsField(True, title="Enabled")
class AYONMenuModel(BaseSettingsModel):
_layout = "expanded"
version_up_current_workfile: bool = SettingsField(
False,
title="Version Up Workfile",
description="Add 'Version Up Workfile' to AYON menu"
)
class WorkfilesToolModel(BaseSettingsModel):
workfile_template_profiles: list[WorkfileTemplateProfile] = SettingsField(
default_factory=list,
@ -268,6 +277,10 @@ class PublishToolModel(BaseSettingsModel):
class GlobalToolsModel(BaseSettingsModel):
ayon_menu: AYONMenuModel = SettingsField(
default_factory=AYONMenuModel,
title="AYON Menu"
)
creator: CreatorToolModel = SettingsField(
default_factory=CreatorToolModel,
title="Creator"
@ -287,6 +300,9 @@ class GlobalToolsModel(BaseSettingsModel):
DEFAULT_TOOLS_VALUES = {
"ayon_menu": {
"version_up_current_workfile": False
},
"creator": {
"product_types_smart_select": [
{

View file

@ -1,3 +1,4 @@
from .version import __version__
from .addon import (
AFTEREFFECTS_ADDON_ROOT,
AfterEffectsAddon,
@ -6,6 +7,8 @@ from .addon import (
__all__ = (
"__version__",
"AFTEREFFECTS_ADDON_ROOT",
"AfterEffectsAddon",
"get_launch_script_path",

View file

@ -2,11 +2,14 @@ import os
from ayon_core.addon import AYONAddon, IHostAddon
from .version import __version__
AFTEREFFECTS_ADDON_ROOT = os.path.dirname(os.path.abspath(__file__))
class AfterEffectsAddon(AYONAddon, IHostAddon):
name = "aftereffects"
version = __version__
host_name = "aftereffects"
def add_implementation_envs(self, env, _app):

View file

@ -25,7 +25,7 @@ download [Anastasiys Extension Manager](https://install.anastasiy.com/)
The easiest way to get the server and After Effects launch is with:
```
python -c ^"import ayon_core.hosts.photoshop;ayon_core.hosts.aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^"
python -c ^"import ayon_core.hosts.photoshop;ayon_aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^"
```
`avalon.aftereffects.launch` launches the application and server, and also closes the server when After Effects exists.

View file

@ -18,8 +18,8 @@ from ayon_core.lib import Logger, is_in_tests
from ayon_core.pipeline import install_host
from ayon_core.addon import AddonsManager
from ayon_core.tools.utils import host_tools, get_ayon_qt_app
from ayon_core.tools.adobe_webserver.app import WebServerTool
from .webserver import WebServerTool
from .ws_stub import get_stub
from .lib import set_settings
@ -35,7 +35,7 @@ def main(*subprocess_args):
"""Main entrypoint to AE launching, called from pre hook."""
sys.excepthook = safe_excepthook
from ayon_core.hosts.aftereffects.api import AfterEffectsHost
from ayon_aftereffects.api import AfterEffectsHost
host = AfterEffectsHost()
install_host(host)
@ -355,7 +355,7 @@ class AfterEffectsRoute(WebSocketRoute):
return "nothing"
def create_placeholder_route(self):
from ayon_core.hosts.aftereffects.api.workfile_template_builder import \
from ayon_aftereffects.api.workfile_template_builder import \
create_placeholder
partial_method = functools.partial(create_placeholder)
@ -365,7 +365,7 @@ class AfterEffectsRoute(WebSocketRoute):
return "nothing"
def update_placeholder_route(self):
from ayon_core.hosts.aftereffects.api.workfile_template_builder import \
from ayon_aftereffects.api.workfile_template_builder import \
update_placeholder
partial_method = functools.partial(update_placeholder)
@ -375,7 +375,7 @@ class AfterEffectsRoute(WebSocketRoute):
return "nothing"
def build_workfile_template_route(self):
from ayon_core.hosts.aftereffects.api.workfile_template_builder import \
from ayon_aftereffects.api.workfile_template_builder import \
build_workfile_template
partial_method = functools.partial(build_workfile_template)

View file

@ -8,7 +8,7 @@ workfile or others.
import os
import sys
from ayon_core.hosts.aftereffects.api.launch_logic import main as host_main
from ayon_aftereffects.api.launch_logic import main as host_main
# Get current file to locate start point of sys.argv
CURRENT_FILE = os.path.abspath(__file__)

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

View file

@ -14,8 +14,6 @@ from ayon_core.pipeline import (
AYON_INSTANCE_ID,
)
from ayon_core.pipeline.load import any_outdated_containers
import ayon_core.hosts.aftereffects
from ayon_core.host import (
HostBase,
IWorkfileHost,
@ -23,6 +21,7 @@ from ayon_core.host import (
IPublishHost
)
from ayon_core.tools.utils import get_ayon_qt_app
from ayon_aftereffects import AFTEREFFECTS_ADDON_ROOT
from .launch_logic import get_stub
from .ws_stub import ConnectionNotEstablishedYet
@ -30,10 +29,7 @@ from .ws_stub import ConnectionNotEstablishedYet
log = Logger.get_logger(__name__)
HOST_DIR = os.path.dirname(
os.path.abspath(ayon_core.hosts.aftereffects.__file__)
)
PLUGINS_DIR = os.path.join(HOST_DIR, "plugins")
PLUGINS_DIR = os.path.join(AFTEREFFECTS_ADDON_ROOT, "plugins")
PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish")
LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")

View file

@ -0,0 +1,241 @@
"""Webserver for communication with AfterEffects.
Aiohttp (Asyncio) based websocket server used for communication with host
application.
This webserver is started in spawned Python process that opens DCC during
its launch, waits for connection from DCC and handles communication going
forward. Server is closed before Python process is killed.
"""
import os
import logging
import urllib
import threading
import asyncio
import socket
from aiohttp import web
from wsrpc_aiohttp import WSRPCClient
from ayon_core.pipeline import get_global_context
log = logging.getLogger(__name__)
class WebServerTool:
"""
Basic POC implementation of asychronic websocket RPC server.
Uses class in external_app_1.py to mimic implementation for single
external application.
'test_client' folder contains two test implementations of client
"""
_instance = None
def __init__(self):
WebServerTool._instance = self
self.client = None
self.handlers = {}
self.on_stop_callbacks = []
port = None
host_name = "localhost"
websocket_url = os.getenv("WEBSOCKET_URL")
if websocket_url:
parsed = urllib.parse.urlparse(websocket_url)
port = parsed.port
host_name = parsed.netloc.split(":")[0]
if not port:
port = 8098 # fallback
self.port = port
self.host_name = host_name
self.app = web.Application()
# add route with multiple methods for single "external app"
self.webserver_thread = WebServerThread(self, self.port)
def add_route(self, *args, **kwargs):
self.app.router.add_route(*args, **kwargs)
def add_static(self, *args, **kwargs):
self.app.router.add_static(*args, **kwargs)
def start_server(self):
if self.webserver_thread and not self.webserver_thread.is_alive():
self.webserver_thread.start()
def stop_server(self):
self.stop()
async def send_context_change(self, host):
"""
Calls running webserver to inform about context change
Used when new PS/AE should be triggered,
but one already running, without
this publish would point to old context.
"""
client = WSRPCClient(os.getenv("WEBSOCKET_URL"),
loop=asyncio.get_event_loop())
await client.connect()
context = get_global_context()
project_name = context["project_name"]
folder_path = context["folder_path"]
task_name = context["task_name"]
log.info("Sending context change to {}{}/{}".format(
project_name, folder_path, task_name
))
await client.call(
'{}.set_context'.format(host),
project=project_name,
folder=folder_path,
task=task_name
)
await client.close()
def port_occupied(self, host_name, port):
"""
Check if 'url' is already occupied.
This could mean, that app is already running and we are trying open it
again. In that case, use existing running webserver.
Check here is easier than capturing exception from thread.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as con:
result = con.connect_ex((host_name, port)) == 0
if result:
print(f"Port {port} is already in use")
return result
def call(self, func):
log.debug("websocket.call {}".format(func))
future = asyncio.run_coroutine_threadsafe(
func,
self.webserver_thread.loop
)
result = future.result()
return result
@staticmethod
def get_instance():
if WebServerTool._instance is None:
WebServerTool()
return WebServerTool._instance
@property
def is_running(self):
if not self.webserver_thread:
return False
return self.webserver_thread.is_running
def stop(self):
if not self.is_running:
return
try:
log.debug("Stopping websocket server")
self.webserver_thread.is_running = False
self.webserver_thread.stop()
except Exception:
log.warning(
"Error has happened during Killing websocket server",
exc_info=True
)
def thread_stopped(self):
for callback in self.on_stop_callbacks:
callback()
class WebServerThread(threading.Thread):
""" Listener for websocket rpc requests.
It would be probably better to "attach" this to main thread (as for
example Harmony needs to run something on main thread), but currently
it creates separate thread and separate asyncio event loop
"""
def __init__(self, module, port):
super(WebServerThread, self).__init__()
self.is_running = False
self.port = port
self.module = module
self.loop = None
self.runner = None
self.site = None
self.tasks = []
def run(self):
self.is_running = True
try:
log.info("Starting web server")
self.loop = asyncio.new_event_loop() # create new loop for thread
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self.start_server())
websocket_url = "ws://localhost:{}/ws".format(self.port)
log.debug(
"Running Websocket server on URL: \"{}\"".format(websocket_url)
)
asyncio.ensure_future(self.check_shutdown(), loop=self.loop)
self.loop.run_forever()
except Exception:
self.is_running = False
log.warning(
"Websocket Server service has failed", exc_info=True
)
raise
finally:
self.loop.close() # optional
self.is_running = False
self.module.thread_stopped()
log.info("Websocket server stopped")
async def start_server(self):
""" Starts runner and TCPsite """
self.runner = web.AppRunner(self.module.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, 'localhost', self.port)
await self.site.start()
def stop(self):
"""Sets is_running flag to false, 'check_shutdown' shuts server down"""
self.is_running = False
async def check_shutdown(self):
""" Future that is running and checks if server should be running
periodically.
"""
while self.is_running:
while self.tasks:
task = self.tasks.pop(0)
log.debug("waiting for task {}".format(task))
await task
log.debug("returned value {}".format(task.result))
await asyncio.sleep(0.5)
log.debug("Starting shutdown")
await self.site.stop()
log.debug("Site stopped")
await self.runner.cleanup()
log.debug("Runner stopped")
tasks = [task for task in asyncio.all_tasks() if
task is not asyncio.current_task()]
list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks
results = await asyncio.gather(*tasks, return_exceptions=True)
log.debug(f'Finished awaiting cancelled tasks, results: {results}...')
await self.loop.shutdown_asyncgens()
# to really make sure everything else has time to stop
await asyncio.sleep(0.07)
self.loop.stop()

View file

@ -12,7 +12,7 @@ from ayon_core.pipeline.workfile.workfile_template_builder import (
PlaceholderPlugin,
PlaceholderItem
)
from ayon_core.hosts.aftereffects.api import get_stub
from ayon_aftereffects.api import get_stub
PLACEHOLDER_SET = "PLACEHOLDERS_SET"
PLACEHOLDER_ID = "openpype.placeholder"

View file

@ -8,7 +8,8 @@ import logging
import attr
from wsrpc_aiohttp import WebSocketAsync
from ayon_core.tools.adobe_webserver.app import WebServerTool
from .webserver import WebServerTool
class ConnectionNotEstablishedYet(Exception):

View file

@ -7,7 +7,7 @@ from ayon_core.lib import (
is_using_ayon_console,
)
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.aftereffects import get_launch_script_path
from ayon_aftereffects import get_launch_script_path
def get_launch_kwargs(kwargs):

View file

@ -2,16 +2,16 @@ import re
from ayon_core import resources
from ayon_core.lib import BoolDef, UISeparatorDef
from ayon_core.hosts.aftereffects import api
from ayon_core.pipeline import (
Creator,
CreatedInstance,
CreatorError
)
from ayon_core.hosts.aftereffects.api.pipeline import cache_and_get_instances
from ayon_core.hosts.aftereffects.api.lib import set_settings
from ayon_core.lib import prepare_template_data
from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS
from ayon_aftereffects import api
from ayon_aftereffects.api.pipeline import cache_and_get_instances
from ayon_aftereffects.api.lib import set_settings
class RenderCreator(Creator):

View file

@ -1,11 +1,11 @@
import ayon_api
import ayon_core.hosts.aftereffects.api as api
from ayon_core.pipeline import (
AutoCreator,
CreatedInstance
)
from ayon_core.hosts.aftereffects.api.pipeline import cache_and_get_instances
from ayon_aftereffects import api
from ayon_aftereffects.api.pipeline import cache_and_get_instances
class AEWorkfileCreator(AutoCreator):

View file

@ -1,9 +1,9 @@
import re
from ayon_core.pipeline import get_representation_path
from ayon_core.hosts.aftereffects import api
from ayon_core.hosts.aftereffects.api.lib import (
from ayon_aftereffects import api
from ayon_aftereffects.api.lib import (
get_background_layers,
get_unique_layer_name,
)

View file

@ -1,8 +1,8 @@
import re
from ayon_core.pipeline import get_representation_path
from ayon_core.hosts.aftereffects import api
from ayon_core.hosts.aftereffects.api.lib import get_unique_layer_name
from ayon_aftereffects import api
from ayon_aftereffects.api.lib import get_unique_layer_name
class FileLoader(api.AfterEffectsLoader):

View file

@ -1,6 +1,6 @@
import pyblish.api
from ayon_core.hosts.aftereffects.api import get_stub
from ayon_aftereffects.api import get_stub
class AddPublishHighlight(pyblish.api.InstancePlugin):

View file

@ -2,7 +2,7 @@
"""Close AE after publish. For Webpublishing only."""
import pyblish.api
from ayon_core.hosts.aftereffects.api import get_stub
from ayon_aftereffects.api import get_stub
class CloseAE(pyblish.api.ContextPlugin):

View file

@ -2,7 +2,7 @@ import os
import pyblish.api
from ayon_core.hosts.aftereffects.api import get_stub
from ayon_aftereffects.api import get_stub
class CollectAudio(pyblish.api.ContextPlugin):

Some files were not shown because too many files have changed in this diff Show more