diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c0ab04abef..2cef7d13b0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,20 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.3.2 + - 1.3.1 + - 1.3.0 + - 1.2.0 + - 1.1.9 + - 1.1.8 + - 1.1.7 + - 1.1.6 + - 1.1.5 + - 1.1.4 + - 1.1.3 + - 1.1.2 + - 1.1.1 + - 1.1.0 - 1.0.14 - 1.0.13 - 1.0.12 diff --git a/.github/workflows/update_bug_report.yml b/.github/workflows/update_bug_report.yml index 1e5da414bb..98a8454e4b 100644 --- a/.github/workflows/update_bug_report.yml +++ b/.github/workflows/update_bug_report.yml @@ -1,10 +1,11 @@ name: 🐞 Update Bug Report on: + workflow_run: + workflows: ["🚀 Release Trigger"] + types: + - completed workflow_dispatch: - release: - # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release - types: [published] jobs: update-bug-report: diff --git a/.gitignore b/.gitignore index 72c4204dc0..4b2dbb6b63 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,7 @@ poetry.lock .editorconfig .pre-commit-config.yaml mypy.ini +poetry.lock .github_changelog_generator diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index 6a7ce8a3cb..a8cf51ae25 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -1,42 +1,38 @@ -# -*- coding: utf-8 -*- +"""Addons for AYON.""" from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, -) - from .base import ( - ProcessPreparationError, - ProcessContext, - AYONAddon, AddonsManager, + AYONAddon, + ProcessContext, + ProcessPreparationError, load_addons, ) - +from .interfaces import ( + IHostAddon, + IPluginPaths, + ITraits, + ITrayAction, + ITrayAddon, + ITrayService, +) from .utils import ( ensure_addons_are_process_context_ready, ensure_addons_are_process_ready, ) - __all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "ProcessPreparationError", - "ProcessContext", "AYONAddon", "AddonsManager", - "load_addons", - + "IHostAddon", + "IPluginPaths", + "ITraits", + "ITrayAction", + "ITrayAddon", + "ITrayService", + "ProcessContext", + "ProcessPreparationError", + "click_wrap", "ensure_addons_are_process_context_ready", "ensure_addons_are_process_ready", + "load_addons", ) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 72191e3453..232c056fb4 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,16 +1,27 @@ +"""Addon interfaces for AYON.""" +from __future__ import annotations + from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Optional, Type from ayon_core import resources +if TYPE_CHECKING: + from qtpy import QtWidgets + + from ayon_core.addon.base import AddonsManager + from ayon_core.pipeline.traits import TraitBase + from ayon_core.tools.tray.ui.tray import TrayManager + class _AYONInterfaceMeta(ABCMeta): - """AYONInterface meta class to print proper string.""" + """AYONInterface metaclass to print proper string.""" - def __str__(self): - return "<'AYONInterface.{}'>".format(self.__name__) + def __str__(cls): + return f"<'AYONInterface.{cls.__name__}'>" - def __repr__(self): - return str(self) + def __repr__(cls): + return str(cls) class AYONInterface(metaclass=_AYONInterfaceMeta): @@ -24,7 +35,7 @@ class AYONInterface(metaclass=_AYONInterfaceMeta): in the interface. By default, interface does not have any abstract parts. """ - pass + log = None class IPluginPaths(AYONInterface): @@ -38,10 +49,25 @@ class IPluginPaths(AYONInterface): """ @abstractmethod - def get_plugin_paths(self): - pass + def get_plugin_paths(self) -> dict[str, list[str]]: + """Return plugin paths for addon. - def _get_plugin_paths_by_type(self, plugin_type): + Returns: + dict[str, list[str]]: Plugin paths for addon. + + """ + + def _get_plugin_paths_by_type( + self, plugin_type: str) -> list[str]: + """Get plugin paths by type. + + Args: + plugin_type (str): Type of plugin paths to get. + + Returns: + list[str]: List of plugin paths. + + """ paths = self.get_plugin_paths() if not paths or plugin_type not in paths: return [] @@ -54,14 +80,18 @@ class IPluginPaths(AYONInterface): paths = [paths] return paths - def get_launcher_action_paths(self): + def get_launcher_action_paths(self) -> list[str]: """Receive launcher actions paths. Give addons ability to add launcher actions paths. + + Returns: + list[str]: List of launcher action paths. + """ return self._get_plugin_paths_by_type("actions") - def get_create_plugin_paths(self, host_name): + def get_create_plugin_paths(self, host_name: str) -> list[str]: """Receive create plugin paths. Give addons ability to add create plugin paths based on host name. @@ -72,11 +102,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of create plugin paths. + + """ return self._get_plugin_paths_by_type("create") - def get_load_plugin_paths(self, host_name): + def get_load_plugin_paths(self, host_name: str) -> list[str]: """Receive load plugin paths. Give addons ability to add load plugin paths based on host name. @@ -87,11 +120,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of load plugin paths. + + """ return self._get_plugin_paths_by_type("load") - def get_publish_plugin_paths(self, host_name): + def get_publish_plugin_paths(self, host_name: str) -> list[str]: """Receive publish plugin paths. Give addons ability to add publish plugin paths based on host name. @@ -102,11 +138,14 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of publish plugin paths. + + """ return self._get_plugin_paths_by_type("publish") - def get_inventory_action_paths(self, host_name): + def get_inventory_action_paths(self, host_name: str) -> list[str]: """Receive inventory action paths. Give addons ability to add inventory action plugin paths. @@ -117,77 +156,84 @@ class IPluginPaths(AYONInterface): Args: host_name (str): For which host are the plugins meant. - """ + Returns: + list[str]: List of inventory action plugin paths. + + """ return self._get_plugin_paths_by_type("inventory") class ITrayAddon(AYONInterface): """Addon has special procedures when used in Tray tool. - IMPORTANT: - The addon. still must be usable if is not used in tray even if - would do nothing. - """ + Important: + The addon. still must be usable if is not used in tray even if it + would do nothing. + """ + manager: AddonsManager tray_initialized = False - _tray_manager = None + _tray_manager: TrayManager = None _admin_submenu = None @abstractmethod - def tray_init(self): + def tray_init(self) -> None: """Initialization part of tray implementation. Triggered between `initialization` and `connect_with_addons`. This is where GUIs should be loaded or tray specific parts should be - prepared. + prepared + """ - pass - @abstractmethod - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: """Add addon's action to tray menu.""" - pass - @abstractmethod - def tray_start(self): + def tray_start(self) -> None: """Start procedure in tray tool.""" - pass - @abstractmethod - def tray_exit(self): + def tray_exit(self) -> None: """Cleanup method which is executed on tray shutdown. This is place where all threads should be shut. + """ - pass + def execute_in_main_thread(self, callback: Callable) -> None: + """Pushes callback to the queue or process 'callback' on a main thread. - def execute_in_main_thread(self, callback): - """ Pushes callback to the queue or process 'callback' on a main thread + Some callbacks need to be processed on main thread (menu actions + must be added on main thread else they won't get triggered etc.) + + Args: + callback (Callable): Function to be executed on main thread - Some callbacks need to be processed on main thread (menu actions - must be added on main thread or they won't get triggered etc.) """ - if not self.tray_initialized: - # TODO Called without initialized tray, still main thread needed + # TODO (Illicit): Called without initialized tray, still + # main thread needed. try: callback() - except Exception: + except Exception: # noqa: BLE001 self.log.warning( - "Failed to execute {} in main thread".format(callback), - exc_info=True) + "Failed to execute %s callback in main thread", + str(callback), exc_info=True) return - self.manager.tray_manager.execute_in_main_thread(callback) + self._tray_manager.tray_manager.execute_in_main_thread(callback) - def show_tray_message(self, title, message, icon=None, msecs=None): + def show_tray_message( + self, + title: str, + message: str, + icon: Optional[QtWidgets.QSystemTrayIcon] = None, + msecs: Optional[int] = None) -> None: """Show tray message. Args: @@ -198,16 +244,22 @@ class ITrayAddon(AYONInterface): msecs (int): Duration of message visibility in milliseconds. Default is 10000 msecs, may differ by Qt version. """ - if self._tray_manager: self._tray_manager.show_tray_message(title, message, icon, msecs) - def add_doubleclick_callback(self, callback): + def add_doubleclick_callback(self, callback: Callable) -> None: + """Add callback to be triggered on tray icon double click.""" if hasattr(self.manager, "add_doubleclick_callback"): self.manager.add_doubleclick_callback(self, callback) @staticmethod - def admin_submenu(tray_menu): + def admin_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create admin submenu. + + Returns: + QtWidgets.QMenu: Admin submenu. + + """ if ITrayAddon._admin_submenu is None: from qtpy import QtWidgets @@ -217,7 +269,18 @@ class ITrayAddon(AYONInterface): return ITrayAddon._admin_submenu @staticmethod - def add_action_to_admin_submenu(label, tray_menu): + def add_action_to_admin_submenu( + label: str, tray_menu: QtWidgets.QMenu) -> QtWidgets.QAction: + """Add action to admin submenu. + + Args: + label (str): Label of action. + tray_menu (QtWidgets.QMenu): Tray menu to add action to. + + Returns: + QtWidgets.QAction: Action added to admin submenu + + """ from qtpy import QtWidgets menu = ITrayAddon.admin_submenu(tray_menu) @@ -244,16 +307,15 @@ class ITrayAction(ITrayAddon): @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass @abstractmethod - def on_action_trigger(self): + def on_action_trigger(self) -> None: """What happens on actions click.""" - pass - def tray_menu(self, tray_menu): + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add action to tray menu.""" from qtpy import QtWidgets if self.admin_action: @@ -265,36 +327,44 @@ class ITrayAction(ITrayAddon): action.triggered.connect(self.on_action_trigger) self._action_item = action - def tray_start(self): + def tray_start(self) -> None: # noqa: PLR6301 + """Start procedure in tray tool.""" return - def tray_exit(self): + def tray_exit(self) -> None: # noqa: PLR6301 + """Cleanup method which is executed on tray shutdown.""" return class ITrayService(ITrayAddon): + """Tray service Interface.""" # Module's property - menu_action = None + menu_action: QtWidgets.QAction = None # Class properties - _services_submenu = None - _icon_failed = None - _icon_running = None - _icon_idle = None + _services_submenu: QtWidgets.QMenu = None + _icon_failed: QtWidgets.QIcon = None + _icon_running: QtWidgets.QIcon = None + _icon_idle: QtWidgets.QIcon = None @property @abstractmethod - def label(self): + def label(self) -> str: """Service label showed in menu.""" - pass - # TODO be able to get any sort of information to show/print + # TODO (Illicit): be able to get any sort of information to show/print # @abstractmethod # def get_service_info(self): # pass @staticmethod - def services_submenu(tray_menu): + def services_submenu(tray_menu: QtWidgets.QMenu) -> QtWidgets.QMenu: + """Get or create services submenu. + + Returns: + QtWidgets.QMenu: Services submenu. + + """ if ITrayService._services_submenu is None: from qtpy import QtWidgets @@ -304,13 +374,15 @@ class ITrayService(ITrayAddon): return ITrayService._services_submenu @staticmethod - def add_service_action(action): + def add_service_action(action: QtWidgets.QAction) -> None: + """Add service action to services submenu.""" ITrayService._services_submenu.addAction(action) if not ITrayService._services_submenu.menuAction().isVisible(): ITrayService._services_submenu.menuAction().setVisible(True) @staticmethod - def _load_service_icons(): + def _load_service_icons() -> None: + """Load service icons.""" from qtpy import QtGui ITrayService._failed_icon = QtGui.QIcon( @@ -324,24 +396,43 @@ class ITrayService(ITrayAddon): ) @staticmethod - def get_icon_running(): + def get_icon_running() -> QtWidgets.QIcon: + """Get running icon. + + Returns: + QtWidgets.QIcon: Returns "running" icon. + + """ if ITrayService._icon_running is None: ITrayService._load_service_icons() return ITrayService._icon_running @staticmethod - def get_icon_idle(): + def get_icon_idle() -> QtWidgets.QIcon: + """Get idle icon. + + Returns: + QtWidgets.QIcon: Returns "idle" icon. + + """ if ITrayService._icon_idle is None: ITrayService._load_service_icons() return ITrayService._icon_idle @staticmethod - def get_icon_failed(): - if ITrayService._failed_icon is None: - ITrayService._load_service_icons() - return ITrayService._failed_icon + def get_icon_failed() -> QtWidgets.QIcon: + """Get failed icon. - def tray_menu(self, tray_menu): + Returns: + QtWidgets.QIcon: Returns "failed" icon. + + """ + if ITrayService._icon_failed is None: + ITrayService._load_service_icons() + return ITrayService._icon_failed + + def tray_menu(self, tray_menu: QtWidgets.QMenu) -> None: + """Add service to tray menu.""" from qtpy import QtWidgets action = QtWidgets.QAction( @@ -354,21 +445,18 @@ class ITrayService(ITrayAddon): self.set_service_running_icon() - def set_service_running_icon(self): + def set_service_running_icon(self) -> None: """Change icon of an QAction to green circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_running()) - def set_service_failed_icon(self): + def set_service_failed_icon(self) -> None: """Change icon of an QAction to red circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_failed()) - def set_service_idle_icon(self): + def set_service_idle_icon(self) -> None: """Change icon of an QAction to orange circle.""" - if self.menu_action: self.menu_action.setIcon(self.get_icon_idle()) @@ -378,18 +466,29 @@ class IHostAddon(AYONInterface): @property @abstractmethod - def host_name(self): + def host_name(self) -> str: """Name of host which addon represents.""" - pass - - def get_workfile_extensions(self): + def get_workfile_extensions(self) -> list[str]: # noqa: PLR6301 """Define workfile extensions for host. Not all hosts support workfiles thus this is optional implementation. Returns: List[str]: Extensions used for workfiles with dot. - """ + """ return [] + + +class ITraits(AYONInterface): + """Interface for traits.""" + + @abstractmethod + def get_addon_traits(self) -> list[Type[TraitBase]]: + """Get trait classes for the addon. + + Returns: + list[Type[TraitBase]]: Traits for the addon. + + """ diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py index f983e37d3c..bb365f42e1 100644 --- a/client/ayon_core/addon/utils.py +++ b/client/ayon_core/addon/utils.py @@ -37,7 +37,7 @@ def _handle_error( if process_context.headless: if detail: print(detail) - print(f"{10*'*'}\n{message}\n{10*'*'}") + print(f"{10 * '*'}\n{message}\n{10 * '*'}") return current_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 6f89a6d17d..2340696ad9 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -24,7 +24,6 @@ from ayon_core.lib.env_tools import ( ) - @click.group(invoke_without_command=True) @click.pass_context @click.option("--use-staging", is_flag=True, @@ -173,7 +172,6 @@ def contextselection( main(output_path, project, folder, strict) - @main_cli.command( context_settings=dict( ignore_unknown_options=True, @@ -237,6 +235,30 @@ def version(build): print(os.environ["AYON_VERSION"]) +@main_cli.command() +@click.option( + "--project", + type=str, + help="Project name", + required=True) +def create_project_structure( + project, +): + """Create project folder structure as defined in setting + `ayon+settings://core/project_folder_structure` + + Args: + project (str): The name of the project for which you + want to create its additional folder structure. + + """ + + from ayon_core.pipeline.project_folders import create_project_folders + + print(f">>> Creating project folder structure for project '{project}'.") + create_project_folders(project) + + def _set_global_environments() -> None: """Set global AYON environments.""" # First resolve general environment diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index 23f725901c..83c4118136 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -32,8 +32,8 @@ class GlobalHostDataHook(PreLaunchHook): "app": app, "project_entity": self.data["project_entity"], - "folder_entity": self.data["folder_entity"], - "task_entity": self.data["task_entity"], + "folder_entity": self.data.get("folder_entity"), + "task_entity": self.data.get("task_entity"), "anatomy": self.data["anatomy"], diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 9f5c8c7339..d1a02e613d 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -29,6 +29,15 @@ class OCIOEnvHook(PreLaunchHook): def execute(self): """Hook entry method.""" + task_entity = self.data.get("task_entity") + + if not task_entity: + self.log.info( + "Skipping OCIO Environment preparation." + "Task Entity is not available." + ) + return + folder_entity = self.data["folder_entity"] template_data = get_template_data( diff --git a/client/ayon_core/hooks/pre_remove_launcher_paths.py b/client/ayon_core/hooks/pre_remove_launcher_paths.py new file mode 100644 index 0000000000..df27e512d0 --- /dev/null +++ b/client/ayon_core/hooks/pre_remove_launcher_paths.py @@ -0,0 +1,30 @@ +""""Pre launch hook to remove launcher paths from the system.""" +import os + +from ayon_applications import PreLaunchHook + + +class PreRemoveLauncherPaths(PreLaunchHook): + """Remove launcher paths from the system. + + This hook is used to remove launcher paths from the system before launching + an application. It is used to ensure that the application is launched with + the correct environment variables. Especially for Windows, where + paths in `PATH` are used to load DLLs. This is important to avoid + conflicts with other applications that may have the same DLLs in their + paths. + """ + order = 1 + + def execute(self) -> None: + """Execute the hook.""" + # Remove launcher paths from the system + ayon_root = os.path.normpath(os.environ["AYON_ROOT"]) + + paths = [ + path + for path in self.launch_context.env.get( + "PATH", "").split(os.pathsep) + if not os.path.normpath(path).startswith(ayon_root) + ] + self.launch_context.env["PATH"] = os.pathsep.join(paths) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 92c3966e77..477eb29c28 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -62,6 +62,7 @@ from .execute import ( run_subprocess, run_detached_process, run_ayon_launcher_process, + run_detached_ayon_launcher_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -98,7 +99,6 @@ from .profiles_filtering import ( from .transcoding import ( get_transcode_temp_directory, should_convert_for_ffmpeg, - convert_for_ffmpeg, convert_input_paths_for_ffmpeg, get_ffprobe_data, get_ffprobe_streams, @@ -132,6 +132,7 @@ from .ayon_info import ( is_staging_enabled, is_dev_mode_enabled, is_in_tests, + get_settings_variant, ) terminal = Terminal @@ -161,6 +162,7 @@ __all__ = [ "run_subprocess", "run_detached_process", "run_ayon_launcher_process", + "run_detached_ayon_launcher_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -198,7 +200,6 @@ __all__ = [ "get_transcode_temp_directory", "should_convert_for_ffmpeg", - "convert_for_ffmpeg", "convert_input_paths_for_ffmpeg", "get_ffprobe_data", "get_ffprobe_streams", @@ -242,4 +243,5 @@ __all__ = [ "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", + "get_settings_variant", ] diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 6b334aa16a..cb74fea0f1 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -22,12 +22,10 @@ import clique if typing.TYPE_CHECKING: from typing import Self, Tuple, Union, TypedDict, Pattern - class EnumItemDict(TypedDict): label: str value: Any - EnumItemsInputType = Union[ Dict[Any, str], List[Tuple[Any, str]], @@ -35,7 +33,6 @@ if typing.TYPE_CHECKING: List[EnumItemDict] ] - class FileDefItemDict(TypedDict): directory: str filenames: List[str] @@ -289,6 +286,7 @@ AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef) # UI attribute definitions won't hold value # ----------------------------------------- + class UIDef(AbstractAttrDef): is_value_def = False diff --git a/client/ayon_core/lib/ayon_connection.py b/client/ayon_core/lib/ayon_connection.py index 1132d77aaa..32aa5ad629 100644 --- a/client/ayon_core/lib/ayon_connection.py +++ b/client/ayon_core/lib/ayon_connection.py @@ -177,10 +177,12 @@ def initialize_ayon_connection(force=False): return _new_get_last_versions( con, *args, **kwargs ) + def _lv_by_pi_wrapper(*args, **kwargs): return _new_get_last_version_by_product_id( con, *args, **kwargs ) + def _lv_by_pn_wrapper(*args, **kwargs): return _new_get_last_version_by_product_name( con, *args, **kwargs diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index 7e194a824e..1a7e4cca76 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,15 +78,15 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename -def is_headless_mode_enabled(): +def is_headless_mode_enabled() -> bool: return os.getenv("AYON_HEADLESS_MODE") == "1" -def is_staging_enabled(): +def is_staging_enabled() -> bool: return os.getenv("AYON_USE_STAGING") == "1" -def is_in_tests(): +def is_in_tests() -> bool: """Process is running in automatic tests mode. Returns: @@ -96,7 +96,7 @@ def is_in_tests(): return os.environ.get("AYON_IN_TESTS") == "1" -def is_dev_mode_enabled(): +def is_dev_mode_enabled() -> bool: """Dev mode is enabled in AYON. Returns: @@ -106,6 +106,22 @@ def is_dev_mode_enabled(): return os.getenv("AYON_USE_DEV") == "1" +def get_settings_variant() -> str: + """Get AYON settings variant. + + Returns: + str: Settings variant. + + """ + if is_dev_mode_enabled(): + return os.environ["AYON_BUNDLE_NAME"] + + if is_staging_enabled(): + return "staging" + + return "production" + + def get_ayon_info(): executable_args = get_ayon_launcher_args() if is_running_from_build(): diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 516ea958f5..7c6efde35c 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import sys import subprocess @@ -201,29 +202,9 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - ``` - run_ayon_process("run", "") - ``` - - Args: - *args (str): ayon-launcher cli arguments. - **kwargs (Any): Keyword arguments for subprocess.Popen. - - Returns: - str: Full output of subprocess concatenated stdout and stderr. - - """ - args = get_ayon_launcher_args(*args) +def _prepare_ayon_launcher_env( + add_sys_paths: bool, kwargs: dict +) -> dict[str, str]: env = kwargs.pop("env", None) # Keep env untouched if are passed and not empty if not env: @@ -239,8 +220,7 @@ def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): new_pythonpath.append(path) lookup_set.add(path) env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) - - return run_subprocess(args, env=env, **kwargs) + return env def run_detached_process(args, **kwargs): @@ -314,6 +294,67 @@ def run_detached_process(args, **kwargs): return process +def run_ayon_launcher_process( + *args, add_sys_paths: bool = False, **kwargs +) -> str: + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_ayon_launcher_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + str: Full output of subprocess concatenated stdout and stderr. + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_subprocess(args, env=env, **kwargs) + + +def run_detached_ayon_launcher_process( + *args, add_sys_paths: bool = False, **kwargs +) -> subprocess.Popen: + """Execute AYON process with passed arguments and wait. + + Wrapper for 'run_process' which prepends AYON executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_ayon_process' function. + + Example: + ``` + run_detached_ayon_launcher_process("run", "") + ``` + + Args: + *args (str): ayon-launcher cli arguments. + add_sys_paths (bool): Add system paths to PYTHONPATH. + **kwargs (Any): Keyword arguments for subprocess.Popen. + + Returns: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + + """ + args = get_ayon_launcher_args(*args) + env = _prepare_ayon_launcher_env(add_sys_paths, kwargs) + return run_detached_process(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. diff --git a/client/ayon_core/lib/file_transaction.py b/client/ayon_core/lib/file_transaction.py index a502403958..d720ff8d30 100644 --- a/client/ayon_core/lib/file_transaction.py +++ b/client/ayon_core/lib/file_transaction.py @@ -1,15 +1,13 @@ +import concurrent.futures import os import logging -import sys import errno +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Optional from ayon_core.lib import create_hard_link -# this is needed until speedcopy for linux is fixed -if sys.platform == "win32": - from speedcopy import copyfile -else: - from shutil import copyfile +from speedcopy import copyfile class DuplicateDestinationError(ValueError): @@ -109,41 +107,52 @@ class FileTransaction: self._transfers[dst] = (src, opts) def process(self): - # Backup any existing files - for dst, (src, _) in self._transfers.items(): - self.log.debug("Checking file ... {} -> {}".format(src, dst)) - path_same = self._same_paths(src, dst) - if path_same or not os.path.exists(dst): - continue + with ThreadPoolExecutor(max_workers=8) as executor: + # Submit backup tasks + backup_futures = [ + executor.submit(self._backup_file, dst, src) + for dst, (src, _) in self._transfers.items() + ] + wait_for_future_errors( + executor, backup_futures, logger=self.log) - # Backup original file - # todo: add timestamp or uuid to ensure unique - backup = dst + ".bak" - self._backup_to_original[backup] = dst + # Submit transfer tasks + transfer_futures = [ + executor.submit(self._transfer_file, dst, src, opts) + for dst, (src, opts) in self._transfers.items() + ] + wait_for_future_errors( + executor, transfer_futures, logger=self.log) + + def _backup_file(self, dst, src): + self.log.debug(f"Checking file ... {src} -> {dst}") + path_same = self._same_paths(src, dst) + if path_same or not os.path.exists(dst): + return + + # Backup original file + backup = dst + ".bak" + self._backup_to_original[backup] = dst + self.log.debug(f"Backup existing file: {dst} -> {backup}") + os.rename(dst, backup) + + def _transfer_file(self, dst, src, opts): + path_same = self._same_paths(src, dst) + if path_same: self.log.debug( - "Backup existing file: {} -> {}".format(dst, backup)) - os.rename(dst, backup) + f"Source and destination are same files {src} -> {dst}") + return - # Copy the files to transfer - for dst, (src, opts) in self._transfers.items(): - path_same = self._same_paths(src, dst) - if path_same: - self.log.debug( - "Source and destination are same files {} -> {}".format( - src, dst)) - continue + self._create_folder_for_file(dst) - self._create_folder_for_file(dst) + if opts["mode"] == self.MODE_COPY: + self.log.debug(f"Copying file ... {src} -> {dst}") + copyfile(src, dst) + elif opts["mode"] == self.MODE_HARDLINK: + self.log.debug(f"Hardlinking file ... {src} -> {dst}") + create_hard_link(src, dst) - if opts["mode"] == self.MODE_COPY: - self.log.debug("Copying file ... {} -> {}".format(src, dst)) - copyfile(src, dst) - elif opts["mode"] == self.MODE_HARDLINK: - self.log.debug("Hardlinking file ... {} -> {}".format( - src, dst)) - create_hard_link(src, dst) - - self._transferred.append(dst) + self._transferred.append(dst) def finalize(self): # Delete any backed up files @@ -212,3 +221,46 @@ class FileTransaction: return os.stat(src) == os.stat(dst) return src == dst + + +def wait_for_future_errors( + executor: ThreadPoolExecutor, + futures: List[Future], + logger: Optional[logging.Logger] = None): + """For the ThreadPoolExecutor shutdown and cancel futures as soon one of + the workers raises an error as they complete. + + The ThreadPoolExecutor only cancels pending futures on exception but will + still complete those that are running - each which also themselves could + fail. We log all exceptions but re-raise the last exception only. + """ + if logger is None: + logger = logging.getLogger(__name__) + + for future in concurrent.futures.as_completed(futures): + exception = future.exception() + if exception: + # As soon as an error occurs, stop executing more futures. + # Running workers, however, will still be complete, so we also want + # to log those errors if any occurred on them. + executor.shutdown(wait=True, cancel_futures=True) + break + else: + # Futures are completed, no exceptions occurred + return + + # An exception occurred in at least one future. Get exceptions from + # all futures that are done and ended up failing until that point. + exceptions = [] + for future in futures: + if not future.cancelled() and future.done(): + exception = future.exception() + if exception: + exceptions.append(exception) + + # Log any exceptions that occurred in all workers + for exception in exceptions: + logger.error("Error occurred in worker", exc_info=exception) + + # Raise the last exception + raise exceptions[-1] diff --git a/client/ayon_core/lib/terminal.py b/client/ayon_core/lib/terminal.py index 10fcc79a27..ea23feeb95 100644 --- a/client/ayon_core/lib/terminal.py +++ b/client/ayon_core/lib/terminal.py @@ -39,6 +39,7 @@ class Terminal: """ from ayon_core.lib import env_value_to_bool + log_no_colors = env_value_to_bool( "AYON_LOG_NO_COLORS", default=None ) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..8c84e1c4dc 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -526,137 +526,6 @@ def should_convert_for_ffmpeg(src_filepath): return False -# Deprecated since 2022 4 20 -# - Reason - Doesn't convert sequences right way: Can't handle gaps, reuse -# first frame for all frames and changes filenames when input -# is sequence. -# - use 'convert_input_paths_for_ffmpeg' instead -def convert_for_ffmpeg( - first_input_path, - output_dir, - input_frame_start=None, - input_frame_end=None, - logger=None -): - """Convert source file to format supported in ffmpeg. - - Currently can convert only exrs. - - Args: - first_input_path (str): Path to first file of a sequence or a single - file path for non-sequential input. - output_dir (str): Path to directory where output will be rendered. - Must not be same as input's directory. - input_frame_start (int): Frame start of input. - input_frame_end (int): Frame end of input. - logger (logging.Logger): Logger used for logging. - - Raises: - ValueError: If input filepath has extension not supported by function. - Currently is supported only ".exr" extension. - """ - if logger is None: - logger = logging.getLogger(__name__) - - logger.warning(( - "DEPRECATED: 'ayon_core.lib.transcoding.convert_for_ffmpeg' is" - " deprecated function of conversion for FFMpeg. Please replace usage" - " with 'ayon_core.lib.transcoding.convert_input_paths_for_ffmpeg'" - )) - - ext = os.path.splitext(first_input_path)[1].lower() - if ext != ".exr": - raise ValueError(( - "Function 'convert_for_ffmpeg' currently support only" - " \".exr\" extension. Got \"{}\"." - ).format(ext)) - - is_sequence = False - if input_frame_start is not None and input_frame_end is not None: - is_sequence = int(input_frame_end) != int(input_frame_start) - - input_info = get_oiio_info_for_input(first_input_path, logger=logger) - - # Change compression only if source compression is "dwaa" or "dwab" - # - they're not supported in ffmpeg - compression = input_info["attribs"].get("compression") - if compression in ("dwaa", "dwab"): - compression = "none" - - # Prepare subprocess arguments - oiio_cmd = get_oiio_tool_args( - "oiiotool", - # Don't add any additional attributes - "--nosoftwareattrib", - ) - # Add input compression if available - if compression: - oiio_cmd.extend(["--compression", compression]) - - # Collect channels to export - input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - - oiio_cmd.extend([ - input_arg, first_input_path, - # Tell oiiotool which channels should be put to top stack (and output) - "--ch", channels_arg, - # Use first subimage - "--subimage", "0" - ]) - - # Add frame definitions to arguments - if is_sequence: - oiio_cmd.extend([ - "--frames", "{}-{}".format(input_frame_start, input_frame_end) - ]) - - for attr_name, attr_value in input_info["attribs"].items(): - if not isinstance(attr_value, str): - continue - - # Remove attributes that have string value longer than allowed length - # for ffmpeg or when contain prohibited symbols - erase_reason = "Missing reason" - erase_attribute = False - if len(attr_value) > MAX_FFMPEG_STRING_LEN: - erase_reason = "has too long value ({} chars).".format( - len(attr_value) - ) - erase_attribute = True - - if not erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break - - if erase_attribute: - # Set attribute to empty string - logger.info(( - "Removed attribute \"{}\" from metadata because {}." - ).format(attr_name, erase_reason)) - oiio_cmd.extend(["--eraseattrib", attr_name]) - - # Add last argument - path to output - if is_sequence: - ext = os.path.splitext(first_input_path)[1] - base_filename = "tmp.%{:0>2}d{}".format( - len(str(input_frame_end)), ext - ) - else: - base_filename = os.path.basename(first_input_path) - output_path = os.path.join(output_dir, base_filename) - oiio_cmd.extend([ - "-o", output_path - ]) - - logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) - run_subprocess(oiio_cmd, logger=logger) - - def convert_input_paths_for_ffmpeg( input_paths, output_dir, @@ -664,7 +533,7 @@ def convert_input_paths_for_ffmpeg( ): """Convert source file to format supported in ffmpeg. - Currently can convert only exrs. The input filepaths should be files + Can currently convert only EXRs. The input filepaths should be files with same type. Information about input is loaded only from first found file. @@ -691,10 +560,10 @@ def convert_input_paths_for_ffmpeg( ext = os.path.splitext(first_input_path)[1].lower() if ext != ".exr": - raise ValueError(( - "Function 'convert_for_ffmpeg' currently support only" - " \".exr\" extension. Got \"{}\"." - ).format(ext)) + raise ValueError( + "Function 'convert_input_paths_for_ffmpeg' currently supports" + f" only \".exr\" extension. Got \"{ext}\"." + ) input_info = get_oiio_info_for_input(first_input_path, logger=logger) diff --git a/client/ayon_core/lib/vendor_bin_utils.py b/client/ayon_core/lib/vendor_bin_utils.py index 41654476c2..412a9292cc 100644 --- a/client/ayon_core/lib/vendor_bin_utils.py +++ b/client/ayon_core/lib/vendor_bin_utils.py @@ -162,7 +162,7 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None): # Handle cases when path is just an executable # - it allows to use executable from PATH # - basename must match 'tool' value (without extension) - extless_path, ext = os.path.splitext(path) + extless_path, _ext = os.path.splitext(path) if extless_path == tool: executable_path = find_executable(tool) if executable_path and ( @@ -181,7 +181,7 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None): # If path is a file validate it if os.path.isfile(normalized): - basename, ext = os.path.splitext(os.path.basename(path)) + basename, _ext = os.path.splitext(os.path.basename(path)) # Check if the filename has actually the sane bane as 'tool' if basename == tool: executable_path = find_executable(normalized) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 41bcd0dbd1..137736c302 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -100,6 +100,10 @@ from .context_tools import ( get_current_task_name ) +from .compatibility import ( + is_product_base_type_supported, +) + from .workfile import ( discover_workfile_build_plugins, register_workfile_build_plugin, @@ -223,4 +227,7 @@ __all__ = ( # Backwards compatible function names "install", "uninstall", + + # Feature detection + "is_product_base_type_supported", ) diff --git a/client/ayon_core/pipeline/anatomy/anatomy.py b/client/ayon_core/pipeline/anatomy/anatomy.py index 98bbaa9bdc..9885e383b7 100644 --- a/client/ayon_core/pipeline/anatomy/anatomy.py +++ b/client/ayon_core/pipeline/anatomy/anatomy.py @@ -462,8 +462,8 @@ class Anatomy(BaseAnatomy): Union[Dict[str, str], None]): Local root overrides. """ if not project_name: - return - return ayon_api.get_project_roots_for_site( + return None + return ayon_api.get_project_root_overrides_by_site_id( project_name, get_local_site_id() ) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 8c4f97ab1c..4b1d14d570 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -834,7 +834,7 @@ def _get_global_config_data( if not product_entities_by_name: # in case no product was found we need to use fallback - fallback_type = fallback_data["type"] + fallback_type = fallback_data["fallback_type"] return _get_config_path_from_profile_data( fallback_data, fallback_type, template_data ) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py new file mode 100644 index 0000000000..f7d48526b7 --- /dev/null +++ b/client/ayon_core/pipeline/compatibility.py @@ -0,0 +1,16 @@ +"""Package to handle compatibility checks for pipeline components.""" + + +def is_product_base_type_supported() -> bool: + """Check support for product base types. + + This function checks if the current pipeline supports product base types. + Once this feature is implemented, it will return True. This should be used + in places where some kind of backward compatibility is needed to avoid + breaking existing functionality that relies on the current behavior. + + Returns: + bool: True if product base types are supported, False otherwise. + + """ + return False diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6ac6685647..f0d9fa8927 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -872,7 +872,7 @@ class CreateContext: """ return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) - def add_instances_removed_callback (self, callback): + def add_instances_removed_callback(self, callback): """Register callback for removed instances. Event is triggered when instances are already removed from context. @@ -933,7 +933,7 @@ class CreateContext: """ self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) - def add_pre_create_attr_defs_change_callback (self, callback): + def add_pre_create_attr_defs_change_callback(self, callback): """Register callback to listen pre-create attribute changes. Create plugin can trigger refresh of pre-create attributes. Usage of @@ -961,7 +961,7 @@ class CreateContext: PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback ) - def add_create_attr_defs_change_callback (self, callback): + def add_create_attr_defs_change_callback(self, callback): """Register callback to listen create attribute changes. Create plugin changed attribute definitions of instance. @@ -986,7 +986,7 @@ class CreateContext: """ self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) - def add_publish_attr_defs_change_callback (self, callback): + def add_publish_attr_defs_change_callback(self, callback): """Register callback to listen publish attribute changes. Publish plugin changed attribute definitions of instance of context. diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 0daec8a7ad..ecffa4a340 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -52,15 +52,15 @@ def get_product_name_template( # TODO remove formatting keys replacement template = ( matching_profile["template"] - .replace("{task[name]}", "{task}") - .replace("{Task[name]}", "{Task}") - .replace("{TASK[NAME]}", "{TASK}") - .replace("{product[type]}", "{family}") - .replace("{Product[type]}", "{Family}") - .replace("{PRODUCT[TYPE]}", "{FAMILY}") - .replace("{folder[name]}", "{asset}") - .replace("{Folder[name]}", "{Asset}") - .replace("{FOLDER[NAME]}", "{ASSET}") + .replace("{task}", "{task[name]}") + .replace("{Task}", "{Task[name]}") + .replace("{TASK}", "{TASK[NAME]}") + .replace("{family}", "{product[type]}") + .replace("{Family}", "{Product[type]}") + .replace("{FAMILY}", "{PRODUCT[TYPE]}") + .replace("{asset}", "{folder[name]}") + .replace("{Asset}", "{Folder[name]}") + .replace("{ASSET}", "{FOLDER[NAME]}") ) # Make sure template is set (matching may have empty string) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 6b45a5c610..d7ba6b9c24 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -369,7 +369,7 @@ class PublishAttributes: return copy.deepcopy(self._origin_data) def attribute_value_changed(self, key, changes): - self._parent.publish_attribute_value_changed(key, changes) + self._parent.publish_attribute_value_changed(key, changes) def set_publish_plugin_attr_defs( self, diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 55c840f3a5..e686b739ae 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -255,7 +255,7 @@ def deliver_sequence( report_items[""].append(msg) return report_items, 0 - dir_path, file_name = os.path.split(str(src_path)) + dir_path, _file_name = os.path.split(str(src_path)) context = repre["context"] ext = context.get("ext", context.get("representation")) @@ -270,7 +270,7 @@ def deliver_sequence( # context.representation could be .psd ext = ext.replace("..", ".") - src_collections, remainder = clique.assemble(os.listdir(dir_path)) + src_collections, _remainder = clique.assemble(os.listdir(dir_path)) src_collection = None for col in src_collections: if col.tail != ext: diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index c6f3ae7115..0d8e70f9d2 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import copy import os import re @@ -660,14 +660,6 @@ def _get_legacy_product_name_and_group( warnings.warn("Using legacy product name for renders", DeprecationWarning) - if not source_product_name.startswith(product_type): - resulting_group_name = '{}{}{}{}{}'.format( - product_type, - task_name[0].upper(), task_name[1:], - source_product_name[0].upper(), source_product_name[1:]) - else: - resulting_group_name = source_product_name - # create product name `` if not source_product_name.startswith(product_type): resulting_group_name = '{}{}{}{}{}'.format( @@ -1168,7 +1160,7 @@ def prepare_cache_representations(skeleton_data, exp_files, anatomy): """ representations = [] - collections, remainders = clique.assemble(exp_files) + collections, _remainders = clique.assemble(exp_files) log = Logger.get_logger("farm_publishing") diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index bdc5ece620..2a33fa119b 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -49,6 +49,11 @@ from .plugins import ( deregister_loader_plugin_path, register_loader_plugin_path, deregister_loader_plugin, + + register_loader_hook_plugin, + deregister_loader_hook_plugin, + register_loader_hook_plugin_path, + deregister_loader_hook_plugin_path, ) @@ -103,4 +108,10 @@ __all__ = ( "deregister_loader_plugin_path", "register_loader_plugin_path", "deregister_loader_plugin", + + "register_loader_hook_plugin", + "deregister_loader_hook_plugin", + "register_loader_hook_plugin_path", + "deregister_loader_hook_plugin_path", + ) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index b601914acd..dc5bb0f66f 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -1,21 +1,28 @@ -import os -import logging +"""Plugins for loading representations and products into host applications.""" +from __future__ import annotations + +from abc import abstractmethod +import logging +import os +from typing import Any, Optional, Type -from ayon_core.settings import get_project_settings from ayon_core.pipeline.plugin_discover import ( + deregister_plugin, + deregister_plugin_path, discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) +from ayon_core.settings import get_project_settings + from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" - product_types = set() + product_types: set[str] = set() + product_base_types: Optional[set[str]] = None representations = set() extensions = {"*"} order = 0 @@ -58,12 +65,12 @@ class LoaderPlugin(list): if not plugin_settings: return - print(">>> We have preset for {}".format(plugin_name)) + print(f">>> We have preset for {plugin_name}") for option, value in plugin_settings.items(): if option == "enabled" and value is False: print(" - is disabled by preset") else: - print(" - setting `{}`: `{}`".format(option, value)) + print(f" - setting `{option}`: `{value}`") setattr(cls, option, value) @classmethod @@ -76,7 +83,6 @@ class LoaderPlugin(list): Returns: bool: Representation has valid extension """ - if "*" in cls.extensions: return True @@ -121,18 +127,34 @@ class LoaderPlugin(list): """ plugin_repre_names = cls.get_representations() - plugin_product_types = cls.product_types + + # If the product base type isn't defined on the loader plugin, + # then we will use the product types. + plugin_product_filter = cls.product_base_types + if plugin_product_filter is None: + plugin_product_filter = cls.product_types + + if plugin_product_filter: + plugin_product_filter = set(plugin_product_filter) + + repre_entity = context.get("representation") + product_entity = context["product"] + + # If no representation names, product types or extensions are defined + # then loader is not compatible with any context. if ( not plugin_repre_names - or not plugin_product_types + or not plugin_product_filter or not cls.extensions ): return False - repre_entity = context.get("representation") + # If no representation entity is provided then loader is not + # compatible with context. if not repre_entity: return False + # Check the compatibility with the representation names. plugin_repre_names = set(plugin_repre_names) if ( "*" not in plugin_repre_names @@ -140,17 +162,34 @@ class LoaderPlugin(list): ): return False + # Check the compatibility with the extension of the representation. if not cls.has_valid_extension(repre_entity): return False - plugin_product_types = set(plugin_product_types) - if "*" in plugin_product_types: + product_type = product_entity.get("productType") + product_base_type = product_entity.get("productBaseType") + + # Use product base type if defined, otherwise use product type. + product_filter = product_base_type + # If there is no product base type defined in the product entity, + # then we will use the product type. + if product_filter is None: + product_filter = product_type + + # If wildcard is used in product types or base types, + # then we will consider the loader compatible with any product type. + if "*" in plugin_product_filter: return True - product_entity = context["product"] - product_type = product_entity["productType"] + # compatibility with legacy loader + if cls.product_base_types is None and product_base_type: + cls.log.error( + f"Loader {cls.__name__} is doesn't specify " + "`product_base_types` but product entity has " + f"`productBaseType` defined as `{product_base_type}`. " + ) - return product_type in plugin_product_types + return product_filter in plugin_product_filter @classmethod def get_representations(cls): @@ -205,35 +244,20 @@ class LoaderPlugin(list): bool: Whether the container was deleted """ - raise NotImplementedError("Loader.remove() must be " "implemented by subclass") @classmethod def get_options(cls, contexts): - """ - Returns static (cls) options or could collect from 'contexts'. + """Returns static (cls) options or could collect from 'contexts'. - Args: - contexts (list): of repre or product contexts - Returns: - (list) + Args: + contexts (list): of repre or product contexts + Returns: + (list) """ return cls.options or [] - @property - def fname(self): - """Backwards compatibility with deprecation warning""" - - self.log.warning(( - "DEPRECATION WARNING: Source - Loader plugin {}." - " The 'fname' property on the Loader plugin will be removed in" - " future versions of OpenPype. Planned version to drop the support" - " is 3.16.6 or 3.17.0." - ).format(self.__class__.__name__)) - if hasattr(self, "_fname"): - return self._fname - @classmethod def get_representation_name_aliases(cls, representation_name: str): """Return representation names to which switching is allowed from @@ -264,28 +288,152 @@ class ProductLoaderPlugin(LoaderPlugin): """ +class LoaderHookPlugin: + """Plugin that runs before and post specific Loader in 'loaders' + + Should be used as non-invasive method to enrich core loading process. + Any studio might want to modify loaded data before or after + they are loaded without need to override existing core plugins. + + The post methods are called after the loader's methods and receive the + return value of the loader's method as `result` argument. + """ + order = 0 + + @classmethod + @abstractmethod + def is_compatible(cls, Loader: Type[LoaderPlugin]) -> bool: + pass + + @abstractmethod + def pre_load( + self, + plugin: LoaderPlugin, + context: dict, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], + ): + pass + + @abstractmethod + def post_load( + self, + plugin: LoaderPlugin, + result: Any, + context: dict, + name: Optional[str], + namespace: Optional[str], + options: Optional[dict], + ): + pass + + @abstractmethod + def pre_update( + self, + plugin: LoaderPlugin, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def post_update( + self, + plugin: LoaderPlugin, + result: Any, + container: dict, # (ayon:container-3.0) + context: dict, + ): + pass + + @abstractmethod + def pre_remove( + self, + plugin: LoaderPlugin, + container: dict, # (ayon:container-3.0) + ): + pass + + @abstractmethod + def post_remove( + self, + plugin: LoaderPlugin, + result: Any, + container: dict, # (ayon:container-3.0) + ): + pass + + def discover_loader_plugins(project_name=None): from ayon_core.lib import Logger from ayon_core.pipeline import get_current_project_name log = Logger.get_logger("LoaderDiscover") - plugins = discover(LoaderPlugin) if not project_name: project_name = get_current_project_name() project_settings = get_project_settings(project_name) + plugins = discover(LoaderPlugin) + hooks = discover(LoaderHookPlugin) + sorted_hooks = sorted(hooks, key=lambda hook: hook.order) for plugin in plugins: try: plugin.apply_settings(project_settings) except Exception: log.warning( - "Failed to apply settings to loader {}".format( - plugin.__name__ - ), + f"Failed to apply settings to loader {plugin.__name__}", exc_info=True ) + compatible_hooks = [] + for hook_cls in sorted_hooks: + if hook_cls.is_compatible(plugin): + compatible_hooks.append(hook_cls) + add_hooks_to_loader(plugin, compatible_hooks) return plugins +def add_hooks_to_loader( + loader_class: LoaderPlugin, compatible_hooks: list[Type[LoaderHookPlugin]] +) -> None: + """Monkey patch method replacing Loader.load|update|remove methods + + It wraps applicable loaders with pre/post hooks. Discovery is called only + once per loaders discovery. + """ + loader_class._load_hooks = compatible_hooks + + def wrap_method(method_name: str): + original_method = getattr(loader_class, method_name) + + def wrapped_method(self, *args, **kwargs): + # Call pre_ on all hooks + pre_hook_name = f"pre_{method_name}" + + hooks: list[LoaderHookPlugin] = [] + for cls in loader_class._load_hooks: + hook = cls() # Instantiate the hook + hooks.append(hook) + pre_hook = getattr(hook, pre_hook_name, None) + if callable(pre_hook): + pre_hook(self, *args, **kwargs) + # Call original method + result = original_method(self, *args, **kwargs) + # Call post_ on all hooks + post_hook_name = f"post_{method_name}" + for hook in hooks: + post_hook = getattr(hook, post_hook_name, None) + if callable(post_hook): + post_hook(self, result, *args, **kwargs) + + return result + + setattr(loader_class, method_name, wrapped_method) + + for method in ("load", "update", "remove"): + if hasattr(loader_class, method): + wrap_method(method) + + def register_loader_plugin(plugin): return register_plugin(LoaderPlugin, plugin) @@ -300,3 +448,19 @@ def deregister_loader_plugin_path(path): def register_loader_plugin_path(path): return register_plugin_path(LoaderPlugin, path) + + +def register_loader_hook_plugin(plugin): + return register_plugin(LoaderHookPlugin, plugin) + + +def deregister_loader_hook_plugin(plugin): + deregister_plugin(LoaderHookPlugin, plugin) + + +def register_loader_hook_plugin_path(path): + return register_plugin_path(LoaderHookPlugin, path) + + +def deregister_loader_hook_plugin_path(path): + deregister_plugin_path(LoaderHookPlugin, path) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index de8e1676e7..3c50d76fb5 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -288,7 +288,12 @@ def get_representation_context(project_name, representation): def load_with_repre_context( - Loader, repre_context, namespace=None, name=None, options=None, **kwargs + Loader, + repre_context, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure the Loader is compatible for the representation @@ -316,17 +321,16 @@ def load_with_repre_context( ) loader = Loader() - - # Backwards compatibility: Originally the loader's __init__ required the - # representation context to set `fname` attribute to the filename to load - # Deprecated - to be removed in OpenPype 3.16.6 or 3.17.0. - loader._fname = get_representation_path_from_context(repre_context) - return loader.load(repre_context, name, namespace, options) def load_with_product_context( - Loader, product_context, namespace=None, name=None, options=None, **kwargs + Loader, + product_context, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -349,7 +353,12 @@ def load_with_product_context( def load_with_product_contexts( - Loader, product_contexts, namespace=None, name=None, options=None, **kwargs + Loader, + product_contexts, + namespace=None, + name=None, + options=None, + **kwargs ): # Ensure options is a dictionary when no explicit options provided @@ -559,15 +568,20 @@ def update_container(container, version=-1): return Loader().update(container, context) -def switch_container(container, representation, loader_plugin=None): +def switch_container( + container, + representation, + loader_plugin=None, +): """Switch a container to representation Args: container (dict): container information representation (dict): representation entity + loader_plugin (LoaderPlugin) Returns: - function call + return from function call """ from ayon_core.pipeline import get_current_project_name diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index 5363e0b378..ede7fc3a35 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -46,6 +46,11 @@ from .lib import ( get_publish_instance_families, main_cli_publish, + + add_trait_representations, + get_trait_representations, + has_trait_representations, + set_trait_representations, ) from .abstract_expected_files import ExpectedFiles @@ -104,4 +109,9 @@ __all__ = ( "RenderInstance", "AbstractCollectRender", + + "add_trait_representations", + "get_trait_representations", + "has_trait_representations", + "set_trait_representations", ) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49ecab2221..464b2b6d8f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -6,7 +6,7 @@ import inspect import copy import warnings import xml.etree.ElementTree -from typing import Optional, Union, List +from typing import TYPE_CHECKING, Optional, Union, List import ayon_api import pyblish.util @@ -27,6 +27,12 @@ from .constants import ( DEFAULT_HERO_PUBLISH_TEMPLATE, ) +if TYPE_CHECKING: + from ayon_core.pipeline.traits import Representation + + +TRAIT_INSTANCE_KEY: str = "representations_with_traits" + def get_template_name_profiles( project_name, project_settings=None, logger=None @@ -1062,3 +1068,66 @@ def main_cli_publish( sys.exit(1) log.info("Publish finished.") + + +def has_trait_representations( + instance: pyblish.api.Instance) -> bool: + """Check if instance has trait representation. + + Args: + instance (pyblish.api.Instance): Instance to check. + + Returns: + True: Instance has trait representation. + False: Instance does not have trait representation. + + """ + return TRAIT_INSTANCE_KEY in instance.data + + +def add_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +) -> None: + """Add trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to add trait + representations to. + representations (list[Representation]): List of representation + trait based representations to add. + + """ + repres = instance.data.setdefault(TRAIT_INSTANCE_KEY, []) + repres.extend(representations) + + +def set_trait_representations( + instance: pyblish.api.Instance, + representations: list[Representation] +) -> None: + """Set trait representations to instance. + + Args: + instance (pyblish.api.Instance): Instance to set trait + representations to. + representations (list[Representation]): List of trait + based representations. + + """ + instance.data[TRAIT_INSTANCE_KEY] = representations + + +def get_trait_representations( + instance: pyblish.api.Instance) -> list[Representation]: + """Get trait representations from instance. + + Args: + instance (pyblish.api.Instance): Instance to get trait + representations from. + + Returns: + list[Representation]: List of representation names. + + """ + return instance.data.get(TRAIT_INSTANCE_KEY, []) diff --git a/client/ayon_core/pipeline/schema/__init__.py b/client/ayon_core/pipeline/schema/__init__.py index d16755696d..5e4e8a668d 100644 --- a/client/ayon_core/pipeline/schema/__init__.py +++ b/client/ayon_core/pipeline/schema/__init__.py @@ -41,7 +41,7 @@ def validate(data, schema=None): if not _CACHED: _precache() - root, schema = data["schema"].rsplit(":", 1) + _root, schema = data["schema"].rsplit(":", 1) if isinstance(schema, str): schema = _cache[schema + ".json"] diff --git a/client/ayon_core/pipeline/staging_dir.py b/client/ayon_core/pipeline/staging_dir.py index 1cb2979415..a172c177fd 100644 --- a/client/ayon_core/pipeline/staging_dir.py +++ b/client/ayon_core/pipeline/staging_dir.py @@ -209,7 +209,7 @@ def get_staging_dir_info( staging_dir_config = get_staging_dir_config( project_entity["name"], task_type, - task_name , + task_name, product_type, product_name, host_name, diff --git a/client/ayon_core/pipeline/traits/README.md b/client/ayon_core/pipeline/traits/README.md new file mode 100644 index 0000000000..96ced3692c --- /dev/null +++ b/client/ayon_core/pipeline/traits/README.md @@ -0,0 +1,453 @@ +# Representations and traits + +## Introduction + +The Representation is the lowest level entity, describing the concrete data chunk that +pipeline can act on. It can be a specific file or just a set of metadata. Idea is that one +product version can have multiple representations - **Image** product can be jpeg or tiff, both formats are representation of the same source. + +### Brief look into the past (and current state) + +So far, representation was defined as a dict-like structure: +```python +{ + "name": "foo", + "ext": "exr", + "files": ["foo_001.exr", "foo_002.exr"], + "stagingDir": "/bar/dir" +} +``` + +This is minimal form, but it can have additional keys like `frameStart`, `fps`, `resolutionWidth`, and more. Thare is also `tags` key that can hold `review`, `thumbnail`, `delete`, `toScanline` and other tags that are controlling the processing. + +This will be *"translated"* to the similar structure in the database: + +```python +{ + "name": "foo", + "version_id": "...", + "files": [ + { + "id": ..., + "hash": ..., + "name": "foo_001.exr", + "path": "{root[work]}/bar/dir/foo_001.exr", + "size": 1234, + "hash_type": "...", + }, + ... + ], + "attrib": { + "path": "root/bar/dir/foo_001.exr", + "template": "{root[work]}/{project[name]}...", + }, + "data": { + "context": { + "ext": "exr", + "root": {...}, + ... + }, + "active": True + ... + +} +``` + +There are also some assumptions and limitations - like that if `files` in the +representation are list they need to be sequence of files (it can't be a bunch of +unrelated files). + +This system is very flexible in one way, but it lacks a few very important things: + +- it is not clearly defined — you can add easily keys, values, tags but without +unforeseeable +consequences +- it cannot handle "bundles" — multiple files that need to be versioned together and +belong together +- it cannot describe important information that you can't get from the file itself, or +it is very expensive (like axis orientation and units from alembic files) + + +### New Representation model + +The idea about a new representation model is about solving points mentioned +above and also adding some benefits, like consistent IDE hints, typing, built-in + validators and much more. + +### Design + +The new representation is "just" a dictionary of traits. Trait can be anything provided +it is based on `TraitBase`. It shouldn't really duplicate information that is +available at the moment of loading (or any usage) by other means. It should contain +information that couldn't be determined by the file, or the AYON context. Some of +those traits are aligned with [OpenAssetIO Media Creation](https://github.com/OpenAssetIO/OpenAssetIO-MediaCreation) with hopes of maintained compatibility (it +should be easy enough to convert between OpenAssetIO Traits and AYON Traits). + +#### Details: Representation + +`Representation` has methods to deal with adding, removing, getting +traits. It has all the usual stuff like `get_trait()`, `add_trait()`, +`remove_trait()`, etc. But it also has plural forms so you can get/set +several traits at the same time with `get_traits()` and so on. +`Representation` also behaves like dictionary. so you can access/set +traits in the same way as you would do with dict: + +```python +# import Image trait +from ayon_core.pipeline.traits import Image, Tagged, Representation + + +# create new representation with name "foo" and add Image trait to it +rep = Representation(name="foo", traits=[Image()]) + +# you can add another trait like so +rep.add_trait(Tagged(tags=["tag"])) + +# or you can +rep[Tagged.id] = Tagged(tags=["tag"]) + +# and getting them in analogous +image = rep.get_trait(Image) + +# or +image = rep[Image.id] +``` + +> [!NOTE] +> Trait and their ids — every Trait has its id as a string with a +> version appended - so **Image** has `ayon.2d.Image.v1`. This is used on +> several places (you see its use above for indexing traits). When querying, +> you can also omit the version at the end, and it will try its best to find +> the latest possible version. More on that in [Traits]() + +You can construct the `Representation` from dictionary (for example, +serialized as JSON) using `Representation.from_dict()`, or you can +serialize `Representation` to dict to store with `Representation.traits_as_dict()`. + +Every time representation is created, a new id is generated. You can pass existing +id when creating the new representation instance. + +##### Equality + +Two Representations are equal if: +- their names are the same +- their IDs are the same +- they have the same traits +- the traits have the same values + +##### Validation + +Representation has `validate()` method that will run `validate()` on +all it's traits. + +#### Details: Traits + +As mentioned there are several traits defined directly in **ayon-core**. They are namespaced +to different packages based on their use: + +| namespace | trait | description | +|-------------------|----------------------|----------------------------------------------------------------------------------------------------------| +| color | ColorManaged | hold color management information | +| content | MimeType | use MIME type (RFC 2046) to describe content (like image/jpeg) | +| | LocatableContent | describe some location (file or URI) | +| | FileLocation | path to file, with size and checksum | +| | FileLocations | list of `FileLocation` | +| | RootlessLocation | Path where root is replaced with AYON root token | +| | Compressed | describes compression (of file or other) | +| | Bundle | list of list of Traits - compound of inseparable "sub-representations" | +| | Fragment | compound type marking the representation as a part of larger group of representations | +| cryptography | DigitallySigned | Type traits marking data to be digitally signed | +| | PGPSigned | Representation is signed by [PGP](https://www.openpgp.org/) | +| lifecycle | Transient | Marks the representation to be temporary - not to be stored. | +| | Persistent | Representation should be integrated (stored). Opposite of Transient. | +| meta | Tagged | holds list of tag strings. | +| | TemplatePath | Template consisted of tokens/keys and data to be used to resolve the template into string | +| | Variant | Used to differentiate between data variants of the same output (mp4 as h.264 and h.265 for example) | +| | KeepOriginalLocation | Marks the representation to keep the original location of the file | +| | KeepOriginalName | Marks the representation to keep the original name of the file | +| | SourceApplication | Holds information about producing application, about it's version, variant and platform. | +| | IntendedUse | For specifying the intended use of the representation if it cannot be easily determined by other traits. | +| three dimensional | Spatial | Spatial information like up-axis, units and handedness. | +| | Geometry | Type trait to mark the representation as a geometry. | +| | Shader | Type trait to mark the representation as a Shader. | +| | Lighting | Type trait to mark the representation as Lighting. | +| | IESProfile | States that the representation is IES Profile. | +| time | FrameRanged | Contains start and end frame information with in and out. | +| | Handless | define additional frames at the end or beginning and if those frames are inclusive of the range or not. | +| | Sequence | Describes sequence of frames and how the frames are defined in that sequence. | +| | SMPTETimecode | Adds timecode information in SMPTE format. | +| | Static | Marks the content as not time-variant. | +| two dimensional | Image | Type traits of image. | +| | PixelBased | Defines resolution and pixel aspect for the image data. | +| | Planar | Whether pixel data is in planar configuration or packed. | +| | Deep | Image encodes deep pixel data. | +| | Overscan | holds overscan/underscan information (added pixels to bottom/sides). | +| | UDIM | Representation is UDIM tile set. | + +Traits are Python data classes with optional +validation and helper methods. If they implement `TraitBase.validate(Representation)` method, they can validate against all other traits +in the representation if needed. + +> [!NOTE] +> They could be easily converted to [Pydantic models](https://docs.pydantic.dev/latest/) but since this must run in diverse Python environments inside DCC, we cannot +> easily resolve pydantic-core dependency (as it is binary written in Rust). + +> [!NOTE] +> Every trait has id, name and some human-readable description. Every trait +> also has `persistent` property that is by default set to True. This +> Controls whether this trait should be stored with the persistent representation +> or not. Useful for traits to be used just to control the publishing process. + +## Examples + +Create a simple image representation to be integrated by AYON: + +```python +from pathlib import Path +from ayon_core.pipeline.traits import ( + FileLocation, + Image, + PixelBased, + Persistent, + Representation, + Static, + + TraitValidationError, +) + +rep = Representation(name="reference image", traits=[ + FileLocation( + file_path=Path("/foo/bar/baz.exr"), + file_size=1234, + file_hash="sha256:...", + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0, + ), + Persistent(), + Static() +]) + +# validate the representation + +try: + rep.validate() +except TraitValidationError as e: + print(f"Representation {rep.name} is invalid: {e}") + +``` + +To work with the resolution of such representation: + +```python + +try: + width = rep.get_trait(PixelBased).display_window_width + # or like this: + height = rep[PixelBased.id].display_window_height +except MissingTraitError: + print(f"resolution isn't set on {rep.name}") +``` + +Accessing non-existent traits will result in an exception. To test if +the representation has some specific trait, you can use `.contains_trait()` method. + + +You can also prepare the whole representation data as a dict and +create it from it: + +```python +rep_dict = { + "ayon.content.FileLocation.v1": { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + }, + "ayon.two_dimensional.Image": {}, + "ayon.two_dimensional.PixelBased": { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + }, + "ayon.two_dimensional.Planar": { + "planar_configuration": "RGB", + } +} + +rep = Representation.from_dict(name="image", rep_dict) + +``` + + +## Addon specific traits + +Addon can define its own traits. To do so, it needs to implement `ITraits` interface: + +```python +from ayon_core.pipeline.traits import TraitBase +from ayon_core.addon import ( + AYONAddon, + ITraits, +) + +class MyTraitFoo(TraitBase): + id = "myaddon.mytrait.foo.v1" + name = "My Trait Foo" + description = "This is my trait foo" + persistent = True + + +class MyTraitBar(TraitBase): + id = "myaddon.mytrait.bar.v1" + name = "My Trait Bar" + description = "This is my trait bar" + persistent = True + + +class MyAddon(AYONAddon, ITraits): + def __init__(self): + super().__init__() + + def get_addon_traits(self): + return [ + MyTraitFoo, + MyTraitBar, + ] +``` +## Usage in Loaders + +In loaders, you can implement `is_compatible_loader()` method to check if the +representation is compatible with the loader. You can use `Representation.from_dict()` to +create the representation from the context. You can also use `Representation.contains_traits()` +to check if the representation contains the required traits. You can even check for specific +values in the traits. + +You can use similar concepts directly in the `load()` method to get the traits. Here is +an example of how to use the traits in the hypothetical Maya loader: + +```python +"""Alembic loader using traits.""" +from __future__ import annotations +import json +from typing import Any, TypeVar, Type +from ayon_maya.api.plugin import MayaLoader +from ayon_core.pipeline.traits import ( + FileLocation, + Spatial, + + Representation, + TraitBase, +) + +T = TypeVar("T", bound=TraitBase) + + +class AlembicTraitLoader(MayaLoader): + """Alembic loader using traits.""" + label = "Alembic Trait Loader" + ... + + required_traits: list[T] = [ + FileLocation, + Spatial, + ] + + @staticmethod + def is_compatible_loader(context: dict[str, Any]) -> bool: + traits_raw = context["representation"].get("traits") + if not traits_raw: + return False + + # construct Representation object from the context + representation = Representation.from_dict( + name=context["representation"]["name"], + representation_id=context["representation"]["id"], + trait_data=json.loads(traits_raw), + ) + + # check if the representation is compatible with this loader + if representation.contains_traits(AlembicTraitLoader.required_traits): + # you can also check for specific values in traits here + return True + return False + + ... +``` + +## Usage Publishing plugins + +You can create the representations in the same way as mentioned in the examples above. +Straightforward way is to use `Representation` class and add the traits to it. Collect +traits in the list and then pass them to the `Representation` constructor. You should add +the new Representation to the instance data using `add_trait_representations()` function. + +```python +class SomeExtractor(Extractor): + """Some extractor.""" + ... + + def extract(self, instance: Instance) -> None: + """Extract the data.""" + # get the path to the file + path = self.get_path(instance) + + # create the representation + traits: list[TraitBase] = [ + Geometry(), + MimeType(mime_type="application/abc"), + Persistent(), + Spatial( + up_axis=cmds.upAxis(q=True, axis=True), + meters_per_unit=maya_units_to_meters_per_unit( + instance.context.data["linearUnits"]), + handedness="right", + ), + ] + + if instance.data.get("frameStart"): + traits.append( + FrameRanged( + frame_start=instance.data["frameStart"], + frame_end=instance.data["frameEnd"], + frames_per_second=instance.context.data["fps"], + ) + ) + + representation = Representation( + name="alembic", + traits=[ + FileLocation( + file_path=Path(path), + file_size=os.path.getsize(path), + file_hash=get_file_hash(Path(path)) + ), + *traits], + ) + + add_trait_representations( + instance, + [representation], + ) + ... +``` + +## Developer notes + +Adding new trait-based representations in to the publishing Instance and working with them is using +a set of helper function defined in `ayon_core.pipeline.publish` module. These are: + +* add_trait_representations +* get_trait_representations +* has_trait_representations +* set_trait_representations + +And their main purpose is to handle the key under which the representation +is stored in the instance data. This is done to avoid name clashes with +other representations. The key is defined in the `AYON_PUBLISH_REPRESENTATION_KEY`. +It is strongly recommended to use those functions instead of +directly accessing the instance data. This is to ensure that the +code will work even if the key is changed in the future. + diff --git a/client/ayon_core/pipeline/traits/__init__.py b/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..645064d59f --- /dev/null +++ b/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1,112 @@ +"""Trait classes for the pipeline.""" +from .color import ColorManaged +from .content import ( + Bundle, + Compressed, + FileLocation, + FileLocations, + Fragment, + LocatableContent, + MimeType, + RootlessLocation, +) +from .cryptography import DigitallySigned, PGPSigned +from .lifecycle import Persistent, Transient +from .meta import ( + IntendedUse, + KeepOriginalLocation, + SourceApplication, + Tagged, + TemplatePath, + Variant, +) +from .representation import Representation +from .temporal import ( + FrameRanged, + GapPolicy, + Handles, + Sequence, + SMPTETimecode, + Static, +) +from .three_dimensional import Geometry, IESProfile, Lighting, Shader, Spatial +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) +from .two_dimensional import ( + UDIM, + Deep, + Image, + Overscan, + PixelBased, + Planar, +) +from .utils import ( + get_sequence_from_files, +) + +__all__ = [ # noqa: RUF022 + # base + "Representation", + "TraitBase", + "MissingTraitError", + "TraitValidationError", + + # color + "ColorManaged", + + # content + "Bundle", + "Compressed", + "FileLocation", + "FileLocations", + "Fragment", + "LocatableContent", + "MimeType", + "RootlessLocation", + + # cryptography + "DigitallySigned", + "PGPSigned", + + # life cycle + "Persistent", + "Transient", + + # meta + "IntendedUse", + "KeepOriginalLocation", + "SourceApplication", + "Tagged", + "TemplatePath", + "Variant", + + # temporal + "FrameRanged", + "GapPolicy", + "Handles", + "Sequence", + "SMPTETimecode", + "Static", + + # three-dimensional + "Geometry", + "IESProfile", + "Lighting", + "Shader", + "Spatial", + + # two-dimensional + "Compressed", + "Deep", + "Image", + "Overscan", + "PixelBased", + "Planar", + "UDIM", + + # utils + "get_sequence_from_files", +] diff --git a/client/ayon_core/pipeline/traits/color.py b/client/ayon_core/pipeline/traits/color.py new file mode 100644 index 0000000000..6da7b86ae7 --- /dev/null +++ b/client/ayon_core/pipeline/traits/color.py @@ -0,0 +1,30 @@ +"""Color-management-related traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Optional + +from .trait import TraitBase + + +@dataclass +class ColorManaged(TraitBase): + """Color managed trait. + + Holds color management information. Can be used with Image-related + traits to define color space and config. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + color_space (str): An OCIO colorspace name available + in the "current" OCIO context. + config (str): An OCIO config name defining color space. + """ + + id: ClassVar[str] = "ayon.color.ColorManaged.v1" + name: ClassVar[str] = "ColorManaged" + color_space: str + description: ClassVar[str] = "Color Managed trait." + persistent: ClassVar[bool] = True + config: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/content.py b/client/ayon_core/pipeline/traits/content.py new file mode 100644 index 0000000000..42c162d28f --- /dev/null +++ b/client/ayon_core/pipeline/traits/content.py @@ -0,0 +1,485 @@ +"""Content traits for the pipeline.""" +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass + +# TCH003 is there because Path in TYPECHECKING will fail in tests +from pathlib import Path # noqa: TCH003 +from typing import ClassVar, Generator, Optional + +from .representation import Representation +from .temporal import FrameRanged, Handles, Sequence +from .trait import ( + MissingTraitError, + TraitBase, + TraitValidationError, +) +from .two_dimensional import UDIM +from .utils import get_sequence_from_files + + +@dataclass +class MimeType(TraitBase): + """MimeType trait model. + + This model represents a mime type trait. For example, image/jpeg. + It is used to describe the type of content in a representation regardless + of the file extension. + + For more information, see RFC 2046 and RFC 4288 (and related RFCs). + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + mime_type (str): Mime type like image/jpeg. + """ + + name: ClassVar[str] = "MimeType" + description: ClassVar[str] = "MimeType Trait Model" + id: ClassVar[str] = "ayon.content.MimeType.v1" + persistent: ClassVar[bool] = True + mime_type: str + + +@dataclass +class LocatableContent(TraitBase): + """LocatableContent trait model. + + This model represents a locatable content trait. Locatable content + is content that has a location. It doesn't have to be a file - it could + be a URL or some other location. + + Sync with OpenAssetIO MediaCreation Traits. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + location (str): Location. + is_templated (Optional[bool]): Is the location templated? + Default is None. + """ + + name: ClassVar[str] = "LocatableContent" + description: ClassVar[str] = "LocatableContent Trait Model" + id: ClassVar[str] = "ayon.content.LocatableContent.v1" + persistent: ClassVar[bool] = True + location: str + is_templated: Optional[bool] = None + + +@dataclass +class FileLocation(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + file_path (str): File path. + file_size (Optional[int]): File size in bytes. + file_hash (Optional[str]): File hash. + """ + + name: ClassVar[str] = "FileLocation" + description: ClassVar[str] = "FileLocation Trait Model" + id: ClassVar[str] = "ayon.content.FileLocation.v1" + persistent: ClassVar[bool] = True + file_path: Path + file_size: Optional[int] = None + file_hash: Optional[str] = None + + +@dataclass +class FileLocations(TraitBase): + """FileLocation trait model. + + This model represents a file path. It is a specialization of the + LocatableContent trait. It is adding optional file size and file hash + for easy access to file information. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + file_paths (list of FileLocation): File locations. + + """ + + name: ClassVar[str] = "FileLocations" + description: ClassVar[str] = "FileLocations Trait Model" + id: ClassVar[str] = "ayon.content.FileLocations.v1" + persistent: ClassVar[bool] = True + file_paths: list[FileLocation] + + def get_files(self) -> Generator[Path, None, None]: + """Get all file paths from the trait. + + This method will return all file paths from the trait. + + Yields: + Path: List of file paths. + + """ + for file_location in self.file_paths: + yield file_location.file_path + + def get_file_location_for_frame( + self, + frame: int, + sequence_trait: Optional[Sequence] = None, + ) -> Optional[FileLocation]: + """Get a file location for a frame. + + This method will return the file location for a given frame. If the + frame is not found in the file paths, it will return None. + + Args: + frame (int): Frame to get the file location for. + sequence_trait (Sequence): Sequence trait to get the + frame range specs from. + + Returns: + Optional[FileLocation]: File location for the frame. + + """ + frame_regex = re.compile(r"\.(?P(?P0*)\d+)\.\D+\d?$") + if sequence_trait and sequence_trait.frame_regex: + frame_regex = sequence_trait.get_frame_pattern() + + for location in self.file_paths: + result = re.search(frame_regex, location.file_path.name) + if result: + frame_index = int(result.group("index")) + if frame_index == frame: + return location + return None + + def validate_trait(self, representation: Representation) -> None: + """Validate the trait. + + This method validates the trait against others in the representation. + In particular, it checks that the sequence trait is present, and if + so, it will compare the frame range to the file paths. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ + super().validate_trait(representation) + if len(self.file_paths) == 0: + # If there are no file paths, we can't validate + msg = "No file locations defined (empty list)" + raise TraitValidationError(self.name, msg) + if representation.contains_trait(FrameRanged): + self._validate_frame_range(representation) + if not representation.contains_trait(Sequence) \ + and not representation.contains_trait(UDIM): + # we have multiple files, but it is not a sequence + # or UDIM tile set what is it then? If the files are not related + # to each other, then this representation is invalid. + msg = ( + "Multiple file locations defined, but no Sequence " + "or UDIM trait defined. If the files are not related to " + "each other, the representation is invalid." + ) + raise TraitValidationError(self.name, msg) + + def _validate_frame_range(self, representation: Representation) -> None: + """Validate the frame range against the file paths. + + If the representation contains a FrameRanged trait, this method will + validate the frame range against the file paths. If the frame range + does not match the file paths, the trait is invalid. It takes into + account the Handles and Sequence traits. + + Args: + representation (Representation): Representation to validate. + + Raises: + TraitValidationError: If the trait is invalid within the + representation. + + """ + tmp_frame_ranged: FrameRanged = get_sequence_from_files( + [f.file_path for f in self.file_paths]) + + frames_from_spec: list[int] = [] + with contextlib.suppress(MissingTraitError): + sequence: Sequence = representation.get_trait(Sequence) + frame_regex = sequence.get_frame_pattern() + if sequence.frame_spec: + frames_from_spec = sequence.get_frame_list( + self, frame_regex) + + frame_start_with_handles, frame_end_with_handles = \ + self._get_frame_info_with_handles(representation, frames_from_spec) + + if frame_start_with_handles \ + and tmp_frame_ranged.frame_start != frame_start_with_handles: + # If the detected frame range does not match the combined + # FrameRanged and Handles trait, the + # trait is invalid. + msg = ( + f"Frame range defined by {self.name} " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + "in files does not match " + "frame range " + f"({frame_start_with_handles}-" + f"{frame_end_with_handles}) defined in FrameRanged trait." + ) + + raise TraitValidationError(self.name, msg) + + if frames_from_spec: + if len(frames_from_spec) != len(self.file_paths): + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range defined by frame spec " + "on Sequence trait: " + f"({len(frames_from_spec)})" + ) + raise TraitValidationError(self.name, msg) + # if there is a frame spec on the Sequence trait, + # we should not validate the frame range from the files. + # the rest is validated by Sequence validators. + return + + length_with_handles: int = ( + frame_end_with_handles - frame_start_with_handles + 1 + ) + + if len(self.file_paths) != length_with_handles: + # If the number of file paths does not match the frame range, + # the trait is invalid + msg = ( + f"Number of file locations ({len(self.file_paths)}) " + "does not match frame range " + f"({length_with_handles})" + ) + raise TraitValidationError(self.name, msg) + + frame_ranged: FrameRanged = representation.get_trait(FrameRanged) + + if frame_start_with_handles != tmp_frame_ranged.frame_start or \ + frame_end_with_handles != tmp_frame_ranged.frame_end: + # If the frame range does not match the FrameRanged trait, the + # trait is invalid. Note that we don't check the frame rate + # because it is not stored in the file paths and is not + # determined by `get_sequence_from_files`. + msg = ( + "Frame range " + f"({frame_ranged.frame_start}-{frame_ranged.frame_end}) " + "in sequence trait does not match " + "frame range " + f"({tmp_frame_ranged.frame_start}-" + f"{tmp_frame_ranged.frame_end}) " + ) + raise TraitValidationError(self.name, msg) + + @staticmethod + def _get_frame_info_with_handles( + representation: Representation, + frames_from_spec: list[int]) -> tuple[int, int]: + """Get the frame range with handles from the representation. + + This will return frame start and frame end with handles calculated + in if there actually is the Handles trait in the representation. + + Args: + representation (Representation): Representation to get the frame + range from. + frames_from_spec (list[int]): List of frames from the frame spec. + This list is modified in place to take into + account the handles. + + Mutates: + frames_from_spec: List of frames from the frame spec. + + Returns: + tuple[int, int]: Start and end frame with handles. + + """ + frame_start = frame_end = 0 + frame_start_handle = frame_end_handle = 0 + # If there is no sequence trait, we can't validate it + if frames_from_spec and representation.contains_trait(FrameRanged): + # if there is no FrameRanged trait (but really there should be) + # we can use the frame range from the frame spec + frame_start = min(frames_from_spec) + frame_end = max(frames_from_spec) + + # Handle the frame range + with contextlib.suppress(MissingTraitError): + frame_start = representation.get_trait(FrameRanged).frame_start + frame_end = representation.get_trait(FrameRanged).frame_end + + # Handle the handles :P + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + if not handles.inclusive: + # if handless are exclusive, we need to adjust the frame range + frame_start_handle = handles.frame_start_handle or 0 + frame_end_handle = handles.frame_end_handle or 0 + if frames_from_spec: + frames_from_spec.extend( + range(frame_start - frame_start_handle, frame_start) + ) + frames_from_spec.extend( + range(frame_end + 1, frame_end_handle + frame_end + 1) + ) + + frame_start_with_handles = frame_start - frame_start_handle + frame_end_with_handles = frame_end + frame_end_handle + + return frame_start_with_handles, frame_end_with_handles + + +@dataclass +class RootlessLocation(TraitBase): + """RootlessLocation trait model. + + RootlessLocation trait is a trait that represents a file path that is + without a specific root. To get the absolute path, the root needs to be + resolved by AYON. Rootless path can be used on multiple platforms. + + Example:: + + RootlessLocation( + rootless_path="{root[work]}/project/asset/asset.jpg" + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + rootless_path (str): Rootless path. + """ + + name: ClassVar[str] = "RootlessLocation" + description: ClassVar[str] = "RootlessLocation Trait Model" + id: ClassVar[str] = "ayon.content.RootlessLocation.v1" + persistent: ClassVar[bool] = True + rootless_path: str + + +@dataclass +class Compressed(TraitBase): + """Compressed trait model. + + This trait can hold information about compressed content. What type + of compression is used. + + Example:: + + Compressed("gzip") + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + compression_type (str): Compression type. + """ + + name: ClassVar[str] = "Compressed" + description: ClassVar[str] = "Compressed Trait" + id: ClassVar[str] = "ayon.content.Compressed.v1" + persistent: ClassVar[bool] = True + compression_type: str + + +@dataclass +class Bundle(TraitBase): + """Bundle trait model. + + This model list of independent Representation traits + that are bundled together. This is useful for representing + a collection of sub-entities that are part of a single + entity. You can easily reconstruct representations from + the bundle. + + Example:: + + Bundle( + items=[ + [ + MimeType(mime_type="image/jpeg"), + FileLocation(file_path="/path/to/file.jpg") + ], + [ + + MimeType(mime_type="image/png"), + FileLocation(file_path="/path/to/file.png") + ] + ] + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + items (list[list[TraitBase]]): List of representations. + """ + + name: ClassVar[str] = "Bundle" + description: ClassVar[str] = "Bundle Trait" + id: ClassVar[str] = "ayon.content.Bundle.v1" + persistent: ClassVar[bool] = True + items: list[list[TraitBase]] + + def to_representations(self) -> Generator[Representation]: + """Convert a bundle to representations. + + Yields: + Representation: Representation of the bundle. + + """ + for idx, item in enumerate(self.items): + yield Representation(name=f"{self.name} {idx}", traits=item) + + +@dataclass +class Fragment(TraitBase): + """Fragment trait model. + + This model represents a fragment trait. A fragment is a part of + a larger entity that is represented by another representation. + + Example:: + + main_representation = Representation(name="parent", + traits=[], + ) + fragment_representation = Representation( + name="fragment", + traits=[ + Fragment(parent=main_representation.id), + ] + ) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + parent (str): Parent representation id. + """ + + name: ClassVar[str] = "Fragment" + description: ClassVar[str] = "Fragment Trait" + id: ClassVar[str] = "ayon.content.Fragment.v1" + persistent: ClassVar[bool] = True + parent: str diff --git a/client/ayon_core/pipeline/traits/cryptography.py b/client/ayon_core/pipeline/traits/cryptography.py new file mode 100644 index 0000000000..7fcbb1b387 --- /dev/null +++ b/client/ayon_core/pipeline/traits/cryptography.py @@ -0,0 +1,42 @@ +"""Cryptography traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, Optional + +from .trait import TraitBase + + +@dataclass +class DigitallySigned(TraitBase): + """Digitally signed trait. + + This type trait means that the data is digitally signed. + + Attributes: + signature (str): Digital signature. + """ + + id: ClassVar[str] = "ayon.cryptography.DigitallySigned.v1" + name: ClassVar[str] = "DigitallySigned" + description: ClassVar[str] = "Digitally signed trait." + persistent: ClassVar[bool] = True + + +@dataclass +class PGPSigned(DigitallySigned): + """PGP signed trait. + + This trait holds PGP (RFC-4880) signed data. + + Attributes: + signed_data (str): Signed data. + clear_text (str): Clear text. + """ + + id: ClassVar[str] = "ayon.cryptography.PGPSigned.v1" + name: ClassVar[str] = "PGPSigned" + description: ClassVar[str] = "PGP signed trait." + persistent: ClassVar[bool] = True + signed_data: str + clear_text: Optional[str] = None diff --git a/client/ayon_core/pipeline/traits/lifecycle.py b/client/ayon_core/pipeline/traits/lifecycle.py new file mode 100644 index 0000000000..4845f04779 --- /dev/null +++ b/client/ayon_core/pipeline/traits/lifecycle.py @@ -0,0 +1,77 @@ +"""Lifecycle traits.""" +from dataclasses import dataclass +from typing import ClassVar + +from .trait import TraitBase, TraitValidationError + + +@dataclass +class Transient(TraitBase): + """Transient trait model. + + Transient trait marks representation as transient. Such representations + are not persisted in the system. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with the version + """ + + name: ClassVar[str] = "Transient" + description: ClassVar[str] = "Transient Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Transient.v1" + persistent: ClassVar[bool] = True # see note in Persistent + + def validate_trait(self, representation) -> None: # noqa: ANN001 + """Validate representation is not Persistent. + + Args: + representation (Representation): Representation model. + + Raises: + TraitValidationError: If representation is marked as both + Persistent and Transient. + + """ + if representation.contains_trait(Persistent): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) + + +@dataclass +class Persistent(TraitBase): + """Persistent trait model. + + Persistent trait is opposite to transient trait. It marks representation + as persistent. Such representations are persisted in the system (e.g. in + the database). + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with the version + """ + + name: ClassVar[str] = "Persistent" + description: ClassVar[str] = "Persistent Trait Model" + id: ClassVar[str] = "ayon.lifecycle.Persistent.v1" + # Note that this affects the persistence of the trait itself, not + # the representation. This is a class variable, so it is shared + # among all instances of the class. + persistent: bool = True + + def validate_trait(self, representation) -> None: # noqa: ANN001 + """Validate representation is not Transient. + + Args: + representation (Representation): Representation model. + + Raises: + TraitValidationError: If representation is marked + as both Persistent and Transient. + + """ + if representation.contains_trait(Transient): + msg = "Representation is marked as both Persistent and Transient." + raise TraitValidationError(self.name, msg) diff --git a/client/ayon_core/pipeline/traits/meta.py b/client/ayon_core/pipeline/traits/meta.py new file mode 100644 index 0000000000..26edf3ffb6 --- /dev/null +++ b/client/ayon_core/pipeline/traits/meta.py @@ -0,0 +1,162 @@ +"""Metadata traits.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar, List, Optional + +from .trait import TraitBase + + +@dataclass +class Tagged(TraitBase): + """Tagged trait model. + + This trait can hold a list of tags. + + Example:: + + Tagged(tags=["tag1", "tag2"]) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + tags (List[str]): Tags. + """ + + name: ClassVar[str] = "Tagged" + description: ClassVar[str] = "Tagged Trait Model" + id: ClassVar[str] = "ayon.meta.Tagged.v1" + persistent: ClassVar[bool] = True + tags: List[str] + + +@dataclass +class TemplatePath(TraitBase): + """TemplatePath trait model. + + This model represents a template path with formatting data. + Template path can be an Anatomy template and data is used to format it. + + Example:: + + TemplatePath(template="path/{key}/file", data={"key": "to"}) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + template (str): Template path. + data (dict[str]): Formatting data. + """ + + name: ClassVar[str] = "TemplatePath" + description: ClassVar[str] = "Template Path Trait Model" + id: ClassVar[str] = "ayon.meta.TemplatePath.v1" + persistent: ClassVar[bool] = True + template: str + data: dict + + +@dataclass +class Variant(TraitBase): + """Variant trait model. + + This model represents a variant of the representation. + + Example:: + + Variant(variant="high") + Variant(variant="prores444) + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + variant (str): Variant name. + """ + + name: ClassVar[str] = "Variant" + description: ClassVar[str] = "Variant Trait Model" + id: ClassVar[str] = "ayon.meta.Variant.v1" + persistent: ClassVar[bool] = True + variant: str + + +@dataclass +class KeepOriginalLocation(TraitBase): + """Keep files in its original location. + + Note: + This is not a persistent trait. + + """ + name: ClassVar[str] = "KeepOriginalLocation" + description: ClassVar[str] = "Keep Original Location Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalLocation.v1" + persistent: ClassVar[bool] = False + + +@dataclass +class KeepOriginalName(TraitBase): + """Keep files in its original name. + + Note: + This is not a persistent trait. + """ + + name: ClassVar[str] = "KeepOriginalName" + description: ClassVar[str] = "Keep Original Name Trait Model" + id: ClassVar[str] = "ayon.meta.KeepOriginalName.v1" + persistent: ClassVar[bool] = False + + +@dataclass +class SourceApplication(TraitBase): + """Metadata about the source (producing) application. + + This can be useful in cases where this information is + needed, but it cannot be determined from other means - like + .txt files used for various motion tracking applications that + must be interpreted by the loader. + + Note that this is not really connected to any logic in + ayon-applications addon. + + Attributes: + application (str): Application name. + variant (str): Application variant. + version (str): Application version. + platform (str): Platform name (Windows, darwin, etc.). + host_name (str): AYON host name if applicable. + """ + + name: ClassVar[str] = "SourceApplication" + description: ClassVar[str] = "Source Application Trait Model" + id: ClassVar[str] = "ayon.meta.SourceApplication.v1" + persistent: ClassVar[bool] = True + application: str + variant: Optional[str] = None + version: Optional[str] = None + platform: Optional[str] = None + host_name: Optional[str] = None + + +@dataclass +class IntendedUse(TraitBase): + """Intended use of the representation. + + This trait describes the intended use of the representation. It + can be used in cases where the other traits are not enough to + describe the intended use. For example, a txt file with tracking + points can be used as a corner pin in After Effect but not in Nuke. + + Attributes: + use (str): Intended use description. + + """ + name: ClassVar[str] = "IntendedUse" + description: ClassVar[str] = "Intended Use Trait Model" + id: ClassVar[str] = "ayon.meta.IntendedUse.v1" + persistent: ClassVar[bool] = True + use: str diff --git a/client/ayon_core/pipeline/traits/representation.py b/client/ayon_core/pipeline/traits/representation.py new file mode 100644 index 0000000000..f76d5df99f --- /dev/null +++ b/client/ayon_core/pipeline/traits/representation.py @@ -0,0 +1,713 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import contextlib +import inspect +import re +import sys +import uuid +from functools import lru_cache +from types import GenericAlias +from typing import ( + ClassVar, + Generic, + ItemsView, + Optional, + Type, + TypeVar, + Union, +) + +from .trait import ( + IncompatibleTraitVersionError, + LooseMatchingTraitError, + MissingTraitError, + TraitBase, + TraitValidationError, + UpgradableTraitError, +) + +T = TypeVar("T", bound="TraitBase") + + +def _get_version_from_id(_id: str) -> Optional[int]: + """Get the version from ID. + + Args: + _id (str): ID. + + Returns: + int: Version. + + """ + match = re.search(r"v(\d+)$", _id) + return int(match[1]) if match else None + + +class Representation(Generic[T]): # noqa: PLR0904 + """Representation of products. + + Representation defines a collection of individual properties that describe + the specific "form" of the product. A trait represents a set of + properties therefore, the Representation is a collection of traits. + + It holds methods to add, remove, get, and check for the existence of a + trait in the representation. + + Note: + `PLR0904` is the rule for checking the number of public methods + in a class. + + Arguments: + name (str): Representation name. Must be unique within instance. + representation_id (str): Representation ID. + """ + + _data: dict[str, T] + _module_blacklist: ClassVar[list[str]] = [ + "_", "builtins", "pydantic", + ] + name: str + representation_id: str + + def __hash__(self): + """Return hash of the representation ID.""" + return hash(self.representation_id) + + def __getitem__(self, key: str) -> T: + """Get the trait by ID. + + Args: + key (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + """ + return self.get_trait_by_id(key) + + def __setitem__(self, key: str, value: T) -> None: + """Set the trait by ID. + + Args: + key (str): Trait ID. + value (TraitBase): Trait instance. + + """ + with contextlib.suppress(KeyError): + self._data.pop(key) + + self.add_trait(value) + + def __delitem__(self, key: str) -> None: + """Remove the trait by ID. + + Args: + key (str): Trait ID. + + + """ + self.remove_trait_by_id(key) + + def __contains__(self, key: str) -> bool: + """Check if the trait exists by ID. + + Args: + key (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return self.contains_trait_by_id(key) + + def __iter__(self): + """Return the trait ID iterator.""" + return iter(self._data) + + def __str__(self): + """Return the representation name.""" + return self.name + + def items(self) -> ItemsView[str, T]: + """Return the traits as items.""" + return ItemsView(self._data) + + def add_trait(self, trait: T, *, exists_ok: bool = False) -> None: + """Add a trait to the Representation. + + Args: + trait (TraitBase): Trait to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + Raises: + ValueError: If the trait ID is not provided, or the trait already + exists. + + """ + if not hasattr(trait, "id"): + error_msg = f"Invalid trait {trait} - ID is required." + raise ValueError(error_msg) + if trait.id in self._data and not exists_ok: + error_msg = f"Trait with ID {trait.id} already exists." + raise ValueError(error_msg) + self._data[trait.id] = trait + + def add_traits( + self, traits: list[T], *, exists_ok: bool = False) -> None: + """Add a list of traits to the Representation. + + Args: + traits (list[TraitBase]): List of traits to add. + exists_ok (bool, optional): If True, do not raise an error if the + trait already exists. Defaults to False. + + """ + for trait in traits: + self.add_trait(trait, exists_ok=exists_ok) + + def remove_trait(self, trait: Type[TraitBase]) -> None: + """Remove a trait from the data. + + Args: + trait (TraitBase, optional): Trait class. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(str(trait.id)) + except KeyError as e: + error_msg = f"Trait with ID {trait.id} not found." + raise ValueError(error_msg) from e + + def remove_trait_by_id(self, trait_id: str) -> None: + """Remove a trait from the data by its ID. + + Args: + trait_id (str): Trait ID. + + Raises: + ValueError: If the trait is not found. + + """ + try: + self._data.pop(trait_id) + except KeyError as e: + error_msg = f"Trait with ID {trait_id} not found." + raise ValueError(error_msg) from e + + def remove_traits(self, traits: list[Type[T]]) -> None: + """Remove a list of traits from the Representation. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + traits (list[TraitBase]): List of trait classes. + + """ + if not traits: + self._data = {} + return + + for trait in traits: + self.remove_trait(trait) + + def remove_traits_by_id(self, trait_ids: list[str]) -> None: + """Remove a list of traits from the Representation by their ID. + + If no trait IDs or traits are provided, all traits will be removed. + + Args: + trait_ids (list[str], optional): List of trait IDs. + + """ + for trait_id in trait_ids: + self.remove_trait_by_id(trait_id) + + def has_traits(self) -> bool: + """Check if the Representation has any traits. + + Returns: + bool: True if the Representation has any traits, False otherwise. + + """ + return bool(self._data) + + def contains_trait(self, trait: Type[T]) -> bool: + """Check if the trait exists in the Representation. + + Args: + trait (TraitBase): Trait class. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(str(trait.id))) + + def contains_trait_by_id(self, trait_id: str) -> bool: + """Check if the trait exists using trait id. + + Args: + trait_id (str): Trait ID. + + Returns: + bool: True if the trait exists, False otherwise. + + """ + return bool(self._data.get(trait_id)) + + def contains_traits(self, traits: list[Type[T]]) -> bool: + """Check if the traits exist. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all(self.contains_trait(trait=trait) for trait in traits) + + def contains_traits_by_id(self, trait_ids: list[str]) -> bool: + """Check if the traits exist by id. + + If no trait IDs or traits are provided, it will check if the + representation has any traits. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + bool: True if all traits exist, False otherwise. + + """ + return all( + self.contains_trait_by_id(trait_id) for trait_id in trait_ids + ) + + def get_trait(self, trait: Type[T]) -> T: + """Get a trait from the representation. + + Args: + trait (TraitBase, optional): Trait class. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + try: + return self._data[str(trait.id)] + except KeyError as e: + msg = f"Trait with ID {trait.id} not found." + raise MissingTraitError(msg) from e + + def get_trait_by_id(self, trait_id: str) -> T: + # sourcery skip: use-named-expression + """Get a trait from the representation by id. + + Args: + trait_id (str): Trait ID. + + Returns: + TraitBase: Trait instance. + + Raises: + MissingTraitError: If the trait is not found. + + """ + version = _get_version_from_id(trait_id) + if version: + try: + return self._data[trait_id] + except KeyError as e: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) from e + + result = next( + ( + self._data.get(trait_id) + for trait_id in self._data + if trait_id.startswith(trait_id) + ), + None, + ) + if result is None: + msg = f"Trait with ID {trait_id} not found." + raise MissingTraitError(msg) + return result + + def get_traits(self, + traits: Optional[list[Type[T]]] = None + ) -> dict[str, T]: + """Get a list of traits from the representation. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + traits (list[TraitBase], optional): List of trait classes. + + Returns: + dict: Dictionary of traits. + + """ + result: dict[str, T] = {} + if not traits: + for trait_id in self._data: + result[trait_id] = self.get_trait_by_id(trait_id=trait_id) + return result + + for trait in traits: + result[str(trait.id)] = self.get_trait(trait=trait) + return result + + def get_traits_by_ids(self, trait_ids: list[str]) -> dict[str, T]: + """Get a list of traits from the representation by their id. + + If no trait IDs or traits are provided, all traits will be returned. + + Args: + trait_ids (list[str]): List of trait IDs. + + Returns: + dict: Dictionary of traits. + + """ + return { + trait_id: self.get_trait_by_id(trait_id) + for trait_id in trait_ids + } + + def traits_as_dict(self) -> dict: + """Return the traits from Representation data as a dictionary. + + Returns: + dict: Traits data dictionary. + + """ + return { + trait_id: trait.as_dict() + for trait_id, trait in self._data.items() + if trait and trait_id + } + + def __len__(self): + """Return the length of the data.""" + return len(self._data) + + def __init__( + self, + name: str, + representation_id: Optional[str] = None, + traits: Optional[list[T]] = None): + """Initialize the data. + + Args: + name (str): Representation name. Must be unique within instance. + representation_id (str, optional): Representation ID. + traits (list[TraitBase], optional): List of traits. + + """ + self.name = name + self.representation_id = representation_id or uuid.uuid4().hex + self._data = {} + if traits: + for trait in traits: + self.add_trait(trait) + + @staticmethod + def _get_version_from_id(trait_id: str) -> Union[int, None]: + # sourcery skip: use-named-expression + """Check if the trait has a version specified. + + Args: + trait_id (str): Trait ID. + + Returns: + int: Trait version. + None: If the trait id does not have a version. + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, trait_id) + return int(match[1]) if match else None + + def __eq__(self, other: object) -> bool: # noqa: PLR0911 + """Check if the representation is equal to another. + + Args: + other (Representation): Representation to compare. + + Returns: + bool: True if the representations are equal, False otherwise. + + """ + if not isinstance(other, Representation): + return False + + if self.representation_id != other.representation_id: + return False + + if self.name != other.name: + return False + + # number of traits + if len(self) != len(other): + return False + + for trait_id, trait in self._data.items(): + if trait_id not in other._data: + return False + if trait != other._data[trait_id]: + return False + + return True + + @classmethod + @lru_cache(maxsize=64) + def _get_possible_trait_classes_from_modules( + cls, + trait_id: str) -> set[type[T]]: + """Get possible trait classes from modules. + + Args: + trait_id (str): Trait ID. + + Returns: + set[type[T]]: Set of trait classes. + + """ + modules = sys.modules.copy() + filtered_modules = modules.copy() + for module_name in modules: + for bl_module in cls._module_blacklist: + if module_name.startswith(bl_module): + filtered_modules.pop(module_name) + + trait_candidates = set() + for module in filtered_modules.values(): + if not module: + continue + + for attr_name in dir(module): + klass = getattr(module, attr_name) + if not inspect.isclass(klass): + continue + # This needs to be done because of the bug? In + # python ABCMeta, where ``issubclass`` is not working + # if it hits the GenericAlias (that is in fact + # tuple[int, int]). This is added to the scope by + # the ``types`` module. + if type(klass) is GenericAlias: + continue + if issubclass(klass, TraitBase) \ + and str(klass.id).startswith(trait_id): + trait_candidates.add(klass) + # I + return trait_candidates # type: ignore[return-value] + + @classmethod + @lru_cache(maxsize=64) + def _get_trait_class( + cls, trait_id: str) -> Union[Type[T], None]: + """Get the trait class with corresponding to given ID. + + This method will search for the trait class in all the modules except + the blocklisted modules. There is some issue in Pydantic where + ``issubclass`` is not working properly, so we are excluding explicit + modules with offending classes. This list can be updated as needed to + speed up the search. + + Args: + trait_id (str): Trait ID. + + Returns: + Type[TraitBase]: Trait class. + + """ + version = cls._get_version_from_id(trait_id) + + trait_candidates = cls._get_possible_trait_classes_from_modules( + trait_id + ) + if not trait_candidates: + return None + + for trait_class in trait_candidates: + if trait_class.id == trait_id: + # we found a direct match + return trait_class + + # if we didn't find direct match, we will search for the highest + # version of the trait. + if not version: + # sourcery skip: use-named-expression + trait_versions = [ + trait_class for trait_class in trait_candidates + if re.match( + rf"{trait_id}.v(\d+)$", str(trait_class.id)) + ] + if trait_versions: + def _get_version_by_id(trait_klass: Type[T]) -> int: + match = re.search(r"v(\d+)$", str(trait_klass.id)) + return int(match[1]) if match else 0 + + error: LooseMatchingTraitError = LooseMatchingTraitError( + "Found trait that might match.") + error.found_trait = max( + trait_versions, key=_get_version_by_id) + error.expected_id = trait_id + raise error + + return None + + @classmethod + def get_trait_class_by_trait_id(cls, trait_id: str) -> Type[T]: + """Get the trait class for the given trait ID. + + Args: + trait_id (str): Trait ID. + + Returns: + type[TraitBase]: Trait class. + + Raises: + IncompatibleTraitVersionError: If the trait version is incompatible + with the current version of the trait. + + """ + try: + trait_class = cls._get_trait_class(trait_id=trait_id) + except LooseMatchingTraitError as e: + requested_version = _get_version_from_id(trait_id) + found_version = _get_version_from_id(e.found_trait.id) + if found_version is None and not requested_version: + msg = ( + "Trait found with no version and requested version " + "is not specified." + ) + raise IncompatibleTraitVersionError(msg) from e + + if found_version is None: + msg = ( + f"Trait {e.found_trait.id} found with no version, " + "but requested version is specified." + ) + raise IncompatibleTraitVersionError(msg) from e + + if requested_version is None: + trait_class = e.found_trait + requested_version = found_version + + if requested_version > found_version: + error_msg = ( + f"Requested trait version {requested_version} is " + f"higher than the found trait version {found_version}." + ) + raise IncompatibleTraitVersionError(error_msg) from e + + if requested_version < found_version and hasattr( + e.found_trait, "upgrade"): + error_msg = ( + "Requested trait version " + f"{requested_version} is lower " + f"than the found trait version {found_version}." + ) + error: UpgradableTraitError = UpgradableTraitError(error_msg) + error.trait = e.found_trait + raise error from e + return trait_class # type: ignore[return-value] + + @classmethod + def from_dict( + cls: Type[Representation], + name: str, + representation_id: Optional[str] = None, + trait_data: Optional[dict] = None) -> Representation: + """Create a representation from a dictionary. + + Args: + name (str): Representation name. + representation_id (str, optional): Representation ID. + trait_data (dict): Representation data. Dictionary with keys + as trait ids and values as trait data. Example:: + + { + "ayon.2d.PixelBased.v1": { + "display_window_width": 1920, + "display_window_height": 1080 + }, + "ayon.2d.Planar.v1": { + "channels": 3 + } + } + + Returns: + Representation: Representation instance. + + Raises: + ValueError: If the trait model with ID is not found. + TypeError: If the trait data is not a dictionary. + IncompatibleTraitVersionError: If the trait version is incompatible + + """ + if not trait_data: + trait_data = {} + traits = [] + for trait_id, value in trait_data.items(): + if not isinstance(value, dict): + msg = ( + f"Invalid trait data for trait ID {trait_id}. " + "Trait data must be a dictionary." + ) + raise TypeError(msg) + + try: + trait_class = cls.get_trait_class_by_trait_id(trait_id) + except UpgradableTraitError as e: + # we found a newer version of trait, we will upgrade the data + if hasattr(e.trait, "upgrade"): + traits.append(e.trait.upgrade(value)) + else: + msg = ( + f"Newer version of trait {e.trait.id} found " + f"for requested {trait_id} but without " + "upgrade method." + ) + raise IncompatibleTraitVersionError(msg) from e + else: + if not trait_class: + error_msg = f"Trait model with ID {trait_id} not found." + raise ValueError(error_msg) + + traits.append(trait_class(**value)) + + return cls( + name=name, representation_id=representation_id, traits=traits) + + def validate(self) -> None: + """Validate the representation. + + This method will validate all the traits in the representation. + + Raises: + TraitValidationError: If the trait is invalid within representation + + """ + errors = [] + for trait in self._data.values(): + # we do this in the loop to catch all the errors + try: + trait.validate_trait(self) + except TraitValidationError as e: # noqa: PERF203 + errors.append(str(e)) + if errors: + msg = "\n".join(errors) + scope = self.name + raise TraitValidationError(scope, msg) diff --git a/client/ayon_core/pipeline/traits/temporal.py b/client/ayon_core/pipeline/traits/temporal.py new file mode 100644 index 0000000000..9ad5424eee --- /dev/null +++ b/client/ayon_core/pipeline/traits/temporal.py @@ -0,0 +1,457 @@ +"""Temporal (time related) traits.""" +from __future__ import annotations + +import contextlib +import re +from dataclasses import dataclass +from enum import Enum, auto +from re import Pattern +from typing import TYPE_CHECKING, ClassVar, Optional + +import clique + +from .trait import MissingTraitError, TraitBase, TraitValidationError + +if TYPE_CHECKING: + + from .content import FileLocations + from .representation import Representation + + +class GapPolicy(Enum): + """Gap policy enumeration. + + This type defines how to handle gaps in a sequence. + + Attributes: + forbidden (int): Gaps are forbidden. + missing (int): Gaps are interpreted as missing frames. + hold (int): Gaps are interpreted as hold frames (last existing frames). + black (int): Gaps are interpreted as black frames. + """ + + forbidden = auto() + missing = auto() + hold = auto() + black = auto() + + +@dataclass +class FrameRanged(TraitBase): + """Frame ranged trait model. + + Model representing a frame-ranged trait. + + Sync with OpenAssetIO MediaCreation Traits. For compatibility with + OpenAssetIO, we'll need to handle different names of attributes: + + * frame_start -> start_frame + * frame_end -> end_frame + ... + + Note: frames_per_second is a string to allow various precision + formats. FPS is a floating point number, but it can be also + represented as a fraction (e.g. "30000/1001") or as a decimal + or even as an irrational number. We need to support all these + formats. To work with FPS, we'll need some helper function + to convert FPS to Decimal from string. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + frame_start (int): Frame start. + frame_end (int): Frame end. + frame_in (int): Frame in. + frame_out (int): Frame out. + frames_per_second (str): Frames per second. + step (int): Step. + """ + + name: ClassVar[str] = "FrameRanged" + description: ClassVar[str] = "Frame Ranged Trait" + id: ClassVar[str] = "ayon.time.FrameRanged.v1" + persistent: ClassVar[bool] = True + frame_start: int + frame_end: int + frame_in: Optional[int] = None + frame_out: Optional[int] = None + frames_per_second: str = None + step: Optional[int] = None + + +@dataclass +class Handles(TraitBase): + """Handles trait model. + + Handles define the range of frames that are included or excluded + from the sequence. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + inclusive (bool): Handles are inclusive. + frame_start_handle (int): Frame start handle. + frame_end_handle (int): Frame end handle. + """ + + name: ClassVar[str] = "Handles" + description: ClassVar[str] = "Handles Trait" + id: ClassVar[str] = "ayon.time.Handles.v1" + persistent: ClassVar[bool] = True + inclusive: Optional[bool] = False + frame_start_handle: Optional[int] = None + frame_end_handle: Optional[int] = None + + +@dataclass +class Sequence(TraitBase): + """Sequence trait model. + + This model represents a sequence trait. Based on the FrameRanged trait + and Handles, adding support for gaps policy, frame padding and frame + list specification. Regex is used to match frame numbers. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + gaps_policy (GapPolicy): Gaps policy - how to handle gaps in + sequence. + frame_padding (int): Frame padding. + frame_regex (str): Frame regex - regular expression to match + frame numbers. Must include 'index' named group and 'padding' + named group. + frame_spec (str): Frame list specification of frames. This takes + string like "1-10,20-30,40-50" etc. + """ + + name: ClassVar[str] = "Sequence" + description: ClassVar[str] = "Sequence Trait Model" + id: ClassVar[str] = "ayon.time.Sequence.v1" + persistent: ClassVar[bool] = True + frame_padding: int + gaps_policy: Optional[GapPolicy] = GapPolicy.forbidden + frame_regex: Optional[Pattern] = None + frame_spec: Optional[str] = None + + @classmethod + def validate_frame_regex( + cls, v: Optional[Pattern] + ) -> Optional[Pattern]: + """Validate frame regex. + + Frame regex must have index and padding named groups. + + Returns: + Optional[Pattern]: Compiled regex pattern. + + Raises: + ValueError: If frame regex does not include 'index' and 'padding' + + """ + if v is None: + return v + if v and any(s not in v.pattern for s in ["?P", "?P"]): + msg = "Frame regex must include 'index' and `padding named groups" + raise ValueError(msg) + return v + + def validate_trait(self, representation: Representation) -> None: + """Validate the trait.""" + super().validate_trait(representation) + + # if there is a FileLocations trait, run validation + # on it as well + + with contextlib.suppress(MissingTraitError): + self._validate_file_locations(representation) + + def _validate_file_locations(self, representation: Representation) -> None: + """Validate file locations trait. + + If along with the Sequence trait, there is a FileLocations trait, + then we need to validate if the file locations match the frame + list specification. + + Args: + representation (Representation): Representation instance. + + """ + from .content import FileLocations + file_locs: FileLocations = representation.get_trait( + FileLocations) + # Validate if the file locations on representation + # match the frame list (if any). + # We need to extend the expected frames with Handles. + frame_start = None + frame_end = None + handles_frame_start = None + handles_frame_end = None + with contextlib.suppress(MissingTraitError): + handles: Handles = representation.get_trait(Handles) + # if handles are inclusive, they should be already + # accounted for in the FrameRaged frame spec + if not handles.inclusive: + handles_frame_start = handles.frame_start_handle + handles_frame_end = handles.frame_end_handle + with contextlib.suppress(MissingTraitError): + frame_ranged: FrameRanged = representation.get_trait( + FrameRanged) + frame_start = frame_ranged.frame_start + frame_end = frame_ranged.frame_end + if self.frame_spec is not None: + self.validate_frame_list( + file_locs, + frame_start, + frame_end, + handles_frame_start, + handles_frame_end) + + self.validate_frame_padding(file_locs) + + def validate_frame_list( + self, + file_locations: FileLocations, + frame_start: Optional[int] = None, + frame_end: Optional[int] = None, + handles_frame_start: Optional[int] = None, + handles_frame_end: Optional[int] = None) -> None: + """Validate a frame list. + + This will take FileLocations trait and validate if the + file locations match the frame list specification. + + For example, if the frame list is "1-10,20-30,40-50", then + the frame numbers in the file locations should match + these frames. + + It will skip the validation if the frame list is not provided. + + Args: + file_locations (FileLocations): File locations trait. + frame_start (Optional[int]): Frame start. + frame_end (Optional[int]): Frame end. + handles_frame_start (Optional[int]): Frame start handle. + handles_frame_end (Optional[int]): Frame end handle. + + Raises: + TraitValidationError: If the frame list does not match + the expected frames. + + """ + if self.frame_spec is None: + return + + frames: list[int] = [] + if self.frame_regex: + frames = self.get_frame_list( + file_locations, self.frame_regex) + else: + frames = self.get_frame_list( + file_locations) + + expected_frames = self.list_spec_to_frames(self.frame_spec) + if frame_start is None or frame_end is None: + if min(expected_frames) != frame_start: + msg = ( + "Frame start does not match the expected frame start. " + f"Expected: {frame_start}, Found: {min(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + if max(expected_frames) != frame_end: + msg = ( + "Frame end does not match the expected frame end. " + f"Expected: {frame_end}, Found: {max(expected_frames)}" + ) + raise TraitValidationError(self.name, msg) + + # we need to extend the expected frames with Handles + if handles_frame_start is not None: + expected_frames.extend( + range( + min(frames) - handles_frame_start, min(frames) + 1)) + + if handles_frame_end is not None: + expected_frames.extend( + range( + max(frames), max(frames) + handles_frame_end + 1)) + + if set(frames) != set(expected_frames): + msg = ( + "Frame list does not match the expected frames. " + f"Expected: {expected_frames}, Found: {frames}" + ) + raise TraitValidationError(self.name, msg) + + def validate_frame_padding( + self, file_locations: FileLocations) -> None: + """Validate frame padding. + + This will take FileLocations trait and validate if the + frame padding matches the expected frame padding. + + Args: + file_locations (FileLocations): File locations trait. + + Raises: + TraitValidationError: If frame padding does not match + the expected frame padding. + + """ + expected_padding = self.get_frame_padding(file_locations) + if self.frame_padding != expected_padding: + msg = ( + "Frame padding does not match the expected frame padding. " + f"Expected: {expected_padding}, Found: {self.frame_padding}" + ) + raise TraitValidationError(self.name, msg) + + @staticmethod + def list_spec_to_frames(list_spec: str) -> list[int]: + """Convert list specification to frames. + + Returns: + list[int]: List of frame numbers. + + Raises: + ValueError: If invalid frame number in the list. + + """ + frames = [] + segments = list_spec.split(",") + for segment in segments: + ranges = segment.split("-") + if len(ranges) == 1: + if not ranges[0].isdigit(): + msg = ( + "Invalid frame number " + f"in the list: {ranges[0]}" + ) + raise ValueError(msg) + frames.append(int(ranges[0])) + continue + start, end = segment.split("-") + frames.extend(range(int(start), int(end) + 1)) + return frames + + @staticmethod + def _get_collection( + file_locations: FileLocations, + regex: Optional[Pattern] = None) -> clique.Collection: + r"""Get the collection from file locations. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + clique.Collection: Collection instance. + + Raises: + ValueError: If zero or multiple of collections are found. + + """ + patterns = [regex] if regex else None + files: list[str] = [ + file.file_path.as_posix() + for file in file_locations.file_paths + ] + src_collections, _ = clique.assemble(files, patterns=patterns) + if len(src_collections) != 1: + msg = ( + f"Zero or multiple collections found: {len(src_collections)} " + "expected 1" + ) + raise ValueError(msg) + return src_collections[0] + + @staticmethod + def get_frame_padding(file_locations: FileLocations) -> int: + """Get frame padding. + + Returns: + int: Frame padding. + + """ + src_collection = Sequence._get_collection(file_locations) + padding = src_collection.padding + # sometimes Clique doesn't get the padding right, so + # we need to calculate it manually + if padding == 0: + padding = len(str(max(src_collection.indexes))) + + return padding + + @staticmethod + def get_frame_list( + file_locations: FileLocations, + regex: Optional[Pattern] = None, + ) -> list[int]: + r"""Get the frame list. + + Args: + file_locations (FileLocations): File locations trait. + regex (Optional[Pattern]): Regular expression to match + frame numbers. This is passed to ``clique.assemble()``. + Default clique pattern is:: + + \.(?P(?P0*)\d+)\.\D+\d?$ + + Returns: + list[int]: List of frame numbers. + + """ + src_collection = Sequence._get_collection(file_locations, regex) + return list(src_collection.indexes) + + def get_frame_pattern(self) -> Pattern: + """Get frame regex as a pattern. + + If the regex is a string, it will compile it to the pattern. + + Returns: + Pattern: Compiled regex pattern. + + """ + if self.frame_regex: + if isinstance(self.frame_regex, str): + return re.compile(self.frame_regex) + return self.frame_regex + return re.compile( + r"\.(?P(?P0*)\d+)\.\D+\d?$") + + +# Do we need one for drop and non-drop frame? +@dataclass +class SMPTETimecode(TraitBase): + """SMPTE Timecode trait model. + + Attributes: + timecode (str): SMPTE Timecode HH:MM:SS:FF + """ + + name: ClassVar[str] = "Timecode" + description: ClassVar[str] = "SMPTE Timecode Trait" + id: ClassVar[str] = "ayon.time.SMPTETimecode.v1" + persistent: ClassVar[bool] = True + timecode: str + + +@dataclass +class Static(TraitBase): + """Static time trait. + + Used to define static time (single frame). + """ + + name: ClassVar[str] = "Static" + description: ClassVar[str] = "Static Time Trait" + id: ClassVar[str] = "ayon.time.Static.v1" + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/three_dimensional.py b/client/ayon_core/pipeline/traits/three_dimensional.py new file mode 100644 index 0000000000..d68fb99e61 --- /dev/null +++ b/client/ayon_core/pipeline/traits/three_dimensional.py @@ -0,0 +1,93 @@ +"""3D traits.""" +from dataclasses import dataclass +from typing import ClassVar + +from .trait import TraitBase + + +@dataclass +class Spatial(TraitBase): + """Spatial trait model. + + Trait describing spatial information. Up axis valid strings are + "Y", "Z", "X". Handedness valid strings are "left", "right". Meters per + unit is a float value. + + Example:: + + Spatial(up_axis="Y", handedness="right", meters_per_unit=1.0) + + Todo: + * Add value validation for up_axis and handedness. + + Attributes: + up_axis (str): Up axis. + handedness (str): Handedness. + meters_per_unit (float): Meters per unit. + """ + + id: ClassVar[str] = "ayon.3d.Spatial.v1" + name: ClassVar[str] = "Spatial" + description: ClassVar[str] = "Spatial trait model." + persistent: ClassVar[bool] = True + up_axis: str + handedness: str + meters_per_unit: float + + +@dataclass +class Geometry(TraitBase): + """Geometry type trait model. + + Type trait for geometry data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Geometry.v1" + name: ClassVar[str] = "Geometry" + description: ClassVar[str] = "Geometry trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class Shader(TraitBase): + """Shader trait model. + + Type trait for shader data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Shader.v1" + name: ClassVar[str] = "Shader" + description: ClassVar[str] = "Shader trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class Lighting(TraitBase): + """Lighting trait model. + + Type trait for lighting data. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.Lighting.v1" + name: ClassVar[str] = "Lighting" + description: ClassVar[str] = "Lighting trait model." + persistent: ClassVar[bool] = True + + +@dataclass +class IESProfile(TraitBase): + """IES profile (IES-LM-64) type trait model. + + Sync with OpenAssetIO MediaCreation Traits. + """ + + id: ClassVar[str] = "ayon.3d.IESProfile.v1" + name: ClassVar[str] = "IESProfile" + description: ClassVar[str] = "IES profile trait model." + persistent: ClassVar[bool] = True diff --git a/client/ayon_core/pipeline/traits/trait.py b/client/ayon_core/pipeline/traits/trait.py new file mode 100644 index 0000000000..85f8e07630 --- /dev/null +++ b/client/ayon_core/pipeline/traits/trait.py @@ -0,0 +1,147 @@ +"""Defines the base trait model and representation.""" +from __future__ import annotations + +import re +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Generic, Optional, TypeVar + +if TYPE_CHECKING: + from .representation import Representation + + +T = TypeVar("T", bound="TraitBase") + + +@dataclass +class TraitBase(ABC): + """Base trait model. + + This model must be used as a base for all trait models. + ``id``, ``name``, and ``description`` are abstract attributes that must be + implemented in the derived classes. + """ + + @property + @abstractmethod + def id(self) -> str: + """Abstract attribute for ID.""" + ... + + @property + @abstractmethod + def name(self) -> str: + """Abstract attribute for name.""" + ... + + @property + @abstractmethod + def description(self) -> str: + """Abstract attribute for description.""" + ... + + def validate_trait(self, representation: Representation) -> None: # noqa: PLR6301 + """Validate the trait. + + This method should be implemented in the derived classes to validate + the trait data. It can be used by traits to validate against other + traits in the representation. + + Args: + representation (Representation): Representation instance. + + """ + return + + @classmethod + def get_version(cls) -> Optional[int]: + # sourcery skip: use-named-expression + """Get a trait version from ID. + + This assumes Trait ID ends with `.v{version}`. If not, it will + return None. + + Returns: + Optional[int]: Trait version + + """ + version_regex = r"v(\d+)$" + match = re.search(version_regex, str(cls.id)) + return int(match[1]) if match else None + + @classmethod + def get_versionless_id(cls) -> str: + """Get a trait ID without a version. + + Returns: + str: Trait ID without a version. + + """ + return re.sub(r"\.v\d+$", "", str(cls.id)) + + def as_dict(self) -> dict: + """Return a trait as a dictionary. + + Returns: + dict: Trait as dictionary. + + """ + return asdict(self) + + +class IncompatibleTraitVersionError(Exception): + """Incompatible trait version exception. + + This exception is raised when the trait version is incompatible with the + current version of the trait. + """ + + +class UpgradableTraitError(Exception, Generic[T]): + """Upgradable trait version exception. + + This exception is raised when the trait can upgrade existing data + meant for older versions of the trait. It must implement an `upgrade` + method that will take old trait data as an argument to handle the upgrade. + """ + + trait: T + old_data: dict + + +class LooseMatchingTraitError(Exception, Generic[T]): + """Loose matching trait exception. + + This exception is raised when the trait is found with a loose matching + criteria. + """ + + found_trait: T + expected_id: str + + +class TraitValidationError(Exception): + """Trait validation error exception. + + This exception is raised when the trait validation fails. + """ + + def __init__(self, scope: str, message: str): + """Initialize the exception. + + We could determine the scope from the stack in the future, + provided the scope is always Trait name. + + Args: + scope (str): Scope of the error. + message (str): Error message. + + """ + super().__init__(f"{scope}: {message}") + + +class MissingTraitError(TypeError): + """Missing trait error exception. + + This exception is raised when the trait is missing. + """ diff --git a/client/ayon_core/pipeline/traits/two_dimensional.py b/client/ayon_core/pipeline/traits/two_dimensional.py new file mode 100644 index 0000000000..d94294bf74 --- /dev/null +++ b/client/ayon_core/pipeline/traits/two_dimensional.py @@ -0,0 +1,208 @@ +"""Two-dimensional image traits.""" +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, Optional + +from .trait import TraitBase + +if TYPE_CHECKING: + from .content import FileLocation, FileLocations + + +@dataclass +class Image(TraitBase): + """Image trait model. + + Type trait model for image. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with version + """ + + name: ClassVar[str] = "Image" + description: ClassVar[str] = "Image Trait" + id: ClassVar[str] = "ayon.2d.Image.v1" + persistent: ClassVar[bool] = True + + +@dataclass +class PixelBased(TraitBase): + """PixelBased trait model. + + The pixel-related trait for image data. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + display_window_width (int): Width of the image display window. + display_window_height (int): Height of the image display window. + pixel_aspect_ratio (float): Pixel aspect ratio. + """ + + name: ClassVar[str] = "PixelBased" + description: ClassVar[str] = "PixelBased Trait Model" + id: ClassVar[str] = "ayon.2d.PixelBased.v1" + persistent: ClassVar[bool] = True + display_window_width: int + display_window_height: int + pixel_aspect_ratio: float + + +@dataclass +class Planar(TraitBase): + """Planar trait model. + + This model represents an Image with planar configuration. + + Todo: + * (antirotor): Is this really a planar configuration? As with + bit planes and everything? If it serves as differentiator for + Deep images, should it be named differently? Like Raster? + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + planar_configuration (str): Planar configuration. + """ + + name: ClassVar[str] = "Planar" + description: ClassVar[str] = "Planar Trait Model" + id: ClassVar[str] = "ayon.2d.Planar.v1" + persistent: ClassVar[bool] = True + planar_configuration: str + + +@dataclass +class Deep(TraitBase): + """Deep trait model. + + Type trait model for deep EXR images. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + """ + + name: ClassVar[str] = "Deep" + description: ClassVar[str] = "Deep Trait Model" + id: ClassVar[str] = "ayon.2d.Deep.v1" + persistent: ClassVar[bool] = True + + +@dataclass +class Overscan(TraitBase): + """Overscan trait model. + + This model represents an overscan (or underscan) trait. Defines the + extra pixels around the image. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be a namespaced trait name with a version + left (int): Left overscan/underscan. + right (int): Right overscan/underscan. + top (int): Top overscan/underscan. + bottom (int): Bottom overscan/underscan. + """ + + name: ClassVar[str] = "Overscan" + description: ClassVar[str] = "Overscan Trait" + id: ClassVar[str] = "ayon.2d.Overscan.v1" + persistent: ClassVar[bool] = True + left: int + right: int + top: int + bottom: int + + +@dataclass +class UDIM(TraitBase): + """UDIM trait model. + + This model represents a UDIM trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + udim (int): UDIM value. + udim_regex (str): UDIM regex. + """ + + name: ClassVar[str] = "UDIM" + description: ClassVar[str] = "UDIM Trait" + id: ClassVar[str] = "ayon.2d.UDIM.v1" + persistent: ClassVar[bool] = True + udim: list[int] + udim_regex: Optional[str] = r"(?:\.|_)(?P\d+)\.\D+\d?$" + + # Field validator for udim_regex - this works in the pydantic model v2 + # but not with the pure data classes. + @classmethod + def validate_frame_regex(cls, v: Optional[str]) -> Optional[str]: + """Validate udim regex. + + Returns: + Optional[str]: UDIM regex. + + Raises: + ValueError: UDIM regex must include 'udim' named group. + + """ + if v is not None and "?P" not in v: + msg = "UDIM regex must include 'udim' named group" + raise ValueError(msg) + return v + + def get_file_location_for_udim( + self, + file_locations: FileLocations, + udim: int, + ) -> Optional[FileLocation]: + """Get file location for UDIM. + + Args: + file_locations (FileLocations): File locations. + udim (int): UDIM value. + + Returns: + Optional[FileLocation]: File location. + + """ + if not self.udim_regex: + return None + pattern = re.compile(self.udim_regex) + for location in file_locations.file_paths: + result = re.search(pattern, location.file_path.name) + if result: + udim_index = int(result.group("udim")) + if udim_index == udim: + return location + return None + + def get_udim_from_file_location( + self, file_location: FileLocation) -> Optional[int]: + """Get UDIM from the file location. + + Args: + file_location (FileLocation): File location. + + Returns: + Optional[int]: UDIM value. + + """ + if not self.udim_regex: + return None + pattern = re.compile(self.udim_regex) + result = re.search(pattern, file_location.file_path.name) + if result: + return int(result.group("udim")) + return None diff --git a/client/ayon_core/pipeline/traits/utils.py b/client/ayon_core/pipeline/traits/utils.py new file mode 100644 index 0000000000..4cb9a643fa --- /dev/null +++ b/client/ayon_core/pipeline/traits/utils.py @@ -0,0 +1,90 @@ +"""Utility functions for traits.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from clique import assemble + +from ayon_core.addon import AddonsManager, ITraits +from ayon_core.pipeline.traits.temporal import FrameRanged + +if TYPE_CHECKING: + from pathlib import Path + from ayon_core.pipeline.traits.trait import TraitBase + + +def get_sequence_from_files(paths: list[Path]) -> FrameRanged: + """Get the original frame range from files. + + Note that this cannot guess frame rate, so it's set to 25. + This will also fail on paths that cannot be assembled into + one collection without any reminders. + + Args: + paths (list[Path]): List of file paths. + + Returns: + FrameRanged: FrameRanged trait. + + Raises: + ValueError: If paths cannot be assembled into one collection + + """ + cols, rems = assemble([path.as_posix() for path in paths]) + if rems: + msg = "Cannot assemble paths into one collection" + raise ValueError(msg) + if len(cols) != 1: + msg = "More than one collection found" + raise ValueError(msg) + col = cols[0] + + sorted_frames = sorted(col.indexes) + # First frame used for end value + first_frame = sorted_frames[0] + # Get last frame for padding + last_frame = sorted_frames[-1] + # Use padding from a collection of the last frame lengths as string + # padding = max(col.padding, len(str(last_frame))) + + return FrameRanged( + frame_start=first_frame, frame_end=last_frame, + frames_per_second="25.0" + ) + + +def get_available_traits( + addons_manager: Optional[AddonsManager] = None +) -> Optional[list[TraitBase]]: + """Get available traits from active addons. + + Args: + addons_manager (Optional[AddonsManager]): Addons manager instance. + If not provided, a new one will be created. Within pyblish + plugins, you can use an already collected instance of + AddonsManager from context `context.data["ayonAddonsManager"]`. + + Returns: + list[TraitBase]: List of available traits. + + """ + if addons_manager is None: + # Create a new instance of AddonsManager + addons_manager = AddonsManager() + + # Get active addons + enabled_addons = addons_manager.get_enabled_addons() + traits = [] + for addon in enabled_addons: + if not issubclass(type(addon), ITraits): + # Skip addons not providing traits + continue + # Get traits from addon + addon_traits = addon.get_addon_traits() + if addon_traits: + # Add traits to a list + for trait in addon_traits: + if trait not in traits: + traits.append(trait) + + return traits diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 61c6e5b876..9b2fe25199 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -329,9 +329,9 @@ def get_last_workfile( Returns: str: Last or first workfile as filename of full path to filename. - """ - filename, version = get_last_workfile_with_version( + """ + filename, _version = get_last_workfile_with_version( workdir, file_template, fill_data, extensions ) if filename is None: diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 27da278c5e..8cea7de86b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -8,7 +8,7 @@ targeted by task types and names. Placeholders are created using placeholder plugins which should care about logic and data of placeholder items. 'PlaceholderItem' is used to keep track -about it's progress. +about its progress. """ import os @@ -17,6 +17,7 @@ import collections import copy from abc import ABC, abstractmethod +import ayon_api from ayon_api import ( get_folders, get_folder_by_path, @@ -60,6 +61,32 @@ from ayon_core.pipeline.create import ( _NOT_SET = object() +class EntityResolutionError(Exception): + """Exception raised when entity URI resolution fails.""" + + +def resolve_entity_uri(entity_uri: str) -> str: + """Resolve AYON entity URI to a filesystem path for local system.""" + response = ayon_api.post( + "resolve", + resolveRoots=True, + uris=[entity_uri] + ) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI filepath for " + f"'{entity_uri}': {response.text}" + ) + + entities = response.data[0]["entities"] + if len(entities) != 1: + raise EntityResolutionError( + f"Unable to resolve AYON entity URI '{entity_uri}' to a " + f"single filepath. Received data: {response.data}" + ) + return entities[0]["filePath"] + + class TemplateNotFound(Exception): """Exception raised when template does not exist.""" pass @@ -823,7 +850,6 @@ class AbstractTemplateBuilder(ABC): """ host_name = self.host_name - project_name = self.project_name task_name = self.current_task_name task_type = self.current_task_type @@ -835,7 +861,6 @@ class AbstractTemplateBuilder(ABC): "task_names": task_name } ) - if not profile: raise TemplateProfileNotFound(( "No matching profile found for task '{}' of type '{}' " @@ -843,6 +868,22 @@ class AbstractTemplateBuilder(ABC): ).format(task_name, task_type, host_name)) path = profile["path"] + if not path: + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) + + resolved_path = self.resolve_template_path(path) + if not resolved_path or not os.path.exists(resolved_path): + raise TemplateNotFound( + "Template file found in AYON settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, resolved_path) + ) + + self.log.info(f"Found template at: '{resolved_path}'") # switch to remove placeholders after they are used keep_placeholder = profile.get("keep_placeholder") @@ -852,44 +893,86 @@ class AbstractTemplateBuilder(ABC): if keep_placeholder is None: keep_placeholder = True - if not path: - raise TemplateLoadFailed(( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles" - ).format(host_name.title())) - - # Try to fill path with environments and anatomy roots - anatomy = Anatomy(project_name) - fill_data = { - key: value - for key, value in os.environ.items() + return { + "path": resolved_path, + "keep_placeholder": keep_placeholder, + "create_first_version": create_first_version } - fill_data["root"] = anatomy.roots - fill_data["project"] = { - "name": project_name, - "code": anatomy.project_code, - } + def resolve_template_path(self, path, fill_data=None) -> str: + """Resolve the template path. - path = self.resolve_template_path(path, fill_data) + By default, this: + - Resolves AYON entity URI to a filesystem path + - Returns path directly if it exists on disk. + - Resolves template keys through anatomy and environment variables. + This can be overridden in host integrations to perform additional + resolving over the template. Like, `hou.text.expandString` in Houdini. + It's recommended to still call the super().resolve_template_path() + to ensure the basic resolving is done across all integrations. + + Arguments: + path (str): The input path. + fill_data (dict[str, str]): Deprecated. This is computed inside + the method using the current environment and project settings. + Used to be the data to use for template formatting. + + Returns: + str: The resolved path. + + """ + + # If the path is an AYON entity URI, then resolve the filepath + # through the backend + if path.startswith("ayon+entity://") or path.startswith("ayon://"): + # This is a special case where the path is an AYON entity URI + # We need to resolve it to a filesystem path + resolved_path = resolve_entity_uri(path) + return resolved_path + + # If the path is set and it's found on disk, return it directly if path and os.path.exists(path): - self.log.info("Found template at: '{}'".format(path)) - return { - "path": path, - "keep_placeholder": keep_placeholder, - "create_first_version": create_first_version + return path + + # We may have path for another platform, like C:/path/to/file + # or a path with template keys, like {project[code]} or both. + # Try to fill path with environments and anatomy roots + project_name = self.project_name + anatomy = Anatomy(project_name) + + # Simple check whether the path contains any template keys + if "{" in path: + fill_data = { + key: value + for key, value in os.environ.items() + } + fill_data["root"] = anatomy.roots + fill_data["project"] = { + "name": project_name, + "code": anatomy.project_code, } - solved_path = None + # Format the template using local fill data + result = StringTemplate.format_template(path, fill_data) + if not result.solved: + return path + + path = result.normalized() + if os.path.exists(path): + return path + + # If the path were set in settings using a Windows path and we + # are now on a Linux system, we try to convert the solved path to + # the current platform. while True: try: solved_path = anatomy.path_remapper(path) except KeyError as missing_key: raise KeyError( - "Could not solve key '{}' in template path '{}'".format( - missing_key, path)) + f"Could not solve key '{missing_key}'" + f" in template path '{path}'" + ) if solved_path is None: solved_path = path @@ -898,40 +981,7 @@ class AbstractTemplateBuilder(ABC): path = solved_path solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): - raise TemplateNotFound( - "Template found in AYON settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) - - self.log.info("Found template at: '{}'".format(solved_path)) - - return { - "path": solved_path, - "keep_placeholder": keep_placeholder, - "create_first_version": create_first_version - } - - def resolve_template_path(self, path, fill_data) -> str: - """Resolve the template path. - - By default, this does nothing except returning the path directly. - - This can be overridden in host integrations to perform additional - resolving over the template. Like, `hou.text.expandString` in Houdini. - - Arguments: - path (str): The input path. - fill_data (dict[str, str]): Data to use for template formatting. - - Returns: - str: The resolved path. - - """ - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - return path + return solved_path def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index f8c45baff6..3a42ccba7e 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -211,7 +211,7 @@ class DeleteOldVersions(load.ProductLoaderPlugin): f"This will keep only the last {versions_to_keep} " f"versions for the {num_contexts} selected product{s}." ) - informative_text="Warning: This will delete files from disk" + informative_text = "Warning: This will delete files from disk" detailed_text = ( f"Keep only {versions_to_keep} versions for:\n{contexts_list}" ) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/load/export_otio.py index e7a844aed3..8094490246 100644 --- a/client/ayon_core/plugins/load/export_otio.py +++ b/client/ayon_core/plugins/load/export_otio.py @@ -22,6 +22,7 @@ from ayon_core.tools.utils import show_message_dialog OTIO = None FRAME_SPLITTER = "__frame_splitter__" + def _import_otio(): global OTIO if OTIO is None: diff --git a/client/ayon_core/plugins/publish/cleanup.py b/client/ayon_core/plugins/publish/cleanup.py index 57ef803352..681fe700a3 100644 --- a/client/ayon_core/plugins/publish/cleanup.py +++ b/client/ayon_core/plugins/publish/cleanup.py @@ -1,11 +1,14 @@ # -*- coding: utf-8 -*- """Cleanup leftover files from publish.""" import os -import shutil -import pyblish.api import re +import shutil +import tempfile + +import pyblish.api from ayon_core.lib import is_in_tests +from ayon_core.pipeline import PublishError class CleanUp(pyblish.api.InstancePlugin): @@ -48,17 +51,15 @@ class CleanUp(pyblish.api.InstancePlugin): if is_in_tests(): # let automatic test process clean up temporary data return - # Get the errored instances - failed = [] + + # If instance has errors, do not clean up for result in instance.context.data["results"]: - if (result["error"] is not None and result["instance"] is not None - and result["instance"] not in failed): - failed.append(result["instance"]) - assert instance not in failed, ( - "Result of '{}' instance were not success".format( - instance.data["name"] - ) - ) + if result["error"] is not None and result["instance"] is instance: + raise PublishError( + "Result of '{}' instance were not success".format( + instance.data["name"] + ) + ) _skip_cleanup_filepaths = instance.context.data.get( "skipCleanupFilepaths" @@ -71,10 +72,17 @@ class CleanUp(pyblish.api.InstancePlugin): self.log.debug("Cleaning renders new...") self.clean_renders(instance, skip_cleanup_filepaths) - if [ef for ef in self.exclude_families - if instance.data["productType"] in ef]: + # TODO: Figure out whether this could be refactored to just a + # product_type in self.exclude_families check. + product_type = instance.data["productType"] + if any( + product_type in exclude_family + for exclude_family in self.exclude_families + ): + self.log.debug( + "Skipping cleanup for instance because product " + f"type is excluded from cleanup: {product_type}") return - import tempfile temp_root = tempfile.gettempdir() staging_dir = instance.data.get("stagingDir", None) diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index 677ebb04a2..2fcf562dd0 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -394,7 +394,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): if aov: anatomy_data["aov"] = aov - def _fill_folder_data(self, instance, project_entity, anatomy_data): # QUESTION: should we make sure that all folder data are popped if # folder data cannot be found? diff --git a/client/ayon_core/plugins/publish/collect_explicit_resolution.py b/client/ayon_core/plugins/publish/collect_explicit_resolution.py new file mode 100644 index 0000000000..3ea3d42102 --- /dev/null +++ b/client/ayon_core/plugins/publish/collect_explicit_resolution.py @@ -0,0 +1,106 @@ +import pyblish.api +from ayon_core.lib import EnumDef +from ayon_core.pipeline import publish +from ayon_core.pipeline.publish import PublishError + + +class CollectExplicitResolution( + pyblish.api.InstancePlugin, + publish.AYONPyblishPluginMixin, +): + """Collect explicit user defined resolution attributes for instances""" + + label = "Choose Explicit Resolution" + order = pyblish.api.CollectorOrder - 0.091 + settings_category = "core" + + enabled = False + + default_resolution_item = (None, "Don't override") + # Settings + product_types = [] + options = [] + + # caching resoluton items + resolution_items = None + + def process(self, instance): + """Process the instance and collect explicit resolution attributes""" + + # Get the values from the instance data + values = self.get_attr_values_from_data(instance.data) + resolution_value = values.get("explicit_resolution", None) + if resolution_value is None: + return + + # Get the width, height and pixel_aspect from the resolution value + resolution_data = self._get_resolution_values(resolution_value) + + # Set the values to the instance data + instance.data.update(resolution_data) + + def _get_resolution_values(self, resolution_value): + """ + Returns width, height and pixel_aspect from the resolution value + + Arguments: + resolution_value (str): resolution value + + Returns: + dict: dictionary with width, height and pixel_aspect + """ + resolution_items = self._get_resolution_items() + # ensure resolution_value is part of expected items + item_values = resolution_items.get(resolution_value) + + # if the item is in the cache, get the values from it + if item_values: + return { + "resolutionWidth": item_values["width"], + "resolutionHeight": item_values["height"], + "pixelAspect": item_values["pixel_aspect"], + } + + raise PublishError( + f"Invalid resolution value: {resolution_value} " + f"expected choices: {resolution_items}" + ) + + @classmethod + def _get_resolution_items(cls): + if cls.resolution_items is None: + resolution_items = {} + for item in cls.options: + item_text = ( + f"{item['width']}x{item['height']} " + f"({item['pixel_aspect']})" + ) + resolution_items[item_text] = item + + cls.resolution_items = resolution_items + + return cls.resolution_items + + @classmethod + def get_attr_defs_for_instance( + cls, create_context, instance, + ): + if instance.product_type not in cls.product_types: + return [] + + # Get the resolution items + resolution_items = cls._get_resolution_items() + + items = [cls.default_resolution_item] + # Add all cached resolution items to the dropdown options + for item_text in resolution_items: + items.append((item_text, item_text)) + + return [ + EnumDef( + "explicit_resolution", + items, + default="Don't override", + label="Force product resolution", + ), + ] diff --git a/client/ayon_core/plugins/publish/collect_farm_env_variables.py b/client/ayon_core/plugins/publish/collect_farm_env_variables.py index 2782ea86ac..39c421381d 100644 --- a/client/ayon_core/plugins/publish/collect_farm_env_variables.py +++ b/client/ayon_core/plugins/publish/collect_farm_env_variables.py @@ -43,4 +43,3 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin): if value: self.log.debug(f"Setting job env: {key}: {value}") env[key] = value - diff --git a/client/ayon_core/plugins/publish/collect_hierarchy.py b/client/ayon_core/plugins/publish/collect_hierarchy.py index 266c2e1458..56b48c37f6 100644 --- a/client/ayon_core/plugins/publish/collect_hierarchy.py +++ b/client/ayon_core/plugins/publish/collect_hierarchy.py @@ -50,7 +50,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): "comments": instance.data.get("comments", []), } - shot_data["attributes"] = {} + shot_data["attributes"] = {} SHOT_ATTRS = ( "handleStart", "handleEnd", diff --git a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py index 1034b9a716..62b007461a 100644 --- a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py +++ b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py @@ -32,16 +32,16 @@ class CollectManagedStagingDir(pyblish.api.InstancePlugin): label = "Collect Managed Staging Directory" order = pyblish.api.CollectorOrder + 0.4990 - def process(self, instance): + def process(self, instance: pyblish.api.Instance): """ Collect the staging data and stores it to the instance. Args: instance (object): The instance to inspect. """ staging_dir_path = get_instance_staging_dir(instance) - persistance = instance.data.get("stagingDir_persistent", False) + persistence: bool = instance.data.get("stagingDir_persistent", False) - self.log.info(( + self.log.debug( f"Instance staging dir was set to `{staging_dir_path}` " - f"and persistence is set to `{persistance}`" - )) + f"and persistence is set to `{persistence}`" + ) diff --git a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py index f1fa6a817d..275b8a7f55 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -194,7 +194,6 @@ class CollectOtioSubsetResources( repre = self._create_representation( frame_start, frame_end, file=filename) - else: _trim = False dirname, filename = os.path.split(media_ref.target_url) @@ -209,7 +208,6 @@ class CollectOtioSubsetResources( repre = self._create_representation( frame_start, frame_end, file=filename, trim=_trim) - instance.data["originalDirname"] = self.staging_dir # add representation to instance data @@ -221,7 +219,6 @@ class CollectOtioSubsetResources( instance.data["representations"].append(repre) - self.log.debug(instance.data) def _create_representation(self, start, end, **kwargs): diff --git a/client/ayon_core/plugins/publish/collect_rendered_files.py b/client/ayon_core/plugins/publish/collect_rendered_files.py index deecf7ba24..5c68af888f 100644 --- a/client/ayon_core/plugins/publish/collect_rendered_files.py +++ b/client/ayon_core/plugins/publish/collect_rendered_files.py @@ -31,6 +31,9 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # Keep "filesequence" for backwards compatibility of older jobs targets = ["filesequence", "farm"] label = "Collect rendered frames" + settings_category = "core" + + remove_files = False _context = None @@ -120,7 +123,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self._fill_staging_dir(repre_data, anatomy) representations.append(repre_data) - if not staging_dir_persistent: + if self.remove_files and not staging_dir_persistent: add_repre_files_for_cleanup(instance, repre_data) instance.data["representations"] = representations @@ -170,7 +173,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ.update(session_data) staging_dir_persistent = self._process_path(data, anatomy) - if not staging_dir_persistent: + if self.remove_files and not staging_dir_persistent: context.data["cleanupFullPaths"].append(path) context.data["cleanupEmptyDirs"].append( os.path.dirname(path) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1f2c2a89af..8a276cf608 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -58,7 +58,7 @@ class ExtractOIIOTranscode(publish.Extractor): optional = True # Supported extensions - supported_exts = ["exr", "jpg", "jpeg", "png", "dpx"] + supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} # Configurable by Settings profiles = None @@ -280,10 +280,14 @@ class ExtractOIIOTranscode(publish.Extractor): collection = collections[0] frames = list(collection.indexes) - if collection.holes(): + if collection.holes().indexes: return files_to_convert - frame_str = "{}-{}#".format(frames[0], frames[-1]) + # Get the padding from the collection + # This is the number of digits used in the frame numbers + padding = collection.padding + + frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding) file_name = "{}{}{}".format(collection.head, frame_str, collection.tail) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 7a9a020ff0..f217be551c 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -54,7 +54,7 @@ class ExtractOTIOReview( # plugin default attributes to_width = 1280 to_height = 720 - output_ext = ".jpg" + output_ext = ".png" def process(self, instance): # Not all hosts can import these modules. @@ -510,6 +510,12 @@ class ExtractOTIOReview( "-tune", "stillimage" ]) + if video or sequence: + command.extend([ + "-vf", f"scale={self.to_width}:{self.to_height}:flags=lanczos", + "-compression_level", "5", + ]) + # add output attributes command.extend([ "-start_number", str(out_frame_start) @@ -520,9 +526,10 @@ class ExtractOTIOReview( input_extension and self.output_ext == input_extension ): - command.extend([ - "-c", "copy" - ]) + command.extend(["-c", "copy"]) + else: + # For lossy formats, force re-encode + command.extend(["-pix_fmt", "rgba"]) # add output path at the end command.append(output_path) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index df87abba91..89bc56c670 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import copy @@ -5,11 +6,16 @@ import json import shutil import subprocess from abc import ABC, abstractmethod +from typing import Any, Optional +from dataclasses import dataclass, field +import tempfile import clique import speedcopy import pyblish.api +from ayon_api import get_last_version_by_product_name, get_representations + from ayon_core.lib import ( get_ffmpeg_tool_args, filter_profiles, @@ -31,6 +37,39 @@ from ayon_core.pipeline.publish import ( from ayon_core.pipeline.publish.lib import add_repre_files_for_cleanup +@dataclass +class TempData: + """Temporary data used across extractor's process.""" + fps: float + frame_start: int + frame_end: int + handle_start: int + handle_end: int + frame_start_handle: int + frame_end_handle: int + output_frame_start: int + output_frame_end: int + pixel_aspect: float + resolution_width: int + resolution_height: int + origin_repre: dict[str, Any] + input_is_sequence: bool + first_sequence_frame: int + input_allow_bg: bool + with_audio: bool + without_handles: bool + handles_are_set: bool + input_ext: str + explicit_input_paths: list[str] + paths_to_remove: list[str] + + # Set later + full_output_path: str = "" + filled_files: dict[int, str] = field(default_factory=dict) + output_ext_is_image: bool = True + output_is_sequence: bool = True + + def frame_to_timecode(frame: int, fps: float) -> str: """Convert a frame number and FPS to editorial timecode (HH:MM:SS:FF). @@ -96,11 +135,11 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] - video_exts = ["mov", "mp4"] - supported_exts = image_exts + video_exts + image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} + video_exts = {"mov", "mp4"} + supported_exts = image_exts | video_exts - alpha_exts = ["exr", "png", "dpx"] + alpha_exts = {"exr", "png", "dpx"} # Preset attributes profiles = [] @@ -400,15 +439,73 @@ class ExtractReview(pyblish.api.InstancePlugin): ) temp_data = self.prepare_temp_data(instance, repre, output_def) - files_to_clean = [] - if temp_data["input_is_sequence"]: + new_frame_files = {} + if temp_data.input_is_sequence: self.log.debug("Checking sequence to fill gaps in sequence..") - files_to_clean = self.fill_sequence_gaps( - files=temp_data["origin_repre"]["files"], - staging_dir=new_repre["stagingDir"], - start_frame=temp_data["frame_start"], - end_frame=temp_data["frame_end"] - ) + + files = temp_data.origin_repre["files"] + collections = clique.assemble( + files, + )[0] + if len(collections) != 1: + raise KnownPublishError( + "Multiple collections {} found.".format(collections)) + + collection = collections[0] + + fill_missing_frames = _output_def["fill_missing_frames"] + if fill_missing_frames == "closest_existing": + new_frame_files = self.fill_sequence_gaps_from_existing( + collection=collection, + staging_dir=new_repre["stagingDir"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + ) + elif fill_missing_frames == "blank": + new_frame_files = self.fill_sequence_gaps_with_blanks( + collection=collection, + staging_dir=new_repre["stagingDir"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + resolution_width=temp_data.resolution_width, + resolution_height=temp_data.resolution_height, + extension=temp_data.input_ext, + temp_data=temp_data + ) + elif fill_missing_frames == "previous_version": + new_frame_files = self.fill_sequence_gaps_with_previous( + collection=collection, + staging_dir=new_repre["stagingDir"], + instance=instance, + current_repre_name=repre["name"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + ) + # fallback to original workflow + if new_frame_files is None: + new_frame_files = ( + self.fill_sequence_gaps_from_existing( + collection=collection, + staging_dir=new_repre["stagingDir"], + start_frame=temp_data.frame_start, + end_frame=temp_data.frame_end, + )) + elif fill_missing_frames == "only_rendered": + temp_data.explicit_input_paths = [ + os.path.join( + new_repre["stagingDir"], file + ).replace("\\", "/") + for file in files + ] + frame_start = min(collection.indexes) + frame_end = max(collection.indexes) + # modify range for burnins + instance.data["frameStart"] = frame_start + instance.data["frameEnd"] = frame_end + temp_data.frame_start = frame_start + temp_data.frame_end = frame_end + + temp_data.filled_files = new_frame_files # create or update outputName output_name = new_repre.get("outputName", "") @@ -416,7 +513,7 @@ class ExtractReview(pyblish.api.InstancePlugin): if output_name: output_name += "_" output_name += output_def["filename_suffix"] - if temp_data["without_handles"]: + if temp_data.without_handles: output_name += "_noHandles" # add outputName to anatomy format fill_data @@ -429,7 +526,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # like Resolve or Premiere can detect the start frame for e.g. # review output files "timecode": frame_to_timecode( - frame=temp_data["frame_start_handle"], + frame=temp_data.frame_start_handle, fps=float(instance.data["fps"]) ) }) @@ -446,7 +543,7 @@ class ExtractReview(pyblish.api.InstancePlugin): except ZeroDivisionError: # TODO recalculate width and height using OIIO before # conversion - if 'exr' in temp_data["origin_repre"]["ext"]: + if 'exr' in temp_data.origin_repre["ext"]: self.log.warning( ( "Unsupported compression on input files." @@ -465,17 +562,20 @@ class ExtractReview(pyblish.api.InstancePlugin): run_subprocess(subprcs_cmd, shell=True, logger=self.log) # delete files added to fill gaps - if files_to_clean: - for f in files_to_clean: - os.unlink(f) + if new_frame_files: + for filepath in new_frame_files.values(): + os.unlink(filepath) + + for filepath in temp_data.paths_to_remove: + os.unlink(filepath) new_repre.update({ - "fps": temp_data["fps"], + "fps": temp_data.fps, "name": "{}_{}".format(output_name, output_ext), "outputName": output_name, "outputDef": output_def, - "frameStartFtrack": temp_data["output_frame_start"], - "frameEndFtrack": temp_data["output_frame_end"], + "frameStartFtrack": temp_data.output_frame_start, + "frameEndFtrack": temp_data.output_frame_end, "ffmpeg_cmd": subprcs_cmd }) @@ -501,7 +601,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # - there can be more than one collection return isinstance(repre["files"], (list, tuple)) - def prepare_temp_data(self, instance, repre, output_def): + def prepare_temp_data(self, instance, repre, output_def) -> TempData: """Prepare dictionary with values used across extractor's process. All data are collected from instance, context, origin representation @@ -517,7 +617,7 @@ class ExtractReview(pyblish.api.InstancePlugin): output_def (dict): Definition of output of this plugin. Returns: - dict: All data which are used across methods during process. + TempData: All data which are used across methods during process. Their values should not change during process but new keys with values may be added. """ @@ -560,6 +660,7 @@ class ExtractReview(pyblish.api.InstancePlugin): input_is_sequence = self.input_is_sequence(repre) input_allow_bg = False first_sequence_frame = None + if input_is_sequence and repre["files"]: # Calculate first frame that should be used cols, _ = clique.assemble(repre["files"]) @@ -578,28 +679,33 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"][0])[1].replace(".", "") if ext.lower() in self.alpha_exts: input_allow_bg = True + else: + ext = os.path.splitext(repre["files"])[1].replace(".", "") - return { - "fps": float(instance.data["fps"]), - "frame_start": frame_start, - "frame_end": frame_end, - "handle_start": handle_start, - "handle_end": handle_end, - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, - "output_frame_start": int(output_frame_start), - "output_frame_end": int(output_frame_end), - "pixel_aspect": instance.data.get("pixelAspect", 1), - "resolution_width": instance.data.get("resolutionWidth"), - "resolution_height": instance.data.get("resolutionHeight"), - "origin_repre": repre, - "input_is_sequence": input_is_sequence, - "first_sequence_frame": first_sequence_frame, - "input_allow_bg": input_allow_bg, - "with_audio": with_audio, - "without_handles": without_handles, - "handles_are_set": handles_are_set - } + return TempData( + fps=float(instance.data["fps"]), + frame_start=frame_start, + frame_end=frame_end, + handle_start=handle_start, + handle_end=handle_end, + frame_start_handle=frame_start_handle, + frame_end_handle=frame_end_handle, + output_frame_start=int(output_frame_start), + output_frame_end=int(output_frame_end), + pixel_aspect=instance.data.get("pixelAspect", 1), + resolution_width=instance.data.get("resolutionWidth"), + resolution_height=instance.data.get("resolutionHeight"), + origin_repre=repre, + input_is_sequence=input_is_sequence, + first_sequence_frame=first_sequence_frame, + input_allow_bg=input_allow_bg, + with_audio=with_audio, + without_handles=without_handles, + handles_are_set=handles_are_set, + input_ext=ext, + explicit_input_paths=[], # absolute paths to rendered files + paths_to_remove=[] + ) def _ffmpeg_arguments( self, @@ -620,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): instance (Instance): Currently processed instance. new_repre (dict): Representation representing output of this process. - temp_data (dict): Base data for successful process. + temp_data (TempData): Base data for successful process. """ # Get FFmpeg arguments from profile presets @@ -662,31 +768,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # Set output frames len to 1 when output is single image if ( - temp_data["output_ext_is_image"] - and not temp_data["output_is_sequence"] + temp_data.output_ext_is_image + and not temp_data.output_is_sequence ): output_frames_len = 1 else: output_frames_len = ( - temp_data["output_frame_end"] - - temp_data["output_frame_start"] + temp_data.output_frame_end + - temp_data.output_frame_start + 1 ) - duration_seconds = float(output_frames_len / temp_data["fps"]) + duration_seconds = float(output_frames_len / temp_data.fps) # Define which layer should be used if layer_name: ffmpeg_input_args.extend(["-layer", layer_name]) - if temp_data["input_is_sequence"]: + explicit_input_paths = temp_data.explicit_input_paths + if temp_data.input_is_sequence and not explicit_input_paths: # Set start frame of input sequence (just frame in filename) # - definition of input filepath # - add handle start if output should be without handles - start_number = temp_data["first_sequence_frame"] - if temp_data["without_handles"] and temp_data["handles_are_set"]: - start_number += temp_data["handle_start"] + start_number = temp_data.first_sequence_frame + if temp_data.without_handles and temp_data.handles_are_set: + start_number += temp_data.handle_start ffmpeg_input_args.extend([ "-start_number", str(start_number) ]) @@ -699,32 +806,32 @@ class ExtractReview(pyblish.api.InstancePlugin): # } # Add framerate to input when input is sequence ffmpeg_input_args.extend([ - "-framerate", str(temp_data["fps"]) + "-framerate", str(temp_data.fps) ]) # Add duration of an input sequence if output is video - if not temp_data["output_is_sequence"]: + if not temp_data.output_is_sequence: ffmpeg_input_args.extend([ "-to", "{:0.10f}".format(duration_seconds) ]) - if temp_data["output_is_sequence"]: + if temp_data.output_is_sequence and not explicit_input_paths: # Set start frame of output sequence (just frame in filename) # - this is definition of an output ffmpeg_output_args.extend([ - "-start_number", str(temp_data["output_frame_start"]) + "-start_number", str(temp_data.output_frame_start) ]) # Change output's duration and start point if should not contain # handles - if temp_data["without_handles"] and temp_data["handles_are_set"]: + if temp_data.without_handles and temp_data.handles_are_set: # Set output duration in seconds ffmpeg_output_args.extend([ "-t", "{:0.10}".format(duration_seconds) ]) # Add -ss (start offset in seconds) if input is not sequence - if not temp_data["input_is_sequence"]: - start_sec = float(temp_data["handle_start"]) / temp_data["fps"] + if not temp_data.input_is_sequence: + start_sec = float(temp_data.handle_start) / temp_data.fps # Set start time without handles # - Skip if start sec is 0.0 if start_sec > 0.0: @@ -733,18 +840,42 @@ class ExtractReview(pyblish.api.InstancePlugin): ]) # Set frame range of output when input or output is sequence - elif temp_data["output_is_sequence"]: + elif temp_data.output_is_sequence: ffmpeg_output_args.extend([ "-frames:v", str(output_frames_len) ]) - # Add video/image input path - ffmpeg_input_args.extend([ - "-i", path_to_subprocess_arg(temp_data["full_input_path"]) - ]) + if not explicit_input_paths: + # Add video/image input path + ffmpeg_input_args.extend([ + "-i", path_to_subprocess_arg(temp_data.full_input_path) + ]) + else: + frame_duration = 1 / temp_data.fps + + explicit_frames_meta = tempfile.NamedTemporaryFile( + mode="w", prefix="explicit_frames", suffix=".txt", delete=False + ) + explicit_frames_meta.close() + explicit_frames_path = explicit_frames_meta.name + with open(explicit_frames_path, "w") as fp: + lines = [ + f"file '{path}'{os.linesep}duration {frame_duration}" + for path in temp_data.explicit_input_paths + ] + fp.write("\n".join(lines)) + temp_data.paths_to_remove.append(explicit_frames_path) + + # let ffmpeg use only rendered files, might have gaps + ffmpeg_input_args.extend([ + "-f", "concat", + "-safe", "0", + "-i", path_to_subprocess_arg(explicit_frames_path), + "-r", str(temp_data.fps) + ]) # Add audio arguments if there are any. Skipped when output are images. - if not temp_data["output_ext_is_image"] and temp_data["with_audio"]: + if not temp_data.output_ext_is_image and temp_data.with_audio: audio_in_args, audio_filters, audio_out_args = self.audio_args( instance, temp_data, duration_seconds ) @@ -766,7 +897,7 @@ class ExtractReview(pyblish.api.InstancePlugin): bg_red, bg_green, bg_blue, bg_alpha = bg_color if bg_alpha > 0.0: - if not temp_data["input_allow_bg"]: + if not temp_data.input_allow_bg: self.log.info(( "Output definition has defined BG color input was" " resolved as does not support adding BG." @@ -797,7 +928,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # NOTE This must be latest added item to output arguments. ffmpeg_output_args.append( - path_to_subprocess_arg(temp_data["full_output_path"]) + path_to_subprocess_arg(temp_data.full_output_path) ) return self.ffmpeg_full_args( @@ -881,8 +1012,159 @@ class ExtractReview(pyblish.api.InstancePlugin): return all_args - def fill_sequence_gaps(self, files, staging_dir, start_frame, end_frame): - # type: (list, str, int, int) -> list + def fill_sequence_gaps_with_previous( + self, + collection: str, + staging_dir: str, + instance: pyblish.plugin.Instance, + current_repre_name: str, + start_frame: int, + end_frame: int + ) -> Optional[dict[int, str]]: + """Tries to replace missing frames from ones from last version""" + repre_file_paths = self._get_last_version_files( + instance, current_repre_name) + if repre_file_paths is None: + # issues in getting last version files, falling back + return None + + prev_collection = clique.assemble( + repre_file_paths, + patterns=[clique.PATTERNS["frames"]], + minimum_items=1 + )[0][0] + prev_col_format = prev_collection.format("{head}{padding}{tail}") + + added_files = {} + anatomy = instance.context.data["anatomy"] + col_format = collection.format("{head}{padding}{tail}") + for frame in range(start_frame, end_frame + 1): + if frame in collection.indexes: + continue + hole_fpath = os.path.join(staging_dir, col_format % frame) + + previous_version_path = prev_col_format % frame + previous_version_path = anatomy.fill_root(previous_version_path) + if not os.path.exists(previous_version_path): + self.log.warning( + "Missing frame should be replaced from " + f"'{previous_version_path}' but that doesn't exist. " + "Falling back to filling from currently last rendered." + ) + return None + + self.log.warning( + f"Replacing missing '{hole_fpath}' with " + f"'{previous_version_path}'" + ) + speedcopy.copyfile(previous_version_path, hole_fpath) + added_files[frame] = hole_fpath + + return added_files + + def _get_last_version_files( + self, + instance: pyblish.plugin.Instance, + current_repre_name: str, + ): + product_name = instance.data["productName"] + project_name = instance.data["projectEntity"]["name"] + folder_entity = instance.data["folderEntity"] + + version_entity = get_last_version_by_product_name( + project_name, + product_name, + folder_entity["id"], + fields={"id"} + ) + if not version_entity: + return None + + matching_repres = get_representations( + project_name, + version_ids=[version_entity["id"]], + representation_names=[current_repre_name], + fields={"files"} + ) + + if not matching_repres: + return None + matching_repre = list(matching_repres)[0] + + repre_file_paths = [ + file_info["path"] + for file_info in matching_repre["files"] + ] + + return repre_file_paths + + def fill_sequence_gaps_with_blanks( + self, + collection: str, + staging_dir: str, + start_frame: int, + end_frame: int, + resolution_width: int, + resolution_height: int, + extension: str, + temp_data: TempData + ) -> Optional[dict[int, str]]: + """Fills missing files by blank frame.""" + + blank_frame_path = None + + added_files = {} + + col_format = collection.format("{head}{padding}{tail}") + for frame in range(start_frame, end_frame + 1): + if frame in collection.indexes: + continue + hole_fpath = os.path.join(staging_dir, col_format % frame) + if blank_frame_path is None: + blank_frame_path = self._create_blank_frame( + staging_dir, extension, resolution_width, resolution_height + ) + temp_data.paths_to_remove.append(blank_frame_path) + speedcopy.copyfile(blank_frame_path, hole_fpath) + added_files[frame] = hole_fpath + + return added_files + + def _create_blank_frame( + self, + staging_dir, + extension, + resolution_width, + resolution_height + ): + blank_frame_path = os.path.join(staging_dir, f"blank.{extension}") + + command = get_ffmpeg_tool_args( + "ffmpeg", + "-f", "lavfi", + "-i", "color=c=black:s={}x{}:d=1".format( + resolution_width, resolution_height + ), + "-tune", "stillimage", + "-frames:v", "1", + blank_frame_path + ) + + self.log.debug("Executing: {}".format(" ".join(command))) + output = run_subprocess( + command, logger=self.log + ) + self.log.debug("Output: {}".format(output)) + + return blank_frame_path + + def fill_sequence_gaps_from_existing( + self, + collection, + staging_dir: str, + start_frame: int, + end_frame: int + ) -> dict[int, str]: """Fill missing files in sequence by duplicating existing ones. This will take nearest frame file and copy it with so as to fill @@ -890,40 +1172,33 @@ class ExtractReview(pyblish.api.InstancePlugin): hole ahead. Args: - files (list): List of representation files. + collection (clique.collection) staging_dir (str): Path to staging directory. start_frame (int): Sequence start (no matter what files are there) end_frame (int): Sequence end (no matter what files are there) Returns: - list of added files. Those should be cleaned after work + dict[int, str] of added files. Those should be cleaned after work is done. Raises: KnownPublishError: if more than one collection is obtained. """ - collections = clique.assemble(files)[0] - if len(collections) != 1: - raise KnownPublishError( - "Multiple collections {} found.".format(collections)) - - col = collections[0] - # Prepare which hole is filled with what frame # - the frame is filled only with already existing frames - prev_frame = next(iter(col.indexes)) + prev_frame = next(iter(collection.indexes)) hole_frame_to_nearest = {} for frame in range(int(start_frame), int(end_frame) + 1): - if frame in col.indexes: + if frame in collection.indexes: prev_frame = frame else: # Use previous frame as source for hole hole_frame_to_nearest[frame] = prev_frame # Calculate paths - added_files = [] - col_format = col.format("{head}{padding}{tail}") + added_files = {} + col_format = collection.format("{head}{padding}{tail}") for hole_frame, src_frame in hole_frame_to_nearest.items(): hole_fpath = os.path.join(staging_dir, col_format % hole_frame) src_fpath = os.path.join(staging_dir, col_format % src_frame) @@ -932,11 +1207,11 @@ class ExtractReview(pyblish.api.InstancePlugin): "Missing previously detected file: {}".format(src_fpath)) speedcopy.copyfile(src_fpath, hole_fpath) - added_files.append(hole_fpath) + added_files[hole_frame] = hole_fpath return added_files - def input_output_paths(self, new_repre, output_def, temp_data): + def input_output_paths(self, new_repre, output_def, temp_data: TempData): """Deduce input nad output file paths based on entered data. Input may be sequence of images, video file or single image file and @@ -949,11 +1224,11 @@ class ExtractReview(pyblish.api.InstancePlugin): "sequence_file" (if output is sequence) keys to new representation. """ - repre = temp_data["origin_repre"] + repre = temp_data.origin_repre src_staging_dir = repre["stagingDir"] dst_staging_dir = new_repre["stagingDir"] - if temp_data["input_is_sequence"]: + if temp_data.input_is_sequence: collections = clique.assemble(repre["files"])[0] full_input_path = os.path.join( src_staging_dir, @@ -978,6 +1253,14 @@ class ExtractReview(pyblish.api.InstancePlugin): # Make sure to have full path to one input file full_input_path_single_file = full_input_path + filled_files = temp_data.filled_files + if filled_files: + first_frame, first_file = next(iter(filled_files.items())) + if first_file < full_input_path_single_file: + self.log.warning(f"Using filled frame: '{first_file}'") + full_input_path_single_file = first_file + temp_data.first_sequence_frame = first_frame + filename_suffix = output_def["filename_suffix"] output_ext = output_def.get("ext") @@ -1004,8 +1287,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ) if output_is_sequence: new_repre_files = [] - frame_start = temp_data["output_frame_start"] - frame_end = temp_data["output_frame_end"] + frame_start = temp_data.output_frame_start + frame_end = temp_data.output_frame_end filename_base = "{}_{}".format(filename, filename_suffix) # Temporary template for frame filling. Example output: @@ -1042,18 +1325,18 @@ class ExtractReview(pyblish.api.InstancePlugin): new_repre["stagingDir"] = dst_staging_dir # Store paths to temp data - temp_data["full_input_path"] = full_input_path - temp_data["full_input_path_single_file"] = full_input_path_single_file - temp_data["full_output_path"] = full_output_path + temp_data.full_input_path = full_input_path + temp_data.full_input_path_single_file = full_input_path_single_file + temp_data.full_output_path = full_output_path # Store information about output - temp_data["output_ext_is_image"] = output_ext_is_image - temp_data["output_is_sequence"] = output_is_sequence + temp_data.output_ext_is_image = output_ext_is_image + temp_data.output_is_sequence = output_is_sequence self.log.debug("Input path {}".format(full_input_path)) self.log.debug("Output path {}".format(full_output_path)) - def audio_args(self, instance, temp_data, duration_seconds): + def audio_args(self, instance, temp_data: TempData, duration_seconds): """Prepares FFMpeg arguments for audio inputs.""" audio_in_args = [] audio_filters = [] @@ -1070,7 +1353,7 @@ class ExtractReview(pyblish.api.InstancePlugin): frame_start_ftrack = instance.data.get("frameStartFtrack") if frame_start_ftrack is not None: offset_frames = frame_start_ftrack - audio["offset"] - offset_seconds = offset_frames / temp_data["fps"] + offset_seconds = offset_frames / temp_data.fps if offset_seconds > 0: audio_in_args.append( @@ -1254,7 +1537,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return output - def rescaling_filters(self, temp_data, output_def, new_repre): + def rescaling_filters(self, temp_data: TempData, output_def, new_repre): """Prepare vieo filters based on tags in new representation. It is possible to add letterboxes to output video or rescale to @@ -1274,7 +1557,7 @@ class ExtractReview(pyblish.api.InstancePlugin): self.log.debug("reformat_in_baking: `{}`".format(reformat_in_baking)) # NOTE Skipped using instance's resolution - full_input_path_single_file = temp_data["full_input_path_single_file"] + full_input_path_single_file = temp_data.full_input_path_single_file try: streams = get_ffprobe_streams( full_input_path_single_file, self.log @@ -1299,7 +1582,7 @@ class ExtractReview(pyblish.api.InstancePlugin): break # Get instance data - pixel_aspect = temp_data["pixel_aspect"] + pixel_aspect = temp_data.pixel_aspect if reformat_in_baking: self.log.debug(( "Using resolution from input. It is already " @@ -1333,7 +1616,7 @@ class ExtractReview(pyblish.api.InstancePlugin): bg_red, bg_green, bg_blue = overscan_color else: # Backwards compatibility - bg_red, bg_green, bg_blue, _ = overscan_color + bg_red, bg_green, bg_blue, _ = overscan_color overscan_color_value = "#{0:0>2X}{1:0>2X}{2:0>2X}".format( bg_red, bg_green, bg_blue @@ -1394,8 +1677,8 @@ class ExtractReview(pyblish.api.InstancePlugin): # - use instance resolution only if there were not scale changes # that may massivelly affect output 'use_input_res' if not use_input_res and output_width is None or output_height is None: - output_width = temp_data["resolution_width"] - output_height = temp_data["resolution_height"] + output_width = temp_data.resolution_width + output_height = temp_data.resolution_height # Use source's input resolution instance does not have set it. if output_width is None or output_height is None: diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 18393022ed..69bb9007f9 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -17,7 +17,7 @@ from ayon_core.lib import ( ) from ayon_core.lib.transcoding import convert_colorspace -from ayon_core.lib.transcoding import VIDEO_EXTENSIONS +from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS class ExtractThumbnail(pyblish.api.InstancePlugin): @@ -164,7 +164,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): instance.context.data["cleanupFullPaths"].append(dst_staging) oiio_supported = is_oiio_supported() - repre_thumb_created = False + thumbnail_created = False for repre in filtered_repres: # Reset for each iteration to handle cases where multiple # reviewable thumbnails are needed @@ -241,6 +241,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): if not repre_thumb_created: continue + thumbnail_created = True if len(explicit_repres) > 1: repre_name = "thumbnail_{}".format(repre["outputName"]) else: @@ -294,7 +295,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # There is no need to create more then one thumbnail break - if not repre_thumb_created: + if not thumbnail_created: self.log.warning("Thumbnail has not been created.") def _is_review_instance(self, instance): @@ -335,7 +336,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return need_thumb_repres def _get_filtered_repres(self, instance): - filtered_repres = [] + review_repres = [] + other_repres = [] src_repres = instance.data.get("representations") or [] for repre in src_repres: @@ -347,17 +349,36 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # to be published locally continue - if "review" not in tags: - continue - if not repre.get("files"): self.log.debug(( "Representation \"{}\" doesn't have files. Skipping" ).format(repre["name"])) continue - filtered_repres.append(repre) - return filtered_repres + if "review" in tags: + review_repres.append(repre) + elif self._is_valid_images_repre(repre): + other_repres.append(repre) + + return review_repres + other_repres + + def _is_valid_images_repre(self, repre): + """Check if representation contains valid image files + + Args: + repre (dict): representation + + Returns: + bool: whether the representation has the valid image content + """ + # Get first file's extension + first_file = repre["files"] + if isinstance(first_file, (list, tuple)): + first_file = first_file[0] + + ext = os.path.splitext(first_file)[1].lower() + + return ext in IMAGE_EXTENSIONS or ext in VIDEO_EXTENSIONS def _create_thumbnail_oiio( self, @@ -485,27 +506,36 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # Set video input attributes max_int = str(2147483647) video_data = get_ffprobe_data(video_file_path, logger=self.log) - # Use duration of the individual streams since it is returned with - # higher decimal precision than 'format.duration'. We need this - # more precise value for calculating the correct amount of frames - # for higher FPS ranges or decimal ranges, e.g. 29.97 FPS - duration = max( - float(stream.get("duration", 0)) - for stream in video_data["streams"] - if stream.get("codec_type") == "video" - ) - cmd_args = [ - "-y", - "-ss", str(duration * self.duration_split), + # Get duration or use a safe default (single frame) + duration = 0 + for stream in video_data["streams"]: + if stream.get("codec_type") == "video": + stream_duration = float(stream.get("duration", 0)) + if stream_duration > duration: + duration = stream_duration + + # For very short videos, just use the first frame + # Calculate seek position safely + seek_position = 0.0 + # Only use timestamp calculation for videos longer than 0.1 seconds + if duration > 0.1: + seek_position = duration * self.duration_split + + # Build command args + cmd_args = [] + if seek_position > 0.0: + cmd_args.extend(["-ss", str(seek_position)]) + + # Add generic ffmpeg commands + cmd_args.extend([ "-i", video_file_path, "-analyzeduration", max_int, "-probesize", max_int, - "-frames:v", "1" - ] - - # add output file path - cmd_args.append(output_thumb_file_path) + "-y", + "-frames:v", "1", + output_thumb_file_path + ]) # create ffmpeg command cmd = get_ffmpeg_tool_args( @@ -516,15 +546,53 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # run subprocess self.log.debug("Executing: {}".format(" ".join(cmd))) run_subprocess(cmd, logger=self.log) - self.log.debug( - "Thumbnail created: {}".format(output_thumb_file_path)) - return output_thumb_file_path + + # Verify the output file was created + if ( + os.path.exists(output_thumb_file_path) + and os.path.getsize(output_thumb_file_path) > 0 + ): + self.log.debug( + "Thumbnail created: {}".format(output_thumb_file_path)) + return output_thumb_file_path + self.log.warning("Output file was not created or is empty") + + # Try to create thumbnail without offset + # - skip if offset did not happen + if "-ss" not in cmd_args: + return None + + self.log.debug("Trying fallback without offset") + # Remove -ss and its value + ss_index = cmd_args.index("-ss") + cmd_args.pop(ss_index) # Remove -ss + cmd_args.pop(ss_index) # Remove the timestamp value + + # Create new command and try again + cmd = get_ffmpeg_tool_args("ffmpeg", *cmd_args) + self.log.debug("Fallback command: {}".format(" ".join(cmd))) + run_subprocess(cmd, logger=self.log) + + if ( + os.path.exists(output_thumb_file_path) + and os.path.getsize(output_thumb_file_path) > 0 + ): + self.log.debug("Fallback thumbnail created") + return output_thumb_file_path + return None except RuntimeError as error: self.log.warning( "Failed intermediate thumb source using ffmpeg: {}".format( error) ) return None + finally: + # Remove output file if is empty + if ( + os.path.exists(output_thumb_file_path) + and os.path.getsize(output_thumb_file_path) == 0 + ): + os.remove(output_thumb_file_path) def _get_resolution_arg( self, diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 8e57980ba6..f1e066018c 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -683,7 +683,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): elif is_sequence_representation: # Collection of files (sequence) - src_collections, remainders = clique.assemble(files) + src_collections, _remainders = clique.assemble(files) src_collection = src_collections[0] destination_indexes = list(src_collection.indexes) diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 2163596864..43f93da293 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -1,7 +1,11 @@ import os import copy import errno +import itertools import shutil +from concurrent.futures import ThreadPoolExecutor + +from speedcopy import copyfile import clique import pyblish.api @@ -13,6 +17,7 @@ from ayon_api.operations import ( from ayon_api.utils import create_entity_id from ayon_core.lib import create_hard_link, source_hash +from ayon_core.lib.file_transaction import wait_for_future_errors from ayon_core.pipeline.publish import ( get_publish_template_name, OptionalPyblishPluginMixin, @@ -415,11 +420,14 @@ class IntegrateHeroVersion( # Copy(hardlink) paths of source and destination files # TODO should we *only* create hardlinks? # TODO should we keep files for deletion until this is successful? - for src_path, dst_path in src_to_dst_file_paths: - self.copy_file(src_path, dst_path) - - for src_path, dst_path in other_file_paths_mapping: - self.copy_file(src_path, dst_path) + with ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + executor.submit(self.copy_file, src_path, dst_path) + for src_path, dst_path in itertools.chain( + src_to_dst_file_paths, other_file_paths_mapping + ) + ] + wait_for_future_errors(executor, futures) # Update prepared representation etity data with files # and integrate it to server. @@ -648,7 +656,7 @@ class IntegrateHeroVersion( src_path, dst_path )) - shutil.copy(src_path, dst_path) + copyfile(src_path, dst_path) def version_from_representations(self, project_name, repres): for repre in repres: diff --git a/client/ayon_core/plugins/publish/integrate_resources_path.py b/client/ayon_core/plugins/publish/integrate_resources_path.py index 56dc0e5ef7..b518f7f6f1 100644 --- a/client/ayon_core/plugins/publish/integrate_resources_path.py +++ b/client/ayon_core/plugins/publish/integrate_resources_path.py @@ -7,7 +7,7 @@ class IntegrateResourcesPath(pyblish.api.InstancePlugin): label = "Integrate Resources Path" order = pyblish.api.IntegratorOrder - 0.05 - families = ["clip", "projectfile", "plate"] + families = ["clip", "projectfile", "plate"] def process(self, instance): resources = instance.data.get("resources") or [] diff --git a/client/ayon_core/plugins/publish/integrate_traits.py b/client/ayon_core/plugins/publish/integrate_traits.py new file mode 100644 index 0000000000..38c9ecdeb4 --- /dev/null +++ b/client/ayon_core/plugins/publish/integrate_traits.py @@ -0,0 +1,1208 @@ +"""Integrate representations with traits.""" +from __future__ import annotations + +import contextlib +import copy +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pyblish.api +from ayon_api import ( + get_attributes_for_type, + get_product_by_name, + # get_representations, + get_version_by_name, +) +from ayon_api.operations import ( + OperationsSession, + new_product_entity, + new_representation_entity, + new_version_entity, +) +from ayon_api.utils import create_entity_id +from ayon_core.lib import source_hash +from ayon_core.lib.file_transaction import ( + FileTransaction, +) +from ayon_core.pipeline.publish import ( + PublishError, + get_publish_template_name, + has_trait_representations, + get_trait_representations, + set_trait_representations, +) +from ayon_core.pipeline.traits import ( + UDIM, + Bundle, + ColorManaged, + FileLocation, + FileLocations, + FrameRanged, + MissingTraitError, + Persistent, + PixelBased, + Representation, + Sequence, + TemplatePath, + TraitValidationError, + Transient, + Variant, +) + +if TYPE_CHECKING: + import logging + + from ayon_core.pipeline import Anatomy + from ayon_core.pipeline.anatomy.templates import ( + AnatomyStringTemplate, + ) + from ayon_core.pipeline.anatomy.templates import ( + TemplateItem as AnatomyTemplateItem, + ) + + +@dataclass(frozen=True) +class TransferItem: + """Represents a single transfer item. + + Source file path, destination file path, template that was used to + construct the destination path, template data that was used in the + template, size of the file, checksum of the file. + + Attributes: + source (Path): Source file path. + destination (Path): Destination file path. + size (int): Size of the file. + checksum (str): Checksum of the file. + template (str): Template path. + template_data (dict[str, Any]): Template data. + representation (Representation): Reference to representation + + """ + source: Path + destination: Path + size: int + checksum: str + template: str + template_data: dict[str, Any] + representation: Representation + related_trait: FileLocation + + @staticmethod + def get_size(file_path: Path) -> int: + """Get the size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @staticmethod + def get_checksum(file_path: Path) -> str: + """Get checksum of the file. + + Args: + file_path (Path): File path. + + Returns: + str: Checksum of the file. + + """ + return hashlib.sha256( + file_path.read_bytes() + ).hexdigest() + + +@dataclass +class TemplateItem: + """Represents single template item. + + Template path, template data that was used in the template. + + Attributes: + anatomy (Anatomy): Anatomy object. + template (str): Template path. + template_data (dict[str, Any]): Template data. + template_object (AnatomyTemplateItem): Template object + """ + anatomy: Anatomy + template: str + template_data: dict[str, Any] + template_object: AnatomyTemplateItem + + +@dataclass +class RepresentationEntity: + """Representation entity data.""" + id: str + versionId: str # noqa: N815 + name: str + files: dict[str, Any] + attrib: dict[str, Any] + data: str + tags: list[str] + status: str + + +def get_instance_families(instance: pyblish.api.Instance) -> list[str]: + """Get all families of the instance. + + Todo: + Move to the library. + + Args: + instance (pyblish.api.Instance): Instance to get families from. + + Returns: + list[str]: List of families. + + """ + family = instance.data.get("family") + families = [] + if family: + families.append(family) + + for _family in (instance.data.get("families") or []): + if _family not in families: + families.append(_family) + + return families + + +def get_changed_attributes( + old_entity: dict, new_entity: dict) -> (dict[str, Any]): + """Prepare changes for entity update. + + Todo: + Move to the library. + + Args: + old_entity (dict[str, Any]): Existing entity. + new_entity (dict[str, Any]): New entity. + + Returns: + dict[str, Any]: Changes that have new entity. + + """ + changes = {} + for key in set(new_entity.keys()): + if key == "attrib": + continue + + if key in new_entity and new_entity[key] != old_entity.get(key): + changes[key] = new_entity[key] + continue + + attrib_changes = {} + if "attrib" in new_entity: + attrib_changes = { + key: value + for key, value in new_entity["attrib"].items() + if value != old_entity["attrib"].get(key) + } + if attrib_changes: + changes["attrib"] = attrib_changes + return changes + + +def prepare_for_json(data: dict[str, Any]) -> dict[str, Any]: + """Prepare data for JSON serialization. + + If there are values that json cannot serialize, this function will + convert them to strings. + + Args: + data (dict[str, Any]): Data to prepare. + + Returns: + dict[str, Any]: Prepared data. + + Raises: + TypeError: If the data cannot be converted to JSON. + + """ + prepared = {} + for key, value in data.items(): + if isinstance(value, dict): + value = prepare_for_json(value) + try: + json.dumps(value) + except TypeError: + value = value.as_posix() if issubclass( + value.__class__, Path) else str(value) + prepared[key] = value + return prepared + + +class IntegrateTraits(pyblish.api.InstancePlugin): + """Integrate representations with traits.""" + + label = "Integrate Traits of an Asset" + order = pyblish.api.IntegratorOrder + log: logging.Logger + + def process(self, instance: pyblish.api.Instance) -> None: + """Integrate representations with traits. + + Todo: + Refactor this method to be more readable and maintainable. + + Args: + instance (pyblish.api.Instance): Instance to process. + + """ + # 1) skip farm and integrate == False + + if instance.data.get("integrate", True) is False: + self.log.debug("Instance is marked to skip integrating. Skipping") + return + + if instance.data.get("farm"): + self.log.debug( + "Instance is marked to be processed on farm. Skipping") + return + + # TODO (antirotor): Find better name for the key + if not has_trait_representations(instance): + self.log.debug( + "Instance has no representations with traits. Skipping") + return + + # 2) filter representations based on LifeCycle traits + set_trait_representations( + instance, + self.filter_lifecycle(get_trait_representations(instance)) + ) + + representations: list[Representation] = get_trait_representations( + instance + ) + if not representations: + self.log.debug( + "Instance has no persistent representations. Skipping") + return + + op_session = OperationsSession() + + product_entity = self.prepare_product(instance, op_session) + + version_entity = self.prepare_version( + instance, op_session, product_entity + ) + instance.data["versionEntity"] = version_entity + + transfers = self.get_transfers_from_representations( + instance, representations) + + # 8) Transfer files + file_transactions = FileTransaction( + log=self.log, + # Enforce unique transfers + allow_queue_replacements=False) + for transfer in transfers: + self.log.debug( + "Transferring file: %s -> %s", + transfer.source, + transfer.destination + ) + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + file_transactions.process() + self.log.debug( + "Transferred files %s", [file_transactions.transferred]) + + # replace original paths with the destination in traits. + for transfer in transfers: + transfer.related_trait.file_path = transfer.destination + + # 9) Create representation entities + for representation in representations: + representation_entity = new_representation_entity( + representation.name, + version_entity["id"], + files=self._get_legacy_files_for_representation( + transfers, + representation, + anatomy=instance.context.data["anatomy"]), + attribs={}, + data="", + tags=[], + status="", + ) + # add traits to representation entity + representation_entity["traits"] = representation.traits_as_dict() + op_session.create_entity( + project_name=instance.context.data["projectName"], + entity_type="representation", + data=prepare_for_json(representation_entity), + ) + + # 10) Commit the session to AYON + self.log.debug("{}".format(op_session.to_data())) + op_session.commit() + + def get_transfers_from_representations( + self, + instance: pyblish.api.Instance, + representations: list[Representation]) -> list[TransferItem]: + """Get transfers from representations. + + This method will go through all representations and prepare transfers + based on the traits they contain. First it will validate the + representation, and then it will prepare template data for the + representation. It specifically handles FileLocations, FileLocation, + Bundle, Sequence and UDIM traits. + + Args: + instance (pyblish.api.Instance): Instance to process. + representations (list[Representation]): List of representations. + + Returns: + list[TransferItem]: List of transfers. + + Raises: + PublishError: If representation is invalid. + + """ + template: str = self.get_publish_template(instance) + instance_template_data: dict[str, str] = {} + transfers: list[TransferItem] = [] + # prepare template and data to format it + for representation in representations: + + # validate representation first, this will go through all traits + # and check if they are valid + try: + representation.validate() + except TraitValidationError as e: + msg = f"Representation '{representation.name}' is invalid: {e}" + raise PublishError(msg) from e + + template_data = self.get_template_data_from_representation( + representation, instance) + # add instance based template data + + template_data.update(instance_template_data) + + # treat Variant as `output` in template data + with contextlib.suppress(MissingTraitError): + template_data["output"] = ( + representation.get_trait(Variant).variant + ) + + template_item = TemplateItem( + anatomy=instance.context.data["anatomy"], + template=template, + template_data=copy.deepcopy(template_data), + template_object=self.get_publish_template_object(instance), + ) + + if representation.contains_trait(FileLocations): + # If representation has FileLocations trait (list of files) + # it can be either Sequence or UDIM tile set. + # We do not allow unrelated files in the single representation. + # Note: we do not support yet frame sequence of multiple UDIM + # tiles in the same representation + self.get_transfers_from_file_locations( + representation, template_item, transfers + ) + elif representation.contains_trait(FileLocation): + # This is just a single file representation + self.get_transfers_from_file_location( + representation, template_item, transfers + ) + + elif representation.contains_trait(Bundle): + # Bundle groups multiple "sub-representations" together. + # It has a list of lists with traits, some might be + # FileLocations,but some might be "file-less" representations + # or even other bundles. + self.get_transfers_from_bundle( + representation, template_item, transfers + ) + return transfers + + def _get_relative_to_root_original_dirname( + self, instance: pyblish.api.Instance) -> str: + """Get path stripped of root of the original directory name. + + If `originalDirname` or `stagingDir` is set in instance data, + this will return it as rootless path. The path must reside + within the project directory. + + Returns: + str: Relative path to the root of the project directory. + + Raises: + PublishError: If the path is not within the project directory. + + """ + original_directory = ( + instance.data.get("originalDirname") or + instance.data.get("stagingDir")) + anatomy = instance.context.data["anatomy"] + + rootless = self.get_rootless_path(anatomy, original_directory) + # this check works because _rootless will be the same as + # original_directory if the original_directory cannot be transformed + # to the rootless path. + if rootless == original_directory: + msg = ( + f"Destination path '{original_directory}' must " + "be in project directory.") + raise PublishError(msg) + # the root is at the beginning - {root[work]}/rest/of/the/path + relative_path_start = rootless.rfind("}") + 2 + return rootless[relative_path_start:] + + # 8) Transfer files + # 9) Commit the session to AYON + # 10) Finalize represetations - add integrated path Trait etc. + + @staticmethod + def filter_lifecycle( + representations: list[Representation]) -> list[Representation]: + """Filter representations based on LifeCycle traits. + + Args: + representations (list): List of representations. + + Returns: + list: Filtered representations. + + """ + return [ + representation + for representation in representations + if representation.contains_trait(Persistent) + ] + + def get_template_name(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + context = instance.context + project_name = context.data["projectName"] + + # Task can be optional in anatomy data + host_name = context.data["hostName"] + anatomy_data = instance.data["anatomyData"] + product_type = instance.data["productType"] + task_info = anatomy_data.get("task") or {} + + return get_publish_template_name( + project_name, + host_name, + product_type, + task_name=task_info.get("name"), + task_type=task_info.get("type"), + project_settings=context.data["project_settings"], + logger=self.log + ) + + def get_publish_template(self, instance: pyblish.api.Instance) -> str: + """Return anatomy template name to use for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + str: Anatomy template name + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + publish_template = anatomy.get_template_item("publish", template_name) + path_template_obj = publish_template["path"] + return path_template_obj.template.replace("\\", "/") + + def get_publish_template_object( + self, instance: pyblish.api.Instance) -> AnatomyTemplateItem: + """Return anatomy template object to use for integration. + + Note: What is the actual type of the object? + + Args: + instance (pyblish.api.Instance): Instance to process. + + Returns: + AnatomyTemplateItem: Anatomy template object + + """ + # Anatomy data is pre-filled by Collectors + template_name = self.get_template_name(instance) + anatomy = instance.context.data["anatomy"] + return anatomy.get_template_item("publish", template_name) + + def prepare_product( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession) -> dict: + """Prepare product for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + + Returns: + dict: Product entity. + + """ + project_name = instance.context.data["projectName"] + folder_entity = instance.data["folderEntity"] + product_name = instance.data["productName"] + product_type = instance.data["productType"] + self.log.debug("Product: %s", product_name) + + # Get existing product if it exists + existing_product_entity = get_product_by_name( + project_name, product_name, folder_entity["id"] + ) + + # Define product data + data = { + "families": get_instance_families(instance) + } + attributes = {} + + product_group = instance.data.get("productGroup") + if product_group: + attributes["productGroup"] = product_group + elif existing_product_entity: + # Preserve previous product group if new version does not set it + product_group = existing_product_entity.get("attrib", {}).get( + "productGroup" + ) + if product_group is not None: + attributes["productGroup"] = product_group + + product_id = existing_product_entity["id"] if existing_product_entity else None # noqa: E501 + product_entity = new_product_entity( + product_name, + product_type, + folder_entity["id"], + data=data, + attribs=attributes, + entity_id=product_id + ) + + if existing_product_entity is None: + # Create a new product + self.log.info( + "Product '%s' not found, creating ...", + product_name + ) + op_session.create_entity( + project_name, "product", product_entity + ) + + else: + # Update existing product data with new data and set in database. + # We also change the found product in-place so we don't need to + # re-query the product afterward + update_data = get_changed_attributes( + existing_product_entity, product_entity + ) + op_session.update_entity( + project_name, + "product", + product_entity["id"], + update_data + ) + + self.log.debug("Prepared product: %s", product_name) + return product_entity + + def prepare_version( + self, + instance: pyblish.api.Instance, + op_session: OperationsSession, + product_entity: dict) -> dict: + """Prepare version for integration. + + Args: + instance (pyblish.api.Instance): Instance to process. + op_session (OperationsSession): Operations session. + product_entity (dict): Product entity. + + Returns: + dict: Version entity. + + """ + project_name = instance.context.data["projectName"] + version_number = instance.data["version"] + task_entity = instance.data.get("taskEntity") + task_id = task_entity["id"] if task_entity else None + existing_version = get_version_by_name( + project_name, + version_number, + product_entity["id"] + ) + version_id = existing_version["id"] if existing_version else None + all_version_data = self.get_version_data_from_instance(instance) + version_data = {} + version_attributes = {} + attr_defs = self.get_attributes_for_type(instance.context, "version") + for key, value in all_version_data.items(): + if key in attr_defs: + version_attributes[key] = value + else: + version_data[key] = value + + version_entity = new_version_entity( + version_number, + product_entity["id"], + task_id=task_id, + status=instance.data.get("status"), + data=version_data, + attribs=version_attributes, + entity_id=version_id, + ) + + if existing_version: + self.log.debug("Updating existing version ...") + update_data = get_changed_attributes( + existing_version, version_entity) + op_session.update_entity( + project_name, + "version", + version_entity["id"], + update_data + ) + else: + self.log.debug("Creating new version ...") + op_session.create_entity( + project_name, "version", version_entity + ) + + self.log.debug( + "Prepared version: v%s", + "{:03d}".format(version_entity["version"]) + ) + + return version_entity + + def get_version_data_from_instance( + self, instance: pyblish.api.Instance) -> dict: + """Get version data from the Instance. + + Args: + instance (pyblish.api.Instance): the current instance + being published. + + Returns: + dict: the required information for ``version["data"]`` + + """ + context = instance.context + + # create relative source path for DB + if "source" in instance.data: + source = instance.data["source"] + else: + source = context.data["currentFile"] + anatomy = instance.context.data["anatomy"] + source = self.get_rootless_path(anatomy, source) + self.log.debug("Source: %s", source) + + version_data = { + "families": get_instance_families(instance), + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": instance.data["comment"], + "machine": context.data.get("machine"), + "fps": instance.data.get("fps", context.data.get("fps")) + } + + intent_value = context.data.get("intent") + if intent_value and isinstance(intent_value, dict): + intent_value = intent_value.get("value") + + if intent_value: + version_data["intent"] = intent_value + + # Include optional data if present in + optionals = [ + "frameStart", "frameEnd", "step", + "handleEnd", "handleStart", "sourceHashes" + ] + for key in optionals: + if key in instance.data: + version_data[key] = instance.data[key] + + # Include instance.data[versionData] directly + version_data_instance = instance.data.get("versionData") + if version_data_instance: + version_data.update(version_data_instance) + + return version_data + + def get_rootless_path(self, anatomy: Anatomy, path: str) -> str: + r"""Get rootless variant of the path. + + Returns, if possible, a path without an absolute portion from the root + (e.g. 'c:\' or '/opt/..'). This is basically a wrapper for the + meth:`Anatomy.find_root_template_from_path` method that displays + a warning if the root path is not found. + + This information is platform-dependent and shouldn't be captured. + For example:: + + 'c:/projects/MyProject1/Assets/publish...' + will be transformed to: + '{root}/MyProject1/Assets...' + + Args: + anatomy (Anatomy): Project anatomy. + path (str): Absolute path. + + Returns: + str: Path where root path is replaced by formatting string. + + """ + success, rootless_path = anatomy.find_root_template_from_path(path) + if success: + path = rootless_path + else: + self.log.warning(( + 'Could not find root path for remapping "%s".' + " This may cause issues on farm." + ), path) + return path + + def get_attributes_for_type( + self, + context: pyblish.api.Context, + entity_type: str) -> dict: + """Get AYON attributes for the given entity type. + + Args: + context (pyblish.api.Context): Context to get attributes from. + entity_type (str): Entity type to get attributes for. + + Returns: + dict: AYON attributes for the given entity type. + + """ + return self.get_attributes_by_type(context)[entity_type] + + @staticmethod + def get_attributes_by_type( + context: pyblish.api.Context) -> dict: + """Gets AYON attributes from the given context. + + Args: + context (pyblish.api.Context): Context to get attributes from. + + Returns: + dict: AYON attributes. + + """ + attributes = context.data.get("ayonAttributes") + if attributes is None: + attributes = { + key: get_attributes_for_type(key) + for key in ( + "project", + "folder", + "product", + "version", + "representation", + ) + } + context.data["ayonAttributes"] = attributes + return attributes + + def get_template_data_from_representation( + self, + representation: Representation, + instance: pyblish.api.Instance) -> dict: + """Get template data from representation. + + Using representation traits and data on instance + prepare data for formatting template. + + Args: + representation (Representation): Representation to process. + instance (pyblish.api.Instance): Instance to process. + + Returns: + dict: Template data. + + """ + template_data = copy.deepcopy(instance.data["anatomyData"]) + template_data["representation"] = representation.name + template_data["version"] = instance.data["version"] + # template_data["hierarchy"] = instance.data["hierarchy"] + + # add colorspace data to template data + if representation.contains_trait(ColorManaged): + colorspace_data: ColorManaged = representation.get_trait( + ColorManaged) + + template_data["colorspace"] = { + "colorspace": colorspace_data.color_space, + "config": colorspace_data.config + } + + # add explicit list of traits properties to template data + # there must be some better way to handle this + try: + # resolution from PixelBased trait + template_data["resolution_width"] = representation.get_trait( + PixelBased).display_window_width + template_data["resolution_height"] = representation.get_trait( + PixelBased).display_window_height + # get fps from representation traits + template_data["fps"] = representation.get_trait( + FrameRanged).frames_per_second + + # Note: handle "output" and "originalBasename" + + except MissingTraitError as e: + self.log.debug("Missing traits: %s", e) + + return template_data + + @staticmethod + def get_transfers_from_file_locations( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem]) -> None: + """Get transfers from FileLocations trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + Raises: + PublishError: If representation is invalid. + + """ + if representation.contains_trait(Sequence): + IntegrateTraits.get_transfers_from_sequence( + representation, template_item, transfers + ) + + elif representation.contains_trait(UDIM) and \ + not representation.contains_trait(Sequence): + # handle UDIM not in sequence + IntegrateTraits.get_transfers_from_udim( + representation, template_item, transfers + ) + + else: + # This should never happen because the representation + # validation should catch this. + msg = ( + "Representation contains FileLocations trait, but " + "is not a Sequence or UDIM." + ) + raise PublishError(msg) + + @staticmethod + def get_transfers_from_sequence( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Sequence trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + sequence: Sequence = representation.get_trait(Sequence) + path_template_object = template_item.template_object["path"] + + # get the padding from the sequence if the padding on the + # template is higher, us the one from the template + dst_padding = representation.get_trait( + Sequence).frame_padding + frames: list[int] = sequence.get_frame_list( + representation.get_trait(FileLocations), + regex=sequence.frame_regex) + template_padding = template_item.anatomy.templates_obj.frame_padding + dst_padding = max(template_padding, dst_padding) + + # Go through all frames in the sequence and + # find their corresponding file locations, then + # format their template and add them to transfers. + for frame in frames: + file_loc: FileLocation = representation.get_trait( + FileLocations).get_file_location_for_frame( + frame, sequence) + + template_item.template_data["frame"] = frame + template_item.template_data["ext"] = ( + file_loc.file_path.suffix.lstrip(".")) + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + + # add template path and the data to resolve it + if not representation.contains_trait(TemplatePath): + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_udim( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from UDIM trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + udim: UDIM = representation.get_trait(UDIM) + path_template_object: AnatomyStringTemplate = ( + template_item.template_object["path"] + ) + for file_loc in representation.get_trait( + FileLocations).file_paths: + template_item.template_data["udim"] = ( + udim.get_udim_from_file_location(file_loc) + ) + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_file_location( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from FileLocation trait. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + path_template_object: AnatomyStringTemplate = ( + template_item.template_object["path"] + ) + template_item.template_data["ext"] = ( + representation.get_trait(FileLocation).file_path.suffix.lstrip(".") + ) + template_item.template_data.pop("frame", None) + with contextlib.suppress(MissingTraitError): + udim = representation.get_trait(UDIM) + template_item.template_data["udim"] = udim.udim[0] + + template_filled = path_template_object.format_strict( + template_item.template_data + ) + + # add used values to the template data + used_values: dict = template_filled.used_values + template_item.template_data.update(used_values) + + file_loc: FileLocation = representation.get_trait(FileLocation) + transfers.append( + TransferItem( + source=file_loc.file_path, + destination=Path(template_filled), + size=file_loc.file_size or TransferItem.get_size( + file_loc.file_path), + checksum=file_loc.file_hash or TransferItem.get_checksum( + file_loc.file_path), + template=template_item.template, + template_data=template_item.template_data, + representation=representation, + related_trait=file_loc + ) + ) + # add template path and the data to resolve it + representation.add_trait(TemplatePath( + template=template_item.template, + data=template_item.template_data + )) + + @staticmethod + def get_transfers_from_bundle( + representation: Representation, + template_item: TemplateItem, + transfers: list[TransferItem] + ) -> None: + """Get transfers from Bundle trait. + + This will be called recursively for each sub-representation in the + bundle that is a Bundle itself. + + Args: + representation (Representation): Representation to process. + template_item (TemplateItem): Template item. + transfers (list): List of transfers. + + Mutates: + transfers (list): List of transfers. + template_item (TemplateItem): Template item. + + """ + bundle: Bundle = representation.get_trait(Bundle) + for idx, sub_representation_traits in enumerate(bundle.items): + sub_representation = Representation( + name=f"{representation.name}_{idx}", + traits=sub_representation_traits) + # sub presentation transient: + sub_representation.add_trait(Transient()) + if sub_representation.contains_trait(FileLocations): + IntegrateTraits.get_transfers_from_file_locations( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(FileLocation): + IntegrateTraits.get_transfers_from_file_location( + sub_representation, template_item, transfers + ) + elif sub_representation.contains_trait(Bundle): + IntegrateTraits.get_transfers_from_bundle( + sub_representation, template_item, transfers + ) + + def _prepare_file_info( + self, path: Path, anatomy: Anatomy) -> dict[str, Any]: + """Prepare information for one file (asset or resource). + + Arguments: + path (Path): Destination url of published file. + anatomy (Anatomy): Project anatomy part from instance. + + Raises: + PublishError: If file does not exist. + + Returns: + dict[str, Any]: Representation file info dictionary. + + """ + if not path.exists(): + msg = f"File '{path}' does not exist." + raise PublishError(msg) + + return { + "id": create_entity_id(), + "name": path.name, + "path": self.get_rootless_path(anatomy, path.as_posix()), + "size": path.stat().st_size, + "hash": source_hash(path.as_posix()), + "hash_type": "op3", + } + + def _get_legacy_files_for_representation( + self, + transfer_items: list[TransferItem], + representation: Representation, + anatomy: Anatomy, + ) -> list[dict[str, str]]: + """Get legacy files for a given representation. + + This expects the file to exist - it must run after the transfer + is done. + + Returns: + list: List of legacy files. + + """ + selected: list[TransferItem] = [] + selected.extend( + item + for item in transfer_items + if item.representation == representation + ) + files: list[dict[str, str]] = [] + files.extend( + self._prepare_file_info(item.destination, anatomy) + for item in selected + ) + return files diff --git a/client/ayon_core/scripts/otio_burnin.py b/client/ayon_core/scripts/otio_burnin.py index cb72606222..77eeecaff6 100644 --- a/client/ayon_core/scripts/otio_burnin.py +++ b/client/ayon_core/scripts/otio_burnin.py @@ -173,7 +173,6 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if frame_end is not None: options["frame_end"] = frame_end - options["label"] = align self._add_burnin(text, align, options, DRAWTEXT) diff --git a/client/ayon_core/scripts/slates/slate_base/base.py b/client/ayon_core/scripts/slates/slate_base/base.py index e1648c916a..a4427bbb86 100644 --- a/client/ayon_core/scripts/slates/slate_base/base.py +++ b/client/ayon_core/scripts/slates/slate_base/base.py @@ -175,7 +175,7 @@ class BaseObj: self.log.warning("Invalid range '{}'".format(part)) continue - for idx in range(sub_parts[0], sub_parts[1]+1): + for idx in range(sub_parts[0], sub_parts[1] + 1): indexes.append(idx) return indexes @@ -353,7 +353,6 @@ class BaseObj: self.items[item.id] = item item.fill_data_format() - def reset(self): for item in self.items.values(): item.reset() diff --git a/client/ayon_core/scripts/slates/slate_base/items.py b/client/ayon_core/scripts/slates/slate_base/items.py index ec3358ed5e..eb7859a6e1 100644 --- a/client/ayon_core/scripts/slates/slate_base/items.py +++ b/client/ayon_core/scripts/slates/slate_base/items.py @@ -282,7 +282,7 @@ class ItemTable(BaseItem): value.draw(image, drawer) def value_width(self): - row_heights, col_widths = self.size_values + _row_heights, col_widths = self.size_values width = 0 for _width in col_widths: width += _width @@ -292,7 +292,7 @@ class ItemTable(BaseItem): return width def value_height(self): - row_heights, col_widths = self.size_values + row_heights, _col_widths = self.size_values height = 0 for _height in row_heights: height += _height @@ -569,21 +569,21 @@ class TableField(BaseItem): @property def item_pos_x(self): - pos_x, pos_y, width, height = ( + pos_x, _pos_y, _width, _height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) return pos_x @property def item_pos_y(self): - pos_x, pos_y, width, height = ( + _pos_x, pos_y, _width, _height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) return pos_y @property def value_pos_x(self): - pos_x, pos_y, width, height = ( + pos_x, _pos_y, width, _height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) alignment_hor = self.style["alignment-horizontal"].lower() @@ -605,7 +605,7 @@ class TableField(BaseItem): @property def value_pos_y(self): - pos_x, pos_y, width, height = ( + _pos_x, pos_y, _width, height = ( self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx) ) diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index aa56fa8326..72af07799f 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -56,14 +56,9 @@ class _AyonSettingsCache: @classmethod def _get_variant(cls): if _AyonSettingsCache.variant is None: - from ayon_core.lib import is_staging_enabled, is_dev_mode_enabled - - variant = "production" - if is_dev_mode_enabled(): - variant = cls._get_bundle_name() - elif is_staging_enabled(): - variant = "staging" + from ayon_core.lib import get_settings_variant + variant = get_settings_variant() # Cache variant _AyonSettingsCache.variant = variant diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0e19702d53..b26d36fb7e 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -829,6 +829,49 @@ HintedLineEditButton { } /* Launcher specific stylesheets */ +ActionsView { + /* font size can't be set on items */ + font-size: 8pt; + border: 0px; + padding: 0px; + margin: 0px; +} + +ActionsView::item { + padding-top: 8px; + padding-bottom: 4px; + border: 0px; + border-radius: 5px; +} + +ActionsView::item:hover { + color: {color:font-hover}; + background: #424A57; +} + +ActionsView::icon {} + +ActionMenuPopup #GroupLabel { + padding: 5px; + color: #ffffff; +} + +ActionMenuPopup #ShadowFrame { + border-radius: 5px; + background: rgba(12, 13, 24, 0.5); +} + +ActionMenuPopup #Wrapper { + border-radius: 5px; + background: #353B46; +} + +ActionMenuPopup ActionsView { + background: transparent; + border: none; + margin: 4px; +} + #IconView[mode="icon"] { /* font size can't be set on items */ font-size: 9pt; @@ -862,6 +905,70 @@ HintedLineEditButton { border-radius: 0.1em; } +/* Launcher specific stylesheets */ +FiltersBar { + background: {color:bg-inputs}; + border: 1px solid {color:border}; + border-radius: 5px; +} + +FiltersBar #ScrollArea { + background: {color:bg-inputs}; +} +FiltersBar #SearchButton { + background: transparent; +} + +FiltersBar #BackButton { + background: transparent; +} + +FiltersBar #BackButton:hover { + background: {color:bg-buttons-hover}; +} + +FiltersBar #ConfirmButton { + background: #91CDFB; + color: #03344D; +} + +FiltersPopup #PopupWrapper, FilterValuePopup #PopupWrapper { + border-radius: 5px; + background: {color:bg-inputs}; +} + +FiltersPopup #ShadowFrame, FilterValuePopup #ShadowFrame { + border-radius: 5px; + background: rgba(0, 0, 0, 0.5); +} + +FilterItemButton, FilterValueItemButton { + border-radius: 5px; + background: transparent; +} +FilterItemButton:hover, FilterValueItemButton:hover { + background: {color:bg-buttons-hover}; +} +FilterValueItemButton[selected="1"] { + background: {color:bg-view-selection}; +} +FilterValueItemButton[selected="1"]:hover { + background: {color:bg-view-selection-hover}; +} +FilterValueItemsView #ContentWidget { + background: {color:bg-inputs}; +} +SearchItemDisplayWidget { + border-radius: 5px; +} +SearchItemDisplayWidget:hover { + background: {color:bg-buttons}; +} +SearchItemDisplayWidget #ValueWidget { + border-radius: 3px; + background: {color:bg-buttons}; +} + /* Subset Manager */ #SubsetManagerDetailsText {} #SubsetManagerDetailsText[state="invalid"] { diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index ef717d576a..7423d58475 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -1,22 +1,58 @@ -from qtpy import QtWidgets +from __future__ import annotations + +from typing import Optional + +from qtpy import QtWidgets, QtGui + +from ayon_core.style import load_stylesheet +from ayon_core.resources import get_ayon_icon_filepath +from ayon_core.lib import AbstractAttrDef from .widgets import AttributeDefinitionsWidget class AttributeDefinitionsDialog(QtWidgets.QDialog): - def __init__(self, attr_defs, parent=None): - super(AttributeDefinitionsDialog, self).__init__(parent) + def __init__( + self, + attr_defs: list[AbstractAttrDef], + title: Optional[str] = None, + submit_label: Optional[str] = None, + cancel_label: Optional[str] = None, + submit_icon: Optional[QtGui.QIcon] = None, + cancel_icon: Optional[QtGui.QIcon] = None, + parent: Optional[QtWidgets.QWidget] = None, + ): + super().__init__(parent) + + if title: + self.setWindowTitle(title) + + icon = QtGui.QIcon(get_ayon_icon_filepath()) + self.setWindowIcon(icon) + self.setStyleSheet(load_stylesheet()) attrs_widget = AttributeDefinitionsWidget(attr_defs, self) + if submit_label is None: + submit_label = "OK" + + if cancel_label is None: + cancel_label = "Cancel" + btns_widget = QtWidgets.QWidget(self) - ok_btn = QtWidgets.QPushButton("OK", btns_widget) - cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + cancel_btn = QtWidgets.QPushButton(cancel_label, btns_widget) + submit_btn = QtWidgets.QPushButton(submit_label, btns_widget) + + if submit_icon is not None: + submit_btn.setIcon(submit_icon) + + if cancel_icon is not None: + cancel_btn.setIcon(cancel_icon) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn, 0) + btns_layout.addWidget(submit_btn, 0) btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) @@ -24,10 +60,33 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) - ok_btn.clicked.connect(self.accept) + submit_btn.clicked.connect(self.accept) cancel_btn.clicked.connect(self.reject) self._attrs_widget = attrs_widget + self._submit_btn = submit_btn + self._cancel_btn = cancel_btn def get_values(self): return self._attrs_widget.current_value() + + def set_values(self, values): + self._attrs_widget.set_value(values) + + def set_submit_label(self, text: str): + self._submit_btn.setText(text) + + def set_submit_icon(self, icon: QtGui.QIcon): + self._submit_btn.setIcon(icon) + + def set_submit_visible(self, visible: bool): + self._submit_btn.setVisible(visible) + + def set_cancel_label(self, text: str): + self._cancel_btn.setText(text) + + def set_cancel_icon(self, icon: QtGui.QIcon): + self._cancel_btn.setIcon(icon) + + def set_cancel_visible(self, visible: bool): + self._cancel_btn.setVisible(visible) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index dbd65fd215..1e948b2d28 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -22,6 +22,7 @@ from ayon_core.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + MarkdownLabel, PlaceholderLineEdit, PlaceholderPlainTextEdit, set_style_property, @@ -247,12 +248,10 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def set_value(self, value): new_value = copy.deepcopy(value) - unused_keys = set(new_value.keys()) for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if attr_def.key not in new_value: continue - unused_keys.remove(attr_def.key) widget_value = new_value[attr_def.key] if widget_value is None: @@ -350,7 +349,7 @@ class SeparatorAttrWidget(_BaseAttrDefWidget): class LabelAttrWidget(_BaseAttrDefWidget): def _ui_init(self): - input_widget = QtWidgets.QLabel(self) + input_widget = MarkdownLabel(self) label = self.attr_def.label if label: input_widget.setText(str(label)) diff --git a/client/ayon_core/tools/common_models/__init__.py b/client/ayon_core/tools/common_models/__init__.py index ece189fdc6..ec69e20b64 100644 --- a/client/ayon_core/tools/common_models/__init__.py +++ b/client/ayon_core/tools/common_models/__init__.py @@ -2,6 +2,7 @@ from .cache import CacheItem, NestedCacheItem from .projects import ( + TagItem, StatusItem, StatusStates, ProjectItem, @@ -25,6 +26,7 @@ __all__ = ( "CacheItem", "NestedCacheItem", + "TagItem", "StatusItem", "StatusStates", "ProjectItem", diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 891eb80960..37d97af625 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -100,12 +100,14 @@ class TaskItem: label: Union[str, None], task_type: str, parent_id: str, + tags: list[str], ): self.task_id = task_id self.name = name self.label = label self.task_type = task_type self.parent_id = parent_id + self.tags = tags self._full_label = None @@ -145,6 +147,7 @@ class TaskItem: "label": self.label, "parent_id": self.parent_id, "task_type": self.task_type, + "tags": self.tags, } @classmethod @@ -176,7 +179,8 @@ def _get_task_items_from_tasks(tasks): task["name"], task["label"], task["type"], - folder_id + folder_id, + task["tags"], )) return output @@ -217,6 +221,8 @@ class HierarchyModel(object): lifetime = 60 # A minute def __init__(self, controller): + self._tags_by_entity_type = NestedCacheItem( + levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_items = NestedCacheItem( levels=1, default_factory=dict, lifetime=self.lifetime) self._folders_by_id = NestedCacheItem( @@ -235,6 +241,7 @@ class HierarchyModel(object): self._controller = controller def reset(self): + self._tags_by_entity_type.reset() self._folders_items.reset() self._folders_by_id.reset() @@ -514,6 +521,31 @@ class HierarchyModel(object): return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags for all entity types in a project.""" + cache = self._tags_by_entity_type.get(project_name) + if not cache.is_valid: + tags = None + if project_name: + response = ayon_api.get(f"projects/{project_name}/tags") + if response.status_code == 200: + tags = response.data + + # Fake empty tags + if tags is None: + tags = { + "folders": [], + "tasks": [], + "products": [], + "versions": [], + "representations": [], + "workfiles": [] + } + cache.update_data(tags) + return cache.get_data() + @contextlib.contextmanager def _folder_refresh_event_manager(self, project_name, sender): self._folders_refreshing.add(project_name) @@ -617,6 +649,6 @@ class HierarchyModel(object): tasks = list(ayon_api.get_tasks( project_name, folder_ids=[folder_id], - fields={"id", "name", "label", "folderId", "type"} + fields={"id", "name", "label", "folderId", "type", "tags"} )) return _get_task_items_from_tasks(tasks) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 7ec941e6bd..034947de3a 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import contextlib from abc import ABC, abstractmethod from typing import Dict, Any +from dataclasses import dataclass import ayon_api @@ -72,6 +75,13 @@ class StatusItem: ) +@dataclass +class TagItem: + """Tag definition set on project anatomy.""" + name: str + color: str + + class FolderTypeItem: """Item representing folder type of project. @@ -140,6 +150,7 @@ class TaskTypeItem: ) +@dataclass class ProjectItem: """Item representing folder entity on a server. @@ -150,21 +161,14 @@ class ProjectItem: active (Union[str, None]): Parent folder id. If 'None' then project is parent. """ - - def __init__(self, name, active, is_library, icon=None): - self.name = name - self.active = active - self.is_library = is_library - if icon is None: - icon = { - "type": "awesome-font", - "name": "fa.book" if is_library else "fa.map", - "color": get_default_entity_icon_color(), - } - self.icon = icon + name: str + active: bool + is_library: bool + icon: dict[str, Any] + is_pinned: bool = False @classmethod - def from_entity(cls, project_entity): + def from_entity(cls, project_entity: dict[str, Any]) -> "ProjectItem": """Creates folder item from entity. Args: @@ -174,10 +178,16 @@ class ProjectItem: ProjectItem: Project item. """ + icon = { + "type": "awesome-font", + "name": "fa.book" if project_entity["library"] else "fa.map", + "color": get_default_entity_icon_color(), + } return cls( project_entity["name"], project_entity["active"], project_entity["library"], + icon ) def to_data(self): @@ -208,16 +218,18 @@ class ProjectItem: return cls(**data) -def _get_project_items_from_entitiy(projects): +def _get_project_items_from_entitiy( + projects: list[dict[str, Any]] +) -> list[ProjectItem]: """ Args: projects (list[dict[str, Any]]): List of projects. Returns: - ProjectItem: Project item. - """ + list[ProjectItem]: Project item. + """ return [ ProjectItem.from_entity(project) for project in projects @@ -288,6 +300,22 @@ class ProjectsModel(object): project_cache.update_data(entity) return project_cache.get_data() + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Get project anatomy tags. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definitions. + + """ + project_entity = self.get_project_entity(project_name) + return [ + TagItem(tag["name"], tag["color"]) + for tag in project_entity["tags"] + ] + def get_project_status_items(self, project_name, sender): """Get project status items. @@ -428,9 +456,20 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _query_projects(self): + def _query_projects(self) -> list[ProjectItem]: projects = ayon_api.get_projects(fields=["name", "active", "library"]) - return _get_project_items_from_entitiy(projects) + user = ayon_api.get_user() + pinned_projects = ( + user + .get("data", {}) + .get("frontendPreferences", {}) + .get("pinnedProjects") + ) or [] + pinned_projects = set(pinned_projects) + project_items = _get_project_items_from_entitiy(list(projects)) + for project in project_items: + project.is_pinned = project.name in pinned_projects + return project_items def _status_items_getter(self, project_entity): if not project_entity: diff --git a/client/ayon_core/tools/console_interpreter/ui/widgets.py b/client/ayon_core/tools/console_interpreter/ui/widgets.py index 2b9361666e..3dc55b081c 100644 --- a/client/ayon_core/tools/console_interpreter/ui/widgets.py +++ b/client/ayon_core/tools/console_interpreter/ui/widgets.py @@ -248,4 +248,3 @@ class EnhancedTabBar(QtWidgets.QTabBar): else: super().mouseReleaseEvent(event) - diff --git a/client/ayon_core/tools/creator/window.py b/client/ayon_core/tools/creator/window.py index 5bdc6da9b6..5d1c0a272a 100644 --- a/client/ayon_core/tools/creator/window.py +++ b/client/ayon_core/tools/creator/window.py @@ -492,7 +492,7 @@ def show(parent=None): try: module.window.close() - del(module.window) + del module.window except (AttributeError, RuntimeError): pass diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py index 33de4bf036..a485c682a1 100644 --- a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -32,7 +32,7 @@ from qtpy import QtWidgets, QtCore, QtGui import pyblish.api from ayon_core import style -TAB = 4* " " +TAB = 4 * " " HEADER_SIZE = "15px" KEY_COLOR = QtGui.QColor("#ffffff") @@ -243,7 +243,7 @@ class DebugUI(QtWidgets.QDialog): self._set_window_title(plugin=result["plugin"]) - print(10*"<", result["plugin"].__name__, 10*">") + print(10 * "<", result["plugin"].__name__, 10 * ">") plugin_order = result["plugin"].order plugin_name = result["plugin"].__name__ diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index ea0842f24d..1d7dafd62f 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,4 +1,59 @@ +from __future__ import annotations + from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.tools.common_models import ( + ProjectItem, + FolderItem, + FolderTypeItem, + TaskItem, + TaskTypeItem, +) + + +@dataclass +class WebactionContext: + """Context used for methods related to webactions.""" + identifier: str + project_name: str + folder_id: str + task_id: str + addon_name: str + addon_version: str + + +@dataclass +class ActionItem: + """Item representing single action to trigger. + + Attributes: + action_type (Literal["webaction", "local"]): Type of action. + identifier (str): Unique identifier of action item. + order (int): Action ordering. + label (str): Action label. + variant_label (Union[str, None]): Variant label, full label is + concatenated with space. Actions are grouped under single + action if it has same 'label' and have set 'variant_label'. + full_label (str): Full label, if not set it is generated + from 'label' and 'variant_label'. + icon (dict[str, str]): Icon definition. + addon_name (Optional[str]): Addon name. + addon_version (Optional[str]): Addon version. + config_fields (list[dict]): Config fields for webaction. + + """ + action_type: str + identifier: str + order: int + label: str + variant_label: Optional[str] + full_label: str + icon: Optional[dict[str, str]] + config_fields: list[dict] + addon_name: Optional[str] = None + addon_version: Optional[str] = None class AbstractLauncherCommon(ABC): @@ -88,7 +143,9 @@ class AbstractLauncherBackend(AbstractLauncherCommon): class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Entity items for UI @abstractmethod - def get_project_items(self, sender=None): + def get_project_items( + self, sender: Optional[str] = None + ) -> list[ProjectItem]: """Project items for all projects. This function may trigger events 'projects.refresh.started' and @@ -106,7 +163,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_type_items(self, project_name, sender=None): + def get_folder_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderTypeItem]: """Folder type items for a project. This function may trigger events with topics @@ -126,7 +185,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_type_items(self, project_name, sender=None): + def get_task_type_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[TaskTypeItem]: """Task type items for a project. This function may trigger events with topics @@ -146,7 +207,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_folder_items(self, project_name, sender=None): + def get_folder_items( + self, project_name: str, sender: Optional[str] = None + ) -> list[FolderItem]: """Folder items to visualize project hierarchy. This function may trigger events 'folders.refresh.started' and @@ -165,7 +228,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_task_items(self, project_name, folder_id, sender=None): + def get_task_items( + self, project_name: str, folder_id: str, sender: Optional[str] = None + ) -> list[TaskItem]: """Task items. This function may trigger events 'tasks.refresh.started' and @@ -185,7 +250,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_project_name(self): + def get_selected_project_name(self) -> Optional[str]: """Selected project name. Returns: @@ -195,7 +260,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_folder_id(self): + def get_selected_folder_id(self) -> Optional[str]: """Selected folder id. Returns: @@ -205,7 +270,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_task_id(self): + def get_selected_task_id(self) -> Optional[str]: """Selected task id. Returns: @@ -215,7 +280,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_task_name(self): + def get_selected_task_name(self) -> Optional[str]: """Selected task name. Returns: @@ -225,7 +290,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_selected_context(self): + def get_selected_context(self) -> dict[str, Optional[str]]: """Get whole selected context. Example: @@ -243,7 +308,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_project(self, project_name): + def set_selected_project(self, project_name: Optional[str]): """Change selected folder. Args: @@ -254,7 +319,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_folder(self, folder_id): + def set_selected_folder(self, folder_id: Optional[str]): """Change selected folder. Args: @@ -265,7 +330,9 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def set_selected_task(self, task_id, task_name): + def set_selected_task( + self, task_id: Optional[str], task_name: Optional[str] + ): """Change selected task. Args: @@ -279,7 +346,12 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): # Actions @abstractmethod - def get_action_items(self, project_name, folder_id, task_id): + def get_action_items( + self, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ) -> list[ActionItem]: """Get action items for given context. Args: @@ -295,30 +367,67 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def trigger_action(self, project_name, folder_id, task_id, action_id): + def trigger_action( + self, + action_id: str, + project_name: Optional[str], + folder_id: Optional[str], + task_id: Optional[str], + ): """Trigger action on given context. Args: + action_id (str): Action identifier. project_name (Union[str, None]): Project name. folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. - action_id (str): Action identifier. """ pass @abstractmethod - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_webaction( + self, + context: WebactionContext, + action_label: str, + form_data: Optional[dict[str, Any]] = None, ): - """This is application action related to force not open last workfile. + """Trigger action on the given context. Args: - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. - action_ids (Iterable[str]): Action identifiers. - enabled (bool): New value of force not open workfile. + context (WebactionContext): Webaction context. + action_label (str): Action label. + form_data (Optional[dict[str, Any]]): Form values of action. + + """ + pass + + @abstractmethod + def get_action_config_values( + self, context: WebactionContext + ) -> dict[str, Any]: + """Get action config values. + + Args: + context (WebactionContext): Webaction context. + + Returns: + dict[str, Any]: Action config values. + + """ + pass + + @abstractmethod + def set_action_config_values( + self, + context: WebactionContext, + values: dict[str, Any], + ): + """Set action config values. + + Args: + context (WebactionContext): Webaction context. + values (dict[str, Any]): Action config values. """ pass @@ -343,14 +452,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): pass @abstractmethod - def get_my_tasks_entity_ids(self, project_name: str): + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: """Get entity ids for my tasks. Args: project_name (str): Project name. Returns: - dict[str, Union[list[str]]]: Folder and task ids. + dict[str, list[str]]: Folder and task ids. """ pass diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 45cb2b7945..58d22453be 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,6 +1,6 @@ from ayon_core.lib import Logger, get_ayon_username from ayon_core.lib.events import QueuedEventSystem -from ayon_core.settings import get_project_settings +from ayon_core.settings import get_project_settings, get_studio_settings from ayon_core.tools.common_models import ProjectsModel, HierarchyModel from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend @@ -32,7 +32,7 @@ class BaseLauncherController( @property def event_system(self): - """Inner event system for workfiles tool controller. + """Inner event system for launcher tool controller. Is used for communication with UI. Event system is created on demand. @@ -85,7 +85,10 @@ class BaseLauncherController( def get_project_settings(self, project_name): if project_name in self._project_settings: return self._project_settings[project_name] - settings = get_project_settings(project_name) + if project_name: + settings = get_project_settings(project_name) + else: + settings = get_studio_settings() self._project_settings[project_name] = settings return settings @@ -135,16 +138,30 @@ class BaseLauncherController( return self._actions_model.get_action_items( project_name, folder_id, task_id) - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - self._actions_model.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, enabled + self._actions_model.trigger_action( + identifier, + project_name, + folder_id, + task_id, ) - def trigger_action(self, project_name, folder_id, task_id, identifier): - self._actions_model.trigger_action( - project_name, folder_id, task_id, identifier) + def trigger_webaction(self, context, action_label, form_data=None): + self._actions_model.trigger_webaction( + context, action_label, form_data + ) + + def get_action_config_values(self, context): + return self._actions_model.get_action_config_values(context) + + def set_action_config_values(self, context, values): + return self._actions_model.set_action_config_values(context, values) # General methods def refresh(self): diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index e1612e2b9f..0ed4bdad3a 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -1,219 +1,47 @@ import os +import uuid +from dataclasses import dataclass, asdict +from urllib.parse import urlencode, urlparse +from typing import Any, Optional +import webbrowser + +import ayon_api from ayon_core import resources -from ayon_core.lib import Logger, AYONSettingsRegistry +from ayon_core.lib import ( + Logger, + NestedCacheItem, + CacheItem, + get_settings_variant, + run_detached_ayon_launcher_process, +) from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, - LauncherAction, LauncherActionSelection, register_launcher_action_path, ) -from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch - -try: - # Available since applications addon 0.2.4 - from ayon_applications.action import ApplicationAction -except ImportError: - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - class ApplicationAction(LauncherAction): - """Action to launch an application. - - Application action based on 'ApplicationManager' system. - - Handling of applications in launcher is not ideal and should be - completely redone from scratch. This is just a temporary solution - to keep backwards compatibility with AYON launcher. - - Todos: - Move handling of errors to frontend. - """ - - # Application object - application = None - # Action attributes - name = None - label = None - label_variant = None - group = None - icon = None - color = None - order = 0 - data = {} - project_settings = {} - project_entities = {} - - _log = None - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(self.__class__.__name__) - return self._log - - def is_compatible(self, selection): - if not selection.is_task_selected: - return False - - project_entity = self.project_entities[selection.project_name] - apps = project_entity["attrib"].get("applications") - if not apps or self.application.full_name not in apps: - return False - - project_settings = self.project_settings[selection.project_name] - only_available = project_settings["applications"]["only_available"] - if only_available and not self.application.find_executable(): - return False - return True - - def _show_message_box(self, title, message, details=None): - from qtpy import QtWidgets, QtGui - from ayon_core import style - - dialog = QtWidgets.QMessageBox() - icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) - dialog.setWindowIcon(icon) - dialog.setStyleSheet(style.load_stylesheet()) - dialog.setWindowTitle(title) - dialog.setText(message) - if details: - dialog.setDetailedText(details) - dialog.exec_() - - def process(self, selection, **kwargs): - """Process the full Application action""" - - from ayon_applications import ( - ApplicationExecutableNotFound, - ApplicationLaunchFailed, - ) - - try: - self.application.launch( - project_name=selection.project_name, - folder_path=selection.folder_path, - task_name=selection.task_name, - **self.data - ) - - except ApplicationExecutableNotFound as exc: - details = exc.details - msg = exc.msg - log_msg = str(msg) - if details: - log_msg += "\n" + details - self.log.warning(log_msg) - self._show_message_box( - "Application executable not found", msg, details - ) - - except ApplicationLaunchFailed as exc: - msg = str(exc) - self.log.warning(msg, exc_info=True) - self._show_message_box("Application launch failed", msg) +from ayon_core.tools.launcher.abstract import ActionItem, WebactionContext -# class Action: -# def __init__(self, label, icon=None, identifier=None): -# self._label = label -# self._icon = icon -# self._callbacks = [] -# self._identifier = identifier or uuid.uuid4().hex -# self._checked = True -# self._checkable = False -# -# def set_checked(self, checked): -# self._checked = checked -# -# def set_checkable(self, checkable): -# self._checkable = checkable -# -# def set_label(self, label): -# self._label = label -# -# def add_callback(self, callback): -# self._callbacks = callback -# -# -# class Menu: -# def __init__(self, label, icon=None): -# self.label = label -# self.icon = icon -# self._actions = [] -# -# def add_action(self, action): -# self._actions.append(action) +@dataclass +class WebactionForm: + fields: list[dict[str, Any]] + title: str + submit_label: str + submit_icon: str + cancel_label: str + cancel_icon: str -class ActionItem: - """Item representing single action to trigger. - - Todos: - Get rid of application specific logic. - - Args: - identifier (str): Unique identifier of action item. - label (str): Action label. - variant_label (Union[str, None]): Variant label, full label is - concatenated with space. Actions are grouped under single - action if it has same 'label' and have set 'variant_label'. - icon (dict[str, str]): Icon definition. - order (int): Action ordering. - is_application (bool): Is action application action. - force_not_open_workfile (bool): Force not open workfile. Application - related. - full_label (Optional[str]): Full label, if not set it is generated - from 'label' and 'variant_label'. - """ - - def __init__( - self, - identifier, - label, - variant_label, - icon, - order, - is_application, - force_not_open_workfile, - full_label=None - ): - self.identifier = identifier - self.label = label - self.variant_label = variant_label - self.icon = icon - self.order = order - self.is_application = is_application - self.force_not_open_workfile = force_not_open_workfile - self._full_label = full_label - - def copy(self): - return self.from_data(self.to_data()) - - @property - def full_label(self): - if self._full_label is None: - if self.variant_label: - self._full_label = " ".join([self.label, self.variant_label]) - else: - self._full_label = self.label - return self._full_label - - def to_data(self): - return { - "identifier": self.identifier, - "label": self.label, - "variant_label": self.variant_label, - "icon": self.icon, - "order": self.order, - "is_application": self.is_application, - "force_not_open_workfile": self.force_not_open_workfile, - "full_label": self._full_label, - } - - @classmethod - def from_data(cls, data): - return cls(**data) +@dataclass +class WebactionResponse: + response_type: str + success: bool + message: Optional[str] = None + clipboard_text: Optional[str] = None + form: Optional[WebactionForm] = None + error_message: Optional[str] = None def get_action_icon(action): @@ -264,8 +92,6 @@ class ActionsModel: controller (AbstractLauncherBackend): Controller instance. """ - _not_open_workfile_reg_key = "force_not_open_workfile" - def __init__(self, controller): self._controller = controller @@ -274,11 +100,21 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} - - self._launcher_tool_reg = AYONSettingsRegistry("launcher_tool") + self._webaction_items = NestedCacheItem( + levels=2, default_factory=list, lifetime=20, + ) self._addons_manager = None + self._variant = get_settings_variant() + + @staticmethod + def calculate_full_label(label: str, variant_label: Optional[str]) -> str: + """Calculate full label from label and variant_label.""" + if variant_label: + return " ".join([label, variant_label]) + return label + @property def log(self): if self._log is None: @@ -289,39 +125,12 @@ class ActionsModel: self._discovered_actions = None self._actions = None self._action_items = {} + self._webaction_items.reset() self._controller.emit_event("actions.refresh.started") self._get_action_objects() self._controller.emit_event("actions.refresh.finished") - def _should_start_last_workfile( - self, - project_name, - task_id, - identifier, - host_name, - not_open_workfile_actions - ): - if identifier in not_open_workfile_actions: - return not not_open_workfile_actions[identifier] - - task_name = None - task_type = None - if task_id is not None: - task_entity = self._controller.get_task_entity( - project_name, task_id - ) - task_name = task_entity["name"] - task_type = task_entity["taskType"] - - output = should_use_last_workfile_on_launch( - project_name, - host_name, - task_name, - task_type - ) - return output - def get_action_items(self, project_name, folder_id, task_id): """Get actions for project. @@ -332,53 +141,31 @@ class ActionsModel: Returns: list[ActionItem]: List of actions. + """ - not_open_workfile_actions = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id) selection = self._prepare_selection(project_name, folder_id, task_id) output = [] action_items = self._get_action_items(project_name) for identifier, action in self._get_action_objects().items(): - if not action.is_compatible(selection): - continue + if action.is_compatible(selection): + output.append(action_items[identifier]) + output.extend(self._get_webactions(selection)) - action_item = action_items[identifier] - # Handling of 'force_not_open_workfile' for applications - if action_item.is_application: - action_item = action_item.copy() - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - not_open_workfile_actions - ) - action_item.force_not_open_workfile = ( - not start_last_workfile - ) - - output.append(action_item) return output - def set_application_force_not_open_workfile( - self, project_name, folder_id, task_id, action_ids, enabled + def trigger_action( + self, + identifier, + project_name, + folder_id, + task_id, ): - no_workfile_reg_data = self._get_no_last_workfile_reg_data() - project_data = no_workfile_reg_data.setdefault(project_name, {}) - folder_data = project_data.setdefault(folder_id, {}) - task_data = folder_data.setdefault(task_id, {}) - for action_id in action_ids: - task_data[action_id] = enabled - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data - ) - - def trigger_action(self, project_name, folder_id, task_id, identifier): selection = self._prepare_selection(project_name, folder_id, task_id) failed = False error_message = None action_label = identifier action_items = self._get_action_items(project_name) + trigger_id = uuid.uuid4().hex try: action = self._actions[identifier] action_item = action_items[identifier] @@ -386,22 +173,11 @@ class ActionsModel: self._controller.emit_event( "action.trigger.started", { + "trigger_id": trigger_id, "identifier": identifier, "full_label": action_label, } ) - if isinstance(action, ApplicationAction): - per_action = self._get_no_last_workfile_for_context( - project_name, folder_id, task_id - ) - start_last_workfile = self._should_start_last_workfile( - project_name, - task_id, - identifier, - action.application.host_name, - per_action - ) - action.data["start_last_workfile"] = start_last_workfile action.process(selection) except Exception as exc: @@ -412,6 +188,7 @@ class ActionsModel: self._controller.emit_event( "action.trigger.finished", { + "trigger_id": trigger_id, "identifier": identifier, "failed": failed, "error_message": error_message, @@ -419,32 +196,148 @@ class ActionsModel: } ) + def trigger_webaction(self, context, action_label, form_data): + entity_type = None + entity_ids = [] + identifier = context.identifier + folder_id = context.folder_id + task_id = context.task_id + project_name = context.project_name + addon_name = context.addon_name + addon_version = context.addon_version + + if task_id: + entity_type = "task" + entity_ids.append(task_id) + elif folder_id: + entity_type = "folder" + entity_ids.append(folder_id) + + query = { + "addonName": addon_name, + "addonVersion": addon_version, + "identifier": identifier, + "variant": self._variant, + } + url = f"actions/execute?{urlencode(query)}" + request_data = { + "projectName": project_name, + "entityType": entity_type, + "entityIds": entity_ids, + } + if form_data is not None: + request_data["formData"] = form_data + + trigger_id = uuid.uuid4().hex + failed = False + try: + self._controller.emit_event( + "webaction.trigger.started", + { + "trigger_id": trigger_id, + "identifier": identifier, + "full_label": action_label, + } + ) + + conn = ayon_api.get_server_api_connection() + # Add 'referer' header to the request + # - ayon-api 1.1.1 adds the value to the header automatically + headers = conn.get_headers() + if "referer" in headers: + headers = None + else: + headers["referer"] = conn.get_base_url() + response = ayon_api.raw_post( + url, headers=headers, json=request_data + ) + response.raise_for_status() + handle_response = self._handle_webaction_response(response.data) + + except Exception: + failed = True + self.log.warning("Action trigger failed.", exc_info=True) + handle_response = WebactionResponse( + "unknown", + False, + error_message="Failed to trigger webaction.", + ) + + data = asdict(handle_response) + data.update({ + "trigger_failed": failed, + "trigger_id": trigger_id, + "identifier": identifier, + "full_label": action_label, + "project_name": project_name, + "folder_id": folder_id, + "task_id": task_id, + "addon_name": addon_name, + "addon_version": addon_version, + }) + self._controller.emit_event( + "webaction.trigger.finished", + data, + ) + + def get_action_config_values(self, context: WebactionContext): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + + query = { + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **request_data) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to collect webaction config values.", + exc_info=True + ) + return {} + return response.data + + def set_action_config_values(self, context, values): + selection = self._prepare_selection( + context.project_name, context.folder_id, context.task_id + ) + if not selection.is_project_selected: + return {} + + request_data = self._get_webaction_request_data(selection) + request_data["value"] = values + + query = { + "addonName": context.addon_name, + "addonVersion": context.addon_version, + "identifier": context.identifier, + "variant": self._variant, + } + url = f"actions/config?{urlencode(query)}" + try: + response = ayon_api.post(url, **request_data) + response.raise_for_status() + except Exception: + self.log.warning( + "Failed to store webaction config values.", + exc_info=True + ) + def _get_addons_manager(self): if self._addons_manager is None: self._addons_manager = AddonsManager() return self._addons_manager - def _get_no_last_workfile_reg_data(self): - try: - no_workfile_reg_data = self._launcher_tool_reg.get_item( - self._not_open_workfile_reg_key) - except ValueError: - no_workfile_reg_data = {} - self._launcher_tool_reg.set_item( - self._not_open_workfile_reg_key, no_workfile_reg_data) - return no_workfile_reg_data - - def _get_no_last_workfile_for_context( - self, project_name, folder_id, task_id - ): - not_open_workfile_reg_data = self._get_no_last_workfile_reg_data() - return ( - not_open_workfile_reg_data - .get(project_name, {}) - .get(folder_id, {}) - .get(task_id, {}) - ) - def _prepare_selection(self, project_name, folder_id, task_id): project_entity = None if project_name: @@ -458,6 +351,179 @@ class ActionsModel: project_settings=project_settings, ) + def _get_webaction_request_data(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return None + + entity_type = None + entity_id = None + entity_subtypes = [] + if selection.is_task_selected: + entity_type = "task" + entity_id = selection.task_entity["id"] + entity_subtypes = [selection.task_entity["taskType"]] + + elif selection.is_folder_selected: + entity_type = "folder" + entity_id = selection.folder_entity["id"] + entity_subtypes = [selection.folder_entity["folderType"]] + + entity_ids = [] + if entity_id: + entity_ids.append(entity_id) + + project_name = selection.project_name + return { + "projectName": project_name, + "entityType": entity_type, + "entitySubtypes": entity_subtypes, + "entityIds": entity_ids, + } + + def _get_webactions(self, selection: LauncherActionSelection): + if not selection.is_project_selected: + return [] + + request_data = self._get_webaction_request_data(selection) + project_name = selection.project_name + entity_id = None + if request_data["entityIds"]: + entity_id = request_data["entityIds"][0] + + cache: CacheItem = self._webaction_items[project_name][entity_id] + if cache.is_valid: + return cache.get_data() + + try: + response = ayon_api.post("actions/list", **request_data) + response.raise_for_status() + except Exception: + self.log.warning("Failed to collect webactions.", exc_info=True) + return [] + + action_items = [] + for action in response.data["actions"]: + # NOTE Settings variant may be important for triggering? + # - action["variant"] + icon = action.get("icon") + if icon and icon["type"] == "url": + if not urlparse(icon["url"]).scheme: + icon["type"] = "ayon_url" + + config_fields = action.get("configFields") or [] + variant_label = action["label"] + group_label = action.get("groupLabel") + if not group_label: + group_label = variant_label + variant_label = None + + full_label = self.calculate_full_label( + group_label, variant_label + ) + action_items.append(ActionItem( + action_type="webaction", + identifier=action["identifier"], + order=action["order"], + label=group_label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + addon_name=action["addonName"], + addon_version=action["addonVersion"], + config_fields=config_fields, + # category=action["category"], + )) + + cache.update_data(action_items) + return cache.get_data() + + def _handle_webaction_response(self, data) -> WebactionResponse: + response_type = data["type"] + # Backwards compatibility -> 'server' type is not available since + # AYON backend 1.8.3 + if response_type == "server": + return WebactionResponse( + response_type, + False, + error_message="Please use AYON web UI to run the action.", + ) + + payload = data.get("payload") or {} + + download_uri = payload.get("extra_download") + if download_uri is not None: + # Find out if is relative or absolute URL + if not urlparse(download_uri).scheme: + ayon_url = ayon_api.get_base_url().rstrip("/") + path = download_uri.lstrip("/") + download_uri = f"{ayon_url}/{path}" + + # Use webbrowser to open file + webbrowser.open_new_tab(download_uri) + + response = WebactionResponse( + response_type, + data["success"], + data.get("message"), + payload.get("extra_clipboard"), + ) + if response_type == "simple": + pass + + elif response_type == "redirect": + # NOTE unused 'newTab' key because we always have to + # open new tab from desktop app. + if not webbrowser.open_new_tab(payload["uri"]): + payload.error_message = "Failed to open web browser." + + elif response_type == "form": + submit_icon = payload["submit_icon"] or None + cancel_icon = payload["cancel_icon"] or None + if submit_icon: + submit_icon = { + "type": "material-symbols", + "name": submit_icon, + } + + if cancel_icon: + cancel_icon = { + "type": "material-symbols", + "name": cancel_icon, + } + + response.form = WebactionForm( + fields=payload["fields"], + title=payload["title"], + submit_label=payload["submit_label"], + cancel_label=payload["cancel_label"], + submit_icon=submit_icon, + cancel_icon=cancel_icon, + ) + + elif response_type == "launcher": + # Run AYON launcher process with uri in arguments + # NOTE This does pass environment variables of current process + # to the subprocess. + # NOTE We could 'take action' directly and use the arguments here + if payload is not None: + uri = payload["uri"] + else: + uri = data["uri"] + run_detached_ayon_launcher_process(uri) + + elif response_type in ("query", "navigate"): + response.error_message = ( + "Please use AYON web UI to run the action." + ) + + else: + self.log.warning( + f"Unknown webaction response type '{response_type}'" + ) + response.error_message = "Unknown webaction response type." + + return response + def _get_discovered_action_classes(self): if self._discovered_actions is None: # NOTE We don't need to register the paths, but that would @@ -470,7 +536,6 @@ class ActionsModel: register_launcher_action_path(path) self._discovered_actions = ( discover_launcher_actions() - + self._get_applications_action_classes() ) return self._discovered_actions @@ -498,62 +563,29 @@ class ActionsModel: action_items = {} for identifier, action in self._get_action_objects().items(): - is_application = isinstance(action, ApplicationAction) # Backwards compatibility from 0.3.3 (24/06/10) # TODO: Remove in future releases - if is_application and hasattr(action, "project_settings"): + if hasattr(action, "project_settings"): action.project_entities[project_name] = project_entity action.project_settings[project_name] = project_settings label = action.label or identifier variant_label = getattr(action, "label_variant", None) + full_label = self.calculate_full_label( + label, variant_label + ) icon = get_action_icon(action) item = ActionItem( - identifier, - label, - variant_label, - icon, - action.order, - is_application, - False + action_type="local", + identifier=identifier, + order=action.order, + label=label, + variant_label=variant_label, + full_label=full_label, + icon=icon, + config_fields=[], ) action_items[identifier] = item self._action_items[project_name] = action_items return action_items - - def _get_applications_action_classes(self): - addons_manager = self._get_addons_manager() - applications_addon = addons_manager.get_enabled_addon("applications") - if hasattr(applications_addon, "get_applications_action_classes"): - return applications_addon.get_applications_action_classes() - - # Backwards compatibility from 0.3.3 (24/06/10) - # TODO: Remove in future releases - actions = [] - if applications_addon is None: - return actions - - manager = applications_addon.get_applications_manager() - for full_name, application in manager.applications.items(): - if not application.enabled: - continue - - action = type( - "app_{}".format(full_name), - (ApplicationAction,), - { - "identifier": "application.{}".format(full_name), - "application": application, - "name": application.name, - "label": application.group.label, - "label_variant": application.label, - "group": None, - "icon": application.icon, - "color": getattr(application, "color", None), - "order": getattr(application, "order", None) or 0, - "data": {} - } - ) - actions.append(action) - return actions diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index c64d718172..51cb8e73bc 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,38 @@ import time +import uuid import collections from qtpy import QtWidgets, QtCore, QtGui +from ayon_core.lib import Logger +from ayon_core.lib.attribute_definitions import ( + UILabelDef, + EnumDef, + TextDef, + BoolDef, + NumberDef, + HiddenDef, +) from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import get_qt_icon - -from .resources import get_options_image_path +from ayon_core.tools.utils import ( + get_qt_icon, +) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog +from ayon_core.tools.launcher.abstract import WebactionContext ANIMATION_LEN = 7 +SHADOW_FRAME_MARGINS = (1, 1, 1, 1) ACTION_ID_ROLE = QtCore.Qt.UserRole + 1 -ACTION_IS_APPLICATION_ROLE = QtCore.Qt.UserRole + 2 +ACTION_TYPE_ROLE = QtCore.Qt.UserRole + 2 ACTION_IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 -ACTION_SORT_ROLE = QtCore.Qt.UserRole + 4 -ANIMATION_START_ROLE = QtCore.Qt.UserRole + 5 -ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 6 -FORCE_NOT_OPEN_WORKFILE_ROLE = QtCore.Qt.UserRole + 7 +ACTION_HAS_CONFIGS_ROLE = QtCore.Qt.UserRole + 4 +ACTION_SORT_ROLE = QtCore.Qt.UserRole + 5 +ACTION_ADDON_NAME_ROLE = QtCore.Qt.UserRole + 6 +ACTION_ADDON_VERSION_ROLE = QtCore.Qt.UserRole + 7 +PLACEHOLDER_ITEM_ROLE = QtCore.Qt.UserRole + 8 +ANIMATION_START_ROLE = QtCore.Qt.UserRole + 9 +ANIMATION_STATE_ROLE = QtCore.Qt.UserRole + 10 def _variant_label_sort_getter(action_item): @@ -34,6 +50,67 @@ def _variant_label_sort_getter(action_item): return action_item.variant_label or "" +# --- Replacement for QAction for action variants --- +class LauncherSettingsLabel(QtWidgets.QWidget): + _settings_icon = None + + @classmethod + def _get_settings_icon(cls): + if cls._settings_icon is None: + cls._settings_icon = get_qt_icon({ + "type": "material-symbols", + "name": "settings", + }) + return cls._settings_icon + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + rect = event.rect() + size = min(rect.height(), rect.width()) + pix_rect = QtCore.QRect( + rect.x(), rect.y(), + size, size + ) + pix = self._get_settings_icon().pixmap(size, size) + painter.drawPixmap(pix_rect, pix) + + painter.end() + + +class ActionOverlayWidget(QtWidgets.QFrame): + def __init__(self, item_id, parent): + super().__init__(parent) + self._item_id = item_id + + settings_icon = LauncherSettingsLabel(self) + settings_icon.setToolTip("Right click for options") + settings_icon.setVisible(False) + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(5, 5, 0, 0) + main_layout.addWidget(settings_icon, 0, 0) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 5) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + self._settings_icon = settings_icon + + def enterEvent(self, event): + super().enterEvent(event) + self._settings_icon.setVisible(True) + + def leaveEvent(self, event): + super().leaveEvent(event) + self._settings_icon.setVisible(False) + + class ActionsQtModel(QtGui.QStandardItemModel): """Qt model for actions. @@ -44,7 +121,8 @@ class ActionsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ActionsQtModel, self).__init__() + self._log = Logger.get_logger(self.__class__.__name__) + super().__init__() controller.register_event_callback( "selection.project.changed", @@ -84,6 +162,23 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_item_by_id(self, action_id): return self._items_by_id.get(action_id) + def get_index_by_id(self, action_id): + item = self.get_item_by_id(action_id) + if item is not None: + return self.indexFromItem(item) + return QtCore.QModelIndex() + + def get_group_item_by_action_id(self, action_id): + item = self._items_by_id.get(action_id) + if item is not None: + return item + + for group_id, items in self._groups_by_id.items(): + for item in items: + if item.identifier == action_id: + return self._items_by_id[group_id] + return None + def get_action_item_by_id(self, action_id): return self._action_items_by_id.get(action_id) @@ -108,8 +203,10 @@ class ActionsQtModel(QtGui.QStandardItemModel): root_item = self.invisibleRootItem() all_action_items_info = [] + action_items_by_id = {} items_by_label = collections.defaultdict(list) for item in items: + action_items_by_id[item.identifier] = item if not item.variant_label: all_action_items_info.append((item, False)) else: @@ -122,16 +219,30 @@ class ActionsQtModel(QtGui.QStandardItemModel): all_action_items_info.append((first_item, len(action_items) > 1)) groups_by_id[first_item.identifier] = action_items + transparent_icon = {"type": "transparent", "size": 256} new_items = [] items_by_id = {} - action_items_by_id = {} for action_item_info in all_action_items_info: action_item, is_group = action_item_info - icon = get_qt_icon(action_item.icon) + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + if is_group: + has_configs = False label = action_item.label else: label = action_item.full_label + has_configs = bool(action_item.config_fields) item = self._items_by_id.get(action_item.identifier) if item is None: @@ -141,16 +252,15 @@ class ActionsQtModel(QtGui.QStandardItemModel): item.setFlags(QtCore.Qt.ItemIsEnabled) item.setData(label, QtCore.Qt.DisplayRole) + item.setData(label, QtCore.Qt.ToolTipRole) item.setData(icon, QtCore.Qt.DecorationRole) item.setData(is_group, ACTION_IS_GROUP_ROLE) + item.setData(has_configs, ACTION_HAS_CONFIGS_ROLE) + item.setData(action_item.action_type, ACTION_TYPE_ROLE) + item.setData(action_item.addon_name, ACTION_ADDON_NAME_ROLE) + item.setData(action_item.addon_version, ACTION_ADDON_VERSION_ROLE) item.setData(action_item.order, ACTION_SORT_ROLE) - item.setData( - action_item.is_application, ACTION_IS_APPLICATION_ROLE) - item.setData( - action_item.force_not_open_workfile, - FORCE_NOT_OPEN_WORKFILE_ROLE) items_by_id[action_item.identifier] = item - action_items_by_id[action_item.identifier] = action_item if new_items: root_item.appendRows(new_items) @@ -166,6 +276,12 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._action_items_by_id = action_items_by_id self.refreshed.emit() + def get_action_config_fields(self, action_id: str): + action_item = self._action_items_by_id.get(action_id) + if action_item is not None: + return action_item.config_fields + return None + def _on_selection_project_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = None @@ -185,14 +301,415 @@ class ActionsQtModel(QtGui.QStandardItemModel): self.refresh() +class ActionMenuPopupModel(QtGui.QStandardItemModel): + def set_action_items(self, action_items): + """Set action items for the popup.""" + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + transparent_icon = {"type": "transparent", "size": 256} + new_items = [] + for action_item in action_items: + icon_def = action_item.icon + if not icon_def: + icon_def = transparent_icon.copy() + + try: + icon = get_qt_icon(icon_def) + except Exception: + self._log.warning( + "Failed to parse icon definition", exc_info=True + ) + # Use empty icon if failed to parse definition + icon = get_qt_icon(transparent_icon.copy()) + + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.ItemIsEnabled) + item.setData(action_item.variant_label, QtCore.Qt.DisplayRole) + item.setData(action_item.full_label, QtCore.Qt.ToolTipRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(action_item.identifier, ACTION_ID_ROLE) + item.setData( + bool(action_item.config_fields), + ACTION_HAS_CONFIGS_ROLE + ) + item.setData(action_item.order, ACTION_SORT_ROLE) + + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + + def fill_to_count(self, count: int): + """Fill up items to specifi counter. + + This is needed to visually organize structure or the viewed items. If + items are shown right to left then mouse would not hover over + last item i there are multiple rows that are uneven. This will + fill the "first items" with invisible items so visually it looks + correct. + + Visually it will cause this: + [ ] [ ] [ ] [A] + [A] [A] [A] [A] + + Instead of: + [A] [A] [A] [A] + [A] [ ] [ ] [ ] + + """ + remainders = count - self.rowCount() + if not remainders: + return + + items = [] + for _ in range(remainders): + item = QtGui.QStandardItem() + item.setFlags(QtCore.Qt.NoItemFlags) + item.setData(True, PLACEHOLDER_ITEM_ROLE) + items.append(item) + + root_item = self.invisibleRootItem() + root_item.appendRows(items) + + +class ActionMenuPopup(QtWidgets.QWidget): + """Popup widget for group varaints. + + The popup is handling most of the layout and showing of the items + manually. + + There 4 parts: + 1. Shadow - semi transparent black widget used as shadow. + 2. Background - painted over the shadow with blur effect. All + other items are painted over. + 3. Label - show group label and positioned manually at the top + of the popup. + 4. View - View with variant action items. View is positioned + and resized manually according to the items in the group and then + animated using mask region. + + """ + action_triggered = QtCore.Signal(str) + config_requested = QtCore.Signal(str, QtCore.QPoint) + + def __init__(self, parent): + super().__init__(parent) + + self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating, True) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Close widget if is not updated by event + close_timer = QtCore.QTimer() + close_timer.setSingleShot(True) + close_timer.setInterval(100) + + expand_anim = QtCore.QVariantAnimation() + expand_anim.setDuration(60) + expand_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + group_label = QtWidgets.QLabel("|", self) + group_label.setObjectName("GroupLabel") + + # View with actions + view = ActionsView(self) + view.setGridSize(QtCore.QSize(75, 80)) + view.setIconSize(QtCore.QSize(32, 32)) + view.move(sh_l, sh_t) + + view.stackUnder(group_label) + + # Background draw + bg_frame = QtWidgets.QFrame(self) + bg_frame.setObjectName("ShadowFrame") + bg_frame.stackUnder(view) + + wrapper = QtWidgets.QFrame(self) + wrapper.setObjectName("Wrapper") + + effect = QtWidgets.QGraphicsBlurEffect(wrapper) + effect.setBlurRadius(3.0) + wrapper.setGraphicsEffect(effect) + + bg_layout = QtWidgets.QVBoxLayout(bg_frame) + bg_layout.setContentsMargins(sh_l, sh_t, sh_r, sh_b) + bg_layout.addWidget(wrapper) + + model = ActionMenuPopupModel() + proxy_model = ActionsProxyModel() + proxy_model.setSourceModel(model) + + view.setModel(proxy_model) + view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + close_timer.timeout.connect(self.close) + expand_anim.valueChanged.connect(self._on_expand_anim) + expand_anim.finished.connect(self._on_expand_finish) + + view.clicked.connect(self._on_clicked) + view.config_requested.connect(self._on_configs_trigger) + + self._group_label = group_label + self._view = view + self._bg_frame = bg_frame + self._effect = effect + self._model = model + self._proxy_model = proxy_model + + self._close_timer = close_timer + self._expand_anim = expand_anim + + self._showed = False + self._current_id = None + self._right_to_left = False + + def showEvent(self, event): + self._showed = True + super().showEvent(event) + + def closeEvent(self, event): + self._showed = False + super().closeEvent(event) + + def enterEvent(self, event): + super().leaveEvent(event) + self._close_timer.stop() + + def leaveEvent(self, event): + super().leaveEvent(event) + self._close_timer.start() + + def show_items(self, group_label, action_id, action_items, pos): + self._group_label.setText(group_label) + if not action_items: + if self._showed: + self._close_timer.start() + self._current_id = None + return + + self._close_timer.stop() + + if action_id != self._current_id: + self.setGeometry(pos.x(), pos.y(), 1, 1) + self._current_id = action_id + self._update_items(action_items) + + # Make sure is visible + if not self._showed: + self.show() + + # Set geometry to position + # - first make sure widget changes from '_update_items' + # are recalculated + app = QtWidgets.QApplication.instance() + app.processEvents() + items_count, start_size, target_size = self._get_size_hint() + self._model.fill_to_count(items_count) + + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() + window = self.screen() + window_geo = window.geometry() + _target_x = pos.x() + target_size.width() + _target_y = pos.y() + target_size.height() + label_height + right_to_left = ( + _target_x > window_geo.right() + or _target_y > window_geo.bottom() + ) + + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + viewport_offset = self._view.viewport().geometry().topLeft() + pos_x = pos.x() - (sh_l + viewport_offset.x() + 2) + pos_y = pos.y() - (sh_t + viewport_offset.y() + 1) + bg_x = bg_y = 0 + sort_order = QtCore.Qt.DescendingOrder + if right_to_left: + sort_order = QtCore.Qt.AscendingOrder + size_diff = target_size - start_size + pos_x -= size_diff.width() + pos_y -= size_diff.height() + bg_x = size_diff.width() + bg_y = size_diff.height() - label_height + + bg_geo = QtCore.QRect( + bg_x, bg_y, + start_size.width(), start_size.height() + label_height + ) + + label_pos_x = sh_l + label_pos_y = bg_y + sh_t + if label_width < start_size.width(): + label_pos_x = bg_x + (start_size.width() - label_width) // 2 + + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + + self._right_to_left = right_to_left + + self._proxy_model.sort(0, sort_order) + self.setUpdatesEnabled(False) + self._view.setMask( + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) + ) + self._view.setMinimumWidth(target_size.width()) + self._view.setMaximumWidth(target_size.width()) + self._view.setMinimumHeight(target_size.height()) + self._view.move(sh_l, sh_t + label_height) + self.setGeometry( + pos_x, pos_y - label_height, + target_size.width(), target_size.height() + label_height + ) + self._bg_frame.setGeometry(bg_geo) + self._group_label.move(label_pos_x, label_pos_y) + self.setUpdatesEnabled(True) + + self._expand_anim.updateCurrentTime(0) + self._expand_anim.setStartValue(start_size) + self._expand_anim.setEndValue(target_size) + self._expand_anim.start() + + self.raise_() + + def _on_clicked(self, index): + if not index or not index.isValid(): + return + + if not index.data(ACTION_HAS_CONFIGS_ROLE): + return + + action_id = index.data(ACTION_ID_ROLE) + self.action_triggered.emit(action_id) + + def _on_expand_anim(self, value): + if not self._showed: + if self._expand_anim.state() == QtCore.QAbstractAnimation.Running: + self._expand_anim.stop() + return + + bg_geo = self._bg_frame.geometry() + + label_sh = self._group_label.sizeHint() + label_width, label_height = label_sh.width(), label_sh.height() + if self._right_to_left: + popup_geo = self.geometry() + diff_size = popup_geo.size() - value + pos = QtCore.QPoint( + diff_size.width(), diff_size.height() - label_height + ) + + bg_geo.moveTopLeft(pos) + + bg_geo.setWidth(value.width()) + bg_geo.setHeight(value.height() + label_height) + + label_width = self._group_label.sizeHint().width() + bgeo_tl = bg_geo.topLeft() + sh_l, sh_t, sh_r, sh_b = SHADOW_FRAME_MARGINS + + label_pos_x = sh_l + if label_width < value.width(): + label_pos_x = bgeo_tl.x() + (value.width() - label_width) // 2 + + self.setUpdatesEnabled(False) + self._view.setMask( + bg_geo.adjusted(sh_l, sh_t, -sh_r, -sh_b) + ) + self._group_label.move(label_pos_x, sh_t) + self._bg_frame.setGeometry(bg_geo) + self.setUpdatesEnabled(True) + + def _on_expand_finish(self): + # Make sure that size is recalculated if src and targe size is same + _, _, size = self._get_size_hint() + self._on_expand_anim(size) + + def _get_size_hint(self): + grid_size = self._view.gridSize() + row_count = self._proxy_model.rowCount() + cols = 4 + rows = 1 + while True: + rows = row_count // cols + if row_count % cols: + rows += 1 + if rows <= cols: + break + cols += 1 + + if rows == 1: + cols = row_count + + viewport_geo = self._view.viewport().geometry() + viewport_offset = viewport_geo.topLeft() + # QUESTION how to get the bottom and right margins from Qt? + vp_lr = viewport_offset.x() + vp_tb = viewport_offset.y() + m_l, m_t, m_r, m_b = ( + s_m + vp_m + for s_m, vp_m in zip( + SHADOW_FRAME_MARGINS, + (vp_lr, vp_tb, vp_lr, vp_tb) + ) + ) + single_width = ( + grid_size.width() + + self._view.horizontalOffset() + m_l + m_r + 1 + ) + single_height = ( + grid_size.height() + + self._view.verticalOffset() + m_b + m_t + 1 + ) + total_width = single_width + total_height = single_height + if cols > 1: + total_width += ( + (cols - 1) * (self._view.spacing() + grid_size.width()) + ) + + if rows > 1: + total_height += ( + (rows - 1) * (grid_size.height() + self._view.spacing()) + ) + return ( + cols * rows, + QtCore.QSize(single_width, single_height), + QtCore.QSize(total_width, total_height) + ) + + def _update_items(self, action_items): + """Update items in the tooltip.""" + # This method can be used to update the content of the tooltip + # with new icon, text and settings button visibility. + self._model.set_action_items(action_items) + self._view.update_on_refresh() + + def _on_trigger(self, action_id): + self.action_triggered.emit(action_id) + self.close() + + def _on_configs_trigger(self, action_id, center_pos): + self.config_requested.emit(action_id, center_pos) + self.close() + + class ActionDelegate(QtWidgets.QStyledItemDelegate): - _cached_extender = {} + _extender_icon = None def __init__(self, *args, **kwargs): - super(ActionDelegate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._anim_start_color = QtGui.QColor(178, 255, 246) self._anim_end_color = QtGui.QColor(5, 44, 50) + def sizeHint(self, option, index): + return option.widget.gridSize() + + def updateEditorGeometry(self, editor, option, index): + editor.setGeometry(option.rect) + def _draw_animation(self, painter, option, index): grid_size = option.widget.gridSize() x_offset = int( @@ -240,54 +757,39 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): painter.restore() @classmethod - def _get_extender_pixmap(cls, size): - pix = cls._cached_extender.get(size) - if pix is not None: - return pix - pix = QtGui.QPixmap(get_options_image_path()).scaled( - size, size, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - cls._cached_extender[size] = pix - return pix + def _get_extender_pixmap(cls): + if cls._extender_icon is None: + cls._extender_icon = get_qt_icon({ + "type": "material-symbols", + "name": "more_horiz", + }) + return cls._extender_icon def paint(self, painter, option, index): painter.setRenderHints( QtGui.QPainter.Antialiasing + | QtGui.QPainter.TextAntialiasing | QtGui.QPainter.SmoothPixmapTransform ) if index.data(ANIMATION_STATE_ROLE): self._draw_animation(painter, option, index) - - super(ActionDelegate, self).paint(painter, option, index) - - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - rect = QtCore.QRectF( - option.rect.x(), option.rect.y() + option.rect.height(), 5, 5) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(200, 0, 0)) - painter.drawEllipse(rect) + option.displayAlignment = QtCore.Qt.AlignHCenter | QtCore.Qt.AlignTop + super().paint(painter, option, index) if not index.data(ACTION_IS_GROUP_ROLE): return grid_size = option.widget.gridSize() - x_offset = int( - (grid_size.width() / 2) - - (option.rect.width() / 2) - ) - item_x = option.rect.x() - x_offset - tenth_size = int(grid_size.width() / 10) - extender_size = int(tenth_size * 2.4) + extender_rect = option.rect.adjusted(5, 5, 0, 0) + extender_size = grid_size.width() // 6 + extender_rect.setWidth(extender_size) + extender_rect.setHeight(extender_size) - extender_x = item_x + tenth_size - extender_y = option.rect.y() + tenth_size - - pix = self._get_extender_pixmap(extender_size) - painter.drawPixmap(extender_x, extender_y, pix) + icon = self._get_extender_pixmap() + pix = icon.pixmap(extender_size, extender_size) + painter.drawPixmap(extender_rect, pix) class ActionsProxyModel(QtCore.QSortFilterProxyModel): @@ -297,7 +799,11 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) def lessThan(self, left, right): - # Sort by action order and then by label + if left.data(PLACEHOLDER_ITEM_ROLE): + return True + if right.data(PLACEHOLDER_ITEM_ROLE): + return False + left_value = left.data(ACTION_SORT_ROLE) right_value = right.data(ACTION_SORT_ROLE) @@ -318,29 +824,87 @@ class ActionsProxyModel(QtCore.QSortFilterProxyModel): return True +class ActionsView(QtWidgets.QListView): + config_requested = QtCore.Signal(str, QtCore.QPoint) + + def __init__(self, parent): + super().__init__(parent) + self.setViewMode(QtWidgets.QListView.IconMode) + self.setResizeMode(QtWidgets.QListView.Adjust) + self.setSelectionMode(QtWidgets.QListView.NoSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + self.setContentsMargins(0, 0, 0, 0) + self.setViewportMargins(0, 0, 0, 0) + self.setWrapping(True) + self.setSpacing(0) + self.setWordWrap(True) + self.setMouseTracking(True) + + vertical_scroll = self.verticalScrollBar() + vertical_scroll.setSingleStep(8) + + delegate = ActionDelegate(self) + self.setItemDelegate(delegate) + + # Make view flickable + flick = FlickCharm(parent=self) + flick.activateOn(self) + + self.customContextMenuRequested.connect(self._on_context_menu) + + self._overlay_widgets = [] + self._flick = flick + self._delegate = delegate + + def _on_context_menu(self, point): + """Creates menu to force skip opening last workfile.""" + index = self.indexAt(point) + if not index.isValid(): + return + action_id = index.data(ACTION_ID_ROLE) + rect = self.visualRect(index) + global_center = self.mapToGlobal(rect.center()) + self.config_requested.emit(action_id, global_center) + + def update_on_refresh(self): + viewport = self.viewport() + viewport.update() + self._add_overlay_widgets() + + def _add_overlay_widgets(self): + overlay_widgets = [] + viewport = self.viewport() + model = self.model() + for row in range(model.rowCount()): + index = model.index(row, 0) + has_configs = index.data(ACTION_HAS_CONFIGS_ROLE) + widget = None + if has_configs: + item_id = index.data(ACTION_ID_ROLE) + widget = ActionOverlayWidget(item_id, viewport) + overlay_widgets.append(widget) + self.setIndexWidget(index, widget) + + while self._overlay_widgets: + widget = self._overlay_widgets.pop(0) + widget.setVisible(False) + widget.setParent(None) + widget.deleteLater() + + self._overlay_widgets = overlay_widgets + + class ActionsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): - super(ActionsWidget, self).__init__(parent) + super().__init__(parent) self._controller = controller - view = QtWidgets.QListView(self) - view.setProperty("mode", "icon") - view.setObjectName("IconView") - view.setViewMode(QtWidgets.QListView.IconMode) - view.setResizeMode(QtWidgets.QListView.Adjust) - view.setSelectionMode(QtWidgets.QListView.NoSelection) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - view.setWrapping(True) + view = ActionsView(self) view.setGridSize(QtCore.QSize(70, 75)) view.setIconSize(QtCore.QSize(30, 30)) - view.setSpacing(0) - view.setWordWrap(True) - - # Make view flickable - flick = FlickCharm(parent=view) - flick.activateOn(view) model = ActionsQtModel(controller) @@ -348,9 +912,6 @@ class ActionsWidget(QtWidgets.QWidget): proxy_model.setSourceModel(model) view.setModel(proxy_model) - delegate = ActionDelegate(self) - view.setItemDelegate(delegate) - layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) @@ -360,32 +921,69 @@ class ActionsWidget(QtWidgets.QWidget): animation_timer.timeout.connect(self._on_animation) view.clicked.connect(self._on_clicked) - view.customContextMenuRequested.connect(self._on_context_menu) + view.config_requested.connect(self._show_config_dialog) model.refreshed.connect(self._on_model_refresh) self._animated_items = set() self._animation_timer = animation_timer - self._context_menu = None - - self._flick = flick self._view = view self._model = model self._proxy_model = proxy_model + self._popup_widget = None + self._set_row_height(1) def refresh(self): self._model.refresh() + def handle_webaction_form_event(self, event): + # NOTE The 'ActionsWidget' should be responsible for handling this + # but because we're showing messages to user it is handled by window + identifier = event["identifier"] + form = event["form"] + submit_icon = form["submit_icon"] + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + + cancel_icon = form["cancel_icon"] + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + dialog = self._create_attrs_dialog( + form["fields"], + form["title"], + form["submit_label"], + form["cancel_label"], + submit_icon, + cancel_icon, + ) + dialog.setMinimumSize(380, 180) + result = dialog.exec_() + if result != QtWidgets.QDialog.Accepted: + return + form_data = dialog.get_values() + self._controller.trigger_webaction( + WebactionContext( + identifier, + event["project_name"], + event["folder_id"], + event["task_id"], + event["addon_name"], + event["addon_version"], + ), + event["action_label"], + form_data, + ) + def _set_row_height(self, rows): self.setMinimumHeight(rows * 75) def _on_model_refresh(self): self._proxy_model.sort(0) # Force repaint all items - viewport = self._view.viewport() - viewport.update() + self._view.update_on_refresh() def _on_animation(self): time_now = time.time() @@ -416,89 +1014,232 @@ class ActionsWidget(QtWidgets.QWidget): self._animated_items.add(action_id) self._animation_timer.start() - def _on_context_menu(self, point): - """Creates menu to force skip opening last workfile.""" - index = self._view.indexAt(point) - if not index.isValid(): - return - - if not index.data(ACTION_IS_APPLICATION_ROLE): - return - - menu = QtWidgets.QMenu(self._view) - checkbox = QtWidgets.QCheckBox( - "Skip opening last workfile.", menu) - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - checkbox.setChecked(True) - - action_id = index.data(ACTION_ID_ROLE) - is_group = index.data(ACTION_IS_GROUP_ROLE) - if is_group: - action_items = self._model.get_group_items(action_id) - else: - action_items = [self._model.get_action_item_by_id(action_id)] - action_ids = {action_item.identifier for action_item in action_items} - checkbox.stateChanged.connect( - lambda: self._on_checkbox_changed( - action_ids, checkbox.isChecked() - ) - ) - action = QtWidgets.QWidgetAction(menu) - action.setDefaultWidget(checkbox) - - menu.addAction(action) - - self._context_menu = menu - global_point = self.mapToGlobal(point) - menu.exec_(global_point) - self._context_menu = None - - def _on_checkbox_changed(self, action_ids, is_checked): - if self._context_menu is not None: - self._context_menu.close() - - project_name = self._model.get_selected_project_name() - folder_id = self._model.get_selected_folder_id() - task_id = self._model.get_selected_task_id() - self._controller.set_application_force_not_open_workfile( - project_name, folder_id, task_id, action_ids, is_checked) - self._model.refresh() - def _on_clicked(self, index): if not index or not index.isValid(): return is_group = index.data(ACTION_IS_GROUP_ROLE) action_id = index.data(ACTION_ID_ROLE) + if is_group: + self._show_group_popup(index) + else: + self._trigger_action(action_id, index) + + def _get_popup_widget(self): + if self._popup_widget is None: + popup_widget = ActionMenuPopup(self) + + popup_widget.action_triggered.connect(self._trigger_action) + popup_widget.config_requested.connect(self._show_config_dialog) + self._popup_widget = popup_widget + return self._popup_widget + + def _show_group_popup(self, index): + action_id = index.data(ACTION_ID_ROLE) + group_label = index.data(QtCore.Qt.DisplayRole) + action_items = self._model.get_group_items(action_id) + rect = self._view.visualRect(index) + pos = self.mapToGlobal(rect.topLeft()) + + popup_widget = self._get_popup_widget() + popup_widget.show_items( + group_label, action_id, action_items, pos + ) + + def _trigger_action(self, action_id, index=None): + project_name = self._model.get_selected_project_name() + folder_id = self._model.get_selected_folder_id() + task_id = self._model.get_selected_task_id() + action_item = self._model.get_action_item_by_id(action_id) + + if action_item.action_type == "webaction": + action_item = self._model.get_action_item_by_id(action_id) + context = WebactionContext( + action_id, + project_name, + folder_id, + task_id, + action_item.addon_name, + action_item.addon_version + ) + self._controller.trigger_webaction( + context, action_item.full_label + ) + else: + self._controller.trigger_action( + action_id, project_name, folder_id, task_id + ) + + if index is None: + item = self._model.get_group_item_by_action_id(action_id) + if item is not None: + index = self._proxy_model.mapFromSource(item.index()) + + if index is not None: + self._start_animation(index) + + def _show_config_dialog(self, action_id, center_point): + action_item = self._model.get_action_item_by_id(action_id) + config_fields = self._model.get_action_config_fields(action_id) + if not config_fields: + return project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() - - if not is_group: - self._controller.trigger_action( - project_name, folder_id, task_id, action_id - ) - self._start_animation(index) - return - - action_items = self._model.get_group_items(action_id) - - menu = QtWidgets.QMenu(self) - actions_mapping = {} - - for action_item in action_items: - menu_action = QtWidgets.QAction(action_item.full_label) - menu.addAction(menu_action) - actions_mapping[menu_action] = action_item - - result = menu.exec_(QtGui.QCursor.pos()) - if not result: - return - - action_item = actions_mapping[result] - - self._controller.trigger_action( - project_name, folder_id, task_id, action_item.identifier + context = WebactionContext( + action_id, + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + addon_name=action_item.addon_name, + addon_version=action_item.addon_version, ) - self._start_animation(index) + values = self._controller.get_action_config_values(context) + + dialog = self._create_attrs_dialog( + config_fields, + "Action Config", + "Save", + "Cancel", + ) + dialog.set_values(values) + dialog.show() + self._center_dialog(dialog, center_point) + result = dialog.exec_() + if result == QtWidgets.QDialog.Accepted: + new_values = dialog.get_values() + self._controller.set_action_config_values(context, new_values) + + @staticmethod + def _center_dialog(dialog, target_center_pos): + dialog_geo = dialog.geometry() + dialog_geo.moveCenter(target_center_pos) + + screen = dialog.screen() + screen_geo = screen.availableGeometry() + if screen_geo.left() > dialog_geo.left(): + dialog_geo.moveLeft(screen_geo.left()) + elif screen_geo.right() < dialog_geo.right(): + dialog_geo.moveRight(screen_geo.right()) + + if screen_geo.top() > dialog_geo.top(): + dialog_geo.moveTop(screen_geo.top()) + elif screen_geo.bottom() < dialog_geo.bottom(): + dialog_geo.moveBottom(screen_geo.bottom()) + dialog.move(dialog_geo.topLeft()) + + def _create_attrs_dialog( + self, + config_fields, + title, + submit_label, + cancel_label, + submit_icon=None, + cancel_icon=None, + ): + """Creates attribute definitions dialog. + + Types: + label - 'text' + text - 'label', 'value', 'placeholder', 'regex', + 'multiline', 'syntax' + boolean - 'label', 'value' + select - 'label', 'value', 'options' + multiselect - 'label', 'value', 'options' + hidden - 'value' + integer - 'label', 'value', 'placeholder', 'min', 'max' + float - 'label', 'value', 'placeholder', 'min', 'max' + + """ + attr_defs = [] + for config_field in config_fields: + field_type = config_field["type"] + attr_def = None + if field_type == "label": + label = config_field.get("value") + if label is None: + label = config_field.get("text") + attr_def = UILabelDef( + label, key=uuid.uuid4().hex + ) + elif field_type == "boolean": + value = config_field["value"] + if isinstance(value, str): + value = value.lower() == "true" + + attr_def = BoolDef( + config_field["name"], + default=value, + label=config_field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + config_field["name"], + default=config_field.get("value"), + label=config_field.get("label"), + placeholder=config_field.get("placeholder"), + multiline=config_field.get("multiline", False), + regex=config_field.get("regex"), + # syntax=config_field["syntax"], + ) + elif field_type in ("integer", "float"): + value = config_field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) + attr_def = NumberDef( + config_field["name"], + default=value, + label=config_field.get("label"), + decimals=0 if field_type == "integer" else 5, + # placeholder=config_field.get("placeholder"), + minimum=config_field.get("min"), + maximum=config_field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + config_field["name"], + items=config_field["options"], + default=config_field.get("value"), + label=config_field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + config_field["name"], + default=config_field.get("value"), + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + + dialog = AttributeDefinitionsDialog( + attr_defs, + title=title, + parent=self, + ) + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + + return dialog diff --git a/client/ayon_core/tools/launcher/ui/projects_widget.py b/client/ayon_core/tools/launcher/ui/projects_widget.py deleted file mode 100644 index e2af54b55d..0000000000 --- a/client/ayon_core/tools/launcher/ui/projects_widget.py +++ /dev/null @@ -1,154 +0,0 @@ -from qtpy import QtWidgets, QtCore - -from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - PlaceholderLineEdit, - RefreshButton, - ProjectsQtModel, - ProjectSortFilterProxy, -) -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER - - -class ProjectIconView(QtWidgets.QListView): - """Styled ListView that allows to toggle between icon and list mode. - - Toggling between the two modes is done by Right Mouse Click. - """ - - IconMode = 0 - ListMode = 1 - - def __init__(self, parent=None, mode=ListMode): - super(ProjectIconView, self).__init__(parent=parent) - - # Workaround for scrolling being super slow or fast when - # toggling between the two visual modes - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.setObjectName("IconView") - - self._mode = None - self.set_mode(mode) - - def set_mode(self, mode): - if mode == self._mode: - return - - self._mode = mode - - if mode == self.IconMode: - self.setViewMode(QtWidgets.QListView.IconMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(True) - self.setWordWrap(True) - self.setGridSize(QtCore.QSize(151, 90)) - self.setIconSize(QtCore.QSize(50, 50)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.setProperty("mode", "icon") - self.style().polish(self) - - self.verticalScrollBar().setSingleStep(30) - - elif self.ListMode: - self.setProperty("mode", "list") - self.style().polish(self) - - self.setViewMode(QtWidgets.QListView.ListMode) - self.setResizeMode(QtWidgets.QListView.Adjust) - self.setWrapping(False) - self.setWordWrap(False) - self.setIconSize(QtCore.QSize(20, 20)) - self.setGridSize(QtCore.QSize(100, 25)) - self.setSpacing(0) - self.setAlternatingRowColors(False) - - self.verticalScrollBar().setSingleStep(34) - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.RightButton: - self.set_mode(int(not self._mode)) - return super(ProjectIconView, self).mousePressEvent(event) - - -class ProjectsWidget(QtWidgets.QWidget): - """Projects Page""" - - refreshed = QtCore.Signal() - - def __init__(self, controller, parent=None): - super(ProjectsWidget, self).__init__(parent=parent) - - header_widget = QtWidgets.QWidget(self) - - projects_filter_text = PlaceholderLineEdit(header_widget) - projects_filter_text.setPlaceholderText("Filter projects...") - - refresh_btn = RefreshButton(header_widget) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(projects_filter_text, 1) - header_layout.addWidget(refresh_btn, 0) - - projects_view = ProjectIconView(parent=self) - projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) - flick = FlickCharm(parent=self) - flick.activateOn(projects_view) - projects_model = ProjectsQtModel(controller) - projects_proxy_model = ProjectSortFilterProxy() - projects_proxy_model.setSourceModel(projects_model) - - projects_view.setModel(projects_proxy_model) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(header_widget, 0) - main_layout.addWidget(projects_view, 1) - - projects_view.clicked.connect(self._on_view_clicked) - projects_model.refreshed.connect(self.refreshed) - projects_filter_text.textChanged.connect( - self._on_project_filter_change) - refresh_btn.clicked.connect(self._on_refresh_clicked) - - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh_finished - ) - - self._controller = controller - - self._projects_view = projects_view - self._projects_model = projects_model - self._projects_proxy_model = projects_proxy_model - - def has_content(self): - """Model has at least one project. - - Returns: - bool: True if there is any content in the model. - """ - - return self._projects_model.has_content() - - def _on_view_clicked(self, index): - if not index.isValid(): - return - model = index.model() - flags = model.flags(index) - if not flags & QtCore.Qt.ItemIsEnabled: - return - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) - - def _on_project_filter_change(self, text): - self._projects_proxy_model.setFilterFixedString(text) - - def _on_refresh_clicked(self): - self._controller.refresh() - - def _on_projects_refresh_finished(self, event): - if event["sender"] != PROJECTS_MODEL_SENDER: - self._projects_model.refresh() diff --git a/client/ayon_core/tools/launcher/ui/resources/__init__.py b/client/ayon_core/tools/launcher/ui/resources/__init__.py deleted file mode 100644 index 27c59af2ba..0000000000 --- a/client/ayon_core/tools/launcher/ui/resources/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -RESOURCES_DIR = os.path.dirname(os.path.abspath(__file__)) - - -def get_options_image_path(): - return os.path.join(RESOURCES_DIR, "options.png") diff --git a/client/ayon_core/tools/launcher/ui/resources/options.png b/client/ayon_core/tools/launcher/ui/resources/options.png deleted file mode 100644 index a9617d0d19..0000000000 Binary files a/client/ayon_core/tools/launcher/ui/resources/options.png and /dev/null differ diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index aa336108ed..819e141d59 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -1,11 +1,15 @@ from qtpy import QtWidgets, QtCore, QtGui -from ayon_core import style -from ayon_core import resources +from ayon_core import style, resources from ayon_core.tools.launcher.control import BaseLauncherController +from ayon_core.tools.utils import ( + MessageOverlayObject, + PlaceholderLineEdit, + RefreshButton, + ProjectsWidget, +) -from .projects_widget import ProjectsWidget from .hierarchy_page import HierarchyPage from .actions_widget import ActionsWidget @@ -41,6 +45,8 @@ class LauncherWindow(QtWidgets.QWidget): self._controller = controller + overlay_object = MessageOverlayObject(self) + # Main content - Pages & Actions content_body = QtWidgets.QSplitter(self) @@ -48,7 +54,25 @@ class LauncherWindow(QtWidgets.QWidget): pages_widget = QtWidgets.QWidget(content_body) # - First page - Projects - projects_page = ProjectsWidget(controller, pages_widget) + projects_page = QtWidgets.QWidget(pages_widget) + projects_header_widget = QtWidgets.QWidget(projects_page) + + projects_filter_text = PlaceholderLineEdit(projects_header_widget) + projects_filter_text.setPlaceholderText("Filter projects...") + + refresh_btn = RefreshButton(projects_header_widget) + + projects_header_layout = QtWidgets.QHBoxLayout(projects_header_widget) + projects_header_layout.setContentsMargins(0, 0, 0, 0) + projects_header_layout.addWidget(projects_filter_text, 1) + projects_header_layout.addWidget(refresh_btn, 0) + + projects_widget = ProjectsWidget(controller, pages_widget) + + projects_layout = QtWidgets.QVBoxLayout(projects_page) + projects_layout.setContentsMargins(0, 0, 0, 0) + projects_layout.addWidget(projects_header_widget, 0) + projects_layout.addWidget(projects_widget, 1) # - Second page - Hierarchy (folders & tasks) hierarchy_page = HierarchyPage(controller, pages_widget) @@ -78,26 +102,18 @@ class LauncherWindow(QtWidgets.QWidget): content_body.setSizes([580, 160]) # Footer - footer_widget = QtWidgets.QWidget(self) - - # - Message label - message_label = QtWidgets.QLabel(footer_widget) - + # footer_widget = QtWidgets.QWidget(self) + # # action_history = ActionHistory(footer_widget) # action_history.setStatusTip("Show Action History") - - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - footer_layout.setContentsMargins(0, 0, 0, 0) - footer_layout.addWidget(message_label, 1) + # + # footer_layout = QtWidgets.QHBoxLayout(footer_widget) + # footer_layout.setContentsMargins(0, 0, 0, 0) # footer_layout.addWidget(action_history, 0) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(content_body, 1) - layout.addWidget(footer_widget, 0) - - message_timer = QtCore.QTimer() - message_timer.setInterval(self.message_interval) - message_timer.setSingleShot(True) + # layout.addWidget(footer_widget, 0) actions_refresh_timer = QtCore.QTimer() actions_refresh_timer.setInterval(self.refresh_interval) @@ -108,13 +124,16 @@ class LauncherWindow(QtWidgets.QWidget): page_slide_anim.setEndValue(1.0) page_slide_anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) - projects_page.refreshed.connect(self._on_projects_refresh) - message_timer.timeout.connect(self._on_message_timeout) + refresh_btn.clicked.connect(self._on_refresh_request) + projects_widget.refreshed.connect(self._on_projects_refresh) + actions_refresh_timer.timeout.connect( self._on_actions_refresh_timeout) page_slide_anim.valueChanged.connect( self._on_page_slide_value_changed) page_slide_anim.finished.connect(self._on_page_slide_finished) + projects_filter_text.textChanged.connect( + self._on_project_filter_change) controller.register_event_callback( "selection.project.changed", @@ -128,6 +147,16 @@ class LauncherWindow(QtWidgets.QWidget): "action.trigger.finished", self._on_action_trigger_finished, ) + controller.register_event_callback( + "webaction.trigger.started", + self._on_webaction_trigger_started, + ) + controller.register_event_callback( + "webaction.trigger.finished", + self._on_webaction_trigger_finished, + ) + + self._overlay_object = overlay_object self._controller = controller @@ -139,13 +168,11 @@ class LauncherWindow(QtWidgets.QWidget): self._pages_widget = pages_widget self._pages_layout = pages_layout self._projects_page = projects_page + self._projects_widget = projects_widget self._hierarchy_page = hierarchy_page self._actions_widget = actions_widget - - self._message_label = message_label # self._action_history = action_history - self._message_timer = message_timer self._actions_refresh_timer = actions_refresh_timer self._page_slide_anim = page_slide_anim @@ -185,13 +212,6 @@ class LauncherWindow(QtWidgets.QWidget): else: self._refresh_on_activate = True - def _echo(self, message): - self._message_label.setText(str(message)) - self._message_timer.start() - - def _on_message_timeout(self): - self._message_label.setText("") - def _on_project_selection_change(self, event): project_name = event["project_name"] self._selected_project_name = project_name @@ -201,6 +221,12 @@ class LauncherWindow(QtWidgets.QWidget): elif self._is_on_projects_page: self._go_to_hierarchy_page(project_name) + def _on_project_filter_change(self, text): + self._projects_widget.set_name_filter(text) + + def _on_refresh_request(self): + self._controller.refresh() + def _on_projects_refresh(self): # Refresh only actions on projects page if self._is_on_projects_page: @@ -208,20 +234,76 @@ class LauncherWindow(QtWidgets.QWidget): return # No projects were found -> go back to projects page - if not self._projects_page.has_content(): + if not self._projects_widget.has_content(): self._go_to_projects_page() return self._hierarchy_page.refresh() self._actions_widget.refresh() + def _show_toast_message(self, message, success=True, message_id=None): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _on_action_trigger_started(self, event): - self._echo("Running action: {}".format(event["full_label"])) + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) def _on_action_trigger_finished(self, event): - if not event["failed"]: + action_label = event["full_label"] + if event["failed"]: + message = f"Failed to run: {action_label}" + else: + message = f"Finished: {action_label}" + self._show_toast_message( + message, + not event["failed"], + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_started(self, event): + self._show_toast_message( + "Running: {}".format(event["full_label"]), + message_id=event["trigger_id"], + ) + + def _on_webaction_trigger_finished(self, event): + clipboard_text = event["clipboard_text"] + if clipboard_text: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(clipboard_text) + + action_label = event["full_label"] + # Avoid to show exception message + if event["trigger_failed"]: + self._show_toast_message( + f"Failed to run: {action_label}", + message_id=event["trigger_id"] + ) return - self._echo("Failed: {}".format(event["error_message"])) + + # Failed to run webaction, e.g. because of missing webaction handling + # - not reported by server + if event["error_message"]: + self._show_toast_message( + event["error_message"], + success=False, + message_id=event["trigger_id"] + ) + return + + if event["message"]: + self._show_toast_message(event["message"], event["success"]) + + if event["form"]: + self._actions_widget.handle_webaction_form_event(event) def _is_page_slide_anim_running(self): return ( @@ -231,6 +313,9 @@ class LauncherWindow(QtWidgets.QWidget): def _go_to_projects_page(self): if self._is_on_projects_page: return + + # Deselect project in projects widget + self._projects_widget.set_selected_project(None) self._is_on_projects_page = True self._hierarchy_page.set_page_visible(False) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d0d7cd430b..5ab7e78212 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,11 +1,15 @@ +"""Abstract base classes for loader tool.""" +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import List +from typing import Iterable, Any, Optional from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, - serialize_attr_defs, deserialize_attr_defs, + serialize_attr_defs, ) +from ayon_core.tools.common_models import TaskItem, TagItem class ProductTypeItem: @@ -16,7 +20,7 @@ class ProductTypeItem: icon (dict[str, Any]): Product type icon definition. """ - def __init__(self, name, icon): + def __init__(self, name: str, icon: dict[str, Any]): self.name = name self.icon = icon @@ -31,6 +35,41 @@ class ProductTypeItem: return cls(**data) +class ProductBaseTypeItem: + """Item representing the product base type.""" + + def __init__(self, name: str, icon: dict[str, Any]): + """Initialize product base type item.""" + self.name = name + self.icon = icon + + def to_data(self) -> dict[str, Any]: + """Convert item to data dictionary. + + Returns: + dict[str, Any]: Data representation of the item. + + """ + return { + "name": self.name, + "icon": self.icon, + } + + @classmethod + def from_data( + cls, data: dict[str, Any]) -> ProductBaseTypeItem: + """Create item from data dictionary. + + Args: + data (dict[str, Any]): Data to create item from. + + Returns: + ProductBaseTypeItem: Item created from the provided data. + + """ + return cls(**data) + + class ProductItem: """Product item with it versions. @@ -49,35 +88,41 @@ class ProductItem: def __init__( self, - product_id, - product_type, - product_name, - product_icon, - product_type_icon, - product_in_scene, - group_name, - folder_id, - folder_label, - version_items, + product_id: str, + product_type: str, + product_base_type: str, + product_name: str, + product_icon: dict[str, Any], + product_type_icon: dict[str, Any], + product_base_type_icon: dict[str, Any], + group_name: str, + folder_id: str, + folder_label: str, + version_items: dict[str, VersionItem], + product_in_scene: bool, ): self.product_id = product_id self.product_type = product_type + self.product_base_type = product_base_type self.product_name = product_name self.product_icon = product_icon self.product_type_icon = product_type_icon + self.product_base_type_icon = product_base_type_icon self.product_in_scene = product_in_scene self.group_name = group_name self.folder_id = folder_id self.folder_label = folder_label self.version_items = version_items - def to_data(self): + def to_data(self) -> dict[str, Any]: return { "product_id": self.product_id, "product_type": self.product_type, + "product_base_type": self.product_base_type, "product_name": self.product_name, "product_icon": self.product_icon, "product_type_icon": self.product_type_icon, + "product_base_type_icon": self.product_base_type_icon, "product_in_scene": self.product_in_scene, "group_name": self.group_name, "folder_id": self.folder_id, @@ -113,6 +158,7 @@ class VersionItem: published_time (Union[str, None]): Published time in format '%Y%m%dT%H%M%SZ'. status (Union[str, None]): Status name. + tags (Union[list[str], None]): Tags. author (Union[str, None]): Author. frame_range (Union[str, None]): Frame range. duration (Union[int, None]): Duration. @@ -124,21 +170,22 @@ class VersionItem: def __init__( self, - version_id, - version, - is_hero, - product_id, - task_id, - thumbnail_id, - published_time, - author, - status, - frame_range, - duration, - handles, - step, - comment, - source, + version_id: str, + version: int, + is_hero: bool, + product_id: str, + task_id: Optional[str], + thumbnail_id: Optional[str], + published_time: Optional[str], + tags: Optional[list[str]], + author: Optional[str], + status: Optional[str], + frame_range: Optional[str], + duration: Optional[int], + handles: Optional[str], + step: Optional[int], + comment: Optional[str], + source: Optional[str], ): self.version_id = version_id self.product_id = product_id @@ -148,6 +195,7 @@ class VersionItem: self.is_hero = is_hero self.published_time = published_time self.author = author + self.tags = tags self.status = status self.frame_range = frame_range self.duration = duration @@ -198,7 +246,7 @@ class VersionItem: def __le__(self, other): return self.__eq__(other) or self.__lt__(other) - def to_data(self): + def to_data(self) -> dict[str, Any]: return { "version_id": self.version_id, "product_id": self.product_id, @@ -208,6 +256,7 @@ class VersionItem: "is_hero": self.is_hero, "published_time": self.published_time, "author": self.author, + "tags": self.tags, "status": self.status, "frame_range": self.frame_range, "duration": self.duration, @@ -218,7 +267,7 @@ class VersionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data: dict[str, Any]) -> VersionItem: return cls(**data) @@ -354,8 +403,8 @@ class ProductTypesFilter: Defines the filtering for product types. """ - def __init__(self, product_types: List[str], is_allow_list: bool): - self.product_types: List[str] = product_types + def __init__(self, product_types: list[str], is_allow_list: bool): + self.product_types: list[str] = product_types self.is_allow_list: bool = is_allow_list @@ -517,8 +566,21 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[ProjectItem]: List of project items. - """ + """ + pass + + @abstractmethod + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + """Tag items defined on project anatomy. + + Args: + project_name (str): Project name. + + Returns: + list[TagItem]: Tag definition items. + + """ pass @abstractmethod @@ -542,7 +604,12 @@ class FrontendLoaderController(_BaseLoaderController): pass @abstractmethod - def get_task_items(self, project_name, folder_ids, sender=None): + def get_task_items( + self, + project_name: str, + folder_ids: Iterable[str], + sender: Optional[str] = None, + ) -> list[TaskItem]: """Task items for folder ids. Args: @@ -590,6 +657,21 @@ class FrontendLoaderController(_BaseLoaderController): """ pass + @abstractmethod + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + """Get available tags by entity type. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Available tags by entity type. + + """ + pass + @abstractmethod def get_project_status_items(self, project_name, sender=None): """Items for all projects available on server. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index b3a80b34d4..95f48b3519 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -13,6 +13,7 @@ from ayon_core.tools.common_models import ( ProjectsModel, HierarchyModel, ThumbnailsModel, + TagItem, ) from .abstract import ( @@ -223,6 +224,16 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output + def get_available_tags_by_entity_type( + self, project_name: str + ) -> dict[str, list[str]]: + return self._hierarchy_model.get_available_tags_by_entity_type( + project_name + ) + + def get_project_anatomy_tags(self, project_name: str) -> list[TagItem]: + return self._projects_model.get_project_anatomy_tags(project_name) + def get_product_items(self, project_name, folder_ids, sender=None): return self._products_model.get_product_items( project_name, folder_ids, sender) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index cfe91cadab..40331d73a4 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -322,7 +322,6 @@ class LoaderActionsModel: available_loaders = self._filter_loaders_by_tool_name( project_name, discover_loader_plugins(project_name) ) - repre_loaders = [] product_loaders = [] loaders_by_identifier = {} @@ -340,6 +339,7 @@ class LoaderActionsModel: loaders_by_identifier_c.update_data(loaders_by_identifier) product_loaders_c.update_data(product_loaders) repre_loaders_c.update_data(repre_loaders) + return product_loaders, repre_loaders def _get_loader_by_identifier(self, project_name, identifier): @@ -719,7 +719,12 @@ class LoaderActionsModel: loader, repre_contexts, options ) - def _load_representations_by_loader(self, loader, repre_contexts, options): + def _load_representations_by_loader( + self, + loader, + repre_contexts, + options + ): """Loops through list of repre_contexts and loads them with one loader Args: @@ -770,7 +775,12 @@ class LoaderActionsModel: )) return error_info - def _load_products_by_loader(self, loader, version_contexts, options): + def _load_products_by_loader( + self, + loader, + version_contexts, + options + ): """Triggers load with ProductLoader type of loaders. Warning: @@ -796,7 +806,6 @@ class LoaderActionsModel: version_contexts, options=options ) - except Exception as exc: formatted_traceback = None if not isinstance(exc, LoadError): diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 34acc0550c..edf8efc3b3 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -1,24 +1,34 @@ +"""Products model for loader tools.""" +from __future__ import annotations import collections import contextlib +from typing import TYPE_CHECKING, Iterable, Optional import arrow import ayon_api from ayon_api.operations import OperationsSession + from ayon_core.lib import NestedCacheItem from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.loader.abstract import ( ProductTypeItem, + ProductBaseTypeItem, ProductItem, VersionItem, RepreItem, ) +if TYPE_CHECKING: + from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict + + PRODUCTS_MODEL_SENDER = "products.model" def version_item_from_entity(version): version_attribs = version["attrib"] + tags = version["tags"] frame_start = version_attribs.get("frameStart") frame_end = version_attribs.get("frameEnd") handle_start = version_attribs.get("handleStart") @@ -59,6 +69,7 @@ def version_item_from_entity(version): thumbnail_id=version["thumbnailId"], published_time=published_time, author=author, + tags=tags, status=version["status"], frame_range=frame_range, duration=duration, @@ -70,9 +81,10 @@ def version_item_from_entity(version): def product_item_from_entity( - product_entity, + product_entity: ProductDict, version_entities, - product_type_items_by_name, + product_type_items_by_name: dict[str, ProductTypeItem], + product_base_type_items_by_name: dict[str, ProductBaseTypeItem], folder_label, product_in_scene, ): @@ -88,8 +100,20 @@ def product_item_from_entity( # Cache the item for future use product_type_items_by_name[product_type] = product_type_item - product_type_icon = product_type_item.icon + product_base_type = product_entity.get("productBaseType") + product_base_type_item = product_base_type_items_by_name.get( + product_base_type) + # Same as for product type item above. Not sure if this is still needed + # though. + if product_base_type_item is None: + product_base_type_item = create_default_product_base_type_item( + product_base_type) + # Cache the item for future use + product_base_type_items_by_name[product_base_type] = ( + product_base_type_item) + product_type_icon = product_type_item.icon + product_base_type_icon = product_base_type_item.icon product_icon = { "type": "awesome-font", "name": "fa.file-o", @@ -103,9 +127,11 @@ def product_item_from_entity( return ProductItem( product_id=product_entity["id"], product_type=product_type, + product_base_type=product_base_type, product_name=product_entity["name"], product_icon=product_icon, product_type_icon=product_type_icon, + product_base_type_icon=product_base_type_icon, product_in_scene=product_in_scene, group_name=group, folder_id=product_entity["folderId"], @@ -114,7 +140,8 @@ def product_item_from_entity( ) -def product_type_item_from_data(product_type_data): +def product_type_item_from_data( + product_type_data: ProductDict) -> ProductTypeItem: # TODO implement icon implementation # icon = product_type_data["icon"] # color = product_type_data["color"] @@ -127,7 +154,29 @@ def product_type_item_from_data(product_type_data): return ProductTypeItem(product_type_data["name"], icon) -def create_default_product_type_item(product_type): +def product_base_type_item_from_data( + product_base_type_data: ProductBaseTypeDict +) -> ProductBaseTypeItem: + """Create product base type item from data. + + Args: + product_base_type_data (ProductBaseTypeDict): Product base type data. + + Returns: + ProductBaseTypeDict: Product base type item. + + """ + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem( + name=product_base_type_data["name"], + icon=icon) + + +def create_default_product_type_item(product_type: str) -> ProductTypeItem: icon = { "type": "awesome-font", "name": "fa.folder", @@ -136,10 +185,28 @@ def create_default_product_type_item(product_type): return ProductTypeItem(product_type, icon) +def create_default_product_base_type_item( + product_base_type: str) -> ProductBaseTypeItem: + """Create default product base type item. + + Args: + product_base_type (str): Product base type name. + + Returns: + ProductBaseTypeItem: Default product base type item. + """ + icon = { + "type": "awesome-font", + "name": "fa.folder", + "color": "#0091B2", + } + return ProductBaseTypeItem(product_base_type, icon) + + class ProductsModel: """Model for products, version and representation. - All of the entities are product based. This model prepares data for UI + All the entities are product based. This model prepares data for UI and caches it for faster access. Note: @@ -161,6 +228,8 @@ class ProductsModel: # Cache helpers self._product_type_items_cache = NestedCacheItem( levels=1, default_factory=list, lifetime=self.lifetime) + self._product_base_type_items_cache = NestedCacheItem( + levels=1, default_factory=list, lifetime=self.lifetime) self._product_items_cache = NestedCacheItem( levels=2, default_factory=dict, lifetime=self.lifetime) self._repre_items_cache = NestedCacheItem( @@ -199,6 +268,36 @@ class ProductsModel: ]) return cache.get_data() + def get_product_base_type_items( + self, + project_name: Optional[str]) -> list[ProductBaseTypeItem]: + """Product base type items for the project. + + Args: + project_name (optional, str): Project name. + + Returns: + list[ProductBaseTypeDict]: Product base type items. + + """ + if not project_name: + return [] + + cache = self._product_base_type_items_cache[project_name] + if not cache.is_valid: + product_base_types = [] + # TODO add temp implementation here when it is actually + # implemented and available on server. + if hasattr(ayon_api, "get_project_product_base_types"): + product_base_types = ayon_api.get_project_product_base_types( + project_name + ) + cache.update_data([ + product_base_type_item_from_data(product_base_type) + for product_base_type in product_base_types + ]) + return cache.get_data() + def get_product_items(self, project_name, folder_ids, sender): """Product items with versions for project and folder ids. @@ -449,11 +548,12 @@ class ProductsModel: def _create_product_items( self, - project_name, - products, - versions, + project_name: str, + products: Iterable[ProductDict], + versions: Iterable[VersionDict], folder_items=None, product_type_items=None, + product_base_type_items: Optional[Iterable[ProductBaseTypeItem]] = None ): if folder_items is None: folder_items = self._controller.get_folder_items(project_name) @@ -461,6 +561,11 @@ class ProductsModel: if product_type_items is None: product_type_items = self.get_product_type_items(project_name) + if product_base_type_items is None: + product_base_type_items = self.get_product_base_type_items( + project_name + ) + loaded_product_ids = self._controller.get_loaded_product_ids() versions_by_product_id = collections.defaultdict(list) @@ -470,7 +575,13 @@ class ProductsModel: product_type_item.name: product_type_item for product_type_item in product_type_items } - output = {} + + product_base_type_items_by_name: dict[str, ProductBaseTypeItem] = { + product_base_type_item.name: product_base_type_item + for product_base_type_item in product_base_type_items + } + + output: dict[str, ProductItem] = {} for product in products: product_id = product["id"] folder_id = product["folderId"] @@ -484,6 +595,7 @@ class ProductsModel: product, versions, product_type_items_by_name, + product_base_type_items_by_name, folder_item.label, product_id in loaded_product_ids, ) diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 5a988ef4c2..b601cd95bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -84,15 +84,17 @@ def _get_options(action, action_item, parent): if not getattr(action, "optioned", False) or not options: return {} + dialog_title = action.label + " Options" if isinstance(options[0], AbstractAttrDef): qargparse_options = False - dialog = AttributeDefinitionsDialog(options, parent) + dialog = AttributeDefinitionsDialog( + options, title=dialog_title, parent=parent + ) else: qargparse_options = True dialog = OptionDialog(parent) dialog.create(options) - - dialog.setWindowTitle(action.label + " Options") + dialog.setWindowTitle(dialog_title) if not dialog.exec_(): return None diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py deleted file mode 100644 index 525f1cae1b..0000000000 --- a/client/ayon_core/tools/loader/ui/product_types_combo.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations -from qtpy import QtGui, QtCore - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3 - - -class ProductTypesQtModel(BaseQtModel): - refreshed = QtCore.Signal() - - def __init__(self, controller): - self._reset_filters_on_refresh = True - self._refreshing = False - self._bulk_change = False - self._items_by_name = {} - - super().__init__( - item_type_role=ITEM_TYPE_ROLE, - item_subtype_role=ITEM_SUBTYPE_ROLE, - empty_values_label="No product types...", - controller=controller, - ) - - def is_refreshing(self): - return self._refreshing - - def refresh(self, project_name): - self._refreshing = True - super().refresh(project_name) - - self._reset_filters_on_refresh = False - self._refreshing = False - self.refreshed.emit() - - def reset_product_types_filter_on_refresh(self): - self._reset_filters_on_refresh = True - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[ - list[QtGui.QStandardItem], list[QtGui.QStandardItem] - ]: - product_type_items = self._controller.get_product_type_items( - project_name) - self._last_project = project_name - - names_to_remove = set(self._items_by_name.keys()) - items = [] - items_filter_required = {} - for product_type_item in product_type_items: - name = product_type_item.name - names_to_remove.discard(name) - item = self._items_by_name.get(name) - # Apply filter to new items or if filters reset is requested - filter_required = self._reset_filters_on_refresh - if item is None: - filter_required = True - item = QtGui.QStandardItem(name) - item.setData(name, PRODUCT_TYPE_ROLE) - item.setEditable(False) - item.setCheckable(True) - self._items_by_name[name] = item - - items.append(item) - - if filter_required: - items_filter_required[name] = item - - if items_filter_required: - product_types_filter = self._controller.get_product_types_filter() - for product_type, item in items_filter_required.items(): - matching = ( - int(product_type in product_types_filter.product_types) - + int(product_types_filter.is_allow_list) - ) - item.setCheckState( - QtCore.Qt.Checked - if matching % 2 == 0 - else QtCore.Qt.Unchecked - ) - - items_to_remove = [] - for name in names_to_remove: - items_to_remove.append( - self._items_by_name.pop(name) - ) - - # Uncheck all if all are checked (same result) - if all( - item.checkState() == QtCore.Qt.Checked - for item in items - ): - for item in items: - item.setCheckState(QtCore.Qt.Unchecked) - - return items, items_to_remove - - -class ProductTypesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = ProductTypesQtModel(controller) - super().__init__( - PRODUCT_TYPE_ROLE, - PRODUCT_TYPE_ROLE, - QtCore.Qt.ForegroundRole, - QtCore.Qt.DecorationRole, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - - model.refreshed.connect(self._on_model_refresh) - - self.set_placeholder_text("Product types filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Product types filter") - self.value_changed.connect( - self._on_product_type_filter_change - ) - - def reset_product_types_filter_on_refresh(self): - self._model.reset_product_types_filter_on_refresh() - - def _on_model_refresh(self): - self.value_changed.emit() - - def _on_product_type_filter_change(self): - lines = ["Product types filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_product_type_filter_change() diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 8cece4687f..e78b32ceb1 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import numbers import uuid from typing import Dict @@ -18,16 +20,19 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_VERSION_ID_ROLE = QtCore.Qt.UserRole + 1 +COMBO_TASK_ID_ROLE = QtCore.Qt.UserRole + 2 +COMBO_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 3 +COMBO_VERSION_TAGS_ROLE = QtCore.Qt.UserRole + 4 +COMBO_TASK_TAGS_ROLE = QtCore.Qt.UserRole + 5 -class VersionsModel(QtGui.QStandardItemModel): +class ComboVersionsModel(QtGui.QStandardItemModel): def __init__(self): super().__init__() self._items_by_id = {} - def update_versions(self, version_items): + def update_versions(self, version_items, task_tags_by_version_id): version_ids = { version_item.version_id for version_item in version_items @@ -39,6 +44,7 @@ class VersionsModel(QtGui.QStandardItemModel): item = self._items_by_id.pop(item_id) root_item.removeRow(item.row()) + version_tags_by_version_id = {} for idx, version_item in enumerate(version_items): version_id = version_item.version_id @@ -48,34 +54,74 @@ class VersionsModel(QtGui.QStandardItemModel): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item - item.setData(version_item.status, STATUS_NAME_ROLE) - item.setData(version_item.task_id, TASK_ID_ROLE) + version_tags = set(version_item.tags) + task_tags = task_tags_by_version_id[version_id] + item.setData(version_id, COMBO_VERSION_ID_ROLE) + item.setData(version_item.status, COMBO_STATUS_NAME_ROLE) + item.setData(version_item.task_id, COMBO_TASK_ID_ROLE) + item.setData("|".join(version_tags), COMBO_VERSION_TAGS_ROLE) + item.setData("|".join(task_tags), COMBO_TASK_TAGS_ROLE) + version_tags_by_version_id[version_id] = set(version_item.tags) if item.row() != idx: root_item.insertRow(idx, item) -class VersionsFilterModel(QtCore.QSortFilterProxyModel): +class ComboVersionsFilterModel(QtCore.QSortFilterProxyModel): def __init__(self): super().__init__() self._status_filter = None self._task_ids_filter = None + self._version_tags_filter = None + self._task_tags_filter = None def filterAcceptsRow(self, row, parent): + index = None if self._status_filter is not None: if not self._status_filter: return False - - index = self.sourceModel().index(row, 0, parent) - status = index.data(STATUS_NAME_ROLE) + if index is None: + index = self.sourceModel().index(row, 0, parent) + status = index.data(COMBO_STATUS_NAME_ROLE) if status not in self._status_filter: return False if self._task_ids_filter: - index = self.sourceModel().index(row, 0, parent) - task_id = index.data(TASK_ID_ROLE) + if index is None: + index = self.sourceModel().index(row, 0, parent) + task_id = index.data(COMBO_TASK_ID_ROLE) if task_id not in self._task_ids_filter: return False + + if self._version_tags_filter is not None: + if not self._version_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + version_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + version_tags = set() + if version_tags_s: + version_tags = set(version_tags_s.split("|")) + + if not version_tags & self._version_tags_filter: + return False + + if self._task_tags_filter is not None: + if not self._task_tags_filter: + return False + + if index is None: + model = self.sourceModel() + index = model.index(row, 0, parent) + task_tags_s = index.data(COMBO_TASK_TAGS_ROLE) + task_tags = set() + if task_tags_s: + task_tags = set(task_tags_s.split("|")) + if not (task_tags & self._task_tags_filter): + return False + return True def set_tasks_filter(self, task_ids): @@ -84,12 +130,24 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): self._task_ids_filter = task_ids self.invalidateFilter() + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def set_statuses_filter(self, status_names): if self._status_filter == status_names: return self._status_filter = status_names self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: + return + self._version_tags_filter = tags + self.invalidateFilter() + class VersionComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal(str, str) @@ -97,8 +155,8 @@ class VersionComboBox(QtWidgets.QComboBox): def __init__(self, product_id, parent): super().__init__(parent) - versions_model = VersionsModel() - proxy_model = VersionsFilterModel() + versions_model = ComboVersionsModel() + proxy_model = ComboVersionsFilterModel() proxy_model.setSourceModel(versions_model) self.setModel(proxy_model) @@ -123,6 +181,13 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_task_tags_filter(self, tags): + self._proxy_model.set_task_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def set_statuses_filter(self, status_names): self._proxy_model.set_statuses_filter(status_names) if self.count() == 0: @@ -130,12 +195,24 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def set_version_tags_filter(self, tags): + self._proxy_model.set_version_tags_filter(tags) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + def all_versions_filtered_out(self): if self._items_by_id: return self.count() == 0 return False - def update_versions(self, version_items, current_version_id): + def update_versions( + self, + version_items, + current_version_id, + task_tags_by_version_id, + ): self.blockSignals(True) version_items = list(version_items) version_ids = [ @@ -146,7 +223,9 @@ class VersionComboBox(QtWidgets.QComboBox): current_version_id = version_ids[0] self._current_id = current_version_id - self._versions_model.update_versions(version_items) + self._versions_model.update_versions( + version_items, task_tags_by_version_id + ) index = version_ids.index(current_version_id) if self.currentIndex() != index: @@ -173,6 +252,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id: Dict[str, VersionComboBox] = {} self._task_ids_filter = None self._statuses_filter = None + self._version_tags_filter = None + self._task_tags_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): @@ -185,10 +266,26 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): widget.set_tasks_filter(task_ids) def set_statuses_filter(self, status_names): - self._statuses_filter = set(status_names) + if status_names is not None: + status_names = set(status_names) + self._statuses_filter = status_names for widget in self._editor_by_id.values(): widget.set_statuses_filter(status_names) + def set_version_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._version_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_version_tags_filter(tags) + + def set_task_tags_filter(self, tags): + if tags is not None: + tags = set(tags) + self._task_tags_filter = tags + for widget in self._editor_by_id.values(): + widget.set_task_tags_filter(tags) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -200,7 +297,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() @@ -263,11 +360,22 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor.clear() # Current value of the index - versions = index.data(VERSION_NAME_EDIT_ROLE) or [] + product_id = index.data(PRODUCT_ID_ROLE) version_id = index.data(VERSION_ID_ROLE) + model = index.model() + while hasattr(model, "sourceModel"): + model = model.sourceModel() + versions = model.get_version_items_by_product_id(product_id) + task_tags_by_version_id = { + version_item.version_id: model.get_task_tags_by_id( + version_item.task_id + ) + for version_item in versions + } - editor.update_versions(versions, version_id) + editor.update_versions(versions, version_id, task_tags_by_version_id) editor.set_tasks_filter(self._task_ids_filter) + editor.set_task_tags_filter(self._task_tags_filter) editor.set_statuses_filter(self._statuses_filter) def setModelData(self, editor, model, index): diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index cebae9bca7..f3e5271f51 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -16,31 +16,34 @@ TASK_ID_ROLE = QtCore.Qt.UserRole + 5 PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 -PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9 -PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10 -VERSION_ID_ROLE = QtCore.Qt.UserRole + 11 -VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12 -VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13 -VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14 -VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15 -VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16 -VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17 -VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18 -VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19 -VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20 -VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21 -VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22 -VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23 -VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24 -VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25 -VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26 -ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27 -REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 -REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29 -SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 -SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +PRODUCT_BASE_TYPE_ROLE = QtCore.Qt.UserRole + 9 +PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 10 +PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 11 +VERSION_ID_ROLE = QtCore.Qt.UserRole + 12 +VERSION_HERO_ROLE = QtCore.Qt.UserRole + 13 +VERSION_NAME_ROLE = QtCore.Qt.UserRole + 14 +VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 15 +VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 16 +VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 17 +VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 18 +VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 19 +VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 20 +VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 21 +VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 22 +VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 23 +VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 24 +VERSION_STEP_ROLE = QtCore.Qt.UserRole + 25 +VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 26 +VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 27 +ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28 +REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 29 +REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 30 +SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31 +SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 32 -STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 33 +TASK_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 34 +VERSION_TAGS_FILTER_ROLE = QtCore.Qt.UserRole + 35 class ProductsModel(QtGui.QStandardItemModel): @@ -49,6 +52,7 @@ class ProductsModel(QtGui.QStandardItemModel): column_labels = [ "Product name", "Product type", + "Product base type", "Folder", "Version", "Status", @@ -79,6 +83,7 @@ class ProductsModel(QtGui.QStandardItemModel): product_name_col = column_labels.index("Product name") product_type_col = column_labels.index("Product type") + product_base_type_col = column_labels.index("Product base type") folders_label_col = column_labels.index("Folder") version_col = column_labels.index("Version") status_col = column_labels.index("Status") @@ -93,6 +98,7 @@ class ProductsModel(QtGui.QStandardItemModel): _display_role_mapping = { product_name_col: QtCore.Qt.DisplayRole, product_type_col: PRODUCT_TYPE_ROLE, + product_base_type_col: PRODUCT_BASE_TYPE_ROLE, folders_label_col: FOLDER_LABEL_ROLE, version_col: VERSION_NAME_ROLE, status_col: VERSION_STATUS_NAME_ROLE, @@ -130,6 +136,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids = [] self._last_project_statuses = {} self._last_status_icons_by_name = {} + self._last_task_tags_by_task_id = {} def get_product_item_indexes(self): return [ @@ -170,6 +177,17 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_folder_ids ) + def get_task_tags_by_id(self, task_id): + return self._last_task_tags_by_task_id.get(task_id, set()) + + def get_version_items_by_product_id(self, product_id: str): + product_item = self._product_items_by_id.get(product_id) + if product_item is None: + return None + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items + def flags(self, index): # Make the version column editable if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE): @@ -224,9 +242,9 @@ class ProductsModel(QtGui.QStandardItemModel): product_item = self._product_items_by_id.get(product_id) if product_item is None: return None - product_items = list(product_item.version_items.values()) - product_items.sort(reverse=True) - return product_items + version_items = list(product_item.version_items.values()) + version_items.sort(reverse=True) + return version_items if role == QtCore.Qt.EditRole: return None @@ -422,6 +440,16 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.status for version_item in product_item.version_items.values() } + version_tags = set() + task_tags = set() + for version_item in product_item.version_items.values(): + version_tags |= set(version_item.tags) + _task_tags = self._last_task_tags_by_task_id.get( + version_item.task_id + ) + if _task_tags: + task_tags |= set(_task_tags) + if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) @@ -432,6 +460,9 @@ class ProductsModel(QtGui.QStandardItemModel): model_item.setData(icon, QtCore.Qt.DecorationRole) model_item.setData(product_id, PRODUCT_ID_ROLE) model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE) + model_item.setData( + product_item.product_base_type, PRODUCT_BASE_TYPE_ROLE + ) model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE) model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) model_item.setData(product_item.folder_id, FOLDER_ID_ROLE) @@ -440,6 +471,8 @@ class ProductsModel(QtGui.QStandardItemModel): self._items_by_id[product_id] = model_item model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) + model_item.setData("|".join(version_tags), VERSION_TAGS_FILTER_ROLE) + model_item.setData("|".join(task_tags), TASK_TAGS_FILTER_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) @@ -470,6 +503,14 @@ class ProductsModel(QtGui.QStandardItemModel): } self._last_status_icons_by_name = {} + task_items = self._controller.get_task_items( + project_name, folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME + ) + self._last_task_tags_by_task_id = { + task_item.task_id: task_item.tags + for task_item in task_items + } + active_site_icon_def = self._controller.get_active_site_icon_def( project_name ) @@ -484,6 +525,7 @@ class ProductsModel(QtGui.QStandardItemModel): folder_ids, sender=PRODUCTS_MODEL_SENDER_NAME ) + product_items_by_id = { product_item.product_id: product_item for product_item in product_items diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 94d95b9026..f1c82ee83d 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -4,6 +4,7 @@ from typing import Optional from qtpy import QtWidgets, QtCore +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.utils import ( RecursiveSortFilterProxyModel, DeselectableTreeView, @@ -26,6 +27,8 @@ from .products_model import ( VERSION_STATUS_ICON_ROLE, VERSION_THUMBNAIL_ID_ROLE, STATUS_NAME_FILTER_ROLE, + VERSION_TAGS_FILTER_ROLE, + TASK_TAGS_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -41,6 +44,8 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._product_type_filters = None self._statuses_filter = None + self._version_tags_filter = None + self._task_tags_filter = None self._task_ids_filter = None self._ascending_sort = True @@ -67,6 +72,18 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): self._statuses_filter = statuses_filter self.invalidateFilter() + def set_version_tags_filter(self, tags): + if self._version_tags_filter == tags: + return + self._version_tags_filter = tags + self.invalidateFilter() + + def set_task_tags_filter(self, tags): + if self._task_tags_filter == tags: + return + self._task_tags_filter = tags + self.invalidateFilter() + def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) @@ -83,6 +100,16 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): ): return False + if not self._accept_row_by_role_value( + index, self._version_tags_filter, VERSION_TAGS_FILTER_ROLE + ): + return False + + if not self._accept_row_by_role_value( + index, self._task_tags_filter, TASK_TAGS_FILTER_ROLE + ): + return False + return super().filterAcceptsRow(source_row, source_parent) def _accept_task_ids_filter(self, index): @@ -102,10 +129,11 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): if not filter_value: return False - status_s = index.data(role) - for status in status_s.split("|"): - if status in filter_value: - return True + value_s = index.data(role) + if value_s: + for value in value_s.split("|"): + if value in filter_value: + return True return False def lessThan(self, left, right): @@ -142,6 +170,7 @@ class ProductsWidget(QtWidgets.QWidget): default_widths = ( 200, # Product name 90, # Product type + 90, # Product base type 130, # Folder label 60, # Version 100, # Status @@ -261,6 +290,12 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.is_sitesync_enabled() ) + if not is_product_base_type_supported(): + # Hide product base type column + products_view.setColumnHidden( + products_model.product_base_type_col, True + ) + def set_name_filter(self, name): """Set filter of product name. @@ -290,6 +325,14 @@ class ProductsWidget(QtWidgets.QWidget): self._version_delegate.set_statuses_filter(status_names) self._products_proxy_model.set_statuses_filter(status_names) + def set_version_tags_filter(self, version_tags): + self._version_delegate.set_version_tags_filter(version_tags) + self._products_proxy_model.set_version_tags_filter(version_tags) + + def set_task_tags_filter(self, task_tags): + self._version_delegate.set_task_tags_filter(task_tags) + self._products_proxy_model.set_task_tags_filter(task_tags) + def set_product_type_filter(self, product_type_filters): """ diff --git a/client/ayon_core/tools/loader/ui/search_bar.py b/client/ayon_core/tools/loader/ui/search_bar.py new file mode 100644 index 0000000000..ab673df1ac --- /dev/null +++ b/client/ayon_core/tools/loader/ui/search_bar.py @@ -0,0 +1,1122 @@ +import copy +import uuid +from dataclasses import dataclass +from typing import Any, Optional + +from qtpy import QtCore, QtWidgets, QtGui + +from ayon_core.style import get_objected_colors +from ayon_core.tools.utils import ( + get_qt_icon, + SquareButton, + BaseClickableFrame, + PixmapLabel, + SeparatorWidget, +) + + +def set_line_edit_focus( + widget: QtWidgets.QLineEdit, + *, + append_text: Optional[str] = None, + backspace: bool = False, +): + full_text = widget.text() + if backspace and full_text: + full_text = full_text[:-1] + + if append_text: + full_text += append_text + widget.setText(full_text) + widget.setFocus() + widget.setCursorPosition(len(full_text)) + + +@dataclass +class FilterDefinition: + """Search bar definition. + + Attributes: + name (str): Name of the definition. + title (str): Title of the search bar. + icon (str): Icon name for the search bar. + placeholder (str): Placeholder text for the search bar. + + """ + name: str + title: str + filter_type: str + icon: Optional[dict[str, Any]] = None + placeholder: Optional[str] = None + items: Optional[list[dict[str, str]]] = None + + +class CloseButton(SquareButton): + """Close button for search item display widget.""" + _icon = None + _hover_color = None + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.__class__._icon is None: + self.__class__._icon = get_qt_icon({ + "type": "material-symbols", + "name": "close", + "color": "#FFFFFF", + }) + if self.__class__._hover_color is None: + color = get_objected_colors("bg-view-selection-hover") + self.__class__._hover_color = color.get_qcolor() + + self.setIcon(self.__class__._icon) + + def paintEvent(self, event): + """Override paint event to draw a close button.""" + painter = QtWidgets.QStylePainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + option = QtWidgets.QStyleOptionButton() + self.initStyleOption(option) + icon = self.icon() + size = min(self.width(), self.height()) + rect = QtCore.QRect(0, 0, size, size) + rect.adjust(2, 2, -2, -2) + painter.setPen(QtCore.Qt.NoPen) + bg_color = QtCore.Qt.transparent + if option.state & QtWidgets.QStyle.State_MouseOver: + bg_color = self._hover_color + + painter.setBrush(bg_color) + painter.setClipRect(event.rect()) + painter.drawEllipse(rect) + rect.adjust(2, 2, -2, -2) + icon.paint(painter, rect) + + +class SearchItemDisplayWidget(BaseClickableFrame): + """Widget displaying a set filter in the bar.""" + close_requested = QtCore.Signal(str) + edit_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(f"{filter_def.title}:", self) + + value_wrapper = QtWidgets.QWidget(self) + value_wrapper.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + value_widget = QtWidgets.QLabel(value_wrapper) + value_widget.setObjectName("ValueWidget") + value_widget.setText("") + value_layout = QtWidgets.QVBoxLayout(value_wrapper) + value_layout.setContentsMargins(2, 2, 2, 2) + value_layout.addWidget(value_widget) + + close_btn = CloseButton(self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(title_widget, 0) + main_layout.addWidget(value_wrapper, 0) + main_layout.addWidget(close_btn, 0) + + close_btn.clicked.connect(self._on_remove_clicked) + + self._value_wrapper = value_wrapper + self._value_widget = value_widget + self._value = None + + def set_value(self, value: "str | list[str]"): + text = "" + ellide = True + if value is None: + pass + elif isinstance(value, str): + text = value + elif len(value) == 1: + text = value[0] + elif len(value) > 1: + ellide = False + text = f"Items: {len(value)}" + + if ellide and len(text) > 9: + text = text[:9] + "..." + + text = " " + text + " " + + self._value = copy.deepcopy(value) + self._value_widget.setText(text) + + def get_value(self): + return copy.deepcopy(self._value) + + def _on_remove_clicked(self): + self.close_requested.emit(self._filter_def.name) + + def _mouse_release_callback(self): + self.edit_requested.emit(self._filter_def.name) + + +class FilterItemButton(BaseClickableFrame): + filter_requested = QtCore.Signal(str) + + def __init__( + self, + filter_def: FilterDefinition, + parent: QtWidgets.QWidget, + ): + super().__init__(parent) + + self._filter_def = filter_def + + title_widget = QtWidgets.QLabel(filter_def.title, self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + self._title_widget = title_widget + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.filter_requested.emit(self._filter_def.name) + + +class FiltersPopup(QtWidgets.QWidget): + filter_requested = QtCore.Signal(str) + text_filter_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(2, 2, 2, 2) + main_layout.addWidget(wrapper) + + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._preferred_width = None + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close() + return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + self.text_filter_requested.emit("") + event.accept() + return + + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + self.text_filter_requested.emit(text) + return + super().keyPressEvent(event) + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_filter_items(self, filter_items): + while self._wrapper_layout.count() > 0: + item = self._wrapper_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + for item in filter_items: + widget = FilterItemButton(item, self._wrapper) + widget.filter_requested.connect(self.filter_requested) + self._wrapper_layout.addWidget(widget, 0) + + if self._wrapper_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No filters available...", self._wrapper + ) + self._wrapper_layout.addWidget(empty_label, 0) + + def showEvent(self, event): + super().showEvent(event) + self._update_shadow() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + + +class FilterValueItemButton(BaseClickableFrame): + selected = QtCore.Signal(str) + + def __init__(self, widget_id, value, icon, color, parent): + super().__init__(parent) + + title_widget = QtWidgets.QLabel(str(value), self) + if color: + title_widget.setStyleSheet(f"color: {color};") + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(5, 5, 5, 5) + main_layout.addWidget(title_widget, 1) + + self._icon_widget = None + self._title_widget = title_widget + self._main_layout = main_layout + self._selected = False + self._value = value + self._widget_id = widget_id + + if icon: + self.set_icon(icon) + + def set_icon(self, icon: dict[str, Any]): + """Set the icon for the widget.""" + icon = get_qt_icon(icon) + pixmap = icon.pixmap(64, 64) + if self._icon_widget is None: + self._icon_widget = PixmapLabel(pixmap, self) + self._main_layout.insertWidget(0, self._icon_widget, 0) + else: + self._icon_widget.setPixmap(pixmap) + + def get_value(self): + return self._value + + def set_selected(self, selected: bool): + """Set the selection state of the widget.""" + if self._selected == selected: + return + self._selected = selected + self.setProperty("selected", "1" if selected else "") + self.style().polish(self) + + def is_selected(self) -> bool: + return self._selected + + def _mouse_release_callback(self): + """Handle mouse release event to emit filter request.""" + self.selected.emit(self._widget_id) + + +class FilterValueTextInput(QtWidgets.QWidget): + back_requested = QtCore.Signal() + value_changed = QtCore.Signal(str) + close_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + text_input = QtWidgets.QLineEdit(self) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(text_input, 0) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + text_input.textChanged.connect(self.value_changed) + text_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + confirm_btn.clicked.connect(self.close_requested) + + self._filter_timeout = filter_timeout + self._text_input = text_input + + def showEvent(self, event): + super().showEvent(event) + + self._filter_timeout.start() + + def get_value(self) -> str: + return self._text_input.text() + + def set_value(self, value: str): + self._text_input.setText(value) + + def set_placeholder_text(self, placeholder_text: str): + self._text_input.setPlaceholderText(placeholder_text) + + def set_text_filter(self, text: str): + kwargs = {} + if text: + kwargs["append_text"] = text + else: + kwargs["backspace"] = True + + set_line_edit_focus(self._text_input, **kwargs) + + def _on_filter_timeout(self): + set_line_edit_focus(self._text_input) + + +class FilterValueItemsView(QtWidgets.QWidget): + value_changed = QtCore.Signal() + close_requested = QtCore.Signal() + back_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + filter_input = QtWidgets.QLineEdit(self) + filter_input.setPlaceholderText("Filter items...") + + # Timeout is used to delay the filter focus change on 'showEvent' + # - the focus is changed to something else if is not delayed + filter_timeout = QtCore.QTimer(self) + filter_timeout.setSingleShot(True) + filter_timeout.setInterval(20) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setObjectName("ScrollArea") + srcoll_viewport = scroll_area.viewport() + srcoll_viewport.setContentsMargins(0, 0, 0, 0) + scroll_area.setWidgetResizable(True) + scroll_area.setMinimumHeight(20) + scroll_area.setMaximumHeight(400) + + content_widget = QtWidgets.QWidget(scroll_area) + content_widget.setObjectName("ContentWidget") + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + scroll_area.setWidget(content_widget) + + btns_sep = SeparatorWidget(size=1, parent=self) + btns_widget = QtWidgets.QWidget(self) + btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + back_btn = QtWidgets.QPushButton("Back", btns_widget) + back_btn.setObjectName("BackButton") + back_btn.setIcon(get_qt_icon({ + "type": "material-symbols", + "name": "arrow_back", + })) + + select_all_btn = QtWidgets.QPushButton("Select all", btns_widget) + clear_btn = QtWidgets.QPushButton("Clear", btns_widget) + swap_btn = QtWidgets.QPushButton("Invert", btns_widget) + + confirm_btn = QtWidgets.QPushButton("Confirm", btns_widget) + confirm_btn.setObjectName("ConfirmButton") + confirm_btn.clicked.connect(self.close_requested) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(back_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(select_all_btn, 0) + btns_layout.addWidget(clear_btn, 0) + btns_layout.addWidget(swap_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(filter_input, 0) + main_layout.addWidget(scroll_area) + main_layout.addWidget(btns_sep, 0) + main_layout.addWidget(btns_widget, 0) + + filter_timeout.timeout.connect(self._on_filter_timeout) + filter_input.textChanged.connect(self._on_filter_change) + filter_input.returnPressed.connect(self.close_requested) + back_btn.clicked.connect(self.back_requested) + select_all_btn.clicked.connect(self._on_select_all) + clear_btn.clicked.connect(self._on_clear_selection) + swap_btn.clicked.connect(self._on_swap_selection) + + self._filter_timeout = filter_timeout + self._filter_input = filter_input + self._btns_widget = btns_widget + self._multiselection = False + self._content_layout = content_layout + self._last_selected_widget = None + self._widgets_by_id = {} + + def showEvent(self, event): + super().showEvent(event) + self._filter_timeout.start() + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + event.accept() + self.close_requested.emit() + return + + if event.key() in ( + QtCore.Qt.Key_Backtab, + QtCore.Qt.Key_Backspace, + ): + event.accept() + set_line_edit_focus(self._filter_input, backspace=True) + return + + valid_modifiers = event.modifiers() in ( + QtCore.Qt.NoModifier, + QtCore.Qt.ShiftModifier, + ) + if valid_modifiers and event.key() not in ( + QtCore.Qt.Key_Escape, + QtCore.Qt.Key_Tab, + QtCore.Qt.Key_Return, + ): + text = event.text() + if text: + event.accept() + set_line_edit_focus(self._filter_input, append_text=text) + return + + super().keyPressEvent(event) + + def set_value(self, value): + current_value = self.get_value() + if self._multiselection: + if value is None: + value = [] + if not isinstance(value, list): + value = [value] + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + widget.set_selected(selected) + + if value != current_value: + self.value_changed.emit() + return + + if isinstance(value, list): + if len(value) > 0: + value = value[0] + else: + value = None + + if value is None: + widget = next(iter(self._widgets_by_id.values())) + value = widget.get_value() + + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = widget.get_value() in value + widget.set_selected(selected) + if selected: + self._last_selected_widget = widget + + if self._last_selected_widget is None: + widget = next(iter(self._widgets_by_id.values())) + self._last_selected_widget = widget + widget.set_selected(True) + + if value != current_value: + self.value_changed.emit() + + def set_multiselection(self, multiselection: bool): + self._multiselection = multiselection + if not self._widgets_by_id or not self._multiselection: + self._btns_widget.setVisible(False) + else: + self._btns_widget.setVisible(True) + + if not self._widgets_by_id or self._multiselection: + return + + value_changed = False + if self._last_selected_widget is None: + value_changed = True + self._last_selected_widget = next( + iter(self._widgets_by_id.values()) + ) + for widget in self._widgets_by_id.values(): + widget.set_selected(widget is self._last_selected_widget) + + if value_changed: + self.value_changed.emit() + + def get_value(self): + """Get the value from the items view.""" + if self._multiselection: + return [ + widget.get_value() + for widget in self._widgets_by_id.values() + if widget.is_selected() + ] + if self._last_selected_widget is not None: + return self._last_selected_widget.get_value() + return None + + def set_items(self, items: list[dict[str, Any]]): + while self._content_layout.count() > 0: + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.setVisible(False) + widget.deleteLater() + + self._widgets_by_id = {} + self._last_selected_widget = None + # Change filter + self._filter_input.setText("") + + for item in items: + widget_id = uuid.uuid4().hex + widget = FilterValueItemButton( + widget_id, + item["value"], + item.get("icon"), + item.get("color"), + self, + ) + widget.selected.connect(self._on_item_clicked) + self._widgets_by_id[widget_id] = widget + self._content_layout.addWidget(widget, 0) + + if self._content_layout.count() == 0: + empty_label = QtWidgets.QLabel( + "No items to select from...", self + ) + self._btns_widget.setVisible(False) + self._filter_input.setVisible(False) + self._content_layout.addWidget(empty_label, 0) + else: + self._filter_input.setVisible(True) + self._btns_widget.setVisible(self._multiselection) + self._content_layout.addStretch(1) + + def _on_filter_timeout(self): + self._filter_input.setFocus() + + def _on_filter_change(self, text): + text = text.lower() + for widget in self._widgets_by_id.values(): + visible = not text or text in widget.get_value().lower() + widget.setVisible(visible) + + def _on_select_all(self): + changed = False + for widget in self._widgets_by_id.values(): + if not widget.is_selected(): + changed = True + widget.set_selected(True) + if self._last_selected_widget is None: + self._last_selected_widget = widget + + if changed: + self.value_changed.emit() + + def _on_swap_selection(self): + self._last_selected_widget = None + for widget in self._widgets_by_id.values(): + selected = not widget.is_selected() + widget.set_selected(selected) + if selected and self._last_selected_widget is None: + self._last_selected_widget = widget + + self.value_changed.emit() + + def _on_clear_selection(self): + self._last_selected_widget = None + changed = False + for widget in self._widgets_by_id.values(): + if widget.is_selected(): + changed = True + widget.set_selected(False) + + if changed: + self.value_changed.emit() + + def _on_item_clicked(self, widget_id): + widget = self._widgets_by_id.get(widget_id) + if widget is None: + return + + previous_widget = self._last_selected_widget + self._last_selected_widget = widget + if self._multiselection: + widget.set_selected(not widget.is_selected()) + else: + widget.set_selected(True) + if previous_widget is not None: + previous_widget.set_selected(False) + self.value_changed.emit() + + +class FilterValuePopup(QtWidgets.QWidget): + value_changed = QtCore.Signal(str) + closed = QtCore.Signal(str) + back_requested = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + self.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + shadow_frame = QtWidgets.QFrame(self) + shadow_frame.setObjectName("ShadowFrame") + + wrapper = QtWidgets.QWidget(self) + wrapper.setObjectName("PopupWrapper") + + text_input = FilterValueTextInput(wrapper) + text_input.setVisible(False) + + items_view = FilterValueItemsView(wrapper) + items_view.setVisible(False) + + wraper_layout = QtWidgets.QVBoxLayout(wrapper) + wraper_layout.setContentsMargins(5, 5, 5, 5) + wraper_layout.setSpacing(5) + wraper_layout.addWidget(text_input, 0) + wraper_layout.addWidget(items_view, 0) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(2, 2, 2, 2) + main_layout.addWidget(wrapper) + + text_input.value_changed.connect(self._text_changed) + text_input.close_requested.connect(self._close_requested) + text_input.back_requested.connect(self._back_requested) + + items_view.value_changed.connect(self._selection_changed) + items_view.close_requested.connect(self._close_requested) + items_view.back_requested.connect(self._back_requested) + + shadow_frame.stackUnder(wrapper) + + self._shadow_frame = shadow_frame + self._wrapper = wrapper + self._wrapper_layout = wraper_layout + self._text_input = text_input + self._items_view = items_view + + self._active_widget = None + self._filter_name = None + self._preferred_width = None + + def set_preferred_width(self, width: int): + self._preferred_width = width + + def sizeHint(self): + sh = super().sizeHint() + if self._preferred_width is not None: + sh.setWidth(self._preferred_width) + return sh + + def set_text_filter(self, text: str): + if self._active_widget is None: + return + + if self._active_widget is self._text_input: + self._active_widget.set_text_filter(text) + + def set_filter_item( + self, + filter_def: FilterDefinition, + value, + ): + self._text_input.setVisible(False) + self._items_view.setVisible(False) + self._filter_name = filter_def.name + self._active_widget = None + if filter_def.filter_type == "text": + if filter_def.items: + if value is None: + value = filter_def.items[0]["value"] + self._active_widget = self._items_view + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(False) + self._items_view.set_value(value) + else: + if value is None: + value = "" + self._text_input.set_placeholder_text( + filter_def.placeholder or "" + ) + self._text_input.set_value(value) + self._active_widget = self._text_input + + elif filter_def.filter_type == "list": + if value is None: + value = [] + self._items_view.set_items(filter_def.items) + self._items_view.set_multiselection(True) + self._items_view.set_value(value) + self._active_widget = self._items_view + + if self._active_widget is not None: + self._active_widget.setVisible(True) + + def showEvent(self, event): + super().showEvent(event) + if self._active_widget is not None: + self._active_widget.setFocus() + self._update_shadow() + + def closeEvent(self, event): + super().closeEvent(event) + self.closed.emit(self._filter_name) + + def hideEvent(self, event): + super().hideEvent(event) + self.closed.emit(self._filter_name) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_shadow() + + def _update_shadow(self): + geo = self.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + self._shadow_frame.setGeometry(geo) + + def get_value(self): + """Get the value from the active widget.""" + if self._active_widget is self._text_input: + return self._text_input.get_value() + elif self._active_widget is self._items_view: + return self._active_widget.get_value() + return None + + def _text_changed(self): + """Handle text change in the text input.""" + if self._active_widget is self._text_input: + # Emit value changed signal if text input is active + self.value_changed.emit(self._filter_name) + + def _selection_changed(self): + self.value_changed.emit(self._filter_name) + + def _close_requested(self): + self.close() + + def _back_requested(self): + self.back_requested.emit(self._filter_name) + self.close() + + +class FiltersBar(BaseClickableFrame): + filter_changed = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + search_icon = get_qt_icon({ + "type": "material-symbols", + "name": "search", + "color": "#FFFFFF", + }) + search_btn = SquareButton(self) + search_btn.setIcon(search_icon) + search_btn.setFlat(True) + search_btn.setObjectName("SearchButton") + + # Wrapper is used to avoid squashing filters + # - the filters are positioned manually without layout + filters_wrap = QtWidgets.QWidget(self) + filters_wrap.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + + # Widget where set filters are displayed + filters_widget = QtWidgets.QWidget(filters_wrap) + filters_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + filters_layout = QtWidgets.QHBoxLayout(filters_widget) + filters_layout.setContentsMargins(0, 0, 0, 0) + filters_layout.addStretch(1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + main_layout.setSpacing(5) + main_layout.addWidget(search_btn, 0) + main_layout.addWidget(filters_wrap, 1) + + filters_popup = FiltersPopup(self) + filter_value_popup = FilterValuePopup(self) + + search_btn.clicked.connect(self._on_filters_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) + + self._search_btn = search_btn + self._filters_wrap = filters_wrap + self._filters_widget = filters_widget + self._filters_layout = filters_layout + self._widgets_by_name = {} + self._filter_defs_by_name = {} + self._filters_popup = filters_popup + self._filter_value_popup = filter_value_popup + + def showEvent(self, event): + super().showEvent(event) + self._update_filters_geo() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_filters_geo() + + def wheelEvent(self, event): + scroll_speed = 15 + diff = event.angleDelta().y() / 120.0 + pos_x = self._filters_widget.pos().x() + if diff > 0: + pos_x = min(0, pos_x + scroll_speed) + self._filters_widget.move(pos_x, 0) + return + + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + return + pos_x = max( + pos_x - scroll_speed, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + + def show_filters_popup(self): + filter_defs = [ + filter_def + for filter_def in self._filter_defs_by_name.values() + if filter_def.name not in self._widgets_by_name + ] + filters_popup = FiltersPopup(self) + filters_popup.filter_requested.connect(self._on_filter_request) + filters_popup.text_filter_requested.connect( + self._on_text_filter_request + ) + filters_popup.set_filter_items(filter_defs) + filters_popup.set_preferred_width(self.width()) + + old_popup, self._filters_popup = self._filters_popup, filters_popup + + self._filter_value_popup.setVisible(False) + old_popup.setVisible(False) + old_popup.deleteLater() + + self._show_popup(filters_popup) + + def set_search_items(self, filter_defs: list[FilterDefinition]): + self._filter_defs_by_name = { + filter_def.name: filter_def + for filter_def in filter_defs + } + + def get_filter_value(self, name: str) -> Optional[Any]: + """Get the value of a filter by its name.""" + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + value = item_widget.get_value() + if isinstance(value, list) and len(value) == 0: + return None + return value + return None + + def set_filter_value(self, name: str, value: Any): + """Set the value of a filter by its name.""" + if name not in self._filter_defs_by_name: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is None: + self.add_item(name) + item_widget = self._widgets_by_name.get(name) + + item_widget.set_value(value) + self.filter_changed.emit(name) + + def add_item(self, name: str): + """Add a new item to the search bar. + + Args: + name (str): Search definition name. + + """ + filter_def = self._filter_defs_by_name.get(name) + if filter_def is None: + return + + item_widget = self._widgets_by_name.get(name) + if item_widget is not None: + return + + item_widget = SearchItemDisplayWidget( + filter_def, + parent=self._filters_widget, + ) + item_widget.edit_requested.connect(self._on_filter_request) + item_widget.close_requested.connect(self._on_item_close_requested) + self._widgets_by_name[name] = item_widget + idx = self._filters_layout.count() - 1 + self._filters_layout.insertWidget(idx, item_widget, 0) + + def _update_filters_geo(self): + geo = self._filters_wrap.geometry() + geo.moveTopLeft(QtCore.QPoint(0, 0)) + # Arbitrary width + geo.setWidth(3000) + + self._filters_widget.setGeometry(geo) + + def _reposition_filters_widget(self): + rect = self._filters_wrap.rect() + size_hint = self._filters_widget.sizeHint() + if size_hint.width() < rect.width(): + self._filters_widget.move(0, 0) + return + pos_x = self._filters_widget.pos().x() + pos_x = max( + pos_x, rect.width() - size_hint.width() + ) + self._filters_widget.move(pos_x, 0) + + def _mouse_release_callback(self): + self.show_filters_popup() + + def _on_filters_request(self): + self.show_filters_popup() + + def _on_text_filter_request(self, text: str): + if "product_name" not in self._filter_defs_by_name: + return + + self._on_filter_request("product_name") + self._filter_value_popup.set_text_filter(text) + + def _on_filter_request(self, filter_name: str): + """Handle filter request from the popup.""" + self.add_item(filter_name) + self._filters_popup.hide() + filter_def = self._filter_defs_by_name.get(filter_name) + widget = self._widgets_by_name.get(filter_name) + value = None + if widget is not None: + value = widget.get_value() + + filter_value_popup = FilterValuePopup(self) + filter_value_popup.set_preferred_width(self.width()) + filter_value_popup.set_filter_item(filter_def, value) + filter_value_popup.value_changed.connect(self._on_filter_value_change) + filter_value_popup.closed.connect(self._on_filter_value_closed) + filter_value_popup.back_requested.connect(self._on_filter_value_back) + + old_popup, self._filter_value_popup = ( + self._filter_value_popup, filter_value_popup + ) + + old_popup.setVisible(False) + old_popup.deleteLater() + + self._filters_popup.setVisible(False) + + self._show_popup(filter_value_popup) + self._on_filter_value_change(filter_def.name) + + def _show_popup(self, popup: QtWidgets.QWidget): + """Show a popup widget.""" + geo = self.geometry() + bl_pos_g = self.mapToGlobal(QtCore.QPoint(0, geo.height() + 5)) + popup.show() + popup.move(bl_pos_g.x(), bl_pos_g.y()) + popup.raise_() + + def _on_filter_value_change(self, name): + value = self._filter_value_popup.get_value() + item_widget = self._widgets_by_name.get(name) + item_widget.set_value(value) + self.filter_changed.emit(name) + + def _on_filter_value_closed(self, name): + widget = self._widgets_by_name.get(name) + if widget is None: + return + + value = widget.get_value() + if not value: + self._on_item_close_requested(name) + + def _on_filter_value_back(self, name): + self._on_filter_value_closed(name) + self.show_filters_popup() + + def _on_item_close_requested(self, name): + widget = self._widgets_by_name.pop(name, None) + if widget is None: + return + idx = self._filters_layout.indexOf(widget) + if idx > -1: + self._filters_layout.takeAt(idx) + widget.setVisible(False) + widget.deleteLater() + self.filter_changed.emit(name) + + self._reposition_filters_widget() diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py deleted file mode 100644 index 2f034d00de..0000000000 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from qtpy import QtCore, QtGui - -from ayon_core.tools.utils import get_qt_icon -from ayon_core.tools.common_models import StatusItem - -from ._multicombobox import ( - CustomPaintMultiselectComboBox, - BaseQtModel, -) - -STATUS_ITEM_TYPE = 0 -SELECT_ALL_TYPE = 1 -DESELECT_ALL_TYPE = 2 -SWAP_STATE_TYPE = 3 - -STATUSES_FILTER_SENDER = "loader.statuses_filter" -STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2 -STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3 -STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4 -ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 -ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 - - -class StatusesQtModel(BaseQtModel): - def __init__(self, controller): - self._items_by_name: dict[str, QtGui.QStandardItem] = {} - self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {} - super().__init__( - ITEM_TYPE_ROLE, - ITEM_SUBTYPE_ROLE, - "No statuses...", - controller, - ) - - def _get_standard_items(self) -> list[QtGui.QStandardItem]: - return list(self._items_by_name.values()) - - def _clear_standard_items(self): - self._items_by_name.clear() - - def _prepare_new_value_items( - self, project_name: str, project_changed: bool - ): - status_items: list[StatusItem] = ( - self._controller.get_project_status_items( - project_name, sender=STATUSES_FILTER_SENDER - ) - ) - items = [] - items_to_remove = [] - if not status_items: - return items, items_to_remove - - names_to_remove = set(self._items_by_name) - for row_idx, status_item in enumerate(status_items): - name = status_item.name - if name in self._items_by_name: - item = self._items_by_name[name] - names_to_remove.discard(name) - else: - item = QtGui.QStandardItem() - item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) - item.setCheckState(QtCore.Qt.Unchecked) - item.setFlags( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsUserCheckable - ) - self._items_by_name[name] = item - - icon = self._get_icon(status_item) - for role, value in ( - (STATUS_NAME_ROLE, status_item.name), - (STATUS_SHORT_ROLE, status_item.short), - (STATUS_COLOR_ROLE, status_item.color), - (STATUS_ICON_ROLE, icon), - ): - if item.data(role) != value: - item.setData(value, role) - - if project_changed: - item.setCheckState(QtCore.Qt.Unchecked) - items.append(item) - - for name in names_to_remove: - items_to_remove.append(self._items_by_name.pop(name)) - - return items, items_to_remove - - def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: - name = status_item.name - color = status_item.color - unique_id = "|".join([name or "", color or ""]) - icon = self._icons_by_name_n_color.get(unique_id) - if icon is not None: - return icon - - icon: QtGui.QIcon = get_qt_icon({ - "type": "material-symbols", - "name": status_item.icon, - "color": status_item.color - }) - self._icons_by_name_n_color[unique_id] = icon - return icon - - -class StatusesCombobox(CustomPaintMultiselectComboBox): - def __init__(self, controller, parent): - self._controller = controller - model = StatusesQtModel(controller) - super().__init__( - STATUS_NAME_ROLE, - STATUS_SHORT_ROLE, - STATUS_COLOR_ROLE, - STATUS_ICON_ROLE, - item_type_role=ITEM_TYPE_ROLE, - model=model, - parent=parent - ) - self.set_placeholder_text("Version status filter...") - self._model = model - self._last_project_name = None - self._fully_disabled_filter = False - - controller.register_event_callback( - "selection.project.changed", - self._on_project_change - ) - controller.register_event_callback( - "projects.refresh.finished", - self._on_projects_refresh - ) - self.setToolTip("Statuses filter") - self.value_changed.connect( - self._on_status_filter_change - ) - - def _on_status_filter_change(self): - lines = ["Statuses filter"] - for item in self.get_value_info(): - status_name, enabled = item - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - - self.setToolTip("\n".join(lines)) - - def _on_project_change(self, event): - project_name = event["project_name"] - self._last_project_name = project_name - self._model.refresh(project_name) - - def _on_projects_refresh(self): - if self._last_project_name: - self._model.refresh(self._last_project_name) - self._on_status_filter_change() diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index 5779fc2a01..cc7e2e9c95 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -332,10 +332,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): "selection.folders.changed", self._on_folders_selection_changed, ) - controller.register_event_callback( - "tasks.refresh.finished", - self._on_tasks_refresh_finished - ) selection_model = tasks_view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) @@ -373,10 +369,6 @@ class LoaderTasksWidget(QtWidgets.QWidget): def _clear(self): self._tasks_model.clear() - def _on_tasks_refresh_finished(self, event): - if event["sender"] != TASKS_MODEL_SENDER_NAME: - self._set_project_name(event["project_name"]) - def _on_folders_selection_changed(self, event): project_name = event["project_name"] folder_ids = event["folder_ids"] diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 3d2e15c630..d056b62b13 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -11,16 +11,24 @@ from ayon_core.tools.utils import ( ) from ayon_core.tools.utils.lib import center_window from ayon_core.tools.utils import ProjectsCombobox +from ayon_core.tools.common_models import StatusItem +from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController from .folders_widget import LoaderFoldersWidget from .tasks_widget import LoaderTasksWidget from .products_widget import ProductsWidget -from .product_types_combo import ProductTypesCombobox from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget -from .statuses_combo import StatusesCombobox +from .search_bar import FiltersBar, FilterDefinition + +FIND_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_F +) +GROUP_KEY_SEQUENCE = QtGui.QKeySequence( + QtCore.Qt.Modifier.CTRL | QtCore.Qt.Key_G +) class LoadErrorMessageBox(ErrorMessageBox): @@ -182,29 +190,19 @@ class LoaderWindow(QtWidgets.QWidget): products_wrap_widget = QtWidgets.QWidget(main_splitter) products_inputs_widget = QtWidgets.QWidget(products_wrap_widget) - - products_filter_input = PlaceholderLineEdit(products_inputs_widget) - products_filter_input.setPlaceholderText("Product name filter...") - - product_types_filter_combo = ProductTypesCombobox( - controller, products_inputs_widget - ) - - product_status_filter_combo = StatusesCombobox(controller, self) + search_bar = FiltersBar(products_inputs_widget) product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) product_group_checkbox.setChecked(True) - products_widget = ProductsWidget(controller, products_wrap_widget) - products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) - products_inputs_layout.addWidget(products_filter_input, 1) - products_inputs_layout.addWidget(product_types_filter_combo, 1) - products_inputs_layout.addWidget(product_status_filter_combo, 1) + products_inputs_layout.addWidget(search_bar, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) + products_widget = ProductsWidget(controller, products_wrap_widget) + products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) products_wrap_layout.setContentsMargins(0, 0, 0, 0) products_wrap_layout.addWidget(products_inputs_widget, 0) @@ -250,15 +248,7 @@ class LoaderWindow(QtWidgets.QWidget): folders_filter_input.textChanged.connect( self._on_folder_filter_change ) - products_filter_input.textChanged.connect( - self._on_product_filter_change - ) - product_types_filter_combo.value_changed.connect( - self._on_product_type_filter_change - ) - product_status_filter_combo.value_changed.connect( - self._on_status_filter_change - ) + search_bar.filter_changed.connect(self._on_filter_change) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -316,9 +306,7 @@ class LoaderWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget - self._products_filter_input = products_filter_input - self._product_types_filter_combo = product_types_filter_combo - self._product_status_filter_combo = product_status_filter_combo + self._search_bar = search_bar self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -337,6 +325,8 @@ class LoaderWindow(QtWidgets.QWidget): self._selected_folder_ids = set() self._selected_version_ids = set() + self._set_product_type_filters = True + self._products_widget.set_enable_grouping( self._product_group_checkbox.isChecked() ) @@ -356,22 +346,24 @@ class LoaderWindow(QtWidgets.QWidget): def closeEvent(self, event): super().closeEvent(event) - ( - self - ._product_types_filter_combo - .reset_product_types_filter_on_refresh() - ) - self._reset_on_show = True def keyPressEvent(self, event): - modifiers = event.modifiers() - ctrl_pressed = QtCore.Qt.ControlModifier & modifiers + if hasattr(event, "keyCombination"): + combination = event.keyCombination() + else: + combination = QtGui.QKeySequence(event.modifiers() | event.key()) + if ( + FIND_KEY_SEQUENCE == combination + and not event.isAutoRepeat() + ): + self._search_bar.show_filters_popup() + event.setAccepted(True) + return # Grouping products on pressing Ctrl + G if ( - ctrl_pressed - and event.key() == QtCore.Qt.Key_G + GROUP_KEY_SEQUENCE == combination and not event.isAutoRepeat() ): self._show_group_dialog() @@ -435,20 +427,30 @@ class LoaderWindow(QtWidgets.QWidget): self._product_group_checkbox.isChecked() ) - def _on_product_filter_change(self, text): - self._products_widget.set_name_filter(text) + def _on_filter_change(self, filter_name): + if filter_name == "product_name": + self._products_widget.set_name_filter( + self._search_bar.get_filter_value("product_name") + ) + elif filter_name == "product_types": + product_types = self._search_bar.get_filter_value("product_types") + self._products_widget.set_product_type_filter(product_types) + + elif filter_name == "statuses": + status_names = self._search_bar.get_filter_value("statuses") + self._products_widget.set_statuses_filter(status_names) + + elif filter_name == "version_tags": + version_tags = self._search_bar.get_filter_value("version_tags") + self._products_widget.set_version_tags_filter(version_tags) + + elif filter_name == "task_tags": + task_tags = self._search_bar.get_filter_value("task_tags") + self._products_widget.set_task_tags_filter(task_tags) def _on_tasks_selection_change(self, event): self._products_widget.set_tasks_filter(event["task_ids"]) - def _on_status_filter_change(self): - status_names = self._product_status_filter_combo.get_value() - self._products_widget.set_statuses_filter(status_names) - - def _on_product_type_filter_change(self): - product_types = self._product_types_filter_combo.get_value() - self._products_widget.set_product_type_filter(product_types) - def _on_merged_products_selection_change(self): items = self._products_widget.get_selected_merged_products() self._folders_widget.set_merged_products_selection(items) @@ -480,6 +482,7 @@ class LoaderWindow(QtWidgets.QWidget): self._projects_combobox.set_current_context_project(project_name) if not self._refresh_handler.project_refreshed: self._projects_combobox.refresh() + self._update_filters() def _on_load_finished(self, event): error_info = event["error_info"] @@ -491,6 +494,124 @@ class LoaderWindow(QtWidgets.QWidget): def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] + self._update_filters() + + def _update_filters(self): + project_name = self._selected_project_name + if not project_name: + self._search_bar.set_search_items([]) + return + + product_type_items: list[ProductTypeItem] = ( + self._controller.get_product_type_items(project_name) + ) + status_items: list[StatusItem] = ( + self._controller.get_project_status_items(project_name) + ) + tags_by_entity_type = ( + self._controller.get_available_tags_by_entity_type(project_name) + ) + tag_items = self._controller.get_project_anatomy_tags(project_name) + tag_color_by_name = { + tag_item.name: tag_item.color + for tag_item in tag_items + } + + filter_product_type_items = [ + { + "value": item.name, + "icon": item.icon, + } + for item in product_type_items + ] + filter_status_items = [ + { + "icon": { + "type": "material-symbols", + "name": status_item.icon, + "color": status_item.color + }, + "color": status_item.color, + "value": status_item.name, + } + for status_item in status_items + ] + version_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("versions") or [] + ] + task_tags = [ + { + "value": tag_name, + "color": tag_color_by_name.get(tag_name), + } + for tag_name in tags_by_entity_type.get("tasks") or [] + ] + + self._search_bar.set_search_items([ + FilterDefinition( + name="product_name", + title="Product name", + filter_type="text", + icon=None, + placeholder="Product name filter...", + items=None, + ), + FilterDefinition( + name="product_types", + title="Product type", + filter_type="list", + icon=None, + items=filter_product_type_items, + ), + FilterDefinition( + name="statuses", + title="Statuses", + filter_type="list", + icon=None, + items=filter_status_items, + ), + FilterDefinition( + name="version_tags", + title="Version tags", + filter_type="list", + icon=None, + items=version_tags, + ), + FilterDefinition( + name="task_tags", + title="Task tags", + filter_type="list", + icon=None, + items=task_tags, + ), + ]) + + # Set product types filter from settings + if self._set_product_type_filters: + self._set_product_type_filters = False + product_types_filter = self._controller.get_product_types_filter() + product_types = [] + for item in filter_product_type_items: + product_type = item["value"] + matching = ( + int(product_type in product_types_filter.product_types) + + int(product_types_filter.is_allow_list) + ) + if matching % 2 == 0: + product_types.append(product_type) + + if ( + product_types + and len(product_types) < len(filter_product_type_items) + ): + self._search_bar.set_filter_value( + "product_types", + product_types + ) def _on_folders_selection_changed(self, event): self._selected_folder_ids = set(event["folder_ids"]) @@ -519,9 +640,9 @@ class LoaderWindow(QtWidgets.QWidget): thumbnail_paths.discard(None) if thumbnail_paths: - self._thumbnails_widget.set_current_thumbnail_paths( - thumbnail_paths - ) + self._thumbnails_widget.set_current_thumbnail_paths( + thumbnail_paths + ) else: self._thumbnails_widget.set_current_thumbnails(None) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 4ed91813d3..6d0027d35d 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass, asdict from typing import ( Optional, Dict, @@ -28,6 +29,19 @@ if TYPE_CHECKING: from .models import CreatorItem, PublishErrorInfo, InstanceItem +@dataclass +class CommentDef: + """Comment attribute definition.""" + minimum_chars_required: int + + def to_data(self): + return asdict(self) + + @classmethod + def from_data(cls, data): + return cls(**data) + + class CardMessageTypes: standard = None info = "info" @@ -135,6 +149,17 @@ class AbstractPublisherCommon(ABC): pass + @abstractmethod + def get_comment_def(self) -> CommentDef: + """Get comment attribute definition. + + This can define how the Comment field should behave, like having + a minimum amount of required characters before being allowed to + publish. + + """ + pass + class AbstractPublisherBackend(AbstractPublisherCommon): @abstractmethod diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 98fdda08cf..ef2e122692 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -20,7 +20,8 @@ from .models import ( from .abstract import ( AbstractPublisherBackend, AbstractPublisherFrontend, - CardMessageTypes + CardMessageTypes, + CommentDef, ) @@ -601,3 +602,17 @@ class PublisherController( def _start_publish(self, up_validation): self._publish_model.set_publish_up_validation(up_validation) self._publish_model.start_publish(wait=True) + + def get_comment_def(self) -> CommentDef: + # Take the cached settings from the Create Context + settings = self.get_create_context().get_current_project_settings() + comment_minimum_required_chars: int = ( + settings + .get("core", {}) + .get("tools", {}) + .get("publish", {}) + .get("comment_minimum_required_chars", 0) + ) + return CommentDef( + minimum_chars_required=comment_minimum_required_chars + ) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 9644af43e0..900168eaef 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -461,19 +461,19 @@ class CreateModel: self._create_context.add_instances_added_callback( self._cc_added_instance ) - self._create_context.add_instances_removed_callback ( + self._create_context.add_instances_removed_callback( self._cc_removed_instance ) self._create_context.add_value_changed_callback( self._cc_value_changed ) - self._create_context.add_pre_create_attr_defs_change_callback ( + self._create_context.add_pre_create_attr_defs_change_callback( self._cc_pre_create_attr_changed ) - self._create_context.add_create_attr_defs_change_callback ( + self._create_context.add_create_attr_defs_change_callback( self._cc_create_attr_changed ) - self._create_context.add_publish_attr_defs_change_callback ( + self._create_context.add_publish_attr_defs_change_callback( self._cc_publish_attr_changed ) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 97a956b18f..97070d106f 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -358,7 +358,7 @@ class PublishReportMaker: exception = result.get("error") if exception: - fname, line_no, func, exc = exception.traceback + fname, line_no, func, _ = exception.traceback # Conversion of exception into string may crash try: diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 0706299f32..e9749c5b07 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -348,8 +348,6 @@ class ScreenMarquee(QtCore.QObject): # QtGui.QPainter.Antialiasing # | QtGui.QPainter.SmoothPixmapTransform # ) - # if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - # render_hints |= QtGui.QPainter.HighQualityAntialiasing # pix_painter.setRenderHints(render_hints) # for item in screen_pixes: # (screen_pix, offset) = item diff --git a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py index 261dcfb43d..f767fdf325 100644 --- a/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py +++ b/client/ayon_core/tools/publisher/widgets/thumbnail_widget.py @@ -135,8 +135,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) @@ -171,8 +169,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) tiled_rect = QtCore.QRectF( @@ -265,8 +261,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index ed5b909a55..dc086a3b48 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -245,6 +245,13 @@ class PublisherWindow(QtWidgets.QDialog): show_timer.setInterval(1) show_timer.timeout.connect(self._on_show_timer) + comment_invalid_timer = QtCore.QTimer() + comment_invalid_timer.setSingleShot(True) + comment_invalid_timer.setInterval(2500) + comment_invalid_timer.timeout.connect( + self._on_comment_invalid_timeout + ) + errors_dialog_message_timer = QtCore.QTimer() errors_dialog_message_timer.setInterval(100) errors_dialog_message_timer.timeout.connect( @@ -395,6 +402,7 @@ class PublisherWindow(QtWidgets.QDialog): self._app_event_listener_installed = False self._show_timer = show_timer + self._comment_invalid_timer = comment_invalid_timer self._show_counter = 0 self._window_is_visible = False @@ -823,15 +831,45 @@ class PublisherWindow(QtWidgets.QDialog): self._controller.set_comment(self._comment_input.text()) def _on_validate_clicked(self): - if self._save_changes(False): + if self._validate_comment() and self._save_changes(False): self._set_publish_comment() self._controller.validate() def _on_publish_clicked(self): - if self._save_changes(False): + if self._validate_comment() and self._save_changes(False): self._set_publish_comment() self._controller.publish() + def _validate_comment(self) -> bool: + # Validate comment length + comment_def = self._controller.get_comment_def() + char_count = len(self._comment_input.text().strip()) + if ( + comment_def.minimum_chars_required + and char_count < comment_def.minimum_chars_required + ): + self._overlay_object.add_message( + "Please enter a comment of at least " + f"{comment_def.minimum_chars_required} characters", + message_type="error" + ) + self._invalidate_comment_field() + return False + return True + + def _invalidate_comment_field(self): + self._comment_invalid_timer.start() + self._comment_input.setStyleSheet("border-color: #DD2020") + # Set focus so user can start typing and is pointed towards the field + self._comment_input.setFocus() + self._comment_input.setCursorPosition( + len(self._comment_input.text()) + ) + + def _on_comment_invalid_timeout(self): + # Reset style + self._comment_input.setStyleSheet("") + def _set_footer_enabled(self, enabled): self._save_btn.setEnabled(True) self._reset_btn.setEnabled(True) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index bb95e37d4e..fdd1bdbe75 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -959,11 +959,13 @@ class SceneInventoryView(QtWidgets.QTreeView): remove_container(container) self.data_changed.emit() - def _show_version_error_dialog(self, version, item_ids): + def _show_version_error_dialog(self, version, item_ids, exception): """Shows QMessageBox when version switch doesn't work Args: version: str or int or None + item_ids (Iterable[str]): List of item ids to run the + exception (Exception): Exception that occurred """ if version == -1: version_str = "latest" @@ -988,10 +990,11 @@ class SceneInventoryView(QtWidgets.QTreeView): dialog.addButton(QtWidgets.QMessageBox.Cancel) msg = ( - "Version update to '{}' failed as representation doesn't exist." + "Version update to '{}' failed with the following error:\n" + "{}." "\n\nPlease update to version with a valid representation" " OR \n use 'Switch Folder' button to change folder." - ).format(version_str) + ).format(version_str, exception) dialog.setText(msg) dialog.exec_() @@ -1105,10 +1108,10 @@ class SceneInventoryView(QtWidgets.QTreeView): container = containers_by_id[item_id] try: update_container(container, item_version) - except AssertionError: + except Exception as exc: log.warning("Update failed", exc_info=True) self._show_version_error_dialog( - item_version, [item_id] + item_version, [item_id], exc ) finally: # Always update the scene inventory view, even if errors occurred diff --git a/client/ayon_core/tools/stdout_broker/app.py b/client/ayon_core/tools/stdout_broker/app.py deleted file mode 100644 index ae73db1bb9..0000000000 --- a/client/ayon_core/tools/stdout_broker/app.py +++ /dev/null @@ -1,12 +0,0 @@ -import warnings -from .broker import StdOutBroker - -warnings.warn( - ( - "Import of 'StdOutBroker' from 'ayon_core.tools.stdout_broker.app'" - " is deprecated. Please use 'ayon_core.tools.stdout_broker' instead." - ), - DeprecationWarning -) - -__all__ = ("StdOutBroker", ) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 13ee1eea5c..deb49b9711 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -738,4 +738,3 @@ def main(force=False): sys.exit(1) main() - diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 9206af9beb..111b7c614b 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -6,6 +6,7 @@ from .widgets import ( CustomTextComboBox, PlaceholderLineEdit, PlaceholderPlainTextEdit, + MarkdownLabel, ElideLabel, HintedLineEdit, ExpandingTextEdit, @@ -28,6 +29,7 @@ from .widgets import ( from .views import ( DeselectableTreeView, TreeView, + ListView, ) from .error_dialog import ErrorMessageBox from .lib import ( @@ -60,6 +62,7 @@ from .dialogs import ( ) from .projects_widget import ( ProjectsCombobox, + ProjectsWidget, ProjectsQtModel, ProjectSortFilterProxy, PROJECT_NAME_ROLE, @@ -91,6 +94,7 @@ __all__ = ( "CustomTextComboBox", "PlaceholderLineEdit", "PlaceholderPlainTextEdit", + "MarkdownLabel", "ElideLabel", "HintedLineEdit", "ExpandingTextEdit", @@ -112,6 +116,7 @@ __all__ = ( "DeselectableTreeView", "TreeView", + "ListView", "ErrorMessageBox", @@ -143,6 +148,7 @@ __all__ = ( "PopupUpdateKeys", "ProjectsCombobox", + "ProjectsWidget", "ProjectsQtModel", "ProjectSortFilterProxy", "PROJECT_NAME_ROLE", diff --git a/client/ayon_core/tools/utils/color_widgets/color_inputs.py b/client/ayon_core/tools/utils/color_widgets/color_inputs.py index 795b80fc1e..5a1c2dc50b 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_inputs.py +++ b/client/ayon_core/tools/utils/color_widgets/color_inputs.py @@ -65,7 +65,7 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/color_widgets/color_triangle.py b/client/ayon_core/tools/utils/color_widgets/color_triangle.py index 290a33f0b0..7691c3e78d 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_triangle.py +++ b/client/ayon_core/tools/utils/color_widgets/color_triangle.py @@ -261,7 +261,7 @@ class QtColorTriangle(QtWidgets.QWidget): pix = self.bg_image.copy() pix_painter = QtGui.QPainter(pix) - pix_painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + pix_painter.setRenderHint(QtGui.QPainter.Antialiasing) trigon_path = QtGui.QPainterPath() trigon_path.moveTo(self.point_a) diff --git a/client/ayon_core/tools/utils/constants.py b/client/ayon_core/tools/utils/constants.py index 0c92e3ccc8..b590d1d778 100644 --- a/client/ayon_core/tools/utils/constants.py +++ b/client/ayon_core/tools/utils/constants.py @@ -14,3 +14,4 @@ except AttributeError: DEFAULT_PROJECT_LABEL = "< Default >" PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 101 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102 +DEFAULT_WEB_ICON_COLOR = "#f4f5f5" diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 4b303c0143..a99c46199b 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -1,11 +1,14 @@ import os import sys +import io import contextlib import collections import traceback +import urllib.request from functools import partial from typing import Union, Any +import ayon_api from qtpy import QtWidgets, QtCore, QtGui import qtawesome import qtmaterialsymbols @@ -17,7 +20,12 @@ from ayon_core.style import ( from ayon_core.resources import get_image_path from ayon_core.lib import Logger -from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT +from .constants import ( + CHECKED_INT, + UNCHECKED_INT, + PARTIALLY_CHECKED_INT, + DEFAULT_WEB_ICON_COLOR, +) log = Logger.get_logger(__name__) @@ -110,9 +118,6 @@ def paint_image_with_color(image, color): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - # Deprecated since 5.14 - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.setClipRegion(alpha_region) @@ -480,11 +485,27 @@ class _IconsCache: if icon_type == "path": parts = [icon_type, icon_def["path"]] - elif icon_type in {"awesome-font", "material-symbols"}: - color = icon_def["color"] or "" + elif icon_type == "awesome-font": + color = icon_def.get("color") or "" if isinstance(color, QtGui.QColor): color = color.name() parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type == "material-symbols": + color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR + if isinstance(color, QtGui.QColor): + color = color.name() + parts = [icon_type, icon_def["name"] or "", color] + + elif icon_type in {"url", "ayon_url"}: + parts = [icon_type, icon_def["url"]] + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + parts = [icon_type, str(size)] + return "|".join(parts) @classmethod @@ -505,7 +526,7 @@ class _IconsCache: elif icon_type == "awesome-font": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") icon = cls.get_qta_icon_by_name_and_color(icon_name, icon_color) if icon is None: icon = cls.get_qta_icon_by_name_and_color( @@ -513,10 +534,40 @@ class _IconsCache: elif icon_type == "material-symbols": icon_name = icon_def["name"] - icon_color = icon_def["color"] + icon_color = icon_def.get("color") or DEFAULT_WEB_ICON_COLOR if qtmaterialsymbols.get_icon_name_char(icon_name) is not None: icon = qtmaterialsymbols.get_icon(icon_name, icon_color) + elif icon_type == "url": + url = icon_def["url"] + try: + content = urllib.request.urlopen(url).read() + pix = QtGui.QPixmap() + pix.loadFromData(content) + icon = QtGui.QIcon(pix) + except Exception: + log.warning( + "Failed to download image '%s'", url, exc_info=True + ) + icon = None + + elif icon_type == "ayon_url": + url = icon_def["url"].lstrip("/") + url = f"{ayon_api.get_base_url()}/{url}" + stream = io.BytesIO() + ayon_api.download_file_to_stream(url, stream) + pix = QtGui.QPixmap() + pix.loadFromData(stream.getvalue()) + icon = QtGui.QIcon(pix) + + elif icon_type == "transparent": + size = icon_def.get("size") + if size is None: + size = 256 + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + icon = QtGui.QIcon(pix) + if icon is None: icon = cls.get_default() cls._cache[cache_key] = icon diff --git a/client/ayon_core/tools/utils/projects_widget.py b/client/ayon_core/tools/utils/projects_widget.py index 88d8a6c9f5..1c87d79a58 100644 --- a/client/ayon_core/tools/utils/projects_widget.py +++ b/client/ayon_core/tools/utils/projects_widget.py @@ -1,21 +1,69 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from collections.abc import Callable +import typing +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.common_models import PROJECTS_MODEL_SENDER +from ayon_core.tools.common_models import ( + ProjectItem, + PROJECTS_MODEL_SENDER, +) +from .views import ListView from .lib import RefreshThread, get_qt_icon +if typing.TYPE_CHECKING: + from typing import TypedDict + + class ExpectedProjectSelectionData(TypedDict): + name: Optional[str] + current: Optional[str] + selected: Optional[str] + + class ExpectedSelectionData(TypedDict): + project: ExpectedProjectSelectionData + + PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3 PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 -LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 +PROJECT_IS_PINNED_ROLE = QtCore.Qt.UserRole + 5 +LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 6 + + +class AbstractProjectController(ABC): + @abstractmethod + def register_event_callback(self, topic: str, callback: Callable): + pass + + @abstractmethod + def get_project_items( + self, sender: Optional[str] = None + ) -> list[str]: + pass + + @abstractmethod + def set_selected_project(self, project_name: str): + pass + + # These are required only if widget should handle expected selection + @abstractmethod + def expected_project_selected(self, project_name: str): + pass + + @abstractmethod + def get_expected_selection_data(self) -> "ExpectedSelectionData": + pass class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() - def __init__(self, controller): - super(ProjectsQtModel, self).__init__() + def __init__(self, controller: AbstractProjectController): + super().__init__() self._controller = controller self._project_items = {} @@ -213,7 +261,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): else: self.refreshed.emit() - def _fill_items(self, project_items): + def _fill_items(self, project_items: list[ProjectItem]): new_project_names = { project_item.name for project_item in project_items @@ -252,6 +300,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): item.setData(project_name, PROJECT_NAME_ROLE) item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE) item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE) + item.setData(project_item.is_pinned, PROJECT_IS_PINNED_ROLE) is_current = project_name == self._current_context_project item.setData(is_current, PROJECT_IS_CURRENT_ROLE) self._project_items[project_name] = item @@ -279,7 +328,7 @@ class ProjectsQtModel(QtGui.QStandardItemModel): class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): - super(ProjectSortFilterProxy, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._filter_inactive = True self._filter_standard = False self._filter_library = False @@ -323,26 +372,51 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): return False # Library separator should be before library projects - result = self._type_sort(left_index, right_index) - if result is not None: - return result + l_is_library = left_index.data(PROJECT_IS_LIBRARY_ROLE) + r_is_library = right_index.data(PROJECT_IS_LIBRARY_ROLE) + l_is_sep = left_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + r_is_sep = right_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE) + if l_is_sep: + return bool(r_is_library) - if left_index.data(PROJECT_NAME_ROLE) is None: + if r_is_sep: + return not l_is_library + + # Non project items should be on top + l_project_name = left_index.data(PROJECT_NAME_ROLE) + r_project_name = right_index.data(PROJECT_NAME_ROLE) + if l_project_name is None: return True - - if right_index.data(PROJECT_NAME_ROLE) is None: + if r_project_name is None: return False left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) - if right_is_active == left_is_active: - return super(ProjectSortFilterProxy, self).lessThan( - left_index, right_index - ) + if right_is_active != left_is_active: + return left_is_active - if left_is_active: + l_is_pinned = left_index.data(PROJECT_IS_PINNED_ROLE) + r_is_pinned = right_index.data(PROJECT_IS_PINNED_ROLE) + if l_is_pinned is True and not r_is_pinned: return True - return False + + if r_is_pinned is True and not l_is_pinned: + return False + + # Move inactive projects to the end + left_is_active = left_index.data(PROJECT_IS_ACTIVE_ROLE) + right_is_active = right_index.data(PROJECT_IS_ACTIVE_ROLE) + if right_is_active != left_is_active: + return left_is_active + + # Move library projects after standard projects + if ( + l_is_library is not None + and r_is_library is not None + and l_is_library != r_is_library + ): + return r_is_library + return super().lessThan(left_index, right_index) def filterAcceptsRow(self, source_row, source_parent): index = self.sourceModel().index(source_row, 0, source_parent) @@ -350,21 +424,21 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): if project_name is None: return True - string_pattern = self.filterRegularExpression().pattern() - if string_pattern: - return string_pattern.lower() in project_name.lower() - - # Current project keep always visible - default = super(ProjectSortFilterProxy, self).filterAcceptsRow( - source_row, source_parent - ) - if not default: - return default - # Make sure current project is visible if index.data(PROJECT_IS_CURRENT_ROLE): return True + default = super().filterAcceptsRow(source_row, source_parent) + if not default: + return default + + string_pattern = self.filterRegularExpression().pattern() + if ( + string_pattern + and string_pattern.lower() not in project_name.lower() + ): + return False + if ( self._filter_inactive and not index.data(PROJECT_IS_ACTIVE_ROLE) @@ -415,15 +489,153 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): self.invalidate() +class ProjectsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pin_icon = None + + def paint(self, painter, option, index): + is_pinned = index.data(PROJECT_IS_PINNED_ROLE) + if not is_pinned: + super().paint(painter, option, index) + return + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + widget = option.widget + if widget is None: + style = QtWidgets.QApplication.style() + else: + style = widget.style() + # CE_ItemViewItem + proxy = style.proxy() + painter.save() + painter.setClipRect(option.rect) + decor_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemDecoration, opt, widget + ) + text_rect = proxy.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, opt, widget + ) + proxy.drawPrimitive( + QtWidgets.QStyle.PE_PanelItemViewItem, opt, painter, widget + ) + mode = QtGui.QIcon.Normal + if not opt.state & QtWidgets.QStyle.State_Enabled: + mode = QtGui.QIcon.Disabled + elif opt.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + state = QtGui.QIcon.Off + if opt.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + + # Draw project icon + opt.icon.paint( + painter, decor_rect, opt.decorationAlignment, mode, state + ) + + # Draw pin icon + if index.data(PROJECT_IS_PINNED_ROLE): + pin_icon = self._get_pin_icon() + pin_rect = QtCore.QRect(decor_rect) + diff = option.rect.width() - pin_rect.width() + pin_rect.moveLeft(diff) + pin_icon.paint( + painter, pin_rect, opt.decorationAlignment, mode, state + ) + + # Draw text + if opt.text: + if not opt.state & QtWidgets.QStyle.State_Enabled: + cg = QtGui.QPalette.Disabled + elif not (opt.state & QtWidgets.QStyle.State_Active): + cg = QtGui.QPalette.Inactive + else: + cg = QtGui.QPalette.Normal + + if opt.state & QtWidgets.QStyle.State_Selected: + painter.setPen( + opt.palette.color(cg, QtGui.QPalette.HighlightedText) + ) + else: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + + if opt.state & QtWidgets.QStyle.State_Editing: + painter.setPen(opt.palette.color(cg, QtGui.QPalette.Text)) + painter.drawRect(text_rect.adjusted(0, 0, -1, -1)) + + margin = proxy.pixelMetric( + QtWidgets.QStyle.PM_FocusFrameHMargin, None, widget + ) + 1 + text_rect.adjust(margin, 0, -margin, 0) + # NOTE skipping some steps e.g. word wrapping and elided + # text (adding '...' when too long). + painter.drawText( + text_rect, + opt.displayAlignment, + opt.text + ) + + # Draw focus rect + if opt.state & QtWidgets.QStyle.State_HasFocus: + focus_opt = QtWidgets.QStyleOptionFocusRect() + focus_opt.state = option.state + focus_opt.direction = option.direction + focus_opt.rect = option.rect + focus_opt.fontMetrics = option.fontMetrics + focus_opt.palette = option.palette + + focus_opt.rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect, + option, + option.widget + ) + focus_opt.state |= ( + QtWidgets.QStyle.State_KeyboardFocusChange + | QtWidgets.QStyle.State_Item + ) + focus_opt.backgroundColor = option.palette.color( + ( + QtGui.QPalette.Normal + if option.state & QtWidgets.QStyle.State_Enabled + else QtGui.QPalette.Disabled + ), + ( + QtGui.QPalette.Highlight + if option.state & QtWidgets.QStyle.State_Selected + else QtGui.QPalette.Window + ) + ) + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_FrameFocusRect, + focus_opt, + painter, + option.widget + ) + painter.restore() + + def _get_pin_icon(self): + if self._pin_icon is None: + self._pin_icon = get_qt_icon({ + "type": "material-symbols", + "name": "keep", + }) + return self._pin_icon + + class ProjectsCombobox(QtWidgets.QWidget): refreshed = QtCore.Signal() - selection_changed = QtCore.Signal() + selection_changed = QtCore.Signal(str) - def __init__(self, controller, parent, handle_expected_selection=False): - super(ProjectsCombobox, self).__init__(parent) + def __init__( + self, + controller: AbstractProjectController, + parent: QtWidgets.QWidget, + handle_expected_selection: bool = False, + ): + super().__init__(parent) projects_combobox = QtWidgets.QComboBox(self) - combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) + combobox_delegate = ProjectsDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() @@ -468,7 +680,7 @@ class ProjectsCombobox(QtWidgets.QWidget): def refresh(self): self._projects_model.refresh() - def set_selection(self, project_name): + def set_selection(self, project_name: str): """Set selection to a given project. Selection change is ignored if project is not found. @@ -480,8 +692,8 @@ class ProjectsCombobox(QtWidgets.QWidget): bool: True if selection was changed, False otherwise. NOTE: Selection may not be changed if project is not found, or if project is already selected. - """ + """ idx = self._projects_combobox.findData( project_name, PROJECT_NAME_ROLE) if idx < 0: @@ -491,7 +703,7 @@ class ProjectsCombobox(QtWidgets.QWidget): return True return False - def set_listen_to_selection_change(self, listen): + def set_listen_to_selection_change(self, listen: bool): """Disable listening to changes of the selection. Because combobox is triggering selection change when it's model @@ -517,11 +729,11 @@ class ProjectsCombobox(QtWidgets.QWidget): return None return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE) - def set_current_context_project(self, project_name): + def set_current_context_project(self, project_name: str): self._projects_model.set_current_context_project(project_name) self._projects_proxy_model.invalidateFilter() - def set_select_item_visible(self, visible): + def set_select_item_visible(self, visible: bool): self._select_item_visible = visible self._projects_model.set_select_item_visible(visible) self._update_select_item_visiblity() @@ -559,7 +771,7 @@ class ProjectsCombobox(QtWidgets.QWidget): idx, PROJECT_NAME_ROLE) self._update_select_item_visiblity(project_name=project_name) self._controller.set_selected_project(project_name) - self.selection_changed.emit() + self.selection_changed.emit(project_name or "") def _on_model_refresh(self): self._projects_proxy_model.sort(0) @@ -614,5 +826,119 @@ class ProjectsCombobox(QtWidgets.QWidget): class ProjectsWidget(QtWidgets.QWidget): - # TODO implement - pass + """Projects widget showing projects in list. + + Warnings: + This widget does not support expected selection handling. + + """ + refreshed = QtCore.Signal() + selection_changed = QtCore.Signal(str) + double_clicked = QtCore.Signal() + + def __init__( + self, + controller: AbstractProjectController, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent=parent) + + projects_view = ListView(parent=self) + projects_view.setResizeMode(QtWidgets.QListView.Adjust) + projects_view.setVerticalScrollMode( + QtWidgets.QAbstractItemView.ScrollPerPixel + ) + projects_view.setAlternatingRowColors(False) + projects_view.setWrapping(False) + projects_view.setWordWrap(False) + projects_view.setSpacing(0) + projects_delegate = ProjectsDelegate(projects_view) + projects_view.setItemDelegate(projects_delegate) + projects_view.activate_flick_charm() + projects_view.set_deselectable(True) + + projects_model = ProjectsQtModel(controller) + projects_proxy_model = ProjectSortFilterProxy() + projects_proxy_model.setSourceModel(projects_model) + projects_view.setModel(projects_proxy_model) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(projects_view, 1) + + projects_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + projects_view.double_clicked.connect(self.double_clicked) + projects_model.refreshed.connect(self._on_model_refresh) + + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh_finished + ) + + self._controller = controller + + self._projects_view = projects_view + self._projects_model = projects_model + self._projects_proxy_model = projects_proxy_model + self._projects_delegate = projects_delegate + + def refresh(self): + self._projects_model.refresh() + + def has_content(self) -> bool: + """Model has at least one project. + + Returns: + bool: True if there is any content in the model. + + """ + return self._projects_model.has_content() + + def set_name_filter(self, text: str): + self._projects_proxy_model.setFilterFixedString(text) + + def get_selected_project(self) -> Optional[str]: + selection_model = self._projects_view.selectionModel() + for index in selection_model.selectedIndexes(): + project_name = index.data(PROJECT_NAME_ROLE) + if project_name: + return project_name + return None + + def set_selected_project(self, project_name: Optional[str]): + if project_name is None: + self._projects_view.clearSelection() + self._projects_view.setCurrentIndex(QtCore.QModelIndex()) + return + + index = self._projects_model.get_index_by_project_name(project_name) + if not index.isValid(): + return + proxy_index = self._projects_proxy_model.mapFromSource(index) + if proxy_index.isValid(): + selection_model = self._projects_view.selectionModel() + selection_model.select( + proxy_index, + QtCore.QItemSelectionModel.ClearAndSelect + ) + + def _on_model_refresh(self): + self._projects_proxy_model.sort(0) + self._projects_proxy_model.invalidateFilter() + self.refreshed.emit() + + def _on_selection_change(self, new_selection, _old_selection): + project_name = None + for index in new_selection.indexes(): + name = index.data(PROJECT_NAME_ROLE) + if name: + project_name = name + break + self.selection_changed.emit(project_name or "") + self._controller.set_selected_project(project_name) + + def _on_projects_refresh_finished(self, event): + if event["sender"] != PROJECTS_MODEL_SENDER: + self._projects_model.refresh() diff --git a/client/ayon_core/tools/utils/sliders.py b/client/ayon_core/tools/utils/sliders.py index ea1e01b9ea..c762b6ade0 100644 --- a/client/ayon_core/tools/utils/sliders.py +++ b/client/ayon_core/tools/utils/sliders.py @@ -58,7 +58,7 @@ class NiceSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) - painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + painter.setRenderHint(QtGui.QPainter.Antialiasing) horizontal = self.orientation() == QtCore.Qt.Horizontal diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index 9118611c23..744eb6060a 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -575,7 +575,7 @@ class TasksWidget(QtWidgets.QWidget): if self._tasks_model.is_refreshing: return - parent_id, task_id, task_name, _ = self._get_selected_item_ids() + _parent_id, task_id, task_name, _ = self._get_selected_item_ids() self._controller.set_selected_task(task_id, task_name) self.selection_changed.emit() diff --git a/client/ayon_core/tools/utils/thumbnail_paint_widget.py b/client/ayon_core/tools/utils/thumbnail_paint_widget.py index 9dbc2bcdd0..e67b820417 100644 --- a/client/ayon_core/tools/utils/thumbnail_paint_widget.py +++ b/client/ayon_core/tools/utils/thumbnail_paint_widget.py @@ -205,8 +205,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) @@ -241,8 +239,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing pix_painter.setRenderHints(render_hints) tiled_rect = QtCore.QRectF( @@ -335,8 +331,6 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing final_painter.setRenderHints(render_hints) diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index d69be9b6a9..2ad1d6c7b5 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -37,7 +37,7 @@ class TreeView(QtWidgets.QTreeView): double_clicked = QtCore.Signal(QtGui.QMouseEvent) def __init__(self, *args, **kwargs): - super(TreeView, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._deselectable = False self._flick_charm_activated = False @@ -60,12 +60,64 @@ class TreeView(QtWidgets.QTreeView): self.clearSelection() # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - super(TreeView, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(event) - return super(TreeView, self).mouseDoubleClickEvent(event) + return super().mouseDoubleClickEvent(event) + + def activate_flick_charm(self): + if self._flick_charm_activated: + return + self._flick_charm_activated = True + self._before_flick_scroll_mode = self.verticalScrollMode() + self._flick_charm.activateOn(self) + self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) + + def deactivate_flick_charm(self): + if not self._flick_charm_activated: + return + self._flick_charm_activated = False + self._flick_charm.deactivateFrom(self) + if self._before_flick_scroll_mode is not None: + self.setVerticalScrollMode(self._before_flick_scroll_mode) + + +class ListView(QtWidgets.QListView): + """A tree view that deselects on clicking on an empty area in the view""" + double_clicked = QtCore.Signal(QtGui.QMouseEvent) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._deselectable = False + + self._flick_charm_activated = False + self._flick_charm = FlickCharm(parent=self) + self._before_flick_scroll_mode = None + + def is_deselectable(self): + return self._deselectable + + def set_deselectable(self, deselectable): + self._deselectable = deselectable + + deselectable = property(is_deselectable, set_deselectable) + + def mousePressEvent(self, event): + if self._deselectable: + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event) + + return super().mouseDoubleClickEvent(event) def activate_flick_charm(self): if self._flick_charm_activated: diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 0cd6d68ab3..de2c42c91f 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -6,6 +6,11 @@ from qtpy import QtWidgets, QtCore, QtGui import qargparse import qtawesome +try: + import markdown +except Exception: + markdown = None + from ayon_core.style import ( get_objected_colors, get_style_image_path, @@ -131,6 +136,37 @@ class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit): viewport.setPalette(filter_palette) +class MarkdownLabel(QtWidgets.QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Enable word wrap by default + self.setWordWrap(True) + + text_format_available = hasattr(QtCore.Qt, "MarkdownText") + if text_format_available: + self.setTextFormat(QtCore.Qt.MarkdownText) + + self._text_format_available = text_format_available + + self.setText(self.text()) + + def setText(self, text): + if not self._text_format_available: + text = self._md_to_html(text) + super().setText(text) + + @staticmethod + def _md_to_html(text): + if markdown is None: + # This does add style definition to the markdown which does not + # feel natural in the UI (but still better than raw MD). + doc = QtGui.QTextDocument() + doc.setMarkdown(text) + return doc.toHtml() + return markdown.markdown(text) + + class ElideLabel(QtWidgets.QLabel): """Label which elide text. @@ -426,7 +462,7 @@ class BaseClickableFrame(QtWidgets.QFrame): Callback is defined by overriding `_mouse_release_callback`. """ def __init__(self, parent): - super(BaseClickableFrame, self).__init__(parent) + super().__init__(parent) self._mouse_pressed = False @@ -434,17 +470,23 @@ class BaseClickableFrame(QtWidgets.QFrame): pass def mousePressEvent(self, event): + super().mousePressEvent(event) + if event.isAccepted(): + return if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(BaseClickableFrame, self).mousePressEvent(event) + event.accept() def mouseReleaseEvent(self, event): - if self._mouse_pressed: - self._mouse_pressed = False - if self.rect().contains(event.pos()): - self._mouse_release_callback() + pressed, self._mouse_pressed = self._mouse_pressed, False + super().mouseReleaseEvent(event) + if event.isAccepted(): + return - super(BaseClickableFrame, self).mouseReleaseEvent(event) + accepted = pressed and self.rect().contains(event.pos()) + if accepted: + event.accept() + self._mouse_release_callback() class ClickableFrame(BaseClickableFrame): @@ -459,15 +501,15 @@ class ClickableLabel(QtWidgets.QLabel): """Label that catch left mouse click and can trigger 'clicked' signal.""" clicked = QtCore.Signal() - def __init__(self, parent): - super(ClickableLabel, self).__init__(parent) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._mouse_pressed = False def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(ClickableLabel, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._mouse_pressed: @@ -475,7 +517,7 @@ class ClickableLabel(QtWidgets.QLabel): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLabel, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class ExpandBtnLabel(QtWidgets.QLabel): @@ -588,8 +630,6 @@ class ClassicExpandBtnLabel(ExpandBtnLabel): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) painter.drawPixmap(QtCore.QPoint(pos_x, pos_y), pixmap) painter.end() @@ -704,7 +744,7 @@ class PixmapLabel(QtWidgets.QLabel): def resizeEvent(self, event): self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) + super().resizeEvent(event) class PixmapButtonPainter(QtWidgets.QWidget): @@ -752,8 +792,6 @@ class PixmapButtonPainter(QtWidgets.QWidget): QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform ) - if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): - render_hints |= QtGui.QPainter.HighQualityAntialiasing painter.setRenderHints(render_hints) if self._cached_pixmap is None: @@ -1153,7 +1191,7 @@ class SquareButton(QtWidgets.QPushButton): """ def __init__(self, *args, **kwargs): - super(SquareButton, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) sp = self.sizePolicy() sp.setVerticalPolicy(QtWidgets.QSizePolicy.Minimum) @@ -1162,17 +1200,17 @@ class SquareButton(QtWidgets.QPushButton): self._ideal_width = None def showEvent(self, event): - super(SquareButton, self).showEvent(event) + super().showEvent(event) self._ideal_width = self.height() self.updateGeometry() def resizeEvent(self, event): - super(SquareButton, self).resizeEvent(event) + super().resizeEvent(event) self._ideal_width = self.height() self.updateGeometry() def sizeHint(self): - sh = super(SquareButton, self).sizeHint() + sh = super().sizeHint() ideal_width = self._ideal_width if ideal_width is None: ideal_width = sh.height() diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index c621a44937..cc034571f3 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -462,7 +462,7 @@ class WorkfileEntitiesModel: anatomy = self._controller.project_anatomy workdir, filename = os.path.split(filepath) - success, rootless_dir = anatomy.find_root_template_from_path(workdir) + _, rootless_dir = anatomy.find_root_template_from_path(workdir) return "/".join([ os.path.normpath(rootless_dir).replace("\\", "/"), filename diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints similarity index 91% rename from client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints rename to client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints index a4451c793a..d5ede9bf32 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints @@ -19,6 +19,7 @@ 21mp e95f 22mp e960 23mp e961 +24fps_select f3f2 24mp e962 2d ef37 2k e963 @@ -27,6 +28,7 @@ 30fps efce 30fps_select efcf 360 e577 +3d ed38 3d_rotation e84d 3g_mobiledata efd0 3g_mobiledata_badge f7f0 @@ -71,6 +73,7 @@ accessibility e84e accessibility_new e92c accessible e914 accessible_forward e934 +accessible_menu f34e account_balance e84f account_balance_wallet e850 account_box e851 @@ -92,6 +95,7 @@ adaptive_audio_mic f4cc adaptive_audio_mic_off f4cb adb e60e add e145 +add_2 f3dd add_a_photo e439 add_ad e72a add_alarm e856 @@ -103,6 +107,8 @@ add_card eb86 add_chart ef3c add_circle e3ba add_circle_outline e3ba +add_column_left f425 +add_column_right f424 add_comment e266 add_diamond f49c add_home f8eb @@ -116,6 +122,8 @@ add_notes e091 add_photo_alternate e43e add_reaction e1d3 add_road ef3b +add_row_above f423 +add_row_below f422 add_shopping_cart e854 add_task f23a add_to_drive e65c @@ -156,6 +164,7 @@ alarm e855 alarm_add e856 alarm_off e857 alarm_on e858 +alarm_pause f35b alarm_smart_wake f6b0 album e019 align_center e356 @@ -230,6 +239,7 @@ area_chart e770 arming_countdown e78a arrow_and_edge f5d7 arrow_back e5c4 +arrow_back_2 f43a arrow_back_ios e5e0 arrow_back_ios_new e2ea arrow_circle_down f181 @@ -247,6 +257,8 @@ arrow_forward_ios e5e1 arrow_insert f837 arrow_left e5de arrow_left_alt ef7d +arrow_menu_close f3d3 +arrow_menu_open f3d2 arrow_or_edge f5d6 arrow_outward f8ce arrow_range f69b @@ -256,14 +268,19 @@ arrow_selector_tool f82f arrow_split ea04 arrow_top_left f72e arrow_top_right f72d +arrow_upload_progress f3f4 +arrow_upload_ready f3f5 arrow_upward e5d8 arrow_upward_alt e986 arrow_warm_up f4b5 +arrows_input f394 arrows_more_down f8ab arrows_more_up f8ac +arrows_output f393 arrows_outward f72c art_track e060 article ef42 +article_person f368 article_shortcut f587 artist e01a aspect_ratio e85b @@ -324,6 +341,7 @@ auto_towing e71e auto_transmission f53f auto_videocam f6c0 autofps_select efdc +automation f421 autopause f6b6 autopay f84b autoplay f6b5 @@ -358,6 +376,7 @@ balcony e58f ballot e172 bar_chart e26b bar_chart_4_bars f681 +bar_chart_off f411 barcode e70b barcode_reader f85c barcode_scanner e70c @@ -382,6 +401,20 @@ battery_6_bar f0a1 battery_80 f0a0 battery_90 f0a1 battery_alert e19c +battery_android_0 f30d +battery_android_1 f30c +battery_android_2 f30b +battery_android_3 f30a +battery_android_4 f309 +battery_android_5 f308 +battery_android_6 f307 +battery_android_alert f306 +battery_android_bolt f305 +battery_android_full f304 +battery_android_plus f303 +battery_android_question f302 +battery_android_share f301 +battery_android_shield f300 battery_change f7eb battery_charging_20 f0a2 battery_charging_30 f0a3 @@ -413,7 +446,7 @@ bed efdf bedroom_baby efe0 bedroom_child efe1 bedroom_parent efe2 -bedtime ef44 +bedtime f159 bedtime_off eb76 beenhere e52d bento f1f4 @@ -445,6 +478,8 @@ blur_medium e84c blur_off e3a4 blur_on e3a5 blur_short e8cf +boat_bus f36d +boat_railway f36c body_fat e098 body_system e099 bolt ea0b @@ -454,10 +489,13 @@ book_2 f53e book_3 f53d book_4 f53c book_5 f53b +book_6 f3df book_online f217 +book_ribbon f3e7 bookmark e8e7 bookmark_add e598 bookmark_added e599 +bookmark_bag f410 bookmark_border e8e7 bookmark_check f457 bookmark_flag f456 @@ -466,6 +504,7 @@ bookmark_manager f7b1 bookmark_remove e59a bookmark_star f454 bookmarks e98b +books_movies_and_music ef82 border_all e228 border_bottom e229 border_clear e22a @@ -478,6 +517,7 @@ border_right e230 border_style e231 border_top e232 border_vertical e233 +borg f40d bottom_app_bar e730 bottom_drawer e72d bottom_navigation e98c @@ -496,6 +536,7 @@ breakfast_dining ea54 breaking_news ea08 breaking_news_alt_1 f0ba breastfeeding f856 +brick f388 brightness_1 e3fa brightness_2 f036 brightness_3 e3a8 @@ -529,6 +570,7 @@ build_circle ef48 bungalow e591 burst_mode e43c bus_alert e98f +bus_railway f36b business e7ee business_center eb3f business_chip f84c @@ -579,9 +621,26 @@ cancel_presentation e0e9 cancel_schedule_send ea39 candle f588 candlestick_chart ead4 +cannabis f2f3 captive_portal f728 capture f727 car_crash ebf2 +car_defrost_left f344 +car_defrost_low_left f343 +car_defrost_low_right f342 +car_defrost_mid_low_left f341 +car_defrost_mid_right f340 +car_defrost_right f33f +car_fan_low_left f33e +car_fan_low_mid_left f33d +car_fan_low_right f33c +car_fan_mid_left f33b +car_fan_mid_low_right f33a +car_fan_mid_right f339 +car_fan_recirculate f338 +car_gear f337 +car_lock f336 +car_mirror_heat f335 car_rental ea55 car_repair ea56 car_tag f4e3 @@ -591,6 +650,7 @@ card_travel e8f8 cardio_load f4b9 cardiology e09c cards e991 +cards_star f375 carpenter f1f8 carry_on_bag eb08 carry_on_bag_checked eb0b @@ -605,6 +665,7 @@ cast_pause f5f0 cast_warning f5ef castle eab1 category e574 +category_search f437 celebration ea65 cell_merge f82e cell_tower ebba @@ -627,6 +688,7 @@ chat_bubble_outline e0cb chat_error f7ac chat_info f52b chat_paste_go f6bd +chat_paste_go_2 f3cb check e5ca check_box e834 check_box_outline_blank e835 @@ -643,7 +705,9 @@ checklist e6b1 checklist_rtl e6b3 checkroom f19e cheer f6a8 +chef_hat f357 chess f5e7 +chess_pawn f3b6 chevron_backward f46b chevron_forward f46a chevron_left e5cb @@ -674,6 +738,8 @@ clear_day f157 clear_night f159 climate_mini_split f8b5 clinical_notes e09e +clock_arrow_down f382 +clock_arrow_up f381 clock_loader_10 f726 clock_loader_20 f725 clock_loader_40 f724 @@ -688,9 +754,11 @@ closed_caption_add f4ae closed_caption_disabled f1dc closed_caption_off e996 cloud f15c +cloud_alert f3cc cloud_circle e2be cloud_done e2bf cloud_download e2c0 +cloud_lock f386 cloud_off e2c1 cloud_queue f15c cloud_sync eb5a @@ -706,6 +774,7 @@ code_off e4f3 coffee efef coffee_maker eff0 cognition e09f +cognition_2 f3b5 collapse_all e944 collapse_content f507 collections e3d3 @@ -713,6 +782,7 @@ collections_bookmark e431 color_lens e40a colorize e3b8 colors e997 +combine_columns f420 comedy_mask f4d6 comic_bubble f5dd comment e24c @@ -730,6 +800,8 @@ component_exchange f1e7 compost e761 compress e94d computer e31e +computer_arrow_up f2f7 +computer_cancel f2f6 concierge f561 conditions e0a0 confirmation_number e638 @@ -769,6 +841,7 @@ control_point_duplicate e3bb controller_gen e83d conversion_path f0c1 conversion_path_off f7b4 +convert_to_text f41f conveyor_belt f867 cookie eaac cookie_off f79a @@ -793,6 +866,7 @@ countertops f1f7 create f097 create_new_folder e2cc credit_card e8a1 +credit_card_clock f438 credit_card_gear f52d credit_card_heart f52c credit_card_off e4f4 @@ -814,6 +888,7 @@ crop_rotate e437 crop_square e3c6 crossword f5e5 crowdsource eb18 +crown ecb3 cruelty_free e799 css eb93 csv e6cf @@ -836,6 +911,7 @@ cyclone ebd5 dangerous e99a dark_mode e51c dashboard e871 +dashboard_2 f3ea dashboard_customize e99b data_alert f7f6 data_array ead1 @@ -850,6 +926,9 @@ data_table e99c data_thresholding eb9f data_usage eff2 database f20e +database_off f414 +database_search f38e +database_upload f3dc dataset f8ee dataset_linked f8ef date_range e916 @@ -864,6 +943,9 @@ delete_forever e92b delete_history f518 delete_outline e92e delete_sweep e16c +delivery_dining eb28 +delivery_truck_bolt f3a2 +delivery_truck_speed f3a1 demography e489 density_large eba9 density_medium eb9e @@ -882,7 +964,10 @@ design_services f10a desk f8f4 deskphone f7fa desktop_access_disabled e99d +desktop_cloud f3db +desktop_cloud_stack f3be desktop_landscape f45e +desktop_landscape_add f439 desktop_mac e30b desktop_portrait f45d desktop_windows e30c @@ -901,17 +986,20 @@ developer_board_off e4ff developer_guide e99e developer_mode e1b0 developer_mode_tv e874 +device_band f2f5 device_hub e335 device_reset e8b3 device_thermostat e1ff device_unknown e339 devices e326 devices_fold ebde +devices_fold_2 f406 devices_off f7a5 devices_other e337 devices_wearables f6ab dew_point f879 diagnosis e0a8 +diagonal_line f41e dialer_sip e0bb dialogs e99f dialpad e0bc @@ -973,9 +1061,11 @@ dock e30e dock_to_bottom f7e6 dock_to_left f7e5 dock_to_right f7e4 +docs ea7d docs_add_on f0c2 docs_apps_script f0c3 document_scanner e5fa +document_search f385 domain e7ee domain_add eb62 domain_disabled e0ef @@ -1015,6 +1105,7 @@ draw_collage f7f7 drawing_recognition eb00 dresser e210 drive_eta eff7 +drive_export f41d drive_file_move e9a1 drive_file_move_outline e9a1 drive_file_move_rtl e9a1 @@ -1022,6 +1113,7 @@ drive_file_rename_outline e9a2 drive_folder_upload e9a3 drive_fusiontable e678 dropdown e9a4 +dropper_eye f351 dry f1b3 dry_cleaning ea58 dual_screen f6cf @@ -1033,7 +1125,12 @@ e911_avatar f11a e911_emergency f119 e_mobiledata f002 e_mobiledata_badge f7e3 +ear_sound f356 +earbud_case f327 +earbud_left f326 +earbud_right f325 earbuds f003 +earbuds_2 f324 earbuds_battery f004 early_on e2ba earthquake f64f @@ -1045,7 +1142,10 @@ eda f6e8 edgesensor_high f005 edgesensor_low f006 edit f097 +edit_arrow_down f380 +edit_arrow_up f37f edit_attributes e578 +edit_audio f42d edit_calendar e742 edit_document f88c edit_location e568 @@ -1093,6 +1193,10 @@ emoticon e5f3 empty_dashboard f844 enable f188 encrypted e593 +encrypted_add f429 +encrypted_add_circle f42a +encrypted_minus_circle f428 +encrypted_off f427 endocrinology e0a9 energy e9a6 energy_program_saving f15f @@ -1105,6 +1209,11 @@ enterprise e70e enterprise_off eb4d equal f77b equalizer e01d +eraser_size_1 f3fc +eraser_size_2 f3fb +eraser_size_3 f3fa +eraser_size_4 f3f9 +eraser_size_5 f3f8 error f8b6 error_circle_rounded f8b6 error_med e49b @@ -1138,6 +1247,8 @@ expand_circle_up f5d2 expand_content f830 expand_less e5ce expand_more e5cf +expansion_panels ef90 +expension_panels ef90 experiment e686 explicit e01e explore e87a @@ -1161,9 +1272,15 @@ face_3 f8db face_4 f8dc face_5 f8dd face_6 f8de +face_down f402 +face_left f401 +face_nod f400 face_retouching_natural ef4e face_retouching_off f007 +face_right f3ff +face_shake f3fe face_unlock f008 +face_up f3fd fact_check f0c5 factory ebbc falling f60d @@ -1173,6 +1290,8 @@ family_home eb26 family_link eb19 family_restroom f1a2 family_star f527 +fan_focus f334 +fan_indirect f333 farsight_digital f559 fast_forward e01f fast_rewind e020 @@ -1203,13 +1322,18 @@ file_copy_off f4d8 file_download f090 file_download_done f091 file_download_off e4fe +file_export f3b2 +file_json f3bb file_map e2c5 +file_map_stack f3e2 file_open eaf3 +file_png f3bc file_present ea0e file_save f17f file_save_off e505 file_upload f09b file_upload_off f886 +files ea85 filter e3d3 filter_1 e3d0 filter_2 e3d1 @@ -1223,6 +1347,7 @@ filter_9 e3d9 filter_9_plus e3da filter_alt ef4f filter_alt_off eb32 +filter_arrow_right f3d1 filter_b_and_w e3db filter_center_focus e3dc filter_drama e3dd @@ -1248,11 +1373,15 @@ fire_truck f8f2 fireplace ea43 first_page e5dc fit_page f77a +fit_page_height f397 +fit_page_width f396 fit_screen ea10 fit_width f779 fitness_center eb43 fitness_tracker f463 flag f0c6 +flag_2 f40f +flag_check f3d8 flag_circle eaf8 flag_filled f0c6 flaky ef50 @@ -1283,6 +1412,7 @@ flood ebe6 floor f6e4 floor_lamp e21e flourescent f07d +flowchart f38d flowsheet e0ae fluid e483 fluid_balance f80d @@ -1296,11 +1426,17 @@ fmd_good f1db foggy e818 folded_hands f5ed folder e2c7 +folder_check f3d7 +folder_check_2 f3d6 +folder_code f3c8 folder_copy ebbd folder_data f586 folder_delete eb34 +folder_eye f3d5 +folder_info f395 folder_limited f4e4 folder_managed f775 +folder_match f3d4 folder_off eb83 folder_open e2c8 folder_shared e2c9 @@ -1317,6 +1453,7 @@ for_you e9ac forest ea99 fork_left eba0 fork_right ebac +fork_spoon f3e4 forklift f868 format_align_center e234 format_align_justify e235 @@ -1353,6 +1490,7 @@ format_overline eb65 format_paint e243 format_paragraph f865 format_quote e244 +format_quote_off f413 format_shapes e25e format_size e245 format_strikethrough e246 @@ -1376,6 +1514,7 @@ forward_circle f6f5 forward_media f6f4 forward_to_inbox f187 foundation f200 +fragrance f345 frame_inspect f772 frame_person f8a6 frame_person_mic f4d5 @@ -1417,12 +1556,15 @@ gesture e155 gesture_select f657 get_app f090 gif e908 +gif_2 f40e gif_box e7a3 girl eb68 gite e58b glass_cup f6e3 globe e64c globe_asia f799 +globe_book f3c9 +globe_location_pin f35d globe_uk f798 glucose e4a0 glyphs f8a3 @@ -1439,10 +1581,17 @@ gpp_maybe f014 gps_fixed e55c gps_not_fixed e1b7 gps_off e1b6 -grade e885 +grade f09a gradient e3e9 grading ea4f grain e3ea +graph_1 f3a0 +graph_2 f39f +graph_3 f39e +graph_4 f39d +graph_5 f39c +graph_6 f39b +graph_7 f346 graphic_eq e1b8 grass f205 grid_3x3 f015 @@ -1458,6 +1607,7 @@ group ea21 group_add e7f0 group_off e747 group_remove e7ad +group_search f3ce group_work e886 grouped_bar_chart f211 groups f233 @@ -1473,12 +1623,14 @@ hail e9b1 hallway e6f8 hand_bones f894 hand_gesture ef9c +hand_gesture_off f3f3 handheld_controller f4c6 handshake ebcb handwriting_recognition eb02 handyman f10b hangout_video e0c1 hangout_video_off e0c2 +hard_disk f3da hard_drive f80e hard_drive_2 f7a4 hardware ea59 @@ -1509,6 +1661,7 @@ heap_snapshot_multiple f76d heap_snapshot_thumbnail f76c hearing e023 hearing_aid f464 +hearing_aid_disabled f3b0 hearing_disabled f104 heart_broken eac2 heart_check f60a @@ -1533,6 +1686,7 @@ high_density f79c high_quality e024 high_res f54b highlight e25f +highlight_alt ef52 highlight_keyboard_focus f510 highlight_mouse_cursor f511 highlight_off e888 @@ -1544,6 +1698,7 @@ highlighter_size_4 f768 highlighter_size_5 f767 hiking e50a history e8b3 +history_2 f3e6 history_edu ea3e history_off f4da history_toggle_off f17d @@ -1569,14 +1724,18 @@ home_work f030 horizontal_distribute e014 horizontal_rule f108 horizontal_split e947 +host f3d9 hot_tub eb46 hotel e549 hotel_class e743 hourglass ebff +hourglass_arrow_down f37e +hourglass_arrow_up f37d hourglass_bottom ea5c hourglass_disabled ef53 hourglass_empty e88b hourglass_full e88c +hourglass_pause f38c hourglass_top ea5b house ea44 house_siding f202 @@ -1599,13 +1758,17 @@ humidity_low f164 humidity_mid f165 humidity_percentage f87e hvac f10e +hvac_max_defrost f332 ice_skating e50b icecream ea69 id_card f4ca +identity_aware_proxy e2dd +identity_platform ebb7 ifl e025 iframe f71b iframe_off f71c image e3f4 +image_arrow_up f317 image_aspect_ratio e3f5 image_not_supported f116 image_search e43f @@ -1619,6 +1782,10 @@ in_home_mode e833 inactive_order e0fc inbox e156 inbox_customize f859 +inbox_text f399 +inbox_text_asterisk f360 +inbox_text_person f35e +inbox_text_share f35c incomplete_circle e79b indeterminate_check_box e909 indeterminate_question_box f56d @@ -1631,6 +1798,7 @@ ink_highlighter e6d1 ink_highlighter_move f524 ink_marker e6d2 ink_pen e6d3 +ink_selection ef52 inpatient e0fe input e890 input_circle f71a @@ -1726,6 +1894,7 @@ labs e105 lan eb2f landscape e564 landscape_2 f4c4 +landscape_2_edit f310 landscape_2_off f4c3 landslide ebd7 language e894 @@ -1747,6 +1916,7 @@ language_us_colemak f75b language_us_dvorak f75a laps f6b9 laptop e31e +laptop_car f3cd laptop_chromebook e31f laptop_mac e320 laptop_windows e321 @@ -1778,6 +1948,7 @@ light_group e28b light_mode e518 light_off e9b8 lightbulb e90f +lightbulb_2 f3e3 lightbulb_circle ebfe lightbulb_outline e90f lightning_stand efa4 @@ -1806,6 +1977,7 @@ liquor ea60 list e896 list_alt e0ee list_alt_add f756 +list_alt_check f3de lists e9b9 live_help e0c6 live_tv e63a @@ -1855,6 +2027,7 @@ locator_tag f8c1 lock e899 lock_clock ef57 lock_open e898 +lock_open_circle f361 lock_open_right f656 lock_outline e899 lock_person f8f3 @@ -1906,6 +2079,7 @@ manage_search f02f manga f5e3 manufacturing e726 map e55b +map_search f3ca maps_home_work f030 maps_ugc ef58 margin e9bb @@ -1921,8 +2095,10 @@ markdown_paste f554 markunread e159 markunread_mailbox e89b masked_transitions e72e +masked_transitions_add f42b masks f218 match_case f6f1 +match_case_off f36f match_word f6f0 matter e907 maximize e930 @@ -1952,6 +2128,7 @@ metabolism e10b metro f474 mfg_nest_yale_lock f11d mic e31d +mic_alert f392 mic_double f5d1 mic_external_off ef59 mic_external_on ef5a @@ -1975,8 +2152,16 @@ mitre f547 mixture_med e4c8 mms e618 mobile_friendly e200 +mobile_hand f323 +mobile_hand_left f313 +mobile_hand_left_off f312 +mobile_hand_off f314 +mobile_loupe f322 mobile_off e201 mobile_screen_share e0e7 +mobile_screensaver f321 +mobile_sound_2 f318 +mobile_speaker f320 mobiledata_off f034 mode f097 mode_comment e253 @@ -1995,8 +2180,10 @@ mode_of_travel e7ce mode_off_on f16f mode_standby f037 model_training f0cf +modeling f3aa monetization_on e263 money e57d +money_bag f3ee money_off f038 money_off_csred f038 monitor ef5b @@ -2009,7 +2196,9 @@ monochrome_photos e403 monorail f473 mood ea22 mood_bad e7f3 +moon_stars f34f mop e28d +moped eb28 more e619 more_down f196 more_horiz e5d3 @@ -2024,6 +2213,7 @@ motion_photos_off e9c0 motion_photos_on e9c1 motion_photos_pause f227 motion_photos_paused f227 +motion_play f40b motion_sensor_active e792 motion_sensor_alert e784 motion_sensor_idle e783 @@ -2057,10 +2247,13 @@ moving_ministry e73e mp e9c3 multicooker e293 multiline_chart e6df +multimodal_hand_eye f41b +multiple_airports efab multiple_stop f1b9 museum ea36 music_cast eb1a music_note e405 +music_note_add f391 music_off e440 music_video e063 my_location e55c @@ -2128,6 +2321,8 @@ nest_wifi_pro_2 f56a nest_wifi_router f133 network_cell e1b9 network_check e640 +network_intel_node f371 +network_intelligence efac network_intelligence_history f5f6 network_intelligence_update f5f5 network_locked e61a @@ -2153,6 +2348,7 @@ newsstand e9c4 next_plan ef5d next_week e16a nfc e1bb +nfc_off f369 night_shelter f1f1 night_sight_auto f1d7 night_sight_auto_off f1f9 @@ -2199,6 +2395,8 @@ notes e26c notification_add e399 notification_important e004 notification_multiple e6c2 +notification_settings f367 +notification_sound f353 notifications e7f5 notifications_active e7f7 notifications_none e7f5 @@ -2232,6 +2430,7 @@ open_run f4b7 open_with e89f ophthalmology e115 oral_disease e116 +orbit f426 order_approve f812 order_play f811 orders eb14 @@ -2254,6 +2453,7 @@ oven e9c7 oven_gen e843 overview e4a7 overview_key f7d4 +owl f3b4 oxygen_saturation e4de p2p f52a pace f6b8 @@ -2262,6 +2462,8 @@ package e48f package_2 f569 padding e9c8 page_control e731 +page_footer f383 +page_header f384 page_info f614 pageless f509 pages e7f9 @@ -2345,6 +2547,7 @@ person_play f7fd person_raised_hand f59a person_remove ef66 person_search f106 +person_shield e384 personal_bag eb0e personal_bag_off eb0f personal_bag_question eb10 @@ -2412,6 +2615,8 @@ pin f045 pin_drop e55e pin_end e767 pin_invoke e763 +pinboard f3ab +pinboard_unread f3ac pinch eb38 pinch_zoom_in f1fa pinch_zoom_out f1fb @@ -2421,6 +2626,7 @@ pivot_table_chart e9ce place f1db place_item f1f0 plagiarism ea5a +planet f387 planner_banner_ad_pt e692 planner_review e694 play_arrow e037 @@ -2438,6 +2644,7 @@ playlist_add_check_circle e7e6 playlist_add_circle e7e5 playlist_play e05f playlist_remove eb80 +plug_connect f35a plumbing f107 plus_one e800 podcasts f048 @@ -2447,6 +2654,7 @@ point_of_sale f17e point_scan f70c poker_chip f49b policy ea17 +policy_alert f407 poll f0cc polyline ebbb polymer e8ab @@ -2463,6 +2671,7 @@ power e63c power_input e336 power_off e646 power_rounded f8c7 +power_settings_circle f418 power_settings_new f8c7 prayer_times f838 precision_manufacturing f049 @@ -2523,8 +2732,8 @@ quick_reference e46e quick_reference_all f801 quick_reorder eb15 quickreply ef6c -quiet_time e1f9 -quiet_time_active e291 +quiet_time f159 +quiet_time_active eb76 quiz f04c r_mobiledata f04d radar f04e @@ -2544,6 +2753,7 @@ ramp_left eb9c ramp_right eb96 range_hood e1ea rate_review e560 +rate_review_rtl e706 raven f555 raw_off f04f raw_on f050 @@ -2555,6 +2765,7 @@ rebase f845 rebase_edit f846 receipt e8b0 receipt_long ef6e +receipt_long_off f40a recent_actors e03f recent_patient f808 recenter f4c0 @@ -2590,6 +2801,9 @@ repeat e040 repeat_on e9d6 repeat_one e041 repeat_one_on e9d7 +replace_audio f451 +replace_image f450 +replace_video f44f replay e042 replay_10 e059 replay_30 e05a @@ -2648,6 +2862,7 @@ room_preferences f1b8 room_service eb49 rotate_90_degrees_ccw e418 rotate_90_degrees_cw eaab +rotate_auto f417 rotate_left e419 rotate_right e41a roundabout_left eb99 @@ -2655,6 +2870,7 @@ roundabout_right eba3 rounded_corner e920 route eacd router e328 +router_off f2f4 routine e20c rowing e921 rss_feed e0e5 @@ -2679,6 +2895,7 @@ sauna f6f7 save e161 save_alt f090 save_as eb60 +save_clock f398 saved_search ea11 savings e2eb scale eb5f @@ -2707,6 +2924,7 @@ screen_search_desktop ef70 screen_share e0e2 screenshot f056 screenshot_frame f677 +screenshot_frame_2 f374 screenshot_keyboard f7d3 screenshot_monitor ec08 screenshot_region f7d2 @@ -2720,11 +2938,18 @@ sd_card_alert f057 sd_storage e623 sdk e720 search e8b6 +search_activity f3e5 search_check f800 search_check_2 f469 search_hands_free e696 search_insights f4bc search_off ea76 +seat_cool_left f331 +seat_cool_right f330 +seat_heat_left f32f +seat_heat_right f32e +seat_vent_left f32d +seat_vent_right f32c security e32a security_key f503 security_update f072 @@ -2768,6 +2993,7 @@ sentiment_very_dissatisfied e814 sentiment_very_satisfied e815 sentiment_worried f6a1 serif f4ac +server_person f3bd service_toolbox e717 set_meal f1ea settings e8b8 @@ -2811,6 +3037,7 @@ shape_line f8d3 shape_recognition eb01 shapes e602 share e80d +share_eta e5f7 share_location f05f share_off f6cb share_reviews f8a4 @@ -2825,6 +3052,7 @@ shield_locked f592 shield_moon eaa9 shield_person f650 shield_question f529 +shield_watch f30f shield_with_heart e78f shield_with_house e78d shift e5f2 @@ -2834,6 +3062,7 @@ shop e8c9 shop_2 e8ca shop_two e8ca shopping_bag f1cc +shopping_bag_speed f39a shopping_basket e8cb shopping_cart e8cc shopping_cart_checkout eb88 @@ -2883,8 +3112,13 @@ signpost eb91 sim_card e32b sim_card_alert f057 sim_card_download f068 +simulation f3e1 single_bed ea48 sip f069 +siren f3a7 +siren_check f3a6 +siren_open f3a5 +siren_question f3a4 skateboarding e511 skeleton f899 skillet f543 @@ -2892,6 +3126,7 @@ skillet_cooktop f544 skip_next e044 skip_previous e045 skull f89a +skull_list f370 slab_serif f4ab sledding e512 sleep e213 @@ -2908,6 +3143,7 @@ smart_outlet e844 smart_screen f06b smart_toy f06c smartphone e32c +smartphone_camera f44e smb_share f74b smoke_free eb4a smoking_rooms eb4b @@ -2971,6 +3207,11 @@ speed_1_7x f493 speed_2x f4eb speed_camera f470 spellcheck e8ce +split_scene f3bf +split_scene_down f2ff +split_scene_left f2fe +split_scene_right f2fd +split_scene_up f2fc splitscreen f06d splitscreen_add f4fd splitscreen_bottom f676 @@ -3006,9 +3247,12 @@ sports_volleyball ea31 sprinkler e29a sprint f81f square eb36 +square_dot f3b3 square_foot ea49 ssid_chart eb66 stack f609 +stack_group f359 +stack_hexagon f41c stack_off f608 stack_star f607 stacked_bar_chart e9e6 @@ -3028,7 +3272,9 @@ star_outline f09a star_purple500 f09a star_rate f0ec star_rate_half ec45 +star_shine f31d stars e8d0 +stars_2 f31c start e089 stat_0 e697 stat_1 e698 @@ -3041,6 +3287,7 @@ stay_current_landscape e0d3 stay_current_portrait e0d4 stay_primary_landscape e0d5 stay_primary_portrait e0d6 +steering_wheel_heat f32b step f6fe step_into f701 step_out f700 @@ -3076,8 +3323,13 @@ stroller f1ae style e41d styler e273 stylus f604 +stylus_brush f366 +stylus_fountain_pen f365 +stylus_highlighter f364 stylus_laser_pointer f747 stylus_note f603 +stylus_pen f363 +stylus_pencil f362 subdirectory_arrow_left e5d9 subdirectory_arrow_right e5da subheader e9ea @@ -3085,6 +3337,7 @@ subject e8d2 subscript f111 subscriptions e064 subtitles e048 +subtitles_gear f355 subtitles_off ef72 subway e56f summarize f071 @@ -3120,6 +3373,7 @@ swipe_vertical eb51 switch e1f4 switch_access f6fd switch_access_2 f506 +switch_access_3 f34d switch_access_shortcut e7e1 switch_access_shortcut_add e7e2 switch_account e9ed @@ -3134,6 +3388,9 @@ symptoms e132 synagogue eab0 sync e627 sync_alt ea18 +sync_arrow_down f37c +sync_arrow_up f37b +sync_desktop f41a sync_disabled e628 sync_lock eaee sync_problem e629 @@ -3146,17 +3403,22 @@ system_update f072 system_update_alt e8d7 tab e8d8 tab_close f745 +tab_close_inactive f3d0 tab_close_right f746 tab_duplicate f744 tab_group f743 +tab_inactive f43b tab_move f742 tab_new_right f741 tab_recent f740 +tab_search f2f2 tab_unselected e8d9 table f191 table_bar ead2 table_chart e265 table_chart_view f6ef +table_convert f3c7 +table_edit f3c6 table_eye f466 table_lamp e1f2 table_restaurant eac6 @@ -3165,6 +3427,7 @@ table_rows_narrow f73f table_view f1be tablet e32f tablet_android e330 +tablet_camera f44d tablet_mac e331 tabs e9ee tactic f564 @@ -3189,6 +3452,7 @@ tenancy f0e3 terminal eb8e terrain e564 text_ad e728 +text_compare f3c5 text_decrease eadd text_fields e262 text_fields_alt e9f1 @@ -3225,10 +3489,13 @@ thermometer_gain f6d8 thermometer_loss f6d7 thermometer_minus f581 thermostat f076 +thermostat_arrow_down f37a +thermostat_arrow_up f379 thermostat_auto f077 thermostat_carbon f178 things_to_do eb2a thread_unread f4f9 +threat_intelligence eaed thumb_down f578 thumb_down_alt f578 thumb_down_filled f578 @@ -3244,6 +3511,9 @@ thumbs_up_down e8dd thunderstorm ebdb tibia f89b tibia_alt f89c +tile_large f3c3 +tile_medium f3c2 +tile_small f3c1 time_auto f0e4 time_to_leave eff7 timelapse e422 @@ -3257,6 +3527,8 @@ timer_3_alt_1 efc0 timer_3_select f07b timer_5 f4b1 timer_5_shutter f4b2 +timer_arrow_down f378 +timer_arrow_up f377 timer_off e426 timer_pause f4bb timer_play f4ba @@ -3282,12 +3554,16 @@ tools_pliers_wire_stripper e2aa tools_power_drill e1e9 tools_wrench f8cd tooltip e9f8 +tooltip_2 f3ed top_panel_close f733 top_panel_open f732 topic f1c8 tornado e199 total_dissolved_solids f877 touch_app e913 +touch_double f38b +touch_long f38a +touch_triple f389 touchpad_mouse f687 touchpad_mouse_off f4e6 tour ef75 @@ -3296,6 +3572,8 @@ toys_and_games efc2 toys_fan f887 track_changes e8e1 trackpad_input f4c7 +trackpad_input_2 f409 +trackpad_input_3 f408 traffic e565 traffic_jam f46f trail_length eb5e @@ -3308,6 +3586,7 @@ transfer_within_a_station e572 transform e428 transgender e58d transit_enterexit e579 +transit_ticket f3f1 transition_chop f50e transition_dissolve f50d transition_fade f50c @@ -3342,8 +3621,10 @@ turn_slight_right eb9a turned_in e8e7 turned_in_not e8e7 tv e63b +tv_displays f3ec tv_gen e830 tv_guide e1dc +tv_next f3eb tv_off e647 tv_options_edit_channels e1dd tv_options_input_settings e1de @@ -3351,6 +3632,7 @@ tv_remote f5d9 tv_signin e71b tv_with_assistant e785 two_pager f51f +two_pager_store f3c4 two_wheeler e9f9 type_specimen f8f0 u_turn_left eba1 @@ -3382,6 +3664,7 @@ upcoming f07e update e923 update_disabled e075 upgrade f0fb +upi_pay f3cf upload f09b upload_2 f521 upload_file e9fc @@ -3401,6 +3684,7 @@ variable_remove f51c variables f851 ventilator e139 verified ef76 +verified_off f30e verified_user f013 vertical_align_bottom e258 vertical_align_center e259 @@ -3412,6 +3696,7 @@ vertical_split e949 vibration e62d video_call e070 video_camera_back f07f +video_camera_back_add f40c video_camera_front f080 video_camera_front_off f83b video_chat f8a0 @@ -3422,10 +3707,12 @@ video_search efc6 video_settings ea75 video_stable f081 videocam e04b +videocam_alert f390 videocam_off e04c videogame_asset e338 videogame_asset_off e500 view_agenda e8e9 +view_apps f376 view_array e8ea view_carousel e8eb view_column e8ec @@ -3443,6 +3730,7 @@ view_in_ar_off f61b view_kanban eb7f view_list e8ef view_module e8f0 +view_object_track f432 view_quilt e8f1 view_real_size f4c2 view_sidebar f114 @@ -3460,7 +3748,9 @@ vo2_max f4aa voice_chat e62e voice_over_off e94a voice_selection f58a +voice_selection_off f42c voicemail e0d9 +voicemail_2 f352 volcano ebda volume_down e04d volume_down_alt e79c @@ -3473,6 +3763,7 @@ vpn_key e0da vpn_key_alert f6cc vpn_key_off eb7a vpn_lock e62f +vpn_lock_2 f350 vr180_create2d efca vr180_create2d_off f571 vrpano f082 @@ -3481,6 +3772,8 @@ wall_lamp e2b4 wallet f8ff wallpaper e1bc wallpaper_slideshow f672 +wand_shine f31f +wand_stars f31e ward e13c warehouse ebb8 warning f083 @@ -3538,6 +3831,9 @@ west f1e6 whatshot e80e wheelchair_pickup f1ab where_to_vote e177 +widget_medium f3ba +widget_small f3b9 +widget_width f3b8 widgets e1bd width f730 width_full f8f5 @@ -3551,6 +3847,9 @@ wifi_calling ef77 wifi_calling_1 f0e7 wifi_calling_2 f0f6 wifi_calling_3 f0e7 +wifi_calling_bar_1 f44c +wifi_calling_bar_2 f44b +wifi_calling_bar_3 f44a wifi_channel eb6a wifi_find eb31 wifi_home f671 @@ -3568,6 +3867,9 @@ window f088 window_closed e77e window_open e78c window_sensor e2bb +windshield_defrost_front f32a +windshield_defrost_rear f329 +windshield_heat_front f328 wine_bar f1e8 woman e13e woman_2 f8e7 @@ -3596,4 +3898,4 @@ zone_person_urgent e788 zoom_in e8ff zoom_in_map eb2d zoom_out e900 -zoom_out_map e56b +zoom_out_map e56b \ No newline at end of file diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json similarity index 91% rename from client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json rename to client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json index 2be9d15d69..2eb48b234b 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json @@ -20,6 +20,7 @@ "21mp": 59743, "22mp": 59744, "23mp": 59745, + "24fps_select": 62450, "24mp": 59746, "2d": 61239, "2k": 59747, @@ -28,6 +29,7 @@ "30fps": 61390, "30fps_select": 61391, "360": 58743, + "3d": 60728, "3d_rotation": 59469, "3g_mobiledata": 61392, "3g_mobiledata_badge": 63472, @@ -72,6 +74,7 @@ "accessibility_new": 59692, "accessible": 59668, "accessible_forward": 59700, + "accessible_menu": 62286, "account_balance": 59471, "account_balance_wallet": 59472, "account_box": 59473, @@ -93,6 +96,7 @@ "adaptive_audio_mic_off": 62667, "adb": 58894, "add": 57669, + "add_2": 62429, "add_a_photo": 58425, "add_ad": 59178, "add_alarm": 59478, @@ -104,6 +108,8 @@ "add_chart": 61244, "add_circle": 58298, "add_circle_outline": 58298, + "add_column_left": 62501, + "add_column_right": 62500, "add_comment": 57958, "add_diamond": 62620, "add_home": 63723, @@ -117,6 +123,8 @@ "add_photo_alternate": 58430, "add_reaction": 57811, "add_road": 61243, + "add_row_above": 62499, + "add_row_below": 62498, "add_shopping_cart": 59476, "add_task": 62010, "add_to_drive": 58972, @@ -157,6 +165,7 @@ "alarm_add": 59478, "alarm_off": 59479, "alarm_on": 59480, + "alarm_pause": 62299, "alarm_smart_wake": 63152, "album": 57369, "align_center": 58198, @@ -231,6 +240,7 @@ "arming_countdown": 59274, "arrow_and_edge": 62935, "arrow_back": 58820, + "arrow_back_2": 62522, "arrow_back_ios": 58848, "arrow_back_ios_new": 58090, "arrow_circle_down": 61825, @@ -248,6 +258,8 @@ "arrow_insert": 63543, "arrow_left": 58846, "arrow_left_alt": 61309, + "arrow_menu_close": 62419, + "arrow_menu_open": 62418, "arrow_or_edge": 62934, "arrow_outward": 63694, "arrow_range": 63131, @@ -257,14 +269,19 @@ "arrow_split": 59908, "arrow_top_left": 63278, "arrow_top_right": 63277, + "arrow_upload_progress": 62452, + "arrow_upload_ready": 62453, "arrow_upward": 58840, "arrow_upward_alt": 59782, "arrow_warm_up": 62645, + "arrows_input": 62356, "arrows_more_down": 63659, "arrows_more_up": 63660, + "arrows_output": 62355, "arrows_outward": 63276, "art_track": 57440, "article": 61250, + "article_person": 62312, "article_shortcut": 62855, "artist": 57370, "aspect_ratio": 59483, @@ -325,6 +342,7 @@ "auto_transmission": 62783, "auto_videocam": 63168, "autofps_select": 61404, + "automation": 62497, "autopause": 63158, "autopay": 63563, "autoplay": 63157, @@ -359,6 +377,7 @@ "ballot": 57714, "bar_chart": 57963, "bar_chart_4_bars": 63105, + "bar_chart_off": 62481, "barcode": 59147, "barcode_reader": 63580, "barcode_scanner": 59148, @@ -383,6 +402,20 @@ "battery_80": 61600, "battery_90": 61601, "battery_alert": 57756, + "battery_android_0": 62221, + "battery_android_1": 62220, + "battery_android_2": 62219, + "battery_android_3": 62218, + "battery_android_4": 62217, + "battery_android_5": 62216, + "battery_android_6": 62215, + "battery_android_alert": 62214, + "battery_android_bolt": 62213, + "battery_android_full": 62212, + "battery_android_plus": 62211, + "battery_android_question": 62210, + "battery_android_share": 62209, + "battery_android_shield": 62208, "battery_change": 63467, "battery_charging_20": 61602, "battery_charging_30": 61603, @@ -414,7 +447,7 @@ "bedroom_baby": 61408, "bedroom_child": 61409, "bedroom_parent": 61410, - "bedtime": 61252, + "bedtime": 61785, "bedtime_off": 60278, "beenhere": 58669, "bento": 61940, @@ -446,6 +479,8 @@ "blur_off": 58276, "blur_on": 58277, "blur_short": 59599, + "boat_bus": 62317, + "boat_railway": 62316, "body_fat": 57496, "body_system": 57497, "bolt": 59915, @@ -455,10 +490,13 @@ "book_3": 62781, "book_4": 62780, "book_5": 62779, + "book_6": 62431, "book_online": 61975, + "book_ribbon": 62439, "bookmark": 59623, "bookmark_add": 58776, "bookmark_added": 58777, + "bookmark_bag": 62480, "bookmark_border": 59623, "bookmark_check": 62551, "bookmark_flag": 62550, @@ -467,6 +505,7 @@ "bookmark_remove": 58778, "bookmark_star": 62548, "bookmarks": 59787, + "books_movies_and_music": 61314, "border_all": 57896, "border_bottom": 57897, "border_clear": 57898, @@ -479,6 +518,7 @@ "border_style": 57905, "border_top": 57906, "border_vertical": 57907, + "borg": 62477, "bottom_app_bar": 59184, "bottom_drawer": 59181, "bottom_navigation": 59788, @@ -497,6 +537,7 @@ "breaking_news": 59912, "breaking_news_alt_1": 61626, "breastfeeding": 63574, + "brick": 62344, "brightness_1": 58362, "brightness_2": 61494, "brightness_3": 58280, @@ -530,6 +571,7 @@ "bungalow": 58769, "burst_mode": 58428, "bus_alert": 59791, + "bus_railway": 62315, "business": 59374, "business_center": 60223, "business_chip": 63564, @@ -580,9 +622,26 @@ "cancel_schedule_send": 59961, "candle": 62856, "candlestick_chart": 60116, + "cannabis": 62195, "captive_portal": 63272, "capture": 63271, "car_crash": 60402, + "car_defrost_left": 62276, + "car_defrost_low_left": 62275, + "car_defrost_low_right": 62274, + "car_defrost_mid_low_left": 62273, + "car_defrost_mid_right": 62272, + "car_defrost_right": 62271, + "car_fan_low_left": 62270, + "car_fan_low_mid_left": 62269, + "car_fan_low_right": 62268, + "car_fan_mid_left": 62267, + "car_fan_mid_low_right": 62266, + "car_fan_mid_right": 62265, + "car_fan_recirculate": 62264, + "car_gear": 62263, + "car_lock": 62262, + "car_mirror_heat": 62261, "car_rental": 59989, "car_repair": 59990, "car_tag": 62691, @@ -592,6 +651,7 @@ "cardio_load": 62649, "cardiology": 57500, "cards": 59793, + "cards_star": 62325, "carpenter": 61944, "carry_on_bag": 60168, "carry_on_bag_checked": 60171, @@ -606,6 +666,7 @@ "cast_warning": 62959, "castle": 60081, "category": 58740, + "category_search": 62519, "celebration": 60005, "cell_merge": 63534, "cell_tower": 60346, @@ -628,6 +689,7 @@ "chat_error": 63404, "chat_info": 62763, "chat_paste_go": 63165, + "chat_paste_go_2": 62411, "check": 58826, "check_box": 59444, "check_box_outline_blank": 59445, @@ -644,7 +706,9 @@ "checklist_rtl": 59059, "checkroom": 61854, "cheer": 63144, + "chef_hat": 62295, "chess": 62951, + "chess_pawn": 62390, "chevron_backward": 62571, "chevron_forward": 62570, "chevron_left": 58827, @@ -675,6 +739,8 @@ "clear_night": 61785, "climate_mini_split": 63669, "clinical_notes": 57502, + "clock_arrow_down": 62338, + "clock_arrow_up": 62337, "clock_loader_10": 63270, "clock_loader_20": 63269, "clock_loader_40": 63268, @@ -689,9 +755,11 @@ "closed_caption_disabled": 61916, "closed_caption_off": 59798, "cloud": 61788, + "cloud_alert": 62412, "cloud_circle": 58046, "cloud_done": 58047, "cloud_download": 58048, + "cloud_lock": 62342, "cloud_off": 58049, "cloud_queue": 61788, "cloud_sync": 60250, @@ -707,6 +775,7 @@ "coffee": 61423, "coffee_maker": 61424, "cognition": 57503, + "cognition_2": 62389, "collapse_all": 59716, "collapse_content": 62727, "collections": 58323, @@ -714,6 +783,7 @@ "color_lens": 58378, "colorize": 58296, "colors": 59799, + "combine_columns": 62496, "comedy_mask": 62678, "comic_bubble": 62941, "comment": 57932, @@ -731,6 +801,8 @@ "compost": 59233, "compress": 59725, "computer": 58142, + "computer_arrow_up": 62199, + "computer_cancel": 62198, "concierge": 62817, "conditions": 57504, "confirmation_number": 58936, @@ -770,6 +842,7 @@ "controller_gen": 59453, "conversion_path": 61633, "conversion_path_off": 63412, + "convert_to_text": 62495, "conveyor_belt": 63591, "cookie": 60076, "cookie_off": 63386, @@ -794,6 +867,7 @@ "create": 61591, "create_new_folder": 58060, "credit_card": 59553, + "credit_card_clock": 62520, "credit_card_gear": 62765, "credit_card_heart": 62764, "credit_card_off": 58612, @@ -815,6 +889,7 @@ "crop_square": 58310, "crossword": 62949, "crowdsource": 60184, + "crown": 60595, "cruelty_free": 59289, "css": 60307, "csv": 59087, @@ -837,6 +912,7 @@ "dangerous": 59802, "dark_mode": 58652, "dashboard": 59505, + "dashboard_2": 62442, "dashboard_customize": 59803, "data_alert": 63478, "data_array": 60113, @@ -851,6 +927,9 @@ "data_thresholding": 60319, "data_usage": 61426, "database": 61966, + "database_off": 62484, + "database_search": 62350, + "database_upload": 62428, "dataset": 63726, "dataset_linked": 63727, "date_range": 59670, @@ -865,6 +944,9 @@ "delete_history": 62744, "delete_outline": 59694, "delete_sweep": 57708, + "delivery_dining": 60200, + "delivery_truck_bolt": 62370, + "delivery_truck_speed": 62369, "demography": 58505, "density_large": 60329, "density_medium": 60318, @@ -883,7 +965,10 @@ "desk": 63732, "deskphone": 63482, "desktop_access_disabled": 59805, + "desktop_cloud": 62427, + "desktop_cloud_stack": 62398, "desktop_landscape": 62558, + "desktop_landscape_add": 62521, "desktop_mac": 58123, "desktop_portrait": 62557, "desktop_windows": 58124, @@ -902,17 +987,20 @@ "developer_guide": 59806, "developer_mode": 57776, "developer_mode_tv": 59508, + "device_band": 62197, "device_hub": 58165, "device_reset": 59571, "device_thermostat": 57855, "device_unknown": 58169, "devices": 58150, "devices_fold": 60382, + "devices_fold_2": 62470, "devices_off": 63397, "devices_other": 58167, "devices_wearables": 63147, "dew_point": 63609, "diagnosis": 57512, + "diagonal_line": 62494, "dialer_sip": 57531, "dialogs": 59807, "dialpad": 57532, @@ -974,9 +1062,11 @@ "dock_to_bottom": 63462, "dock_to_left": 63461, "dock_to_right": 63460, + "docs": 60029, "docs_add_on": 61634, "docs_apps_script": 61635, "document_scanner": 58874, + "document_search": 62341, "domain": 59374, "domain_add": 60258, "domain_disabled": 57583, @@ -1016,6 +1106,7 @@ "drawing_recognition": 60160, "dresser": 57872, "drive_eta": 61431, + "drive_export": 62493, "drive_file_move": 59809, "drive_file_move_outline": 59809, "drive_file_move_rtl": 59809, @@ -1023,6 +1114,7 @@ "drive_folder_upload": 59811, "drive_fusiontable": 59000, "dropdown": 59812, + "dropper_eye": 62289, "dry": 61875, "dry_cleaning": 59992, "dual_screen": 63183, @@ -1034,7 +1126,12 @@ "e911_emergency": 61721, "e_mobiledata": 61442, "e_mobiledata_badge": 63459, + "ear_sound": 62294, + "earbud_case": 62247, + "earbud_left": 62246, + "earbud_right": 62245, "earbuds": 61443, + "earbuds_2": 62244, "earbuds_battery": 61444, "early_on": 58042, "earthquake": 63055, @@ -1046,7 +1143,10 @@ "edgesensor_high": 61445, "edgesensor_low": 61446, "edit": 61591, + "edit_arrow_down": 62336, + "edit_arrow_up": 62335, "edit_attributes": 58744, + "edit_audio": 62509, "edit_calendar": 59202, "edit_document": 63628, "edit_location": 58728, @@ -1094,6 +1194,10 @@ "empty_dashboard": 63556, "enable": 61832, "encrypted": 58771, + "encrypted_add": 62505, + "encrypted_add_circle": 62506, + "encrypted_minus_circle": 62504, + "encrypted_off": 62503, "endocrinology": 57513, "energy": 59814, "energy_program_saving": 61791, @@ -1106,6 +1210,11 @@ "enterprise_off": 60237, "equal": 63355, "equalizer": 57373, + "eraser_size_1": 62460, + "eraser_size_2": 62459, + "eraser_size_3": 62458, + "eraser_size_4": 62457, + "eraser_size_5": 62456, "error": 63670, "error_circle_rounded": 63670, "error_med": 58523, @@ -1139,6 +1248,8 @@ "expand_content": 63536, "expand_less": 58830, "expand_more": 58831, + "expansion_panels": 61328, + "expension_panels": 61328, "experiment": 59014, "explicit": 57374, "explore": 59514, @@ -1162,9 +1273,15 @@ "face_4": 63708, "face_5": 63709, "face_6": 63710, + "face_down": 62466, + "face_left": 62465, + "face_nod": 62464, "face_retouching_natural": 61262, "face_retouching_off": 61447, + "face_right": 62463, + "face_shake": 62462, "face_unlock": 61448, + "face_up": 62461, "fact_check": 61637, "factory": 60348, "falling": 62989, @@ -1174,6 +1291,8 @@ "family_link": 60185, "family_restroom": 61858, "family_star": 62759, + "fan_focus": 62260, + "fan_indirect": 62259, "farsight_digital": 62809, "fast_forward": 57375, "fast_rewind": 57376, @@ -1204,13 +1323,18 @@ "file_download": 61584, "file_download_done": 61585, "file_download_off": 58622, + "file_export": 62386, + "file_json": 62395, "file_map": 58053, + "file_map_stack": 62434, "file_open": 60147, + "file_png": 62396, "file_present": 59918, "file_save": 61823, "file_save_off": 58629, "file_upload": 61595, "file_upload_off": 63622, + "files": 60037, "filter": 58323, "filter_1": 58320, "filter_2": 58321, @@ -1224,6 +1348,7 @@ "filter_9_plus": 58330, "filter_alt": 61263, "filter_alt_off": 60210, + "filter_arrow_right": 62417, "filter_b_and_w": 58331, "filter_center_focus": 58332, "filter_drama": 58333, @@ -1249,11 +1374,15 @@ "fireplace": 59971, "first_page": 58844, "fit_page": 63354, + "fit_page_height": 62359, + "fit_page_width": 62358, "fit_screen": 59920, "fit_width": 63353, "fitness_center": 60227, "fitness_tracker": 62563, "flag": 61638, + "flag_2": 62479, + "flag_check": 62424, "flag_circle": 60152, "flag_filled": 61638, "flaky": 61264, @@ -1284,6 +1413,7 @@ "floor": 63204, "floor_lamp": 57886, "flourescent": 61565, + "flowchart": 62349, "flowsheet": 57518, "fluid": 58499, "fluid_balance": 63501, @@ -1297,11 +1427,17 @@ "foggy": 59416, "folded_hands": 62957, "folder": 58055, + "folder_check": 62423, + "folder_check_2": 62422, + "folder_code": 62408, "folder_copy": 60349, "folder_data": 62854, "folder_delete": 60212, + "folder_eye": 62421, + "folder_info": 62357, "folder_limited": 62692, "folder_managed": 63349, + "folder_match": 62420, "folder_off": 60291, "folder_open": 58056, "folder_shared": 58057, @@ -1318,6 +1454,7 @@ "forest": 60057, "fork_left": 60320, "fork_right": 60332, + "fork_spoon": 62436, "forklift": 63592, "format_align_center": 57908, "format_align_justify": 57909, @@ -1354,6 +1491,7 @@ "format_paint": 57923, "format_paragraph": 63589, "format_quote": 57924, + "format_quote_off": 62483, "format_shapes": 57950, "format_size": 57925, "format_strikethrough": 57926, @@ -1377,6 +1515,7 @@ "forward_media": 63220, "forward_to_inbox": 61831, "foundation": 61952, + "fragrance": 62277, "frame_inspect": 63346, "frame_person": 63654, "frame_person_mic": 62677, @@ -1418,12 +1557,15 @@ "gesture_select": 63063, "get_app": 61584, "gif": 59656, + "gif_2": 62478, "gif_box": 59299, "girl": 60264, "gite": 58763, "glass_cup": 63203, "globe": 58956, "globe_asia": 63385, + "globe_book": 62409, + "globe_location_pin": 62301, "globe_uk": 63384, "glucose": 58528, "glyphs": 63651, @@ -1440,10 +1582,17 @@ "gps_fixed": 58716, "gps_not_fixed": 57783, "gps_off": 57782, - "grade": 59525, + "grade": 61594, "gradient": 58345, "grading": 59983, "grain": 58346, + "graph_1": 62368, + "graph_2": 62367, + "graph_3": 62366, + "graph_4": 62365, + "graph_5": 62364, + "graph_6": 62363, + "graph_7": 62278, "graphic_eq": 57784, "grass": 61957, "grid_3x3": 61461, @@ -1459,6 +1608,7 @@ "group_add": 59376, "group_off": 59207, "group_remove": 59309, + "group_search": 62414, "group_work": 59526, "grouped_bar_chart": 61969, "groups": 62003, @@ -1474,12 +1624,14 @@ "hallway": 59128, "hand_bones": 63636, "hand_gesture": 61340, + "hand_gesture_off": 62451, "handheld_controller": 62662, "handshake": 60363, "handwriting_recognition": 60162, "handyman": 61707, "hangout_video": 57537, "hangout_video_off": 57538, + "hard_disk": 62426, "hard_drive": 63502, "hard_drive_2": 63396, "hardware": 59993, @@ -1510,6 +1662,7 @@ "heap_snapshot_thumbnail": 63340, "hearing": 57379, "hearing_aid": 62564, + "hearing_aid_disabled": 62384, "hearing_disabled": 61700, "heart_broken": 60098, "heart_check": 62986, @@ -1534,6 +1687,7 @@ "high_quality": 57380, "high_res": 62795, "highlight": 57951, + "highlight_alt": 61266, "highlight_keyboard_focus": 62736, "highlight_mouse_cursor": 62737, "highlight_off": 59528, @@ -1545,6 +1699,7 @@ "highlighter_size_5": 63335, "hiking": 58634, "history": 59571, + "history_2": 62438, "history_edu": 59966, "history_off": 62682, "history_toggle_off": 61821, @@ -1570,14 +1725,18 @@ "horizontal_distribute": 57364, "horizontal_rule": 61704, "horizontal_split": 59719, + "host": 62425, "hot_tub": 60230, "hotel": 58697, "hotel_class": 59203, "hourglass": 60415, + "hourglass_arrow_down": 62334, + "hourglass_arrow_up": 62333, "hourglass_bottom": 59996, "hourglass_disabled": 61267, "hourglass_empty": 59531, "hourglass_full": 59532, + "hourglass_pause": 62348, "hourglass_top": 59995, "house": 59972, "house_siding": 61954, @@ -1600,13 +1759,17 @@ "humidity_mid": 61797, "humidity_percentage": 63614, "hvac": 61710, + "hvac_max_defrost": 62258, "ice_skating": 58635, "icecream": 60009, "id_card": 62666, + "identity_aware_proxy": 58077, + "identity_platform": 60343, "ifl": 57381, "iframe": 63259, "iframe_off": 63260, "image": 58356, + "image_arrow_up": 62231, "image_aspect_ratio": 58357, "image_not_supported": 61718, "image_search": 58431, @@ -1620,6 +1783,10 @@ "inactive_order": 57596, "inbox": 57686, "inbox_customize": 63577, + "inbox_text": 62361, + "inbox_text_asterisk": 62304, + "inbox_text_person": 62302, + "inbox_text_share": 62300, "incomplete_circle": 59291, "indeterminate_check_box": 59657, "indeterminate_question_box": 62829, @@ -1632,6 +1799,7 @@ "ink_highlighter_move": 62756, "ink_marker": 59090, "ink_pen": 59091, + "ink_selection": 61266, "inpatient": 57598, "input": 59536, "input_circle": 63258, @@ -1727,6 +1895,7 @@ "lan": 60207, "landscape": 58724, "landscape_2": 62660, + "landscape_2_edit": 62224, "landscape_2_off": 62659, "landslide": 60375, "language": 59540, @@ -1748,6 +1917,7 @@ "language_us_dvorak": 63322, "laps": 63161, "laptop": 58142, + "laptop_car": 62413, "laptop_chromebook": 58143, "laptop_mac": 58144, "laptop_windows": 58145, @@ -1779,6 +1949,7 @@ "light_mode": 58648, "light_off": 59832, "lightbulb": 59663, + "lightbulb_2": 62435, "lightbulb_circle": 60414, "lightbulb_outline": 59663, "lightning_stand": 61348, @@ -1807,6 +1978,7 @@ "list": 59542, "list_alt": 57582, "list_alt_add": 63318, + "list_alt_check": 62430, "lists": 59833, "live_help": 57542, "live_tv": 58938, @@ -1856,6 +2028,7 @@ "lock": 59545, "lock_clock": 61271, "lock_open": 59544, + "lock_open_circle": 62305, "lock_open_right": 63062, "lock_outline": 59545, "lock_person": 63731, @@ -1907,6 +2080,7 @@ "manga": 62947, "manufacturing": 59174, "map": 58715, + "map_search": 62410, "maps_home_work": 61488, "maps_ugc": 61272, "margin": 59835, @@ -1922,8 +2096,10 @@ "markunread": 57689, "markunread_mailbox": 59547, "masked_transitions": 59182, + "masked_transitions_add": 62507, "masks": 61976, "match_case": 63217, + "match_case_off": 62319, "match_word": 63216, "matter": 59655, "maximize": 59696, @@ -1953,6 +2129,7 @@ "metro": 62580, "mfg_nest_yale_lock": 61725, "mic": 58141, + "mic_alert": 62354, "mic_double": 62929, "mic_external_off": 61273, "mic_external_on": 61274, @@ -1976,8 +2153,16 @@ "mixture_med": 58568, "mms": 58904, "mobile_friendly": 57856, + "mobile_hand": 62243, + "mobile_hand_left": 62227, + "mobile_hand_left_off": 62226, + "mobile_hand_off": 62228, + "mobile_loupe": 62242, "mobile_off": 57857, "mobile_screen_share": 57575, + "mobile_screensaver": 62241, + "mobile_sound_2": 62232, + "mobile_speaker": 62240, "mobiledata_off": 61492, "mode": 61591, "mode_comment": 57939, @@ -1996,8 +2181,10 @@ "mode_off_on": 61807, "mode_standby": 61495, "model_training": 61647, + "modeling": 62378, "monetization_on": 57955, "money": 58749, + "money_bag": 62446, "money_off": 61496, "money_off_csred": 61496, "monitor": 61275, @@ -2010,7 +2197,9 @@ "monorail": 62579, "mood": 59938, "mood_bad": 59379, + "moon_stars": 62287, "mop": 57997, + "moped": 60200, "more": 58905, "more_down": 61846, "more_horiz": 58835, @@ -2025,6 +2214,7 @@ "motion_photos_on": 59841, "motion_photos_pause": 61991, "motion_photos_paused": 61991, + "motion_play": 62475, "motion_sensor_active": 59282, "motion_sensor_alert": 59268, "motion_sensor_idle": 59267, @@ -2058,10 +2248,13 @@ "mp": 59843, "multicooker": 58003, "multiline_chart": 59103, + "multimodal_hand_eye": 62491, + "multiple_airports": 61355, "multiple_stop": 61881, "museum": 59958, "music_cast": 60186, "music_note": 58373, + "music_note_add": 62353, "music_off": 58432, "music_video": 57443, "my_location": 58716, @@ -2129,6 +2322,8 @@ "nest_wifi_router": 61747, "network_cell": 57785, "network_check": 58944, + "network_intel_node": 62321, + "network_intelligence": 61356, "network_intelligence_history": 62966, "network_intelligence_update": 62965, "network_locked": 58906, @@ -2154,6 +2349,7 @@ "next_plan": 61277, "next_week": 57706, "nfc": 57787, + "nfc_off": 62313, "night_shelter": 61937, "night_sight_auto": 61911, "night_sight_auto_off": 61945, @@ -2200,6 +2396,8 @@ "notification_add": 58265, "notification_important": 57348, "notification_multiple": 59074, + "notification_settings": 62311, + "notification_sound": 62291, "notifications": 59381, "notifications_active": 59383, "notifications_none": 59381, @@ -2233,6 +2431,7 @@ "open_with": 59551, "ophthalmology": 57621, "oral_disease": 57622, + "orbit": 62502, "order_approve": 63506, "order_play": 63505, "orders": 60180, @@ -2255,6 +2454,7 @@ "oven_gen": 59459, "overview": 58535, "overview_key": 63444, + "owl": 62388, "oxygen_saturation": 58590, "p2p": 62762, "pace": 63160, @@ -2263,6 +2463,8 @@ "package_2": 62825, "padding": 59848, "page_control": 59185, + "page_footer": 62339, + "page_header": 62340, "page_info": 62996, "pageless": 62729, "pages": 59385, @@ -2346,6 +2548,7 @@ "person_raised_hand": 62874, "person_remove": 61286, "person_search": 61702, + "person_shield": 58244, "personal_bag": 60174, "personal_bag_off": 60175, "personal_bag_question": 60176, @@ -2413,6 +2616,8 @@ "pin_drop": 58718, "pin_end": 59239, "pin_invoke": 59235, + "pinboard": 62379, + "pinboard_unread": 62380, "pinch": 60216, "pinch_zoom_in": 61946, "pinch_zoom_out": 61947, @@ -2422,6 +2627,7 @@ "place": 61915, "place_item": 61936, "plagiarism": 59994, + "planet": 62343, "planner_banner_ad_pt": 59026, "planner_review": 59028, "play_arrow": 57399, @@ -2439,6 +2645,7 @@ "playlist_add_circle": 59365, "playlist_play": 57439, "playlist_remove": 60288, + "plug_connect": 62298, "plumbing": 61703, "plus_one": 59392, "podcasts": 61512, @@ -2448,6 +2655,7 @@ "point_scan": 63244, "poker_chip": 62619, "policy": 59927, + "policy_alert": 62471, "poll": 61644, "polyline": 60347, "polymer": 59563, @@ -2464,6 +2672,7 @@ "power_input": 58166, "power_off": 58950, "power_rounded": 63687, + "power_settings_circle": 62488, "power_settings_new": 63687, "prayer_times": 63544, "precision_manufacturing": 61513, @@ -2524,8 +2733,8 @@ "quick_reference_all": 63489, "quick_reorder": 60181, "quickreply": 61292, - "quiet_time": 57849, - "quiet_time_active": 58001, + "quiet_time": 61785, + "quiet_time_active": 60278, "quiz": 61516, "r_mobiledata": 61517, "radar": 61518, @@ -2545,6 +2754,7 @@ "ramp_right": 60310, "range_hood": 57834, "rate_review": 58720, + "rate_review_rtl": 59142, "raven": 62805, "raw_off": 61519, "raw_on": 61520, @@ -2556,6 +2766,7 @@ "rebase_edit": 63558, "receipt": 59568, "receipt_long": 61294, + "receipt_long_off": 62474, "recent_actors": 57407, "recent_patient": 63496, "recenter": 62656, @@ -2591,6 +2802,9 @@ "repeat_on": 59862, "repeat_one": 57409, "repeat_one_on": 59863, + "replace_audio": 62545, + "replace_image": 62544, + "replace_video": 62543, "replay": 57410, "replay_10": 57433, "replay_30": 57434, @@ -2649,6 +2863,7 @@ "room_service": 60233, "rotate_90_degrees_ccw": 58392, "rotate_90_degrees_cw": 60075, + "rotate_auto": 62487, "rotate_left": 58393, "rotate_right": 58394, "roundabout_left": 60313, @@ -2656,6 +2871,7 @@ "rounded_corner": 59680, "route": 60109, "router": 58152, + "router_off": 62196, "routine": 57868, "rowing": 59681, "rss_feed": 57573, @@ -2680,6 +2896,7 @@ "save": 57697, "save_alt": 61584, "save_as": 60256, + "save_clock": 62360, "saved_search": 59921, "savings": 58091, "scale": 60255, @@ -2708,6 +2925,7 @@ "screen_share": 57570, "screenshot": 61526, "screenshot_frame": 63095, + "screenshot_frame_2": 62324, "screenshot_keyboard": 63443, "screenshot_monitor": 60424, "screenshot_region": 63442, @@ -2721,11 +2939,18 @@ "sd_storage": 58915, "sdk": 59168, "search": 59574, + "search_activity": 62437, "search_check": 63488, "search_check_2": 62569, "search_hands_free": 59030, "search_insights": 62652, "search_off": 60022, + "seat_cool_left": 62257, + "seat_cool_right": 62256, + "seat_heat_left": 62255, + "seat_heat_right": 62254, + "seat_vent_left": 62253, + "seat_vent_right": 62252, "security": 58154, "security_key": 62723, "security_update": 61554, @@ -2769,6 +2994,7 @@ "sentiment_very_satisfied": 59413, "sentiment_worried": 63137, "serif": 62636, + "server_person": 62397, "service_toolbox": 59159, "set_meal": 61930, "settings": 59576, @@ -2812,6 +3038,7 @@ "shape_recognition": 60161, "shapes": 58882, "share": 59405, + "share_eta": 58871, "share_location": 61535, "share_off": 63179, "share_reviews": 63652, @@ -2826,6 +3053,7 @@ "shield_moon": 60073, "shield_person": 63056, "shield_question": 62761, + "shield_watch": 62223, "shield_with_heart": 59279, "shield_with_house": 59277, "shift": 58866, @@ -2835,6 +3063,7 @@ "shop_2": 59594, "shop_two": 59594, "shopping_bag": 61900, + "shopping_bag_speed": 62362, "shopping_basket": 59595, "shopping_cart": 59596, "shopping_cart_checkout": 60296, @@ -2884,8 +3113,13 @@ "sim_card": 58155, "sim_card_alert": 61527, "sim_card_download": 61544, + "simulation": 62433, "single_bed": 59976, "sip": 61545, + "siren": 62375, + "siren_check": 62374, + "siren_open": 62373, + "siren_question": 62372, "skateboarding": 58641, "skeleton": 63641, "skillet": 62787, @@ -2893,6 +3127,7 @@ "skip_next": 57412, "skip_previous": 57413, "skull": 63642, + "skull_list": 62320, "slab_serif": 62635, "sledding": 58642, "sleep": 57875, @@ -2909,6 +3144,7 @@ "smart_screen": 61547, "smart_toy": 61548, "smartphone": 58156, + "smartphone_camera": 62542, "smb_share": 63307, "smoke_free": 60234, "smoking_rooms": 60235, @@ -2972,6 +3208,11 @@ "speed_2x": 62699, "speed_camera": 62576, "spellcheck": 59598, + "split_scene": 62399, + "split_scene_down": 62207, + "split_scene_left": 62206, + "split_scene_right": 62205, + "split_scene_up": 62204, "splitscreen": 61549, "splitscreen_add": 62717, "splitscreen_bottom": 63094, @@ -3007,9 +3248,12 @@ "sprinkler": 58010, "sprint": 63519, "square": 60214, + "square_dot": 62387, "square_foot": 59977, "ssid_chart": 60262, "stack": 62985, + "stack_group": 62297, + "stack_hexagon": 62492, "stack_off": 62984, "stack_star": 62983, "stacked_bar_chart": 59878, @@ -3029,7 +3273,9 @@ "star_purple500": 61594, "star_rate": 61676, "star_rate_half": 60485, + "star_shine": 62237, "stars": 59600, + "stars_2": 62236, "start": 57481, "stat_0": 59031, "stat_1": 59032, @@ -3042,6 +3288,7 @@ "stay_current_portrait": 57556, "stay_primary_landscape": 57557, "stay_primary_portrait": 57558, + "steering_wheel_heat": 62251, "step": 63230, "step_into": 63233, "step_out": 63232, @@ -3077,8 +3324,13 @@ "style": 58397, "styler": 57971, "stylus": 62980, + "stylus_brush": 62310, + "stylus_fountain_pen": 62309, + "stylus_highlighter": 62308, "stylus_laser_pointer": 63303, "stylus_note": 62979, + "stylus_pen": 62307, + "stylus_pencil": 62306, "subdirectory_arrow_left": 58841, "subdirectory_arrow_right": 58842, "subheader": 59882, @@ -3086,6 +3338,7 @@ "subscript": 61713, "subscriptions": 57444, "subtitles": 57416, + "subtitles_gear": 62293, "subtitles_off": 61298, "subway": 58735, "summarize": 61553, @@ -3121,6 +3374,7 @@ "switch": 57844, "switch_access": 63229, "switch_access_2": 62726, + "switch_access_3": 62285, "switch_access_shortcut": 59361, "switch_access_shortcut_add": 59362, "switch_account": 59885, @@ -3135,6 +3389,9 @@ "synagogue": 60080, "sync": 58919, "sync_alt": 59928, + "sync_arrow_down": 62332, + "sync_arrow_up": 62331, + "sync_desktop": 62490, "sync_disabled": 58920, "sync_lock": 60142, "sync_problem": 58921, @@ -3147,17 +3404,22 @@ "system_update_alt": 59607, "tab": 59608, "tab_close": 63301, + "tab_close_inactive": 62416, "tab_close_right": 63302, "tab_duplicate": 63300, "tab_group": 63299, + "tab_inactive": 62523, "tab_move": 63298, "tab_new_right": 63297, "tab_recent": 63296, + "tab_search": 62194, "tab_unselected": 59609, "table": 61841, "table_bar": 60114, "table_chart": 57957, "table_chart_view": 63215, + "table_convert": 62407, + "table_edit": 62406, "table_eye": 62566, "table_lamp": 57842, "table_restaurant": 60102, @@ -3166,6 +3428,7 @@ "table_view": 61886, "tablet": 58159, "tablet_android": 58160, + "tablet_camera": 62541, "tablet_mac": 58161, "tabs": 59886, "tactic": 62820, @@ -3190,6 +3453,7 @@ "terminal": 60302, "terrain": 58724, "text_ad": 59176, + "text_compare": 62405, "text_decrease": 60125, "text_fields": 57954, "text_fields_alt": 59889, @@ -3226,10 +3490,13 @@ "thermometer_loss": 63191, "thermometer_minus": 62849, "thermostat": 61558, + "thermostat_arrow_down": 62330, + "thermostat_arrow_up": 62329, "thermostat_auto": 61559, "thermostat_carbon": 61816, "things_to_do": 60202, "thread_unread": 62713, + "threat_intelligence": 60141, "thumb_down": 62840, "thumb_down_alt": 62840, "thumb_down_filled": 62840, @@ -3245,6 +3512,9 @@ "thunderstorm": 60379, "tibia": 63643, "tibia_alt": 63644, + "tile_large": 62403, + "tile_medium": 62402, + "tile_small": 62401, "time_auto": 61668, "time_to_leave": 61431, "timelapse": 58402, @@ -3258,6 +3528,8 @@ "timer_3_select": 61563, "timer_5": 62641, "timer_5_shutter": 62642, + "timer_arrow_down": 62328, + "timer_arrow_up": 62327, "timer_off": 58406, "timer_pause": 62651, "timer_play": 62650, @@ -3283,12 +3555,16 @@ "tools_power_drill": 57833, "tools_wrench": 63693, "tooltip": 59896, + "tooltip_2": 62445, "top_panel_close": 63283, "top_panel_open": 63282, "topic": 61896, "tornado": 57753, "total_dissolved_solids": 63607, "touch_app": 59667, + "touch_double": 62347, + "touch_long": 62346, + "touch_triple": 62345, "touchpad_mouse": 63111, "touchpad_mouse_off": 62694, "tour": 61301, @@ -3297,6 +3573,8 @@ "toys_fan": 63623, "track_changes": 59617, "trackpad_input": 62663, + "trackpad_input_2": 62473, + "trackpad_input_3": 62472, "traffic": 58725, "traffic_jam": 62575, "trail_length": 60254, @@ -3309,6 +3587,7 @@ "transform": 58408, "transgender": 58765, "transit_enterexit": 58745, + "transit_ticket": 62449, "transition_chop": 62734, "transition_dissolve": 62733, "transition_fade": 62732, @@ -3343,8 +3622,10 @@ "turned_in": 59623, "turned_in_not": 59623, "tv": 58939, + "tv_displays": 62444, "tv_gen": 59440, "tv_guide": 57820, + "tv_next": 62443, "tv_off": 58951, "tv_options_edit_channels": 57821, "tv_options_input_settings": 57822, @@ -3352,6 +3633,7 @@ "tv_signin": 59163, "tv_with_assistant": 59269, "two_pager": 62751, + "two_pager_store": 62404, "two_wheeler": 59897, "type_specimen": 63728, "u_turn_left": 60321, @@ -3383,6 +3665,7 @@ "update": 59683, "update_disabled": 57461, "upgrade": 61691, + "upi_pay": 62415, "upload": 61595, "upload_2": 62753, "upload_file": 59900, @@ -3402,6 +3685,7 @@ "variables": 63569, "ventilator": 57657, "verified": 61302, + "verified_off": 62222, "verified_user": 61459, "vertical_align_bottom": 57944, "vertical_align_center": 57945, @@ -3413,6 +3697,7 @@ "vibration": 58925, "video_call": 57456, "video_camera_back": 61567, + "video_camera_back_add": 62476, "video_camera_front": 61568, "video_camera_front_off": 63547, "video_chat": 63648, @@ -3423,10 +3708,12 @@ "video_settings": 60021, "video_stable": 61569, "videocam": 57419, + "videocam_alert": 62352, "videocam_off": 57420, "videogame_asset": 58168, "videogame_asset_off": 58624, "view_agenda": 59625, + "view_apps": 62326, "view_array": 59626, "view_carousel": 59627, "view_column": 59628, @@ -3444,6 +3731,7 @@ "view_kanban": 60287, "view_list": 59631, "view_module": 59632, + "view_object_track": 62514, "view_quilt": 59633, "view_real_size": 62658, "view_sidebar": 61716, @@ -3461,7 +3749,9 @@ "voice_chat": 58926, "voice_over_off": 59722, "voice_selection": 62858, + "voice_selection_off": 62508, "voicemail": 57561, + "voicemail_2": 62290, "volcano": 60378, "volume_down": 57421, "volume_down_alt": 59292, @@ -3474,6 +3764,7 @@ "vpn_key_alert": 63180, "vpn_key_off": 60282, "vpn_lock": 58927, + "vpn_lock_2": 62288, "vr180_create2d": 61386, "vr180_create2d_off": 62833, "vrpano": 61570, @@ -3482,6 +3773,8 @@ "wallet": 63743, "wallpaper": 57788, "wallpaper_slideshow": 63090, + "wand_shine": 62239, + "wand_stars": 62238, "ward": 57660, "warehouse": 60344, "warning": 61571, @@ -3539,6 +3832,9 @@ "whatshot": 59406, "wheelchair_pickup": 61867, "where_to_vote": 57719, + "widget_medium": 62394, + "widget_small": 62393, + "widget_width": 62392, "widgets": 57789, "width": 63280, "width_full": 63733, @@ -3552,6 +3848,9 @@ "wifi_calling_1": 61671, "wifi_calling_2": 61686, "wifi_calling_3": 61671, + "wifi_calling_bar_1": 62540, + "wifi_calling_bar_2": 62539, + "wifi_calling_bar_3": 62538, "wifi_channel": 60266, "wifi_find": 60209, "wifi_home": 63089, @@ -3569,6 +3868,9 @@ "window_closed": 59262, "window_open": 59276, "window_sensor": 58043, + "windshield_defrost_front": 62250, + "windshield_defrost_rear": 62249, + "windshield_heat_front": 62248, "wine_bar": 61928, "woman": 57662, "woman_2": 63719, diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf new file mode 100644 index 0000000000..26f767e075 Binary files /dev/null and b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf differ diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf deleted file mode 100644 index 255db6f479..0000000000 Binary files a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf and /dev/null differ diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py index 6da4c6986b..581b37c213 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py @@ -5,12 +5,32 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) def get_font_filepath( - font_name: Optional[str] = "MaterialSymbolsOutlined" + font_name: Optional[str] = "MaterialSymbolsOutlined-Regular" ) -> str: return os.path.join(CURRENT_DIR, f"{font_name}.ttf") def get_mapping_filepath( - font_name: Optional[str] = "MaterialSymbolsOutlined" + font_name: Optional[str] = "MaterialSymbolsOutlined-Regular" ) -> str: return os.path.join(CURRENT_DIR, f"{font_name}.json") + + +def regenerate_mapping(): + """Regenerate the MaterialSymbolsOutlined.json file, assuming + MaterialSymbolsOutlined.codepoints and the TrueType font file have been + updated to support the new symbols. + """ + import json + jfile = get_mapping_filepath() + cpfile = jfile.replace(".json", ".codepoints") + with open(cpfile, "r") as cpf: + codepoints = cpf.read() + + mapping = {} + for cp in codepoints.splitlines(): + name, code = cp.split() + mapping[name] = int(f"0x{code}", 16) + + with open(jfile, "w") as jf: + json.dump(mapping, jf, indent=4) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 01e431577e..11fc31799b 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.1.8+dev" +__version__ = "1.3.2+dev" diff --git a/client/pyproject.toml b/client/pyproject.toml index edf7f57317..6416d9b8e1 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -4,6 +4,7 @@ description="AYON core addon." [tool.poetry.dependencies] python = ">=3.9.1,<3.10" +markdown = "^3.4.1" clique = "1.6.*" jsonschema = "^2.6.0" pyblish-base = "^1.8.11" diff --git a/package.py b/package.py index a4ffe1a20d..908d34ffa8 100644 --- a/package.py +++ b/package.py @@ -1,12 +1,18 @@ name = "core" title = "Core" -version = "1.1.8+dev" +version = "1.3.2+dev" client_dir = "ayon_core" plugin_for = ["ayon_server"] -ayon_server_version = ">=1.0.3,<2.0.0" +ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} -ayon_compatible_addons = {} +ayon_compatible_addons = { + "ayon_ocio": ">=1.2.1", + "applications": ">=1.1.2", + "harmony": ">0.4.0", + "fusion": ">=0.3.3", + "openrv": ">=1.0.2", +} diff --git a/pyproject.toml b/pyproject.toml index 3da97e6b2a..f4a452a2b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.1.8+dev" +version = "1.3.2+dev" description = "" authors = ["Ynput Team "] readme = "README.md" @@ -20,15 +20,12 @@ pytest = "^8.0" pytest-print = "^1.0" ayon-python-api = "^1.0" # linting dependencies -ruff = "^0.3.3" -pre-commit = "^3.6.2" +ruff = "^0.11.7" +pre-commit = "^4" codespell = "^2.2.6" semver = "^3.0.2" +mypy = "^1.14.0" mock = "^5.0.0" -attrs = "^25.0.0" -pyblish-base = "^1.8.7" -clique = "^2.0.0" -opentimelineio = "^0.17.0" tomlkit = "^0.13.2" requests = "^2.32.3" mkdocs-material = "^9.6.7" @@ -40,80 +37,17 @@ mdx-gh-links = "^0.4" pymdown-extensions = "^10.14.3" mike = "^2.1.3" mkdocstrings-shell = "^1.0.2" +nxtools = "^1.6" - -[tool.ruff] -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".ipynb_checkpoints", - ".mypy_cache", - ".nox", - ".pants.d", - ".pyenv", - ".pytest_cache", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - ".vscode", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "site-packages", - "venv", - "vendor", - "generated", -] - -# Same as Black. -line-length = 79 -indent-width = 4 - -# Assume Python 3.9 -target-version = "py39" - -[tool.ruff.lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E", "F", "W"] -ignore = [] - -# Allow fix for all enabled rules (when `--fix`) is provided. -fixable = ["ALL"] -unfixable = [] - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -exclude = [ - "client/ayon_core/modules/click_wrap.py", - "client/ayon_core/scripts/slates/__init__.py" -] - -[tool.ruff.lint.per-file-ignores] -"client/ayon_core/lib/__init__.py" = ["E402"] - -[tool.ruff.format] -# Like Black, use double quotes for strings. -quote-style = "double" - -# Like Black, indent with spaces, rather than tabs. -indent-style = "space" - -# Like Black, respect magic trailing commas. -skip-magic-trailing-comma = false - -# Like Black, automatically detect the appropriate line ending. -line-ending = "auto" +[tool.poetry.group.test.dependencies] +attrs = "^25.0.0" +pyblish-base = "^1.8.7" +clique = "^2.0.0" +opentimelineio = "^0.17.0" +speedcopy = "^2.1" +qtpy="^2.4.3" +pyside6 = "^6.5.2" +pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "chore/align-dependencies" } [tool.codespell] # Ignore words that are not in the dictionary. @@ -123,15 +57,17 @@ ignore-words-list = "ayon,ynput,parms,parm,hda,developpement" # Remove with next codespell release (>2.2.6) ignore-regex = ".*codespell:ignore.*" -skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/extension/js/libs/*" +skip = "./.*,./package/*,*/client/ayon_core/vendor/*" count = true quiet-level = 3 +[tool.mypy] +mypy_path = "$MYPY_CONFIG_FILE_DIR/client" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - [tool.pytest.ini_options] log_cli = true log_cli_level = "INFO" @@ -139,3 +75,11 @@ addopts = "-ra -q" testpaths = [ "client/ayon_core/tests" ] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "api: API tests", + "cli: CLI tests", + "slow: Slow tests", + "server: Tests that require a running AYON server", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000000..c0a501a5dc --- /dev/null +++ b/ruff.toml @@ -0,0 +1,87 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + "vendor", + "generated", +] + +# Same as Black. +line-length = 79 +indent-width = 4 + +# Assume Python 3.9 +target-version = "py39" + +[lint] +preview = true +pydocstyle.convention = "google" +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = ["E", "F", "W"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +exclude = [ + "client/ayon_core/scripts/slates/__init__.py" +] + +[lint.per-file-ignores] +"client/ayon_core/lib/__init__.py" = ["E402"] +"tests/*.py" = ["S101", "PLR2004"] # allow asserts and magical values + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" diff --git a/server/__init__.py b/server/__init__.py index d60f50f471..620cb3285c 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -1,6 +1,15 @@ from typing import Any from ayon_server.addons import BaseServerAddon +from ayon_server.actions import ( + ActionExecutor, + ExecuteResponseModel, + SimpleActionManifest, +) +try: + from ayon_server.logging import logger +except ImportError: + from nxtools import logging as logger from .settings import ( CoreSettings, @@ -26,3 +35,67 @@ class CoreAddon(BaseServerAddon): return await super().convert_settings_overrides( source_version, overrides ) + + async def get_simple_actions( + self, + project_name: str | None = None, + variant: str = "production", + ) -> list[SimpleActionManifest]: + """Return a list of simple actions provided by the addon""" + output = [] + + if project_name: + # Add 'Create Project Folder Structure' action to folders. + output.append( + SimpleActionManifest( + identifier="core.createprojectstructure", + label="Create Project Folder Structure", + icon={ + "type": "material-symbols", + "name": "create_new_folder", + }, + order=100, + entity_type="project", + entity_subtypes=None, + allow_multiselection=False, + ) + ) + + return output + + async def execute_action( + self, + executor: ActionExecutor, + ) -> ExecuteResponseModel: + """Execute webactions.""" + + project_name = executor.context.project_name + + if executor.identifier == "core.createprojectstructure": + if not project_name: + logger.error( + f"Can't execute {executor.identifier} because" + " of missing project name." + ) + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Missing project name", success=False + ) + return + + args = [ + "create-project-structure", "--project", project_name, + ] + # Works since AYON server 1.8.3 + if hasattr(executor, "get_launcher_response"): + return await executor.get_launcher_response(args) + + return await executor.get_launcher_action_response(args) + + logger.debug(f"Unknown action: {executor.identifier}") + # Works since AYON server 1.8.3 + if hasattr(executor, "get_simple_response"): + return await executor.get_simple_response( + "Unknown action", success=False + ) diff --git a/server/settings/main.py b/server/settings/main.py index 249bab85fd..cca885303f 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -71,6 +71,24 @@ def _fallback_ocio_config_profile_types(): def _ocio_built_in_paths(): return [ + { + "value": "{BUILTIN_OCIO_ROOT}/aces_2.0/studio-config-v3.0.0_aces-v2.0_ocio-v2.4.ocio", # noqa: E501 + "label": "ACES 2.0 Studio (OCIO v2.4)", + "description": ( + "Aces 2.0 Studio OCIO config file. Requires OCIO v2.4.") + }, + { + "value": "{BUILTIN_OCIO_ROOT}/aces_1.3/studio-config-v1.0.0_aces-v1.3_ocio-v2.1.ocio", # noqa: E501 + "label": "ACES 1.3 Studio (OCIO v2.1)", + "description": ( + "Aces 1.3 Studio OCIO config file. Requires OCIO v2.1.") + }, + { + "value": "{BUILTIN_OCIO_ROOT}/aces_1.3/studio-config-v1.0.0_aces-v1.3_ocio-v2.0.ocio", # noqa: E501 + "label": "ACES 1.3 Studio (OCIO v2)", + "description": ( + "Aces 1.3 Studio OCIO config file. Requires OCIO v2.") + }, { "value": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", "label": "ACES 1.2", @@ -88,7 +106,7 @@ class FallbackProductModel(BaseSettingsModel): fallback_type: str = SettingsField( title="Fallback config type", enum_resolver=_fallback_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", description=( "Type of config which needs to be used in case published " @@ -144,7 +162,7 @@ class CoreImageIOConfigProfilesModel(BaseSettingsModel): type: str = SettingsField( title="Profile type", enum_resolver=_ocio_config_profile_types, - conditionalEnum=True, + conditional_enum=True, default="builtin_path", section="---", ) @@ -301,6 +319,10 @@ class CoreSettings(BaseSettingsModel): "{}", widget="textarea", title="Project folder structure", + description=( + "Defines project folders to create on disk" + " for 'Create project folders' action." + ), section="---" ) project_environments: str = SettingsField( diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 39a9c028f9..d690d79607 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1,4 +1,5 @@ from pydantic import validator +from typing import Any from ayon_server.settings import ( BaseSettingsModel, @@ -7,11 +8,21 @@ from ayon_server.settings import ( normalize_name, ensure_unique_names, task_types_enum, + anatomy_template_items_enum ) - +from ayon_server.exceptions import BadRequestException from ayon_server.types import ColorRGBA_uint8 +def _handle_missing_frames_enum(): + return [ + {"value": "closest_existing", "label": "Use closest existing"}, + {"value": "blank", "label": "Generate blank frame"}, + {"value": "previous_version", "label": "Use previous version"}, + {"value": "only_rendered", "label": "Use only rendered"}, + ] + + class EnabledModel(BaseSettingsModel): enabled: bool = SettingsField(True) @@ -157,6 +168,78 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel): return value +class ResolutionOptionsModel(BaseSettingsModel): + _layout = "compact" + width: int = SettingsField( + 1920, + ge=0, + le=100000, + title="Width", + description=( + "Width resolution number value"), + placeholder="Width" + ) + height: int = SettingsField( + 1080, + title="Height", + ge=0, + le=100000, + description=( + "Height resolution number value"), + placeholder="Height" + ) + pixel_aspect: float = SettingsField( + 1.0, + title="Pixel aspect", + ge=0.0, + le=100000.0, + description=( + "Pixel Aspect resolution decimal number value"), + placeholder="Pixel aspect" + ) + + +def ensure_unique_resolution_option( + objects: list[Any], field_name: str | None = None) -> None: # noqa: C901 + """Ensure a list of objects have unique option attributes. + + This function checks if the list of objects has unique 'width', + 'height' and 'pixel_aspect' properties. + """ + options = set() + for obj in objects: + item_test_text = f"{obj.width}x{obj.height}x{obj.pixel_aspect}" + if item_test_text in options: + raise BadRequestException( + f"Duplicate option '{item_test_text}'") + + options.add(item_test_text) + + +class CollectExplicitResolutionModel(BaseSettingsModel): + enabled: bool = SettingsField(True, title="Enabled") + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types", + description=( + "Only activate the attribute for following product types." + ) + ) + options: list[ResolutionOptionsModel] = SettingsField( + default_factory=list, + title="Resolution choices", + description=( + "Available resolution choices to be displayed in " + "the publishers attribute." + ) + ) + + @validator("options") + def validate_unique_resolution_options(cls, value): + ensure_unique_resolution_option(value) + return value + + class AyonEntityURIModel(BaseSettingsModel): use_ayon_entity_uri: bool = SettingsField( title="Use AYON Entity URI", @@ -257,7 +340,7 @@ class ResizeModel(BaseSettingsModel): title="Type", description="Type of resizing", enum_resolver=lambda: _resize_types_enum, - conditionalEnum=True, + conditional_enum=True, default="source" ) @@ -290,7 +373,7 @@ class ExtractThumbnailOIIODefaultsModel(BaseSettingsModel): title="Type", description="Transcoding type", enum_resolver=lambda: _thumbnail_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, default="colorspace" ) @@ -393,7 +476,7 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): "colorspace", title="Transcoding type", enum_resolver=_extract_oiio_transcoding_type, - conditionalEnum=True, + conditional_enum=True, description=( "Select the transcoding type for your output, choosing either " "*Colorspace* or *Display&View* transform." @@ -642,6 +725,12 @@ class ExtractReviewOutputDefModel(BaseSettingsModel): default_factory=ExtractReviewLetterBox, title="Letter Box" ) + fill_missing_frames: str = SettingsField( + title="Handle missing frames", + default="closest_existing", + description="How to handle gaps in sequence frame ranges.", + enum_resolver=_handle_missing_frames_enum + ) @validator("name") def validate_name(cls, value): @@ -889,7 +978,11 @@ class IntegrateANTemplateNameProfileModel(BaseSettingsModel): default_factory=list, title="Task names" ) - template_name: str = SettingsField("", title="Template name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="publish") + ) class IntegrateHeroTemplateNameProfileModel(BaseSettingsModel): @@ -910,7 +1003,11 @@ class IntegrateHeroTemplateNameProfileModel(BaseSettingsModel): default_factory=list, title="Task names" ) - template_name: str = SettingsField("", title="Template name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="hero") + ) class IntegrateHeroVersionModel(BaseSettingsModel): @@ -929,6 +1026,20 @@ class IntegrateHeroVersionModel(BaseSettingsModel): "hero versions.") +class CollectRenderedFilesModel(BaseSettingsModel): + remove_files: bool = SettingsField( + False, + title="Remove rendered files", + description=( + "Remove rendered files and metadata json on publish.\n\n" + "Note that when enabled but the render is to a configured " + "persistent staging directory the files will not be removed. " + "However with this disabled the files will **not** be removed in " + "either case." + ) + ) + + class CleanUpModel(BaseSettingsModel): _isGroup = True paterns: list[str] = SettingsField( # codespell:ignore paterns @@ -974,6 +1085,10 @@ class PublishPuginsModel(BaseSettingsModel): title="Collect USD Layer Contributions", ) ) + CollectExplicitResolution: CollectExplicitResolutionModel = SettingsField( + default_factory=CollectExplicitResolutionModel, + title="Collect Explicit Resolution" + ) ValidateEditorialAssetName: ValidateBaseModel = SettingsField( default_factory=ValidateBaseModel, title="Validate Editorial Asset Name" @@ -1041,6 +1156,10 @@ class PublishPuginsModel(BaseSettingsModel): "published as a render/review product of its own." ) ) + CollectRenderedFiles: CollectRenderedFilesModel = SettingsField( + default_factory=CollectRenderedFilesModel, + title="Clean up farm rendered files" + ) CleanUp: CleanUpModel = SettingsField( default_factory=CleanUpModel, title="Clean Up" @@ -1144,6 +1263,13 @@ DEFAULT_PUBLISH_VALUES = { }, ] }, + "CollectExplicitResolution": { + "enabled": True, + "product_types": [ + "shot" + ], + "options": [] + }, "ValidateEditorialAssetName": { "enabled": True, "optional": False, @@ -1261,7 +1387,8 @@ DEFAULT_PUBLISH_VALUES = { "fill_color": [0, 0, 0, 1.0], "line_thickness": 0, "line_color": [255, 0, 0, 1.0] - } + }, + "fill_missing_frames": "closest_existing" }, { "name": "h264", @@ -1311,7 +1438,8 @@ DEFAULT_PUBLISH_VALUES = { "fill_color": [0, 0, 0, 1.0], "line_thickness": 0, "line_color": [255, 0, 0, 1.0] - } + }, + "fill_missing_frames": "closest_existing" } ] } @@ -1428,6 +1556,9 @@ DEFAULT_PUBLISH_VALUES = { "AttachReviewables": { "enabled": True, }, + "CollectRenderedFiles": { + "remove_files": False + }, "CleanUp": { "paterns": [], # codespell:ignore paterns "remove_temp_renders": False diff --git a/server/settings/tools.py b/server/settings/tools.py index ed23cee69c..f40c7c3627 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -5,6 +5,7 @@ from ayon_server.settings import ( normalize_name, ensure_unique_names, task_types_enum, + anatomy_template_items_enum ) @@ -283,7 +284,34 @@ class PublishTemplateNameProfile(BaseSettingsModel): task_names: list[str] = SettingsField( default_factory=list, title="Task names" ) - template_name: str = SettingsField("", title="Template name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="publish") + ) + + +class HeroTemplateNameProfile(BaseSettingsModel): + _layout = "expanded" + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types" + ) + # TODO this should use hosts enum + hosts: list[str] = SettingsField(default_factory=list, title="Hosts") + task_types: list[str] = SettingsField( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + task_names: list[str] = SettingsField( + default_factory=list, title="Task names" + ) + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="hero") + ) class CustomStagingDirProfileModel(BaseSettingsModel): @@ -306,7 +334,11 @@ class CustomStagingDirProfileModel(BaseSettingsModel): custom_staging_dir_persistent: bool = SettingsField( False, title="Custom Staging Folder Persistent" ) - template_name: str = SettingsField("", title="Template Name") + template_name: str = SettingsField( + "", + title="Template name", + enum_resolver=anatomy_template_items_enum(category="staging") + ) class PublishToolModel(BaseSettingsModel): @@ -314,7 +346,7 @@ class PublishToolModel(BaseSettingsModel): default_factory=list, title="Template name profiles" ) - hero_template_name_profiles: list[PublishTemplateNameProfile] = ( + hero_template_name_profiles: list[HeroTemplateNameProfile] = ( SettingsField( default_factory=list, title="Hero template name profiles" @@ -326,6 +358,14 @@ class PublishToolModel(BaseSettingsModel): title="Custom Staging Dir Profiles" ) ) + comment_minimum_required_chars: int = SettingsField( + 0, + title="Publish comment minimum required characters", + description=( + "Minimum number of characters required in the comment field " + "before the publisher UI is allowed to continue publishing" + ) + ) class GlobalToolsModel(BaseSettingsModel): @@ -639,6 +679,7 @@ DEFAULT_TOOLS_VALUES = { "task_names": [], "template_name": "simpleUnrealTextureHero" } - ] + ], + "comment_minimum_required_chars": 0, } } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000000..d420712d8b --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests.""" diff --git a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py index 20f0c05804..2f67ee244c 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py +++ b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py @@ -101,6 +101,7 @@ def test_image_sequence(): expected_data, ) + def test_media_retimed(): """ EXR image sequence. diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 8ad2e44b06..6a74df7f43 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -103,17 +103,18 @@ def test_image_sequence_with_embedded_tc_and_handles_out_of_range(): # 10 head black handles generated from gap (991-1000) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 991 " - "C:/result/output.%04d.jpg", + "-pix_fmt rgba C:/result/output.%04d.png", # 10 tail black handles generated from gap (1102-1111) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 1102 " - "C:/result/output.%04d.jpg", + "-pix_fmt rgba C:/result/output.%04d.png", # Report from source exr (1001-1101) with enforce framerate "/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i " - f"C:\\exr_embedded_tc{os.sep}output.%04d.exr -start_number 1001 " - "C:/result/output.%04d.jpg" + f"C:\\exr_embedded_tc{os.sep}output.%04d.exr " + "-vf scale=1280:720:flags=lanczos -compression_level 5 " + "-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png" ] assert calls == expected @@ -130,20 +131,23 @@ def test_image_sequence_and_handles_out_of_range(): expected = [ # 5 head black frames generated from gap (991-995) - "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720" - " -tune stillimage -start_number 991 C:/result/output.%04d.jpg", + "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720 " + "-tune stillimage -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png", # 9 tail back frames generated from gap (1097-1105) - "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720" - " -tune stillimage -start_number 1097 C:/result/output.%04d.jpg", + "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720 " + "-tune stillimage -start_number 1097 -pix_fmt rgba " + "C:/result/output.%04d.png", # Report from source tiff (996-1096) # 996-1000 = additional 5 head frames # 1001-1095 = source range conformed to 25fps # 1096-1096 = additional 1 tail frames "/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i " - f"C:\\tif_seq{os.sep}output.%04d.tif -start_number 996" - f" C:/result/output.%04d.jpg" + f"C:\\tif_seq{os.sep}output.%04d.tif " + "-vf scale=1280:720:flags=lanczos -compression_level 5 " + "-start_number 996 -pix_fmt rgba C:/result/output.%04d.png" ] assert calls == expected @@ -163,8 +167,9 @@ def test_movie_with_embedded_tc_no_gap_handles(): # - first_frame = 14 src - 10 (head tail) = frame 4 = 0.1666s # - duration = 68fr (source) + 20fr (handles) = 88frames = 3.666s "/path/to/ffmpeg -ss 0.16666666666666666 -t 3.6666666666666665 " - "-i C:\\data\\qt_embedded_tc.mov -start_number 991 " - "C:/result/output.%04d.jpg" + "-i C:\\data\\qt_embedded_tc.mov -vf scale=1280:720:flags=lanczos " + "-compression_level 5 -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png" ] assert calls == expected @@ -181,12 +186,14 @@ def test_short_movie_head_gap_handles(): expected = [ # 10 head black frames generated from gap (991-1000) "/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720" - " -tune stillimage -start_number 991 C:/result/output.%04d.jpg", + " -tune stillimage -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png", # source range + 10 tail frames # duration = 50fr (source) + 10fr (tail handle) = 60 fr = 2.4s - "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4" - " -start_number 1001 C:/result/output.%04d.jpg" + "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4 -vf " + "scale=1280:720:flags=lanczos -compression_level 5 " + "-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png" ] assert calls == expected @@ -204,17 +211,19 @@ def test_short_movie_tail_gap_handles(): # 10 tail black frames generated from gap (1067-1076) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 1067 " - "C:/result/output.%04d.jpg", + "-pix_fmt rgba C:/result/output.%04d.png", # 10 head frames + source range # duration = 10fr (head handle) + 66fr (source) = 76fr = 3.16s "/path/to/ffmpeg -ss 1.0416666666666667 -t 3.1666666666666665 -i " - "C:\\data\\qt_no_tc_24fps.mov -start_number 991" - " C:/result/output.%04d.jpg" + "C:\\data\\qt_no_tc_24fps.mov -vf scale=1280:720:flags=lanczos " + "-compression_level 5 -start_number 991 -pix_fmt rgba " + "C:/result/output.%04d.png" ] assert calls == expected + def test_multiple_review_clips_no_gap(): """ Use multiple review clips (image sequence). @@ -238,66 +247,80 @@ def test_multiple_review_clips_no_gap(): # 10 head black frames generated from gap (991-1000) '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi' ' -i color=c=black:s=1280x720 -tune ' - 'stillimage -start_number 991 C:/result/output.%04d.jpg', + 'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png', # Alternance 25fps tiff sequence and 24fps exr sequence # for 100 frames each '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1001 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1102 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1198 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1198 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1299 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1299 -pix_fmt rgba C:/result/output.%04d.png', # Repeated 25fps tiff sequence multiple times till the end '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1395 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1395 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1496 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1496 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1597 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1597 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1698 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1698 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1799 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1799 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1900 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1900 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2001 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 2001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2102 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 2102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2203 C:/result/output.%04d.jpg' + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 2203 -pix_fmt rgba C:/result/output.%04d.png' ] assert calls == expected + def test_multiple_review_clips_with_gap(): """ Use multiple review clips (image sequence) with gap. @@ -321,15 +344,17 @@ def test_multiple_review_clips_with_gap(): # Gap on review track (12 frames) '/path/to/ffmpeg -t 0.5 -r 24.0 -f lavfi' ' -i color=c=black:s=1280x720 -tune ' - 'stillimage -start_number 991 C:/result/output.%04d.jpg', + 'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1003 C:/result/output.%04d.jpg', + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1003 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1091 C:/result/output.%04d.jpg' + '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-start_number 1091 -pix_fmt rgba C:/result/output.%04d.png' ] assert calls == expected diff --git a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py index 112d00b3e4..b475d629bb 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py +++ b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py @@ -257,7 +257,6 @@ def test_movie_timewarp(): ) - def test_img_sequence_no_handles(): """ Img sequence clip (no embedded timecode) @@ -334,6 +333,7 @@ def test_img_sequence_relative_source_range(): expected_data ) + def test_img_sequence_conform_to_23_976fps(): """ Img sequence clip @@ -409,6 +409,7 @@ def test_img_sequence_reverse_speed_no_tc(): handle_end=0, ) + def test_img_sequence_reverse_speed_from_24_to_23_976fps(): """ Img sequence clip diff --git a/tests/client/ayon_core/pipeline/load/test_loaders.py b/tests/client/ayon_core/pipeline/load/test_loaders.py new file mode 100644 index 0000000000..490efe1b1e --- /dev/null +++ b/tests/client/ayon_core/pipeline/load/test_loaders.py @@ -0,0 +1,88 @@ +"""Test loaders in the pipeline module.""" + +from ayon_core.pipeline.load import LoaderPlugin + + +def test_is_compatible_loader(): + """Test if a loader is compatible with a given representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock representation context + context = { + "loader": "test_loader", + "representation": {"name": "test_representation"}, + } + + # Create a mock loader plugin + class MockLoader(LoaderPlugin): + name = "test_loader" + version = "1.0.0" + + def is_compatible_loader(self, context): + return True + + # Check compatibility + assert is_compatible_loader(MockLoader(), context) is True + + +def test_complex_is_compatible_loader(): + """Test if a loader is compatible with a complex representation.""" + from ayon_core.pipeline.load import is_compatible_loader + + # Create a mock complex representation context + context = { + "loader": "complex_loader", + "representation": { + "name": "complex_representation", + "extension": "exr" + }, + "additional_data": {"key": "value"}, + "product": { + "name": "complex_product", + "productType": "foo", + "productBaseType": "bar", + }, + } + + # Create a mock loader plugin + class ComplexLoaderA(LoaderPlugin): + name = "complex_loaderA" + + # False because the loader doesn't specify any compatibility (missing + # wildcard for product type and product base type) + assert is_compatible_loader(ComplexLoaderA(), context) is False + + class ComplexLoaderB(LoaderPlugin): + name = "complex_loaderB" + product_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product type + assert is_compatible_loader(ComplexLoaderB(), context) is True + + class ComplexLoaderC(LoaderPlugin): + name = "complex_loaderC" + product_base_types = {"*"} + representations = {"*"} + + # True, it is compatible with any product base type + assert is_compatible_loader(ComplexLoaderC(), context) is True + + class ComplexLoaderD(LoaderPlugin): + name = "complex_loaderD" + product_types = {"foo"} + representations = {"*"} + + # legacy loader defining compatibility only with product type + # is compatible provided the same product type is defined in context + assert is_compatible_loader(ComplexLoaderD(), context) is False + + class ComplexLoaderE(LoaderPlugin): + name = "complex_loaderE" + product_types = {"foo"} + representations = {"*"} + + # remove productBaseType from context to simulate legacy behavior + context["product"].pop("productBaseType", None) + + assert is_compatible_loader(ComplexLoaderE(), context) is True diff --git a/tests/client/ayon_core/pipeline/traits/__init__.py b/tests/client/ayon_core/pipeline/traits/__init__.py new file mode 100644 index 0000000000..ead0593ced --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/__init__.py @@ -0,0 +1 @@ +"""Tests for the representation traits.""" diff --git a/tests/client/ayon_core/pipeline/traits/lib/__init__.py b/tests/client/ayon_core/pipeline/traits/lib/__init__.py new file mode 100644 index 0000000000..d7ea7ae0ad --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/lib/__init__.py @@ -0,0 +1,25 @@ +"""Metadata traits.""" +from typing import ClassVar + +from ayon_core.pipeline.traits import TraitBase + + +class NewTestTrait(TraitBase): + """New Test trait model. + + This model represents a tagged trait. + + Attributes: + name (str): Trait name. + description (str): Trait description. + id (str): id should be namespaced trait name with version + """ + + name: ClassVar[str] = "New Test Trait" + description: ClassVar[str] = ( + "This test trait is used for testing updating." + ) + id: ClassVar[str] = "ayon.test.NewTestTrait.v999" + + +__all__ = ["NewTestTrait"] diff --git a/tests/client/ayon_core/pipeline/traits/test_content_traits.py b/tests/client/ayon_core/pipeline/traits/test_content_traits.py new file mode 100644 index 0000000000..3fcbd04ac0 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_content_traits.py @@ -0,0 +1,184 @@ +"""Tests for the content traits.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + PixelBased, + Planar, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_bundles() -> None: + """Test bundle trait.""" + diffuse_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/diffuse.jpg"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/jpeg"), + ] + bump_texture = [ + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + FileLocation( + file_path=Path("/path/to/bump.tif"), + file_size=1024, + file_hash=None), + MimeType(mime_type="image/tiff"), + ] + bundle = Bundle(items=[diffuse_texture, bump_texture]) + representation = Representation(name="test_bundle", traits=[bundle]) + + if representation.contains_trait(trait=Bundle): + assert representation.get_trait(trait=Bundle).items == [ + diffuse_texture, bump_texture + ] + + for item in representation.get_trait(trait=Bundle).items: + sub_representation = Representation(name="test", traits=item) + assert sub_representation.contains_trait(trait=Image) + sub: MimeType = sub_representation.get_trait(trait=MimeType) + assert sub.mime_type in { + "image/jpeg", "image/tiff" + } + + +def test_file_locations_validation() -> None: + """Test FileLocations trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), + ]) + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + # this should be valid trait + file_locations_trait.validate_trait(representation) + + # add valid FrameRanged trait + frameranged_trait = FrameRanged( + frame_start=1001, + frame_end=1050, + frames_per_second="25" + ) + representation.add_trait(frameranged_trait) + + # it should still validate fine + file_locations_trait.validate_trait(representation) + + # create empty file locations trait + empty_file_locations_trait = FileLocations(file_paths=[]) + representation = Representation(name="test", traits=[ + empty_file_locations_trait + ]) + with pytest.raises(TraitValidationError): + empty_file_locations_trait.validate_trait(representation) + + # create valid file locations trait but with not matching + # frame range trait + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list), + Sequence(frame_padding=4), + ]) + invalid_sequence_trait = FrameRanged( + frame_start=1001, + frame_end=1051, + frames_per_second="25" + ) + + representation.add_trait(invalid_sequence_trait) + with pytest.raises(TraitValidationError): + file_locations_trait.validate_trait(representation) + + # invalid representation with multiple file locations but + # unrelated to either Sequence or Bundle traits + representation = Representation(name="test", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path("/path/to/file_foo.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/anotherfile.obj"), + file_size=1234, + file_hash=None, + ) + ]) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + +def test_get_file_location_from_frame() -> None: + """Test get_file_location_from_frame method.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait: FileLocations = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_location_for_frame(frame=1001) == \ + file_locations_list[0] + assert file_locations_trait.get_file_location_for_frame(frame=1050) == \ + file_locations_list[-1] + assert file_locations_trait.get_file_location_for_frame(frame=1100) is None + + # test with custom regex + sequence = Sequence( + frame_padding=4, + frame_regex=re.compile(r"boo_(?P(?P0*)\d+)\.exr")) + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/boo_{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + file_locations_trait = FileLocations( + file_paths=file_locations_list) + + assert file_locations_trait.get_file_location_for_frame( + frame=1001, sequence_trait=sequence) == \ + file_locations_list[0] diff --git a/tests/client/ayon_core/pipeline/traits/test_time_traits.py b/tests/client/ayon_core/pipeline/traits/test_time_traits.py new file mode 100644 index 0000000000..28ace89910 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_time_traits.py @@ -0,0 +1,248 @@ +"""Tests for the time related traits.""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + FileLocation, + FileLocations, + FrameRanged, + Handles, + Representation, + Sequence, +) +from ayon_core.pipeline.traits.trait import TraitValidationError + + +def test_sequence_validations() -> None: + """Test Sequence trait validation.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1010 + 1) # because range is zero based + ] + + file_locations_list += [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1015, 1020 + 1) + ] + + file_locations_list += [ + FileLocation + ( + file_path=Path("/path/to/file.1100.exr"), + file_size=1024, + file_hash=None, + ) + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence( + frame_padding=4, + frame_spec="1001-1010,1015-1020,1100") + ]) + + representation.get_trait(Sequence).validate_trait(representation) + + # here we set handles and set them as inclusive, so this should pass + representation = Representation(name="test_2", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1100 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=True + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # do the same but set handles as exclusive + representation = Representation(name="test_3", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1105 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1100, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + representation.validate() + + # invalid representation with file range not extended for handles + representation = Representation(name="test_4", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4) + ]) + + with pytest.raises(TraitValidationError): + representation.validate() + + # invalid representation with frame spec not matching the files + del representation + representation = Representation(name="test_5", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + FrameRanged( + frame_start=1001, + frame_end=1050, frames_per_second="25"), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000") + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1-1010,1012-1050"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_6", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence(frame_padding=4, frame_spec="1001-1010,1012-2000"), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + with pytest.raises(TraitValidationError): + representation.validate() + + representation = Representation(name="test_7", traits=[ + FileLocations(file_paths=[ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(996, 1050 + 1) # because range is zero based + ]), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$")), + Handles( + frame_start_handle=5, + frame_end_handle=5, + inclusive=False + ) + ]) + representation.validate() + + +def test_list_spec_to_frames() -> None: + """Test converting list specification to frames.""" + assert Sequence.list_spec_to_frames("1-10,20-30,55") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 55 + ] + assert Sequence.list_spec_to_frames("1,2,3,4,5") == [ + 1, 2, 3, 4, 5 + ] + assert Sequence.list_spec_to_frames("1-10") == [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 + ] + test_list = list(range(1001, 1011)) + test_list += list(range(1012, 2001)) + assert Sequence.list_spec_to_frames("1001-1010,1012-2000") == test_list + + assert Sequence.list_spec_to_frames("1") == [1] + with pytest.raises( + ValueError, + match=r"Invalid frame number in the list: .*"): + Sequence.list_spec_to_frames("a") + + +def test_sequence_get_frame_padding() -> None: + """Test getting frame padding from FileLocations trait.""" + file_locations_list = [ + FileLocation( + file_path=Path(f"/path/to/file.{frame}.exr"), + file_size=1024, + file_hash=None, + ) + for frame in range(1001, 1051) + ] + + representation = Representation(name="test", traits=[ + FileLocations(file_paths=file_locations_list) + ]) + + assert Sequence.get_frame_padding( + file_locations=representation.get_trait(FileLocations)) == 4 diff --git a/tests/client/ayon_core/pipeline/traits/test_traits.py b/tests/client/ayon_core/pipeline/traits/test_traits.py new file mode 100644 index 0000000000..e4aef1ba18 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_traits.py @@ -0,0 +1,405 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +from pathlib import Path + +import pytest +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + Image, + MimeType, + Overscan, + PixelBased, + Planar, + Representation, + TraitBase, +) + +REPRESENTATION_DATA: dict = { + FileLocation.id: { + "file_path": Path("/path/to/file"), + "file_size": 1024, + "file_hash": None, + # "persistent": True, + }, + Image.id: {}, + PixelBased.id: { + "display_window_width": 1920, + "display_window_height": 1080, + "pixel_aspect_ratio": 1.0, + # "persistent": True, + }, + Planar.id: { + "planar_configuration": "RGB", + # "persistent": True, + }, + } + + +class UpgradedImage(Image): + """Upgraded image class.""" + id = "ayon.2d.Image.v2" + + @classmethod + def upgrade(cls, data: dict) -> UpgradedImage: # noqa: ARG003 + """Upgrade the trait. + + Returns: + UpgradedImage: Upgraded image instance. + + """ + return cls() + + +class InvalidTrait: + """Invalid trait class.""" + foo = "bar" + + +@pytest.fixture +def representation() -> Representation: + """Return a traits data instance.""" + return Representation(name="test", traits=[ + FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image(), + PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar(**REPRESENTATION_DATA[Planar.id]), + ]) + + +def test_representation_errors(representation: Representation) -> None: + """Test errors in representation.""" + with pytest.raises(ValueError, + match=r"Invalid trait .* - ID is required."): + representation.add_trait(InvalidTrait()) + + with pytest.raises(ValueError, + match=f"Trait with ID {Image.id} already exists."): + representation.add_trait(Image()) + + with pytest.raises(ValueError, + match=r"Trait with ID .* not found."): + representation.remove_trait_by_id("foo") + + +def test_representation_traits(representation: Representation) -> None: + """Test setting and getting traits.""" + assert representation.get_trait_by_id( + "ayon.2d.PixelBased").get_version() == 1 + + assert len(representation) == len(REPRESENTATION_DATA) + assert representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait_by_id(Image.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Image.v1") + assert representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.PixelBased.v1") + assert representation.get_trait_by_id(Planar.id) + assert representation.get_trait_by_id(trait_id="ayon.2d.Planar.v1") + + assert representation.get_trait(FileLocation) + assert representation.get_trait(Image) + assert representation.get_trait(PixelBased) + assert representation.get_trait(Planar) + + assert issubclass( + type(representation.get_trait(FileLocation)), TraitBase) + + assert representation.get_trait(FileLocation) == \ + representation.get_trait_by_id(FileLocation.id) + assert representation.get_trait(Image) == \ + representation.get_trait_by_id(Image.id) + assert representation.get_trait(PixelBased) == \ + representation.get_trait_by_id(PixelBased.id) + assert representation.get_trait(Planar) == \ + representation.get_trait_by_id(Planar.id) + + assert representation.get_trait_by_id( + "ayon.2d.PixelBased.v1").display_window_width == \ + REPRESENTATION_DATA[PixelBased.id]["display_window_width"] + assert representation.get_trait( + trait=PixelBased).display_window_height == \ + REPRESENTATION_DATA[PixelBased.id]["display_window_height"] + + repre_dict = { + FileLocation.id: FileLocation(**REPRESENTATION_DATA[FileLocation.id]), + Image.id: Image(), + PixelBased.id: PixelBased(**REPRESENTATION_DATA[PixelBased.id]), + Planar.id: Planar(**REPRESENTATION_DATA[Planar.id]), + } + assert representation.get_traits() == repre_dict + + assert representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) == \ + repre_dict + assert representation.get_traits( + [FileLocation, Image, PixelBased, Planar]) == \ + repre_dict + + assert representation.has_traits() is True + empty_representation: Representation = Representation( + name="test", traits=[]) + assert empty_representation.has_traits() is False + + assert representation.contains_trait(trait=FileLocation) is True + assert representation.contains_traits([Image, FileLocation]) is True + assert representation.contains_trait_by_id(FileLocation.id) is True + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Image.id]) is True + + assert representation.contains_trait(trait=Bundle) is False + assert representation.contains_traits([Image, Bundle]) is False + assert representation.contains_trait_by_id(Bundle.id) is False + assert representation.contains_traits_by_id( + trait_ids=[FileLocation.id, Bundle.id]) is False + + +def test_trait_removing(representation: Representation) -> None: + """Test removing traits.""" + assert representation.contains_trait_by_id("nonexistent") is False + with pytest.raises( + ValueError, match=r"Trait with ID nonexistent not found."): + representation.remove_trait_by_id("nonexistent") + + assert representation.contains_trait(trait=FileLocation) is True + representation.remove_trait(trait=FileLocation) + assert representation.contains_trait(trait=FileLocation) is False + + assert representation.contains_trait_by_id(Image.id) is True + representation.remove_trait_by_id(Image.id) + assert representation.contains_trait_by_id(Image.id) is False + + assert representation.contains_traits([PixelBased, Planar]) is True + representation.remove_traits([Planar, PixelBased]) + assert representation.contains_traits([PixelBased, Planar]) is False + + assert representation.has_traits() is False + + with pytest.raises( + ValueError, match=f"Trait with ID {Image.id} not found."): + representation.remove_trait(Image) + + +def test_representation_dict_properties( + representation: Representation) -> None: + """Test representation as dictionary.""" + representation = Representation(name="test") + representation[Image.id] = Image() + assert Image.id in representation + image = representation[Image.id] + assert image == Image() + for trait_id, trait in representation.items(): + assert trait_id == Image.id + assert trait == Image() + + +def test_getting_traits_data(representation: Representation) -> None: + """Test getting a batch of traits.""" + result = representation.get_traits_by_ids( + trait_ids=[FileLocation.id, Image.id, PixelBased.id, Planar.id]) + assert result == { + "ayon.2d.Image.v1": Image(), + "ayon.2d.PixelBased.v1": PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + "ayon.2d.Planar.v1": Planar(planar_configuration="RGB"), + "ayon.content.FileLocation.v1": FileLocation( + file_path=Path("/path/to/file"), + file_size=1024, + file_hash=None) + } + + +def test_traits_data_to_dict(representation: Representation) -> None: + """Test converting traits data to dictionary.""" + result = representation.traits_as_dict() + assert result == REPRESENTATION_DATA + + +def test_get_version_from_id() -> None: + """Test getting version from trait ID.""" + assert Image().get_version() == 1 + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_version() == 2 + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_version() is None + + +def test_get_versionless_id() -> None: + """Test getting versionless trait ID.""" + assert Image().get_versionless_id() == "ayon.2d.Image" + + class TestOverscan(Overscan): + id = "ayon.2d.Overscan.v2" + + assert TestOverscan( + left=0, + right=0, + top=0, + bottom=0 + ).get_versionless_id() == "ayon.2d.Overscan" + + class TestMimeType(MimeType): + id = "ayon.content.MimeType" + + assert TestMimeType(mime_type="foo/bar").get_versionless_id() == \ + "ayon.content.MimeType" + + +def test_from_dict() -> None: + """Test creating representation from dictionary.""" + traits_data = { + "ayon.content.FileLocation.v1": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + "ayon.2d.Image.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 2 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + assert representation.get_trait_by_id("ayon.2d.Image.v1") + + traits_data = { + "ayon.content.FileLocation.v999": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + with pytest.raises(ValueError, match=r"Trait model with ID .* not found."): + representation = Representation.from_dict( + "test", trait_data=traits_data) + + traits_data = { + "ayon.content.FileLocation": { + "file_path": "/path/to/file", + "file_size": 1024, + "file_hash": None, + }, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + + assert len(representation) == 1 + assert representation.get_trait_by_id("ayon.content.FileLocation.v1") + + # this won't work right now because we would need to somewhat mock + # the import + """ + from .lib import NewTestTrait + + traits_data = { + "ayon.test.NewTestTrait.v1": {}, + } + + representation = Representation.from_dict( + "test", trait_data=traits_data) + """ + + +def test_representation_equality() -> None: + """Test representation equality.""" + # rep_a and rep_b are equal + rep_a = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + # rep_c has different value for planar_configuration then rep_a and rep_b + rep_c = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGBA"), + ]) + + rep_d = Representation(name="test", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_e = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + ]) + rep_f = Representation(name="foo", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Planar(planar_configuration="RGBA"), + ]) + + # let's assume ids are the same (because ids are randomly generated) + rep_b.representation_id = rep_d.representation_id = rep_a.representation_id + rep_c.representation_id = rep_e.representation_id = rep_a.representation_id + rep_f.representation_id = rep_a.representation_id + assert rep_a == rep_b + + # because of the trait value difference + assert rep_a != rep_c + # because of the type difference + assert rep_a != "foo" + # because of the trait count difference + assert rep_a != rep_d + # because of the name difference + assert rep_d != rep_e + # because of the trait difference + assert rep_d != rep_f + + +def test_get_repre_by_name(): + """Test getting representation by name.""" + rep_a = Representation(name="test_a", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + rep_b = Representation(name="test_b", traits=[ + FileLocation(file_path=Path("/path/to/file"), file_size=1024), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + Planar(planar_configuration="RGB"), + ]) + + representations = [rep_a, rep_b] + _ = next(rep for rep in representations if rep.name == "test_a") diff --git a/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py new file mode 100644 index 0000000000..f09d2b0864 --- /dev/null +++ b/tests/client/ayon_core/pipeline/traits/test_two_dimensional_traits.py @@ -0,0 +1,63 @@ +"""Tests for the 2d related traits.""" +from __future__ import annotations + +from pathlib import Path + +from ayon_core.pipeline.traits import ( + UDIM, + FileLocation, + FileLocations, + Representation, +) + + +def test_get_file_location_for_udim() -> None: + """Test get_file_location_for_udim.""" + file_locations_list = [ + FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1002.exr"), + file_size=1024, + file_hash=None, + ), + FileLocation( + file_path=Path("/path/to/file.1003.exr"), + file_size=1024, + file_hash=None, + ), + ] + + representation = Representation(name="test_1", traits=[ + FileLocations(file_paths=file_locations_list), + UDIM(udim=[1001, 1002, 1003]), + ]) + + udim_trait = representation.get_trait(UDIM) + assert udim_trait.get_file_location_for_udim( + file_locations=representation.get_trait(FileLocations), + udim=1001 + ) == file_locations_list[0] + + +def test_get_udim_from_file_location() -> None: + """Test get_udim_from_file_location.""" + file_location_1 = FileLocation( + file_path=Path("/path/to/file.1001.exr"), + file_size=1024, + file_hash=None, + ) + + file_location_2 = FileLocation( + file_path=Path("/path/to/file.xxxxx.exr"), + file_size=1024, + file_hash=None, + ) + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_1) == 1001 + + assert UDIM(udim=[1001]).get_udim_from_file_location( + file_location_2) is None diff --git a/tests/client/ayon_core/plugins/publish/test_integrate_traits.py b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py new file mode 100644 index 0000000000..abb605a121 --- /dev/null +++ b/tests/client/ayon_core/plugins/publish/test_integrate_traits.py @@ -0,0 +1,451 @@ +"""Tests for the representation traits.""" +from __future__ import annotations + +import base64 +import re +import time +from pathlib import Path +from typing import TYPE_CHECKING + +import pyblish.api +import pytest + +from ayon_core.lib.file_transaction import ( + FileTransaction, +) + +from ayon_core.pipeline.anatomy import Anatomy +from ayon_core.pipeline.traits import ( + Bundle, + FileLocation, + FileLocations, + FrameRanged, + Image, + MimeType, + Persistent, + PixelBased, + Representation, + Sequence, + Transient, +) +from ayon_core.pipeline.version_start import get_versioning_start + +# Tagged, +# TemplatePath, +from ayon_core.plugins.publish.integrate_traits import ( + IntegrateTraits, + TransferItem, +) + +from ayon_core.settings import get_project_settings + +from ayon_api.operations import ( + OperationsSession, +) + +if TYPE_CHECKING: + import pytest_ayon + +PNG_FILE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==" # noqa: E501 +SEQUENCE_LENGTH = 10 +CURRENT_TIME = time.time() + + +@pytest.fixture(scope="session") +def single_file(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Return a temporary image file.""" + filename = tmp_path_factory.mktemp("single") / "img.png" + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) + return filename + + +@pytest.fixture(scope="session") +def sequence_files(tmp_path_factory: pytest.TempPathFactory) -> list[Path]: + """Return a sequence of temporary image files.""" + files = [] + dir_name = tmp_path_factory.mktemp("sequence") + for i in range(SEQUENCE_LENGTH): + frame = i + 1 + filename = dir_name / f"img.{frame:04d}.png" + filename.write_bytes(base64.b64decode(PNG_FILE_B64)) + files.append(filename) + return files + + +@pytest.fixture +def mock_context( + project: pytest_ayon.ProjectInfo, + single_file: Path, + sequence_files: list[Path]) -> pyblish.api.Context: + """Return a mock instance. + + This is mocking pyblish context for testing. It is using real AYON project + thanks to the ``project`` fixture. + + Args: + project (object): The project info. It is `ProjectInfo` object + returned by pytest fixture. + single_file (Path): The path to a single image file. + sequence_files (list[Path]): The paths to a sequence of image files. + + """ + anatomy = Anatomy(project.project_name) + context = pyblish.api.Context() + context.data["projectName"] = project.project_name + context.data["hostName"] = "test_host" + context.data["project_settings"] = get_project_settings( + project.project_name) + context.data["anatomy"] = anatomy + context.data["time"] = CURRENT_TIME + context.data["user"] = "test_user" + context.data["machine"] = "test_machine" + context.data["fps"] = 25 + + instance = context.create_instance("mock_instance") + instance.data["source"] = "test_source" + instance.data["families"] = ["render"] + + parents = project.folder_entity["path"].lstrip("/").split("/") + hierarchy = "/".join(parents) if parents else "" + + instance.data["anatomyData"] = { + "project": { + "name": project.project_name, + "code": project.project_code + }, + "task": { + "name": project.task.name, + "type": "test" # pytest-ayon doesn't return the task type yet + }, + "folder": { + "name": project.folder.name, + "type": "test" # pytest-ayon doesn't return the folder type yet + }, + "product": { + "name": project.product.name, + "type": "test" # pytest-ayon doesn't return the product type yet + }, + "hierarchy": hierarchy, + + } + instance.data["folderEntity"] = project.folder_entity + instance.data["productType"] = "test_product" + instance.data["productName"] = project.product.name + instance.data["anatomy"] = anatomy + instance.data["comment"] = "test_comment" + + instance.data["integrate"] = True + instance.data["farm"] = False + + parents = project.folder_entity["path"].lstrip("/").split("/") + + hierarchy = "/".join(parents) if parents else "" + instance.data["hierarchy"] = hierarchy + + version_number = get_versioning_start( + context.data["projectName"], + instance.context.data["hostName"], + task_name=project.task.name, + task_type="test", + product_type=instance.data["productType"], + product_name=instance.data["productName"] + ) + + instance.data["version"] = version_number + + file_size = len(base64.b64decode(PNG_FILE_B64)) + file_locations = [ + FileLocation( + file_path=f, + file_size=file_size) + for f in sequence_files] + + instance.data["representations_with_traits"] = [ + Representation(name="test_single", traits=[ + Persistent(), + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_sequence", traits=[ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ]), + Representation(name="test_bundle", traits=[ + Persistent(), + Bundle( + items=[ + [ + FileLocation( + file_path=single_file, + file_size=len(base64.b64decode(PNG_FILE_B64))), + Image(), + MimeType(mime_type="image/png"), + ], + [ + Persistent(), + FrameRanged( + frame_start=1, + frame_end=SEQUENCE_LENGTH, + frame_in=0, + frame_out=SEQUENCE_LENGTH - 1, + frames_per_second="25", + ), + Sequence( + frame_padding=4, + frame_regex=re.compile( + r"img\.(?P(?P0*)\d{4})\.png$"), + ), + FileLocations( + file_paths=file_locations, + ), + Image(), + PixelBased( + display_window_width=1920, + display_window_height=1080, + pixel_aspect_ratio=1.0), + MimeType(mime_type="image/png"), + ], + ], + ), + ]), + ] + + return context + + +@pytest.mark.server +def test_get_template_name(mock_context: pyblish.api.Context) -> None: + """Test get_template_name. + + TODO (antirotor): this will always return "default" probably, if + there are no studio overrides. To test this properly, we need + to set up the studio overrides in the test environment. + + """ + integrator = IntegrateTraits() + template_name = integrator.get_template_name( + mock_context[0]) + + assert template_name == "default" + + +class TestGetSize: + @staticmethod + def get_size(file_path: Path) -> int: + """Get size of the file. + + Args: + file_path (Path): File path. + + Returns: + int: Size of the file. + + """ + return file_path.stat().st_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_1.txt"), 10), # id: happy_path_small_file + (Path("./test_file_2.txt"), 1024), # id: happy_path_medium_file + (Path("./test_file_3.txt"), 10485760) # id: happy_path_large_file + ], + ids=["happy_path_small_file", + "happy_path_medium_file", + "happy_path_large_file"] + ) + def test_get_size_happy_path( + self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.write_bytes(b"\0" * expected_size) + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_size", + [ + (Path("./test_file_empty.txt"), 0) # id: edge_case_empty_file + ], + ids=["edge_case_empty_file"] + ) + def test_get_size_edge_cases( + self, file_path: Path, expected_size: int, tmp_path: Path): + # Arrange + file_path = tmp_path / file_path + file_path.touch() # Create an empty file + + # Act + size = self.get_size(file_path) + + # Assert + assert size == expected_size + + @pytest.mark.parametrize( + "file_path, expected_exception", + [ + ( + Path("./non_existent_file.txt"), + FileNotFoundError + ), # id: error_file_not_found + (123, TypeError) # id: error_invalid_input_type + ], + ids=["error_file_not_found", "error_invalid_input_type"] + ) + def test_get_size_error_cases( + self, file_path, expected_exception, tmp_path): + + # Act & Assert + with pytest.raises(expected_exception): + file_path = tmp_path / file_path + self.get_size(file_path) + + +def test_filter_lifecycle() -> None: + """Test filter_lifecycle.""" + integrator = IntegrateTraits() + persistent_representation = Representation( + name="test", + traits=[ + Persistent(), + FileLocation( + file_path=Path("test"), + file_size=1234), + Image(), + MimeType(mime_type="image/png"), + ]) + transient_representation = Representation( + name="test", + traits=[ + Transient(), + Image(), + MimeType(mime_type="image/png"), + ]) + filtered = integrator.filter_lifecycle( + [persistent_representation, transient_representation]) + + assert len(filtered) == 1 + assert filtered[0] == persistent_representation + + +@pytest.mark.server +def test_prepare_product( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_product.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + + assert product == { + "attrib": {}, + "data": { + "families": ["default", "render"], + }, + "folderId": project.folder_entity["id"], + "name": "renderMain", + "productType": "test_product", + "id": project.product_entity["id"], + } + + +@pytest.mark.server +def test_prepare_version( + project: pytest_ayon.ProjectInfo, + mock_context: pyblish.api.Context) -> None: + """Test prepare_version.""" + integrator = IntegrateTraits() + op_session = OperationsSession() + product = integrator.prepare_product(mock_context[0], op_session) + version = integrator.prepare_version( + mock_context[0], op_session, product) + + assert version == { + "attrib": { + "comment": "test_comment", + "families": ["default", "render"], + "fps": 25, + "machine": "test_machine", + "source": "test_source", + }, + "data": { + "author": "test_user", + "time": CURRENT_TIME, + }, + "id": project.version_entity["id"], + "productId": project.product_entity["id"], + "version": 1, + } + + +@pytest.mark.server +def test_get_transfers_from_representation( + mock_context: pyblish.api.Context) -> None: + """Test get_transfers_from_representation. + + This tests getting actual transfers from the representations and + also the legacy files. + + Todo: This test will benefit massively from a proper mocking of the + context. We need to parametrize the test with different + representations and test the output of the function. + + """ + integrator = IntegrateTraits() + + instance = mock_context[0] + representations: list[Representation] = instance.data[ + "representations_with_traits"] + transfers = integrator.get_transfers_from_representations( + instance, representations) + + assert len(representations) == 3 + assert len(transfers) == 22 + + for transfer in transfers: + assert transfer.checksum == TransferItem.get_checksum( + transfer.source) + + file_transactions = FileTransaction( + # Enforce unique transfers + allow_queue_replacements=False) + + for transfer in transfers: + file_transactions.add( + transfer.source.as_posix(), + transfer.destination.as_posix(), + mode=FileTransaction.MODE_COPY, + ) + + file_transactions.process() + + for representation in representations: + _ = integrator._get_legacy_files_for_representation( # noqa: SLF001 + transfers, representation, anatomy=instance.data["anatomy"]) diff --git a/tests/conftest.py b/tests/conftest.py index a3c46a9dd7..33c29d13f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +"""conftest.py: pytest configuration file.""" import sys from pathlib import Path @@ -5,5 +6,3 @@ client_path = Path(__file__).resolve().parent.parent / "client" # add client path to sys.path sys.path.append(str(client_path)) - -print(f"Added {client_path} to sys.path") diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 8324277713..306a61e30d 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -242,7 +242,7 @@ function Run-From-Code { function Run-Tests { $Poetry = "$RepoRoot\.poetry\bin\poetry.exe" - $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests") + $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests", "-m", "not server") & $Poetry $RunArgs @arguments } diff --git a/tools/manage.sh b/tools/manage.sh index 86ae7155c5..5362374045 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -186,7 +186,7 @@ run_command () { run_tests () { echo -e "${BIGreen}>>>${RST} Running tests..." shift; # will remove first arg ("run-tests") from the "$@" - "$POETRY_HOME/bin/poetry" run pytest ./tests + "$POETRY_HOME/bin/poetry" run pytest ./tests -m "not server" } main () {