diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ce5982969c..60693f088d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,13 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.6 + - 1.6.5 + - 1.6.4 + - 1.6.3 + - 1.6.2 + - 1.6.1 + - 1.6.0 - 1.5.3 - 1.5.2 - 1.5.1 diff --git a/.github/workflows/deploy_mkdocs.yml b/.github/workflows/deploy_mkdocs.yml new file mode 100644 index 0000000000..deafc7b850 --- /dev/null +++ b/.github/workflows/deploy_mkdocs.yml @@ -0,0 +1,18 @@ +name: Deploy MkDocs + +on: + push: + tags: + - "*" + workflow_dispatch: + +jobs: + build-mk-docs: + # FIXME: Update @develop to @main after `ops-repo-automation` release. + uses: ynput/ops-repo-automation/.github/workflows/deploy_mkdocs.yml@develop + with: + repo: ${{ github.repository }} + secrets: + YNPUT_BOT_TOKEN: ${{ secrets.YNPUT_BOT_TOKEN }} + CI_USER: ${{ secrets.CI_USER }} + CI_EMAIL: ${{ secrets.CI_EMAIL }} diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index f7fee13dc7..9207bb74c0 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Base class for AYON addons.""" -import copy +from __future__ import annotations + import os import sys import time @@ -11,10 +12,12 @@ import collections import warnings from uuid import uuid4 from abc import ABC, abstractmethod -from typing import Optional +from urllib.parse import urlencode +from types import ModuleType +import typing +from typing import Optional, Any, Union import ayon_api -from semver import VersionInfo from ayon_core import AYON_CORE_ROOT from ayon_core.lib import ( @@ -30,6 +33,11 @@ from .interfaces import ( IHostAddon, ) +if typing.TYPE_CHECKING: + import click + + from ayon_core.host import HostBase + # Files that will be always ignored on addons import IGNORED_FILENAMES = { "__pycache__", @@ -39,33 +47,6 @@ IGNORED_DEFAULT_FILENAMES = { "__init__.py", } -# When addon was moved from ayon-core codebase -# - this is used to log the missing addon -MOVED_ADDON_MILESTONE_VERSIONS = { - "aftereffects": VersionInfo(0, 2, 0), - "applications": VersionInfo(0, 2, 0), - "blender": VersionInfo(0, 2, 0), - "celaction": VersionInfo(0, 2, 0), - "clockify": VersionInfo(0, 2, 0), - "deadline": VersionInfo(0, 2, 0), - "flame": VersionInfo(0, 2, 0), - "fusion": VersionInfo(0, 2, 0), - "harmony": VersionInfo(0, 2, 0), - "hiero": VersionInfo(0, 2, 0), - "max": VersionInfo(0, 2, 0), - "photoshop": VersionInfo(0, 2, 0), - "timers_manager": VersionInfo(0, 2, 0), - "traypublisher": VersionInfo(0, 2, 0), - "tvpaint": VersionInfo(0, 2, 0), - "maya": VersionInfo(0, 2, 0), - "nuke": VersionInfo(0, 2, 0), - "resolve": VersionInfo(0, 2, 0), - "royalrender": VersionInfo(0, 2, 0), - "substancepainter": VersionInfo(0, 2, 0), - "houdini": VersionInfo(0, 3, 0), - "unreal": VersionInfo(0, 2, 0), -} - class ProcessPreparationError(Exception): """Exception that can be used when process preparation failed. @@ -128,7 +109,7 @@ class _LoadCache: addon_modules = [] -def load_addons(force=False): +def load_addons(force: bool = False) -> None: """Load AYON addons as python modules. Modules does not load only classes (like in Interfaces) because there must @@ -155,106 +136,76 @@ def load_addons(force=False): time.sleep(0.1) -def _get_ayon_bundle_data(): +def _get_ayon_bundle_data() -> tuple[ + dict[str, Any], Optional[dict[str, Any]] +]: studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") project_bundle_name = os.getenv("AYON_BUNDLE_NAME") bundles = ayon_api.get_bundles()["bundles"] - project_bundle = next( + studio_bundle = next( ( bundle for bundle in bundles - if bundle["name"] == project_bundle_name + if bundle["name"] == studio_bundle_name ), None ) - studio_bundle = None - if studio_bundle_name and project_bundle_name != studio_bundle_name: - studio_bundle = next( + + if studio_bundle is None: + raise RuntimeError(f"Failed to find bundle '{studio_bundle_name}'.") + + project_bundle = None + if project_bundle_name and project_bundle_name != studio_bundle_name: + project_bundle = next( ( bundle for bundle in bundles - if bundle["name"] == studio_bundle_name + if bundle["name"] == project_bundle_name ), None ) - if project_bundle and studio_bundle: - addons = copy.deepcopy(studio_bundle["addons"]) - addons.update(project_bundle["addons"]) - project_bundle["addons"] = addons - return project_bundle + if project_bundle is None: + raise RuntimeError( + f"Failed to find project bundle '{project_bundle_name}'." + ) + + return studio_bundle, project_bundle -def _get_ayon_addons_information(bundle_info): +def _get_ayon_addons_information( + studio_bundle: dict[str, Any], + project_bundle: Optional[dict[str, Any]], +) -> dict[str, str]: """Receive information about addons to use from server. Todos: Actually ask server for the information. Allow project name as optional argument to be able to query information about used addons for specific project. + Wrap versions into an object. Returns: - List[Dict[str, Any]]: List of addon information to use. + list[dict[str, Any]]: List of addon information to use. + """ + key_values = { + "summary": "true", + "bundle_name": studio_bundle["name"], + } + if project_bundle: + key_values["project_bundle_name"] = project_bundle["name"] - output = [] - bundle_addons = bundle_info["addons"] - addons = ayon_api.get_addons_info()["addons"] - for addon in addons: - name = addon["name"] - versions = addon.get("versions") - addon_version = bundle_addons.get(name) - if addon_version is None or not versions: - continue - version = versions.get(addon_version) - if version: - version = copy.deepcopy(version) - version["name"] = name - version["version"] = addon_version - output.append(version) - return output + query = urlencode(key_values) + + response = ayon_api.get(f"settings?{query}") + return { + addon["name"]: addon["version"] + for addon in response.data["addons"] + } -def _handle_moved_addons(addon_name, milestone_version, log): - """Log message that addon version is not compatible with current core. - - The function can return path to addon client code, but that can happen - only if ayon-core is used from code (for development), but still - logs a warning. - - Args: - addon_name (str): Addon name. - milestone_version (str): Milestone addon version. - log (logging.Logger): Logger object. - - Returns: - Union[str, None]: Addon dir or None. - """ - # Handle addons which were moved out of ayon-core - # - Try to fix it by loading it directly from server addons dir in - # ayon-core repository. But that will work only if ayon-core is - # used from code. - addon_dir = os.path.join( - os.path.dirname(os.path.dirname(AYON_CORE_ROOT)), - "server_addon", - addon_name, - "client", - ) - if not os.path.exists(addon_dir): - log.error( - f"Addon '{addon_name}' is not available. Please update " - f"{addon_name} addon to '{milestone_version}' or higher." - ) - return None - - log.warning(( - "Please update '{}' addon to '{}' or higher." - " Using client code from ayon-core repository." - ).format(addon_name, milestone_version)) - return addon_dir - - -def _load_ayon_addons(log): +def _load_ayon_addons(log: logging.Logger) -> list[ModuleType]: """Load AYON addons based on information from server. This function should not trigger downloading of any addons but only use @@ -264,10 +215,13 @@ def _load_ayon_addons(log): Args: log (logging.Logger): Logger object. + Returns: + list[ModuleType]: Loaded addon modules. + """ all_addon_modules = [] - bundle_info = _get_ayon_bundle_data() - addons_info = _get_ayon_addons_information(bundle_info) + studio_bundle, project_bundle = _get_ayon_bundle_data() + addons_info = _get_ayon_addons_information(studio_bundle, project_bundle) if not addons_info: return all_addon_modules @@ -279,18 +233,16 @@ def _load_ayon_addons(log): dev_addons_info = {} if dev_mode_enabled: # Get dev addons info only when dev mode is enabled - dev_addons_info = bundle_info.get("addonDevelopment", dev_addons_info) + dev_addons_info = studio_bundle.get( + "addonDevelopment", dev_addons_info + ) addons_dir_exists = os.path.exists(addons_dir) if not addons_dir_exists: - log.warning("Addons directory does not exists. Path \"{}\"".format( - addons_dir - )) - - for addon_info in addons_info: - addon_name = addon_info["name"] - addon_version = addon_info["version"] + log.warning( + f"Addons directory does not exists. Path \"{addons_dir}\"") + for addon_name, addon_version in addons_info.items(): # core addon does not have any addon object if addon_name == "core": continue @@ -299,32 +251,28 @@ def _load_ayon_addons(log): use_dev_path = dev_addon_info.get("enabled", False) addon_dir = None - milestone_version = MOVED_ADDON_MILESTONE_VERSIONS.get(addon_name) if use_dev_path: addon_dir = dev_addon_info["path"] - if not addon_dir or not os.path.exists(addon_dir): - log.warning(( - "Dev addon {} {} path does not exists. Path \"{}\"" - ).format(addon_name, addon_version, addon_dir)) - continue + if addon_dir: + addon_dir = os.path.expandvars( + addon_dir.format_map(os.environ) + ) - elif ( - milestone_version is not None - and VersionInfo.parse(addon_version) < milestone_version - ): - addon_dir = _handle_moved_addons( - addon_name, milestone_version, log - ) - if not addon_dir: + if not addon_dir or not os.path.exists(addon_dir): + log.warning( + f"Dev addon {addon_name} {addon_version} path" + f" does not exists. Path \"{addon_dir}\"" + ) continue elif addons_dir_exists: - folder_name = "{}_{}".format(addon_name, addon_version) + folder_name = f"{addon_name}_{addon_version}" addon_dir = os.path.join(addons_dir, folder_name) if not os.path.exists(addon_dir): - log.debug(( - "No localized client code found for addon {} {}." - ).format(addon_name, addon_version)) + log.debug( + "No localized client code found" + f" for addon {addon_name} {addon_version}." + ) continue if not addon_dir: @@ -363,24 +311,22 @@ def _load_ayon_addons(log): except BaseException: log.warning( - "Failed to import \"{}\"".format(basename), + f"Failed to import \"{basename}\"", exc_info=True ) if not addon_modules: - log.warning("Addon {} {} has no content to import".format( - addon_name, addon_version - )) + log.warning( + f"Addon {addon_name} {addon_version} has no content to import" + ) continue if len(addon_modules) > 1: - log.warning(( - "Multiple modules ({}) were found in addon '{}' in dir {}." - ).format( - ", ".join([m.__name__ for m in addon_modules]), - addon_name, - addon_dir, - )) + joined_modules = ", ".join([m.__name__ for m in addon_modules]) + log.warning( + f"Multiple modules ({joined_modules}) were found in" + f" addon '{addon_name}' in dir {addon_dir}." + ) all_addon_modules.extend(addon_modules) return all_addon_modules @@ -398,20 +344,21 @@ class AYONAddon(ABC): Attributes: enabled (bool): Is addon enabled. - name (str): Addon name. Args: manager (AddonsManager): Manager object who discovered addon. settings (dict[str, Any]): AYON settings. """ - enabled = True + enabled: bool = True _id = None # Temporary variable for 'version' property _missing_version_warned = False - def __init__(self, manager, settings): + def __init__( + self, manager: AddonsManager, settings: dict[str, Any] + ) -> None: self.manager = manager self.log = Logger.get_logger(self.name) @@ -419,7 +366,7 @@ class AYONAddon(ABC): self.initialize(settings) @property - def id(self): + def id(self) -> str: """Random id of addon object. Returns: @@ -432,7 +379,7 @@ class AYONAddon(ABC): @property @abstractmethod - def name(self): + def name(self) -> str: """Addon name. Returns: @@ -442,7 +389,7 @@ class AYONAddon(ABC): pass @property - def version(self): + def version(self) -> str: """Addon version. Todo: @@ -461,7 +408,7 @@ class AYONAddon(ABC): ) return "0.0.0" - def initialize(self, settings): + def initialize(self, settings: dict[str, Any]) -> None: """Initialization of addon attributes. It is not recommended to override __init__ that's why specific method @@ -473,7 +420,7 @@ class AYONAddon(ABC): """ pass - def connect_with_addons(self, enabled_addons): + def connect_with_addons(self, enabled_addons: list[AYONAddon]) -> None: """Connect with other enabled addons. Args: @@ -484,7 +431,7 @@ class AYONAddon(ABC): def ensure_is_process_ready( self, process_context: ProcessContext - ): + ) -> None: """Make sure addon is prepared for a process. This method is called when some action makes sure that addon has set @@ -505,7 +452,7 @@ class AYONAddon(ABC): """ pass - def get_global_environments(self): + def get_global_environments(self) -> dict[str, str]: """Get global environments values of addon. Environment variables that can be get only from system settings. @@ -516,20 +463,12 @@ class AYONAddon(ABC): """ return {} - def modify_application_launch_arguments(self, application, env): - """Give option to modify launch environments before application launch. - - Implementation is optional. To change environments modify passed - dictionary of environments. - - Args: - application (Application): Application that is launched. - env (dict[str, str]): Current environment variables. - - """ - pass - - def on_host_install(self, host, host_name, project_name): + def on_host_install( + self, + host: HostBase, + host_name: str, + project_name: str, + ) -> None: """Host was installed which gives option to handle in-host logic. It is a good option to register in-host event callbacks which are @@ -540,7 +479,7 @@ class AYONAddon(ABC): to receive from 'host' object. Args: - host (Union[ModuleType, HostBase]): Access to installed/registered + host (HostBase): Access to installed/registered host object. host_name (str): Name of host. project_name (str): Project name which is main part of host @@ -549,7 +488,7 @@ class AYONAddon(ABC): """ pass - def cli(self, addon_click_group): + def cli(self, addon_click_group: click.Group) -> None: """Add commands to click group. The best practise is to create click group for whole addon which is @@ -580,15 +519,21 @@ class AYONAddon(ABC): class _AddonReportInfo: def __init__( - self, class_name, name, version, report_value_by_label - ): + self, + class_name: str, + name: str, + version: str, + report_value_by_label: dict[str, Optional[str]], + ) -> None: self.class_name = class_name self.name = name self.version = version self.report_value_by_label = report_value_by_label @classmethod - def from_addon(cls, addon, report): + def from_addon( + cls, addon: AYONAddon, report: dict[str, dict[str, int]] + ) -> "_AddonReportInfo": class_name = addon.__class__.__name__ report_value_by_label = { label: reported.get(class_name) @@ -615,29 +560,35 @@ class AddonsManager: _report_total_key = "Total" _log = None - def __init__(self, settings=None, initialize=True): + def __init__( + self, + settings: Optional[dict[str, Any]] = None, + initialize: bool = True, + ) -> None: self._settings = settings - self._addons = [] - self._addons_by_id = {} - self._addons_by_name = {} + self._addons: list[AYONAddon] = [] + self._addons_by_id: dict[str, AYONAddon] = {} + self._addons_by_name: dict[str, AYONAddon] = {} # For report of time consumption - self._report = {} + self._report: dict[str, dict[str, int]] = {} if initialize: self.initialize_addons() self.connect_addons() - def __getitem__(self, addon_name): + def __getitem__(self, addon_name: str) -> AYONAddon: return self._addons_by_name[addon_name] @property - def log(self): + def log(self) -> logging.Logger: if self._log is None: - self._log = logging.getLogger(self.__class__.__name__) + self._log = Logger.get_logger(self.__class__.__name__) return self._log - def get(self, addon_name, default=None): + def get( + self, addon_name: str, default: Optional[Any] = None + ) -> Union[AYONAddon, Any]: """Access addon by name. Args: @@ -651,18 +602,20 @@ class AddonsManager: return self._addons_by_name.get(addon_name, default) @property - def addons(self): + def addons(self) -> list[AYONAddon]: return list(self._addons) @property - def addons_by_id(self): + def addons_by_id(self) -> dict[str, AYONAddon]: return dict(self._addons_by_id) @property - def addons_by_name(self): + def addons_by_name(self) -> dict[str, AYONAddon]: return dict(self._addons_by_name) - def get_enabled_addon(self, addon_name, default=None): + def get_enabled_addon( + self, addon_name: str, default: Optional[Any] = None + ) -> Union[AYONAddon, Any]: """Fast access to enabled addon. If addon is available but is not enabled default value is returned. @@ -673,7 +626,7 @@ class AddonsManager: not enabled. Returns: - Union[AYONAddon, None]: Enabled addon found by name or None. + Union[AYONAddon, Any]: Enabled addon found by name or None. """ addon = self.get(addon_name) @@ -681,7 +634,7 @@ class AddonsManager: return addon return default - def get_enabled_addons(self): + def get_enabled_addons(self) -> list[AYONAddon]: """Enabled addons initialized by the manager. Returns: @@ -694,7 +647,7 @@ class AddonsManager: if addon.enabled ] - def initialize_addons(self): + def initialize_addons(self) -> None: """Import and initialize addons.""" # Make sure modules are loaded load_addons() @@ -775,7 +728,7 @@ class AddonsManager: report[self._report_total_key] = time.time() - time_start self._report["Initialization"] = report - def connect_addons(self): + def connect_addons(self) -> None: """Trigger connection with other enabled addons. Addons should handle their interfaces in `connect_with_addons`. @@ -784,7 +737,7 @@ class AddonsManager: time_start = time.time() prev_start_time = time_start enabled_addons = self.get_enabled_addons() - self.log.debug("Has {} enabled addons.".format(len(enabled_addons))) + self.log.debug(f"Has {len(enabled_addons)} enabled addons.") for addon in enabled_addons: try: addon.connect_with_addons(enabled_addons) @@ -803,7 +756,7 @@ class AddonsManager: report[self._report_total_key] = time.time() - time_start self._report["Connect modules"] = report - def collect_global_environments(self): + def collect_global_environments(self) -> dict[str, str]: """Helper to collect global environment variabled from modules. Returns: @@ -826,7 +779,7 @@ class AddonsManager: module_envs[key] = value return module_envs - def collect_plugin_paths(self): + def collect_plugin_paths(self) -> dict[str, list[str]]: """Helper to collect all plugins from modules inherited IPluginPaths. Unknown keys are logged out. @@ -885,7 +838,7 @@ class AddonsManager: # Report unknown keys (Developing purposes) if unknown_keys_by_addon: expected_keys = ", ".join([ - "\"{}\"".format(key) for key in output.keys() + f'"{key}"' for key in output.keys() ]) msg_template = "Addon: \"{}\" - got key {}" msg_items = [] @@ -894,12 +847,14 @@ class AddonsManager: "\"{}\"".format(key) for key in keys ]) msg_items.append(msg_template.format(addon_name, joined_keys)) - self.log.warning(( - "Expected keys from `get_plugin_paths` are {}. {}" - ).format(expected_keys, " | ".join(msg_items))) + joined_items = " | ".join(msg_items) + self.log.warning( + f"Expected keys from `get_plugin_paths` are {expected_keys}." + f" {joined_items}" + ) return output - def _collect_plugin_paths(self, method_name, *args, **kwargs): + def _collect_plugin_paths(self, method_name: str, *args, **kwargs): output = [] for addon in self.get_enabled_addons(): # Skip addon that do not inherit from `IPluginPaths` @@ -930,7 +885,7 @@ class AddonsManager: output.extend(paths) return output - def collect_launcher_action_paths(self): + def collect_launcher_action_paths(self) -> list[str]: """Helper to collect launcher action paths from addons. Returns: @@ -945,16 +900,16 @@ class AddonsManager: output.insert(0, actions_dir) return output - def collect_create_plugin_paths(self, host_name): + def collect_create_plugin_paths(self, host_name: str) -> list[str]: """Helper to collect creator plugin paths from addons. Args: host_name (str): For which host are creators meant. Returns: - list: List of creator plugin paths. - """ + list[str]: List of creator plugin paths. + """ return self._collect_plugin_paths( "get_create_plugin_paths", host_name @@ -962,37 +917,37 @@ class AddonsManager: collect_creator_plugin_paths = collect_create_plugin_paths - def collect_load_plugin_paths(self, host_name): + def collect_load_plugin_paths(self, host_name: str) -> list[str]: """Helper to collect load plugin paths from addons. Args: host_name (str): For which host are load plugins meant. Returns: - list: List of load plugin paths. - """ + list[str]: List of load plugin paths. + """ return self._collect_plugin_paths( "get_load_plugin_paths", host_name ) - def collect_publish_plugin_paths(self, host_name): + def collect_publish_plugin_paths(self, host_name: str) -> list[str]: """Helper to collect load plugin paths from addons. Args: host_name (str): For which host are load plugins meant. Returns: - list: List of pyblish plugin paths. - """ + list[str]: List of pyblish plugin paths. + """ return self._collect_plugin_paths( "get_publish_plugin_paths", host_name ) - def collect_inventory_action_paths(self, host_name): + def collect_inventory_action_paths(self, host_name: str) -> list[str]: """Helper to collect load plugin paths from addons. Args: @@ -1000,21 +955,21 @@ class AddonsManager: Returns: list: List of pyblish plugin paths. - """ + """ return self._collect_plugin_paths( "get_inventory_action_paths", host_name ) - def get_host_addon(self, host_name): + def get_host_addon(self, host_name: str) -> Optional[AYONAddon]: """Find host addon by host name. Args: host_name (str): Host name for which is found host addon. Returns: - Union[AYONAddon, None]: Found host addon by name or `None`. + Optional[AYONAddon]: Found host addon by name or `None`. """ for addon in self.get_enabled_addons(): @@ -1025,21 +980,21 @@ class AddonsManager: return addon return None - def get_host_names(self): + def get_host_names(self) -> set[str]: """List of available host names based on host addons. Returns: - Iterable[str]: All available host names based on enabled addons + set[str]: All available host names based on enabled addons inheriting 'IHostAddon'. - """ + """ return { addon.host_name for addon in self.get_enabled_addons() if isinstance(addon, IHostAddon) } - def print_report(self): + def print_report(self) -> None: """Print out report of time spent on addons initialization parts. Reporting is not automated must be implemented for each initialization diff --git a/client/ayon_core/hooks/pre_add_last_workfile_arg.py b/client/ayon_core/hooks/pre_add_last_workfile_arg.py index c6afaaa083..752302bb20 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -38,18 +38,20 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): launch_types = {LaunchTypes.local} def execute(self): - if not self.data.get("start_last_workfile"): - self.log.info("It is set to not start last workfile on start.") - return + workfile_path = self.data.get("workfile_path") + if not workfile_path: + if not self.data.get("start_last_workfile"): + self.log.info("It is set to not start last workfile on start.") + return - last_workfile = self.data.get("last_workfile_path") - if not last_workfile: - self.log.warning("Last workfile was not collected.") - return + workfile_path = self.data.get("last_workfile_path") + if not workfile_path: + self.log.warning("Last workfile was not collected.") + return - if not os.path.exists(last_workfile): + if not os.path.exists(workfile_path): self.log.info("Current context does not have any workfile yet.") return # Add path to workfile to arguments - self.launch_context.launch_args.append(last_workfile) + self.launch_context.launch_args.append(workfile_path) diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 85fcef47f2..be086dae65 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -14,7 +14,7 @@ class OCIOEnvHook(PreLaunchHook): "fusion", "blender", "aftereffects", - "3dsmax", + "max", "houdini", "maya", "nuke", diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index 950c14564e..7d5918b0ac 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -1,5 +1,5 @@ from .constants import ContextChangeReason -from .abstract import AbstractHost +from .abstract import AbstractHost, ApplicationInformation from .host import ( HostBase, ContextChangeData, @@ -21,6 +21,7 @@ __all__ = ( "ContextChangeReason", "AbstractHost", + "ApplicationInformation", "HostBase", "ContextChangeData", diff --git a/client/ayon_core/host/abstract.py b/client/ayon_core/host/abstract.py index 26771aaffa..7b4bb5b791 100644 --- a/client/ayon_core/host/abstract.py +++ b/client/ayon_core/host/abstract.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from abc import ABC, abstractmethod +from dataclasses import dataclass import typing from typing import Optional, Any @@ -13,6 +14,19 @@ if typing.TYPE_CHECKING: from .typing import HostContextData +@dataclass +class ApplicationInformation: + """Application information. + + Attributes: + app_name (Optional[str]): Application name. e.g. Maya, NukeX, Nuke + app_version (Optional[str]): Application version. e.g. 15.2.1 + + """ + app_name: Optional[str] = None + app_version: Optional[str] = None + + class AbstractHost(ABC): """Abstract definition of host implementation.""" @property @@ -26,6 +40,16 @@ class AbstractHost(ABC): """Host name.""" pass + @abstractmethod + def get_app_information(self) -> ApplicationInformation: + """Information about the application where host is running. + + Returns: + ApplicationInformation: Application information. + + """ + pass + @abstractmethod def get_current_context(self) -> HostContextData: """Get the current context of the host. diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 28cb6b0a09..7d6d3ddbe4 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -12,7 +12,7 @@ import ayon_api from ayon_core.lib import emit_event from .constants import ContextChangeReason -from .abstract import AbstractHost +from .abstract import AbstractHost, ApplicationInformation if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -96,6 +96,18 @@ class HostBase(AbstractHost): pass + def get_app_information(self) -> ApplicationInformation: + """Running application information. + + Host integration should override this method and return correct + information. + + Returns: + ApplicationInformation: Application information. + + """ + return ApplicationInformation() + def install(self): """Install host specific functionality. diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 93aad4c117..5dbf29bd7b 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -55,7 +55,7 @@ class _WorkfileOptionalData: ): if kwargs: cls_name = self.__class__.__name__ - keys = ", ".join(['"{}"'.format(k) for k in kwargs.keys()]) + keys = ", ".join([f'"{k}"' for k in kwargs.keys()]) warnings.warn( f"Unknown keywords passed to {cls_name}: {keys}", ) @@ -1554,6 +1554,27 @@ class IWorkfileHost(AbstractHost): if platform.system().lower() == "windows": rootless_path = rootless_path.replace("\\", "/") + # Get application information + app_info = self.get_app_information() + data = {} + if app_info.app_name: + data["app_name"] = app_info.app_name + if app_info.app_version: + data["app_version"] = app_info.app_version + + # Use app group and app variant from applications addon (if available) + app_addon_name = os.environ.get("AYON_APP_NAME") + if not app_addon_name: + app_addon_name = None + + app_addon_tools_s = os.environ.get("AYON_APP_TOOLS") + app_addon_tools = [] + if app_addon_tools_s: + app_addon_tools = app_addon_tools_s.split(";") + + data["ayon_app_name"] = app_addon_name + data["ayon_app_tools"] = app_addon_tools + workfile_info = save_workfile_info( project_name, save_workfile_context.task_entity["id"], @@ -1562,6 +1583,7 @@ class IWorkfileHost(AbstractHost): version, comment, description, + data=data, workfile_entities=save_workfile_context.workfile_entities, ) return workfile_info diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 5ccc8d03e5..d5629cbf3d 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -11,6 +11,7 @@ from .local_settings import ( get_launcher_storage_dir, get_addons_resources_dir, get_local_site_id, + get_ayon_user_entity, get_ayon_username, ) from .ayon_connection import initialize_ayon_connection @@ -73,6 +74,7 @@ from .log import ( ) from .path_templates import ( + DefaultKeysDict, TemplateUnsolved, StringTemplate, FormatObject, @@ -148,6 +150,7 @@ __all__ = [ "get_launcher_storage_dir", "get_addons_resources_dir", "get_local_site_id", + "get_ayon_user_entity", "get_ayon_username", "initialize_ayon_connection", @@ -228,6 +231,7 @@ __all__ = [ "get_version_from_path", "get_last_version_from_path", + "DefaultKeysDict", "TemplateUnsolved", "StringTemplate", "FormatObject", diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 1edfc3c1b6..8a17b7af38 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -5,6 +5,7 @@ import json import platform import configparser import warnings +import copy from datetime import datetime from abc import ABC, abstractmethod from functools import lru_cache @@ -13,6 +14,8 @@ from typing import Optional, Any import platformdirs import ayon_api +from .cache import NestedCacheItem, CacheItem + _PLACEHOLDER = object() @@ -23,6 +26,7 @@ class RegistryItemNotFound(ValueError): class _Cache: username = None + user_entities_by_name = NestedCacheItem() def _get_ayon_appdirs(*args: str) -> str: @@ -569,6 +573,68 @@ def get_local_site_id(): return site_id +def _get_ayon_service_username() -> Optional[str]: + # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather + # use public method to get username from connection stack. + con = ayon_api.get_server_api_connection() + user_stack = getattr(con, "_as_user_stack", None) + if user_stack is None: + return None + return user_stack.username + + +def get_ayon_user_entity(username: Optional[str] = None) -> dict[str, Any]: + """AYON user entity used for templates and publishing. + + Note: + Usually only service and admin users can receive the full user entity. + + Args: + username (Optional[str]): Username of the user. If not passed, then + the current user in 'ayon_api' is used. + + Returns: + dict[str, Any]: User entity. + + """ + service_username = _get_ayon_service_username() + # Handle service user handling first + if service_username: + if username is None: + username = service_username + cache: CacheItem = _Cache.user_entities_by_name[username] + if not cache.is_valid: + if username == service_username: + user = ayon_api.get_user() + else: + user = ayon_api.get_user(username) + cache.update_data(user) + return copy.deepcopy(cache.get_data()) + + # Cache current user + current_user = None + if _Cache.username is None: + current_user = ayon_api.get_user() + _Cache.username = current_user["name"] + + if username is None: + username = _Cache.username + + cache: CacheItem = _Cache.user_entities_by_name[username] + if not cache.is_valid: + user = None + if username == _Cache.username: + if current_user is None: + current_user = ayon_api.get_user() + user = current_user + + if user is None: + user = ayon_api.get_user(username) + cache.update_data(user) + + return copy.deepcopy(cache.get_data()) + + def get_ayon_username(): """AYON username used for templates and publishing. @@ -578,20 +644,5 @@ def get_ayon_username(): str: Username. """ - # Look for username in the connection stack - # - this is used when service is working as other user - # (e.g. in background sync) - # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather - # use public method to get username from connection stack. - con = ayon_api.get_server_api_connection() - user_stack = getattr(con, "_as_user_stack", None) - if user_stack is not None: - username = user_stack.username - if username is not None: - return username - - # Cache the username to avoid multiple API calls - # - it is not expected that user would change - if _Cache.username is None: - _Cache.username = ayon_api.get_user()["name"] - return _Cache.username + user = get_ayon_user_entity() + return user["name"] diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index c6e9e14eac..aba2f296e3 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import re import copy @@ -5,11 +7,7 @@ import numbers import warnings import platform from string import Formatter -import typing -from typing import List, Dict, Any, Set - -if typing.TYPE_CHECKING: - from typing import Union +from typing import Any, Union, Iterable SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") @@ -44,6 +42,54 @@ class TemplateUnsolved(Exception): ) +class DefaultKeysDict(dict): + """Dictionary that supports the default key to use for str conversion. + + Is helpful for changes of a key in a template from string to dictionary + for example '{folder}' -> '{folder[name]}'. + >>> data = DefaultKeysDict( + >>> "name", + >>> {"folder": {"name": "FolderName"}} + >>> ) + >>> print("{folder[name]}".format_map(data)) + FolderName + >>> print("{folder}".format_map(data)) + FolderName + + Args: + default_key (Union[str, Iterable[str]]): Default key to use for str + conversion. Can also expect multiple keys for more nested + dictionary. + + """ + def __init__( + self, default_keys: Union[str, Iterable[str]], *args, **kwargs + ) -> None: + if isinstance(default_keys, str): + default_keys = [default_keys] + else: + default_keys = list(default_keys) + if not default_keys: + raise ValueError( + "Default key must be set. Got empty default keys." + ) + + self._default_keys = default_keys + super().__init__(*args, **kwargs) + + def __str__(self) -> str: + return str(self.get_default_value()) + + def get_default_keys(self) -> list[str]: + return list(self._default_keys) + + def get_default_value(self) -> Any: + value = self + for key in self._default_keys: + value = value[key] + return value + + class StringTemplate: """String that can be formatted.""" def __init__(self, template: str): @@ -84,7 +130,7 @@ class StringTemplate: if substr: new_parts.append(substr) - self._parts: List["Union[str, OptionalPart, FormattingPart]"] = ( + self._parts: list[Union[str, OptionalPart, FormattingPart]] = ( self.find_optional_parts(new_parts) ) @@ -105,7 +151,7 @@ class StringTemplate: def template(self) -> str: return self._template - def format(self, data: Dict[str, Any]) -> "TemplateResult": + def format(self, data: dict[str, Any]) -> "TemplateResult": """ Figure out with whole formatting. Separate advanced keys (*Like '{project[name]}') from string which must @@ -145,29 +191,29 @@ class StringTemplate: invalid_types ) - def format_strict(self, data: Dict[str, Any]) -> "TemplateResult": + def format_strict(self, data: dict[str, Any]) -> "TemplateResult": result = self.format(data) result.validate() return result @classmethod def format_template( - cls, template: str, data: Dict[str, Any] + cls, template: str, data: dict[str, Any] ) -> "TemplateResult": objected_template = cls(template) return objected_template.format(data) @classmethod def format_strict_template( - cls, template: str, data: Dict[str, Any] + cls, template: str, data: dict[str, Any] ) -> "TemplateResult": objected_template = cls(template) return objected_template.format_strict(data) @staticmethod def find_optional_parts( - parts: List["Union[str, FormattingPart]"] - ) -> List["Union[str, OptionalPart, FormattingPart]"]: + parts: list[Union[str, FormattingPart]] + ) -> list[Union[str, OptionalPart, FormattingPart]]: new_parts = [] tmp_parts = {} counted_symb = -1 @@ -192,7 +238,7 @@ class StringTemplate: len(parts) == 1 and isinstance(parts[0], str) ): - value = "<{}>".format(parts[0]) + value = f"<{parts[0]}>" else: value = OptionalPart(parts) @@ -223,7 +269,7 @@ class TemplateResult(str): only used keys. solved (bool): For check if all required keys were filled. template (str): Original template. - missing_keys (Iterable[str]): Missing keys that were not in the data. + missing_keys (list[str]): Missing keys that were not in the data. Include missing optional keys. invalid_types (dict): When key was found in data, but value had not allowed DataType. Allowed data types are `numbers`, @@ -232,11 +278,11 @@ class TemplateResult(str): of number. """ - used_values: Dict[str, Any] = None + used_values: dict[str, Any] = None solved: bool = None template: str = None - missing_keys: List[str] = None - invalid_types: Dict[str, Any] = None + missing_keys: list[str] = None + invalid_types: dict[str, Any] = None def __new__( cls, filled_template, template, solved, @@ -296,21 +342,21 @@ class TemplatePartResult: """Result to store result of template parts.""" def __init__(self, optional: bool = False): # Missing keys or invalid value types of required keys - self._missing_keys: Set[str] = set() - self._invalid_types: Dict[str, Any] = {} + self._missing_keys: set[str] = set() + self._invalid_types: dict[str, Any] = {} # Missing keys or invalid value types of optional keys - self._missing_optional_keys: Set[str] = set() - self._invalid_optional_types: Dict[str, Any] = {} + self._missing_optional_keys: set[str] = set() + self._invalid_optional_types: dict[str, Any] = {} # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} - self._used_values: Dict[str, Any] = {} + self._used_values: dict[str, Any] = {} # Used values stored by key with all modifirs # - value is already formatted string # Example: {"version:0>3": "001"} - self._really_used_values: Dict[str, Any] = {} + self._really_used_values: dict[str, Any] = {} # Concatenated string output after formatting self._output: str = "" # Is this result from optional part @@ -336,8 +382,9 @@ class TemplatePartResult: self._really_used_values.update(other.really_used_values) else: - raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( - str(type(other)), self.__class__.__name__) + raise TypeError( + f"Cannot add data from \"{type(other)}\"" + f" to \"{self.__class__.__name__}\"" ) @property @@ -362,40 +409,41 @@ class TemplatePartResult: return self._output @property - def missing_keys(self) -> Set[str]: + def missing_keys(self) -> set[str]: return self._missing_keys @property - def missing_optional_keys(self) -> Set[str]: + def missing_optional_keys(self) -> set[str]: return self._missing_optional_keys @property - def invalid_types(self) -> Dict[str, Any]: + def invalid_types(self) -> dict[str, Any]: return self._invalid_types @property - def invalid_optional_types(self) -> Dict[str, Any]: + def invalid_optional_types(self) -> dict[str, Any]: return self._invalid_optional_types @property - def really_used_values(self) -> Dict[str, Any]: + def really_used_values(self) -> dict[str, Any]: return self._really_used_values @property - def realy_used_values(self) -> Dict[str, Any]: + def realy_used_values(self) -> dict[str, Any]: warnings.warn( "Property 'realy_used_values' is deprecated." " Use 'really_used_values' instead.", - DeprecationWarning + DeprecationWarning, + stacklevel=2, ) return self._really_used_values @property - def used_values(self) -> Dict[str, Any]: + def used_values(self) -> dict[str, Any]: return self._used_values @staticmethod - def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]: + def split_keys_to_subdicts(values: dict[str, Any]) -> dict[str, Any]: output = {} formatter = Formatter() for key, value in values.items(): @@ -410,7 +458,7 @@ class TemplatePartResult: data[last_key] = value return output - def get_clean_used_values(self) -> Dict[str, Any]: + def get_clean_used_values(self) -> dict[str, Any]: new_used_values = {} for key, value in self.used_values.items(): if isinstance(value, FormatObject): @@ -426,7 +474,8 @@ class TemplatePartResult: warnings.warn( "Method 'add_realy_used_value' is deprecated." " Use 'add_really_used_value' instead.", - DeprecationWarning + DeprecationWarning, + stacklevel=2, ) self.add_really_used_value(key, value) @@ -479,7 +528,7 @@ class FormattingPart: self, field_name: str, format_spec: str, - conversion: "Union[str, None]", + conversion: Union[str, None], ): format_spec_v = "" if format_spec: @@ -546,7 +595,7 @@ class FormattingPart: return not queue @staticmethod - def keys_to_template_base(keys: List[str]): + def keys_to_template_base(keys: list[str]): if not keys: return None # Create copy of keys @@ -556,7 +605,7 @@ class FormattingPart: return f"{template_base}{joined_keys}" def format( - self, data: Dict[str, Any], result: TemplatePartResult + self, data: dict[str, Any], result: TemplatePartResult ) -> TemplatePartResult: """Format the formattings string. @@ -635,6 +684,12 @@ class FormattingPart: result.add_output(self.template) return result + if isinstance(value, DefaultKeysDict): + try: + value = value.get_default_value() + except KeyError: + pass + if not self.validate_value_type(value): result.add_invalid_type(key, value) result.add_output(self.template) @@ -687,23 +742,25 @@ class OptionalPart: def __init__( self, - parts: List["Union[str, OptionalPart, FormattingPart]"] + parts: list[Union[str, OptionalPart, FormattingPart]] ): - self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts + self._parts: list[Union[str, OptionalPart, FormattingPart]] = parts @property - def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]: + def parts(self) -> list[Union[str, OptionalPart, FormattingPart]]: return self._parts def __str__(self) -> str: - return "<{}>".format("".join([str(p) for p in self._parts])) + joined_parts = "".join([str(p) for p in self._parts]) + return f"<{joined_parts}>" def __repr__(self) -> str: - return "".format("".join([str(p) for p in self._parts])) + joined_parts = "".join([str(p) for p in self._parts]) + return f"" def format( self, - data: Dict[str, Any], + data: dict[str, Any], result: TemplatePartResult, ) -> TemplatePartResult: new_result = TemplatePartResult(True) diff --git a/client/ayon_core/lib/plugin_tools.py b/client/ayon_core/lib/plugin_tools.py index 654bc7ac4a..b19fe1e200 100644 --- a/client/ayon_core/lib/plugin_tools.py +++ b/client/ayon_core/lib/plugin_tools.py @@ -1,11 +1,9 @@ # -*- coding: utf-8 -*- """AYON plugin tools.""" import os -import logging import re import collections -log = logging.getLogger(__name__) CAPITALIZE_REGEX = re.compile(r"[a-zA-Z0-9]") diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 8c84e1c4dc..127bd3bac4 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -6,6 +6,8 @@ import collections import tempfile import subprocess import platform +import warnings +import functools from typing import Optional import xml.etree.ElementTree @@ -67,6 +69,47 @@ VIDEO_EXTENSIONS = { } +def deprecated(new_destination): + """Mark functions as deprecated. + + It will result in a warning being emitted when the function is used. + """ + + func = None + if callable(new_destination): + func = new_destination + new_destination = None + + def _decorator(decorated_func): + if new_destination is None: + warning_message = ( + " Please check content of deprecated function to figure out" + " possible replacement." + ) + else: + warning_message = " Please replace your usage with '{}'.".format( + new_destination + ) + + @functools.wraps(decorated_func) + def wrapper(*args, **kwargs): + warnings.simplefilter("always", DeprecationWarning) + warnings.warn( + ( + "Call to deprecated function '{}'" + "\nFunction was moved or removed.{}" + ).format(decorated_func.__name__, warning_message), + category=DeprecationWarning, + stacklevel=4 + ) + return decorated_func(*args, **kwargs) + return wrapper + + if func is None: + return _decorator + return _decorator(func) + + def get_transcode_temp_directory(): """Creates temporary folder for transcoding. @@ -377,11 +420,14 @@ def get_review_info_by_layer_name(channel_names): channel = last_part[0].upper() rgba_by_layer_name[layer_name][channel] = channel_name - # Put empty layer to the beginning of the list + # Put empty layer or 'rgba' to the beginning of the list # - if input has R, G, B, A channels they should be used for review - if "" in layer_names_order: - layer_names_order.remove("") - layer_names_order.insert(0, "") + # NOTE They are iterated in reversed order because they're inserted to + # the beginning of 'layer_names_order' -> last added will be first. + for name in reversed(["", "rgba"]): + if name in layer_names_order: + layer_names_order.remove(name) + layer_names_order.insert(0, name) output = [] for layer_name in layer_names_order: @@ -966,6 +1012,8 @@ def convert_ffprobe_fps_to_float(value): return dividend / divisor +# --- Deprecated functions --- +@deprecated("oiio_color_convert") def convert_colorspace( input_path, output_path, @@ -977,7 +1025,62 @@ def convert_colorspace( additional_command_args=None, logger=None, ): - """Convert source file from one color space to another. + """DEPRECATED function use `oiio_color_convert` instead + + Args: + input_path (str): Path to input file that should be converted. + output_path (str): Path to output file where result will be stored. + config_path (str): Path to OCIO config file. + source_colorspace (str): OCIO valid color space of source files. + target_colorspace (str, optional): OCIO valid target color space. + If filled, 'view' and 'display' must be empty. + view (str, optional): Name for target viewer space (OCIO valid). + Both 'view' and 'display' must be filled + (if not 'target_colorspace'). + display (str, optional): Name for target display-referred + reference space. Both 'view' and 'display' must be filled + (if not 'target_colorspace'). + additional_command_args (list, optional): Additional arguments + for oiiotool (like binary depth for .dpx). + logger (logging.Logger, optional): Logger used for logging. + + Returns: + None: Function returns None. + + Raises: + ValueError: If parameters are misconfigured. + """ + return oiio_color_convert( + input_path, + output_path, + config_path, + source_colorspace, + target_colorspace=target_colorspace, + target_display=display, + target_view=view, + additional_command_args=additional_command_args, + logger=logger, + ) + + +def oiio_color_convert( + input_path, + output_path, + config_path, + source_colorspace, + source_display=None, + source_view=None, + target_colorspace=None, + target_display=None, + target_view=None, + additional_command_args=None, + logger=None, +): + """Transcode source file to other with colormanagement. + + Oiiotool also support additional arguments for transcoding. + For more information, see the official documentation: + https://openimageio.readthedocs.io/en/latest/oiiotool.html Args: input_path (str): Path that should be converted. It is expected that @@ -989,17 +1092,26 @@ def convert_colorspace( sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) config_path (str): path to OCIO config file source_colorspace (str): ocio valid color space of source files + source_display (str, optional): name for source display-referred + reference space (ocio valid). If provided, source_view must also be + provided, and source_colorspace will be ignored + source_view (str, optional): name for source viewer space (ocio valid) + If provided, source_display must also be provided, and + source_colorspace will be ignored target_colorspace (str): ocio valid target color space if filled, 'view' and 'display' must be empty - view (str): name for viewer space (ocio valid) - both 'view' and 'display' must be filled (if 'target_colorspace') - display (str): name for display-referred reference space (ocio valid) + target_display (str): name for target display-referred reference space + (ocio valid) both 'view' and 'display' must be filled (if + 'target_colorspace') + target_view (str): name for target viewer space (ocio valid) both 'view' and 'display' must be filled (if 'target_colorspace') additional_command_args (list): arguments for oiiotool (like binary depth for .dpx) logger (logging.Logger): Logger used for logging. + Raises: ValueError: if misconfigured + """ if logger is None: logger = logging.getLogger(__name__) @@ -1024,23 +1136,82 @@ def convert_colorspace( "--ch", channels_arg ]) - if all([target_colorspace, view, display]): - raise ValueError("Colorspace and both screen and display" - " cannot be set together." - "Choose colorspace or screen and display") - if not target_colorspace and not all([view, display]): - raise ValueError("Both screen and display must be set.") + # Validate input parameters + if target_colorspace and target_view and target_display: + raise ValueError( + "Colorspace and both view and display cannot be set together." + "Choose colorspace or screen and display" + ) + + if not target_colorspace and not target_view and not target_display: + raise ValueError( + "Both view and display must be set if target_colorspace is not " + "provided." + ) + + if ( + (source_view and not source_display) + or (source_display and not source_view) + ): + raise ValueError( + "Both source_view and source_display must be provided if using " + "display/view inputs." + ) + + if source_view and source_display and source_colorspace: + logger.warning( + "Both source display/view and source_colorspace provided. " + "Using source display/view pair and ignoring source_colorspace." + ) if additional_command_args: oiio_cmd.extend(additional_command_args) - if target_colorspace: - oiio_cmd.extend(["--colorconvert:subimages=0", - source_colorspace, - target_colorspace]) - if view and display: - oiio_cmd.extend(["--iscolorspace", source_colorspace]) - oiio_cmd.extend(["--ociodisplay:subimages=0", display, view]) + # Handle the different conversion cases + # Source view and display are known + if source_view and source_display: + if target_colorspace: + # This is a two-step conversion process since there's no direct + # display/view to colorspace command + # This could be a config parameter or determined from OCIO config + # Use temporarty role space 'scene_linear' + color_convert_args = ("scene_linear", target_colorspace) + elif source_display != target_display or source_view != target_view: + # Complete display/view pair conversion + # - go through a reference space + color_convert_args = (target_display, target_view) + else: + color_convert_args = None + logger.debug( + "Source and target display/view pairs are identical." + " No color conversion needed." + ) + + if color_convert_args: + oiio_cmd.extend([ + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view, + "--colorconvert:subimages=0", + *color_convert_args + ]) + + elif target_colorspace: + # Standard color space to color space conversion + oiio_cmd.extend([ + "--colorconvert:subimages=0", + source_colorspace, + target_colorspace, + ]) + else: + # Standard conversion from colorspace to display/view + oiio_cmd.extend([ + "--iscolorspace", + source_colorspace, + "--ociodisplay:subimages=0", + target_display, + target_view, + ]) oiio_cmd.extend(["-o", output_path]) @@ -1351,12 +1522,27 @@ def get_media_mime_type(filepath: str) -> Optional[str]: Optional[str]: Mime type or None if is unknown mime type. """ + # The implementation is identical or better with ayon_api >=1.1.0, + # which is used in AYON launcher >=1.3.0. + # NOTE Remove safe import when AYON launcher >=1.2.0. + try: + from ayon_api.utils import ( + get_media_mime_type_for_content as _ayon_api_func + ) + except ImportError: + _ayon_api_func = None + if not filepath or not os.path.exists(filepath): return None with open(filepath, "rb") as stream: content = stream.read() + if _ayon_api_func is not None: + mime_type = _ayon_api_func(content) + if mime_type is not None: + return mime_type + content_len = len(content) # Pre-validation (largest definition check) # - hopefully there cannot be media defined in less than 12 bytes @@ -1383,11 +1569,13 @@ def get_media_mime_type(filepath: str) -> Optional[str]: if b'xmlns="http://www.w3.org/2000/svg"' in content: return "image/svg+xml" - # JPEG, JFIF or Exif - if ( - content[0:4] == b"\xff\xd8\xff\xdb" - or content[6:10] in (b"JFIF", b"Exif") - ): + # JPEG + # - [0:2] is constant b"\xff\xd8" + # (ref. https://www.file-recovery.com/jpg-signature-format.htm) + # - [2:4] Marker identifier b"\xff{?}" + # (ref. https://www.disktuna.com/list-of-jpeg-markers/) + # NOTE: File ends with b"\xff\xd9" + if content[0:3] == b"\xff\xd8\xff": return "image/jpeg" # Webp diff --git a/client/ayon_core/pipeline/actions.py b/client/ayon_core/pipeline/actions.py index 860fed5e8b..6892af4252 100644 --- a/client/ayon_core/pipeline/actions.py +++ b/client/ayon_core/pipeline/actions.py @@ -37,16 +37,19 @@ class LauncherActionSelection: project_name, folder_id, task_id, + workfile_id, folder_path=None, task_name=None, project_entity=None, folder_entity=None, task_entity=None, + workfile_entity=None, project_settings=None, ): self._project_name = project_name self._folder_id = folder_id self._task_id = task_id + self._workfile_id = workfile_id self._folder_path = folder_path self._task_name = task_name @@ -54,6 +57,7 @@ class LauncherActionSelection: self._project_entity = project_entity self._folder_entity = folder_entity self._task_entity = task_entity + self._workfile_entity = workfile_entity self._project_settings = project_settings @@ -213,6 +217,15 @@ class LauncherActionSelection: self._task_name = self.task_entity["name"] return self._task_name + def get_workfile_id(self): + """Selected workfile id. + + Returns: + Union[str, None]: Selected workfile id. + + """ + return self._workfile_id + def get_project_entity(self): """Project entity for the selection. @@ -259,6 +272,24 @@ class LauncherActionSelection: ) return self._task_entity + def get_workfile_entity(self): + """Workfile entity for the selection. + + Returns: + Union[dict[str, Any], None]: Workfile entity. + + """ + if ( + self._project_name is None + or self._workfile_id is None + ): + return None + if self._workfile_entity is None: + self._workfile_entity = ayon_api.get_workfile_info_by_id( + self._project_name, self._workfile_id + ) + return self._workfile_entity + def get_project_settings(self): """Project settings for the selection. @@ -305,15 +336,27 @@ class LauncherActionSelection: """ return self._task_id is not None + @property + def is_workfile_selected(self): + """Return whether a task is selected. + + Returns: + bool: Whether a task is selected. + + """ + return self._workfile_id is not None + project_name = property(get_project_name) folder_id = property(get_folder_id) task_id = property(get_task_id) + workfile_id = property(get_workfile_id) folder_path = property(get_folder_path) task_name = property(get_task_name) project_entity = property(get_project_entity) folder_entity = property(get_folder_entity) task_entity = property(get_task_entity) + workfile_entity = property(get_workfile_entity) class LauncherAction(object): diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index a7d1d80b0a..41241e17ca 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -1404,7 +1404,7 @@ def _get_display_view_colorspace_name(config_path, display, view): """ config = _get_ocio_config(config_path) colorspace = config.getDisplayViewColorSpaceName(display, view) - # Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa + # Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa if colorspace == "": colorspace = display diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 0d8e70f9d2..a5053844b9 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -249,7 +249,8 @@ def create_skeleton_instance( # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))), "colorspace": data.get("colorspace"), - "hasExplicitFrames": data.get("hasExplicitFrames") + "hasExplicitFrames": data.get("hasExplicitFrames", False), + "reuseLastVersion": data.get("reuseLastVersion", False), } if data.get("renderlayer"): diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 48e860e834..ed963110c6 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -2,10 +2,10 @@ from __future__ import annotations from abc import abstractmethod -import logging import os from typing import Any, Optional, Type +from ayon_core.lib import Logger from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path, @@ -31,8 +31,7 @@ class LoaderPlugin(list): options = [] - log = logging.getLogger("ProductLoader") - log.propagate = True + log = Logger.get_logger("ProductLoader") @classmethod def apply_settings(cls, project_settings): diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 836fc5e096..d1731d4cf9 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -9,7 +9,7 @@ from typing import Optional, Union, Any import ayon_api -from ayon_core.host import ILoadHost +from ayon_core.host import ILoadHost, AbstractHost from ayon_core.lib import ( StringTemplate, TemplateUnsolved, @@ -942,15 +942,21 @@ def any_outdated_containers(host=None, project_name=None): return False -def get_outdated_containers(host=None, project_name=None): +def get_outdated_containers( + host: Optional[AbstractHost] = None, + project_name: Optional[str] = None, + ignore_locked_versions: bool = False, +): """Collect outdated containers from host scene. Currently registered host and project in global session are used if arguments are not passed. Args: - host (ModuleType): Host implementation with 'ls' function available. - project_name (str): Name of project in which context we are. + host (Optional[AbstractHost]): Host implementation. + project_name (Optional[str]): Name of project in which context we are. + ignore_locked_versions (bool): Locked versions are ignored. + """ from ayon_core.pipeline import registered_host, get_current_project_name @@ -964,7 +970,16 @@ def get_outdated_containers(host=None, project_name=None): containers = host.get_containers() else: containers = host.ls() - return filter_containers(containers, project_name).outdated + + outdated_containers = [] + for container in filter_containers(containers, project_name).outdated: + if ( + not ignore_locked_versions + and container.get("version_locked") is True + ): + continue + outdated_containers.append(container) + return outdated_containers def _is_valid_representation_id(repre_id: Any) -> bool: @@ -985,6 +1000,9 @@ def filter_containers(containers, project_name): 'invalid' are invalid containers (invalid content) and 'not_found' has some missing entity in database. + Todos: + Respect 'project_name' on containers if is available. + Args: containers (Iterable[dict]): List of containers referenced into scene. project_name (str): Name of project in which context shoud look for @@ -993,8 +1011,8 @@ def filter_containers(containers, project_name): Returns: ContainersFilterResult: Named tuple with 'latest', 'outdated', 'invalid' and 'not_found' containers. - """ + """ # Make sure containers is list that won't change containers = list(containers) @@ -1042,13 +1060,13 @@ def filter_containers(containers, project_name): hero=True, fields={"id", "productId", "version"} ) - verisons_by_id = {} + versions_by_id = {} versions_by_product_id = collections.defaultdict(list) hero_version_ids = set() for version_entity in version_entities: version_id = version_entity["id"] # Store versions by their ids - verisons_by_id[version_id] = version_entity + versions_by_id[version_id] = version_entity # There's no need to query products for hero versions # - they are considered as latest? if version_entity["version"] < 0: @@ -1083,24 +1101,23 @@ def filter_containers(containers, project_name): repre_entity = repre_entities_by_id.get(repre_id) if not repre_entity: - log.debug(( - "Container '{}' has an invalid representation." + log.debug( + f"Container '{container_name}' has an invalid representation." " It is missing in the database." - ).format(container_name)) + ) not_found_containers.append(container) continue version_id = repre_entity["versionId"] - if version_id in outdated_version_ids: - outdated_containers.append(container) - - elif version_id not in verisons_by_id: - log.debug(( - "Representation on container '{}' has an invalid version." - " It is missing in the database." - ).format(container_name)) + if version_id not in versions_by_id: + log.debug( + f"Representation on container '{container_name}' has an" + " invalid version. It is missing in the database." + ) not_found_containers.append(container) + elif version_id in outdated_version_ids: + outdated_containers.append(container) else: uptodate_containers.append(container) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 3b82d961f8..1f983808b0 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -7,13 +7,20 @@ import copy import warnings import hashlib import xml.etree.ElementTree -from typing import TYPE_CHECKING, Optional, Union, List +from typing import TYPE_CHECKING, Optional, Union, List, Any +import clique +import speedcopy +import logging -import ayon_api import pyblish.util import pyblish.plugin import pyblish.api +from ayon_api import ( + get_server_api_connection, + get_representations, + get_last_version_by_product_name +) from ayon_core.lib import ( import_filepath, Logger, @@ -34,6 +41,8 @@ if TYPE_CHECKING: TRAIT_INSTANCE_KEY: str = "representations_with_traits" +log = logging.getLogger(__name__) + def get_template_name_profiles( project_name, project_settings=None, logger=None @@ -974,7 +983,26 @@ def get_instance_expected_output_path( "version": version }) - path_template_obj = anatomy.get_template_item("publish", "default")["path"] + # Get instance publish template name + task_name = task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template_name = get_publish_template_name( + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=instance.data["productType"], + task_name=task_name, + task_type=task_type, + project_settings=instance.context.data["project_settings"], + ) + + path_template_obj = anatomy.get_template_item( + "publish", + template_name + )["path"] template_filled = path_template_obj.format_strict(template_data) return os.path.normpath(template_filled) @@ -1030,7 +1058,7 @@ def main_cli_publish( # NOTE: ayon-python-api does not have public api function to find # out if is used service user. So we need to have try > except # block. - con = ayon_api.get_server_api_connection() + con = get_server_api_connection() try: con.set_default_service_username(username) except ValueError: @@ -1143,3 +1171,90 @@ def get_trait_representations( """ return instance.data.get(TRAIT_INSTANCE_KEY, []) + + +def fill_sequence_gaps_with_previous_version( + collection: str, + staging_dir: str, + instance: pyblish.plugin.Instance, + current_repre_name: str, + start_frame: int, + end_frame: int +) -> tuple[Optional[dict[str, Any]], Optional[dict[int, str]]]: + """Tries to replace missing frames from ones from last version""" + used_version_entity, repre_file_paths = _get_last_version_files( + instance, current_repre_name + ) + if repre_file_paths is None: + # issues in getting last version files + return (None, 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): + log.warning( + "Missing frame should be replaced from " + f"'{previous_version_path}' but that doesn't exist. " + ) + return (None, None) + + 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 (used_version_entity, added_files) + + +def _get_last_version_files( + instance: pyblish.plugin.Instance, + current_repre_name: str, +) -> tuple[Optional[dict[str, Any]], Optional[list[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", "attrib"} + ) + + if not version_entity: + return None, None + + matching_repres = get_representations( + project_name, + version_ids=[version_entity["id"]], + representation_names=[current_repre_name], + fields={"files"} + ) + + matching_repre = next(matching_repres, None) + if not matching_repre: + return None, None + + repre_file_paths = [ + file_info["path"] + for file_info in matching_repre["files"] + ] + + return (version_entity, repre_file_paths) diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py index 0a95a98be8..dc7e95c788 100644 --- a/client/ayon_core/pipeline/template_data.py +++ b/client/ayon_core/pipeline/template_data.py @@ -1,27 +1,50 @@ +from __future__ import annotations + +from typing import Optional, Any + import ayon_api from ayon_core.settings import get_studio_settings -from ayon_core.lib.local_settings import get_ayon_username +from ayon_core.lib import DefaultKeysDict +from ayon_core.lib.local_settings import get_ayon_user_entity -def get_general_template_data(settings=None, username=None): +def get_general_template_data( + settings: Optional[dict[str, Any]] = None, + username: Optional[str] = None, + user_entity: Optional[dict[str, Any]] = None, +): """General template data based on system settings or machine. Output contains formatting keys: - - 'studio[name]' - Studio name filled from system settings - - 'studio[code]' - Studio code filled from system settings - - 'user' - User's name using 'get_ayon_username' + - 'studio[name]' - Studio name filled from system settings + - 'studio[code]' - Studio code filled from system settings + - 'user[name]' - User's name + - 'user[attrib][...]' - User's attributes + - 'user[data][...]' - User's data Args: settings (Dict[str, Any]): Studio or project settings. username (Optional[str]): AYON Username. - """ + user_entity (Optional[dict[str, Any]]): User entity. + """ if not settings: settings = get_studio_settings() - if username is None: - username = get_ayon_username() + if user_entity is None: + user_entity = get_ayon_user_entity(username) + + # Use dictionary with default value for backwards compatibility + # - we did support '{user}' now it should be '{user[name]}' + user_data = DefaultKeysDict( + "name", + { + "name": user_entity["name"], + "attrib": user_entity["attrib"], + "data": user_entity["data"], + } + ) core_settings = settings["core"] return { @@ -29,7 +52,7 @@ def get_general_template_data(settings=None, username=None): "name": core_settings["studio_name"], "code": core_settings["studio_code"] }, - "user": username + "user": user_data, } @@ -150,7 +173,8 @@ def get_template_data( task_entity=None, host_name=None, settings=None, - username=None + username=None, + user_entity=None, ): """Prepare data for templates filling from entered documents and info. @@ -173,13 +197,18 @@ def get_template_data( host_name (Optional[str]): Used to fill '{app}' key. settings (Union[Dict, None]): Prepared studio or project settings. They're queried if not passed (may be slower). - username (Optional[str]): AYON Username. + username (Optional[str]): DEPRECATED AYON Username. + user_entity (Optional[dict[str, Any]): AYON user entity. Returns: Dict[str, Any]: Data prepared for filling workdir template. """ - template_data = get_general_template_data(settings, username=username) + template_data = get_general_template_data( + settings, + username=username, + user_entity=user_entity, + ) template_data.update(get_project_template_data(project_entity)) if folder_entity: template_data.update(get_folder_template_data( diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 6666853998..c2b6fad660 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -207,6 +207,7 @@ def save_workfile_info( comment: Optional[str] = None, description: Optional[str] = None, username: Optional[str] = None, + data: Optional[dict[str, Any]] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, ) -> dict[str, Any]: """Save workfile info entity for a workfile path. @@ -221,6 +222,7 @@ def save_workfile_info( description (Optional[str]): Workfile description. username (Optional[str]): Username of user who saves the workfile. If not provided, current user is used. + data (Optional[dict[str, Any]]): Additional workfile entity data. workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched workfile entities related to task. @@ -246,6 +248,18 @@ def save_workfile_info( if username is None: username = get_ayon_username() + attrib = {} + extension = os.path.splitext(rootless_path)[1] + for key, value in ( + ("extension", extension), + ("description", description), + ): + if value is not None: + attrib[key] = value + + if data is None: + data = {} + if not workfile_entity: return _create_workfile_info_entity( project_name, @@ -255,34 +269,38 @@ def save_workfile_info( username, version, comment, - description, + attrib, + data, ) - data = { - key: value - for key, value in ( - ("host_name", host_name), - ("version", version), - ("comment", comment), - ) - if value is not None - } - - old_data = workfile_entity["data"] + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value changed_data = {} + old_data = workfile_entity["data"] for key, value in data.items(): if key not in old_data or old_data[key] != value: changed_data[key] = value + workfile_entity["data"][key] = value + + changed_attrib = {} + old_attrib = workfile_entity["attrib"] + for key, value in attrib.items(): + if key not in old_attrib or old_attrib[key] != value: + changed_attrib[key] = value + workfile_entity["attrib"][key] = value update_data = {} if changed_data: update_data["data"] = changed_data - old_description = workfile_entity["attrib"].get("description") - if description is not None and old_description != description: - update_data["attrib"] = {"description": description} - workfile_entity["attrib"]["description"] = description + if changed_attrib: + update_data["attrib"] = changed_attrib # Automatically fix 'createdBy' and 'updatedBy' fields # NOTE both fields were not automatically filled by server @@ -749,7 +767,8 @@ def _create_workfile_info_entity( username: str, version: Optional[int], comment: Optional[str], - description: Optional[str], + attrib: dict[str, Any], + data: dict[str, Any], ) -> dict[str, Any]: """Create workfile entity data. @@ -761,27 +780,18 @@ def _create_workfile_info_entity( username (str): Username. version (Optional[int]): Workfile version. comment (Optional[str]): Workfile comment. - description (Optional[str]): Workfile description. + attrib (dict[str, Any]): Workfile entity attributes. + data (dict[str, Any]): Workfile entity data. Returns: dict[str, Any]: Created workfile entity data. """ - extension = os.path.splitext(rootless_path)[1] - - attrib = {} - for key, value in ( - ("extension", extension), - ("description", description), - ): - if value is not None: - attrib[key] = value - - data = { + data.update({ "host_name": host_name, "version": version, "comment": comment, - } + }) workfile_info = { "id": uuid.uuid4().hex, diff --git a/client/ayon_core/plugins/publish/collect_anatomy_context_data.py b/client/ayon_core/plugins/publish/collect_anatomy_context_data.py index cccf392e40..5d2ecec433 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_context_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_context_data.py @@ -16,6 +16,7 @@ Provides: import json import pyblish.api +from ayon_core.lib import get_ayon_user_entity from ayon_core.pipeline.template_data import get_template_data @@ -55,17 +56,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): if folder_entity: task_entity = context.data["taskEntity"] + username = context.data["user"] + user_entity = get_ayon_user_entity(username) anatomy_data = get_template_data( project_entity, folder_entity, task_entity, - host_name, - project_settings + host_name=host_name, + settings=project_settings, + user_entity=user_entity, ) anatomy_data.update(context.data.get("datetimeData") or {}) - username = context.data["user"] - anatomy_data["user"] = username # Backwards compatibility for 'username' key anatomy_data["username"] = username 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 39c421381d..d35f02b9df 100644 --- a/client/ayon_core/plugins/publish/collect_farm_env_variables.py +++ b/client/ayon_core/plugins/publish/collect_farm_env_variables.py @@ -32,6 +32,7 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin): for key in [ "AYON_BUNDLE_NAME", + "AYON_STUDIO_BUNDLE_NAME", "AYON_USE_STAGING", "AYON_IN_TESTS", # NOTE Not sure why workdir is needed? diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index d68970d428..543277f37e 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -71,6 +71,12 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): import opentimelineio as otio otio_clip = instance.data["otioClip"] + if isinstance( + otio_clip.media_reference, + otio.schema.MissingReference + ): + self.log.info("Clip has no media reference") + return # Collect timeline ranges if workfile start frame is available if "workfileFrameStart" in instance.data: 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 275b8a7f55..4d3c1cfb13 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -60,6 +60,13 @@ class CollectOtioSubsetResources( # get basic variables otio_clip = instance.data["otioClip"] + if isinstance( + otio_clip.media_reference, + otio.schema.MissingReference + ): + self.log.info("Clip has no media reference") + return + otio_available_range = otio_clip.available_range() media_fps = otio_available_range.start_time.rate available_duration = otio_available_range.duration.value diff --git a/client/ayon_core/plugins/publish/collect_resources_path.py b/client/ayon_core/plugins/publish/collect_resources_path.py index 2e5b296228..704c69a6ab 100644 --- a/client/ayon_core/plugins/publish/collect_resources_path.py +++ b/client/ayon_core/plugins/publish/collect_resources_path.py @@ -13,6 +13,8 @@ import copy import pyblish.api +from ayon_core.pipeline.publish import get_publish_template_name + class CollectResourcesPath(pyblish.api.InstancePlugin): """Generate directory path where the files and resources will be stored. @@ -77,16 +79,29 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): # This is for cases of Deprecated anatomy without `folder` # TODO remove when all clients have solved this issue - template_data.update({ - "frame": "FRAME_TEMP", - "representation": "TEMP" - }) + template_data.update({"frame": "FRAME_TEMP", "representation": "TEMP"}) - publish_templates = anatomy.get_template_item( - "publish", "default", "directory" + task_name = task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template_name = get_publish_template_name( + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=instance.data["productType"], + task_name=task_name, + task_type=task_type, + project_settings=instance.context.data["project_settings"], + logger=self.log, ) + + publish_template = anatomy.get_template_item( + "publish", template_name, "directory") + publish_folder = os.path.normpath( - publish_templates.format_strict(template_data) + publish_template.format_strict(template_data) ) resources_folder = os.path.join(publish_folder, "resources") diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index bbb6f9585b..8b351c7f31 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -11,7 +11,7 @@ from ayon_core.lib import ( is_oiio_supported, ) from ayon_core.lib.transcoding import ( - convert_colorspace, + oiio_color_convert, ) from ayon_core.lib.profiles_filtering import filter_profiles @@ -87,6 +87,14 @@ class ExtractOIIOTranscode(publish.Extractor): new_representations = [] repres = instance.data["representations"] for idx, repre in enumerate(list(repres)): + # target space, display and view might be defined upstream + # TODO: address https://github.com/ynput/ayon-core/pull/1268#discussion_r2156555474 + # Implement upstream logic to handle target_colorspace, + # target_display, target_view in other DCCs + target_colorspace = False + target_display = instance.data.get("colorspaceDisplay") + target_view = instance.data.get("colorspaceView") + self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) if not self._repre_is_valid(repre): continue @@ -96,6 +104,8 @@ class ExtractOIIOTranscode(publish.Extractor): colorspace_data = repre["colorspaceData"] source_colorspace = colorspace_data["colorspace"] + source_display = colorspace_data.get("display") + source_view = colorspace_data.get("view") config_path = colorspace_data.get("config", {}).get("path") if not config_path or not os.path.exists(config_path): self.log.warning("Config file doesn't exist, skipping") @@ -126,7 +136,6 @@ class ExtractOIIOTranscode(publish.Extractor): transcoding_type = output_def["transcoding_type"] - target_colorspace = view = display = None # NOTE: we use colorspace_data as the fallback values for # the target colorspace. if transcoding_type == "colorspace": @@ -138,18 +147,20 @@ class ExtractOIIOTranscode(publish.Extractor): colorspace_data.get("colorspace")) elif transcoding_type == "display_view": display_view = output_def["display_view"] - view = display_view["view"] or colorspace_data.get("view") - display = ( + target_view = ( + display_view["view"] + or colorspace_data.get("view")) + target_display = ( display_view["display"] or colorspace_data.get("display") ) # both could be already collected by DCC, # but could be overwritten when transcoding - if view: - new_repre["colorspaceData"]["view"] = view - if display: - new_repre["colorspaceData"]["display"] = display + if target_view: + new_repre["colorspaceData"]["view"] = target_view + if target_display: + new_repre["colorspaceData"]["display"] = target_display if target_colorspace: new_repre["colorspaceData"]["colorspace"] = \ target_colorspace @@ -168,16 +179,18 @@ class ExtractOIIOTranscode(publish.Extractor): new_staging_dir, output_extension) - convert_colorspace( - input_path, - output_path, - config_path, - source_colorspace, - target_colorspace, - view, - display, - additional_command_args, - self.log + oiio_color_convert( + input_path=input_path, + output_path=output_path, + config_path=config_path, + source_colorspace=source_colorspace, + target_colorspace=target_colorspace, + target_display=target_display, + target_view=target_view, + source_display=source_display, + source_view=source_view, + additional_command_args=additional_command_args, + logger=self.log ) # cleanup temporary transcoded files diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 90215bd2c9..f338fba746 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -130,7 +130,7 @@ class ExtractOTIOReview( # NOTE it looks like it is set only in hiero integration res_data = {"width": self.to_width, "height": self.to_height} for key in res_data: - for meta_prefix in ("ayon.source.", "openpype.source."): + for meta_prefix in ("ayon.source", "openpype.source"): meta_key = f"{meta_prefix}.{key}" value = media_metadata.get(meta_key) if value is not None: diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 04e534054e..580aa27eef 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -13,14 +13,15 @@ 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, path_to_subprocess_arg, run_subprocess, ) +from ayon_core.pipeline.publish.lib import ( + fill_sequence_gaps_with_previous_version +) from ayon_core.lib.transcoding import ( IMAGE_EXTENSIONS, get_ffprobe_streams, @@ -130,7 +131,7 @@ def frame_to_timecode(frame: int, fps: float) -> str: class ExtractReview(pyblish.api.InstancePlugin): - """Extracting Review mov file for Ftrack + """Extracting Reviewable medias Compulsory attribute of representation is tags list with "review", otherwise the representation is ignored. @@ -508,10 +509,10 @@ class ExtractReview(pyblish.api.InstancePlugin): resolution_width=temp_data.resolution_width, resolution_height=temp_data.resolution_height, extension=temp_data.input_ext, - temp_data=temp_data + temp_data=temp_data, ) elif fill_missing_frames == "previous_version": - new_frame_files = self.fill_sequence_gaps_with_previous( + fill_output = fill_sequence_gaps_with_previous_version( collection=collection, staging_dir=new_repre["stagingDir"], instance=instance, @@ -519,8 +520,13 @@ class ExtractReview(pyblish.api.InstancePlugin): start_frame=temp_data.frame_start, end_frame=temp_data.frame_end, ) + _, new_frame_files = fill_output # fallback to original workflow if new_frame_files is None: + self.log.warning( + "Falling back to filling from currently " + "last rendered." + ) new_frame_files = ( self.fill_sequence_gaps_from_existing( collection=collection, @@ -612,8 +618,6 @@ class ExtractReview(pyblish.api.InstancePlugin): "name": "{}_{}".format(output_name, output_ext), "outputName": output_name, "outputDef": output_def, - "frameStartFtrack": temp_data.output_frame_start, - "frameEndFtrack": temp_data.output_frame_end, "ffmpeg_cmd": subprcs_cmd }) @@ -1050,92 +1054,6 @@ class ExtractReview(pyblish.api.InstancePlugin): return all_args - 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, @@ -1384,15 +1302,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return audio_in_args, audio_filters, audio_out_args for audio in audio_inputs: - # NOTE modified, always was expected "frameStartFtrack" which is - # STRANGE?!!! There should be different key, right? - # TODO use different frame start! offset_seconds = 0 - 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 - if offset_seconds > 0: audio_in_args.append( "-ss {}".format(offset_seconds) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 705fea1f72..b5885178d0 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -6,6 +6,7 @@ import re import pyblish.api from ayon_core.lib import ( + get_oiio_tool_args, get_ffmpeg_tool_args, get_ffprobe_data, @@ -15,7 +16,11 @@ from ayon_core.lib import ( path_to_subprocess_arg, run_subprocess, ) -from ayon_core.lib.transcoding import convert_colorspace +from ayon_core.lib.transcoding import ( + oiio_color_convert, + get_oiio_input_and_channel_args, + get_oiio_info_for_input, +) from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS @@ -210,6 +215,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): full_output_path = os.path.join(dst_staging, jpeg_file) colorspace_data = repre.get("colorspaceData") + # NOTE We should find out what is happening here. Why don't we + # use oiiotool all the time if it is available? Only possible + # reason might be that video files should be converted using + # ffmpeg, but other then that, we should use oiio all the time. + # - We should also probably get rid of the ffmpeg settings... + # only use OIIO if it is supported and representation has # colorspace data if oiio_supported and colorspace_data: @@ -219,7 +230,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg - repre_thumb_created = self._create_thumbnail_oiio( + repre_thumb_created = self._create_colorspace_thumbnail( full_input_path, full_output_path, colorspace_data @@ -229,17 +240,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # oiiotool isn't available or representation is not having # colorspace data if not repre_thumb_created: - if oiio_supported: - self.log.debug( - "Converting with FFMPEG because input" - " can't be read by OIIO." - ) - repre_thumb_created = self._create_thumbnail_ffmpeg( full_input_path, full_output_path ) - # Skip representation and try next one if wasn't created + # Skip representation and try next one if wasn't created + if not repre_thumb_created and oiio_supported: + repre_thumb_created = self._create_thumbnail_oiio( + full_input_path, full_output_path + ) + if not repre_thumb_created: continue @@ -382,7 +392,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return ext in IMAGE_EXTENSIONS or ext in VIDEO_EXTENSIONS - def _create_thumbnail_oiio( + def _create_colorspace_thumbnail( self, src_path, dst_path, @@ -433,13 +443,15 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): oiio_default_view = display_and_view["view"] try: - convert_colorspace( + oiio_color_convert( src_path, dst_path, colorspace_data["config"]["path"], colorspace_data["colorspace"], - display=repre_display or oiio_default_display, - view=repre_view or oiio_default_view, + source_display=colorspace_data.get("display"), + source_view=colorspace_data.get("view"), + target_display=repre_display or oiio_default_display, + target_view=repre_view or oiio_default_view, target_colorspace=oiio_default_colorspace, additional_command_args=resolution_arg, logger=self.log, @@ -453,9 +465,50 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True + def _create_thumbnail_oiio(self, src_path, dst_path): + self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}") + + try: + resolution_arg = self._get_resolution_arg("oiiotool", src_path) + except RuntimeError: + self.log.warning( + "Failed to create thumbnail using oiio", exc_info=True + ) + return False + + input_info = get_oiio_info_for_input(src_path, logger=self.log) + input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) + oiio_cmd = get_oiio_tool_args( + "oiiotool", + input_arg, src_path, + # Tell oiiotool which channels should be put to top stack + # (and output) + "--ch", channels_arg, + # Use first subimage + "--subimage", "0" + ) + oiio_cmd.extend(resolution_arg) + oiio_cmd.extend(("-o", dst_path)) + self.log.debug("Running: {}".format(" ".join(oiio_cmd))) + try: + run_subprocess(oiio_cmd, logger=self.log) + return True + except Exception: + self.log.warning( + "Failed to create thumbnail using oiiotool", + exc_info=True + ) + return False + def _create_thumbnail_ffmpeg(self, src_path, dst_path): - self.log.debug("Extracting thumbnail with FFMPEG: {}".format(dst_path)) - resolution_arg = self._get_resolution_arg("ffmpeg", src_path) + try: + resolution_arg = self._get_resolution_arg("ffmpeg", src_path) + except RuntimeError: + self.log.warning( + "Failed to create thumbnail using ffmpeg", exc_info=True + ) + return False + ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") ffmpeg_args = self.ffmpeg_args or {} diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index f1e066018c..d18e546392 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -121,7 +121,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "version", "representation", "username", - "user", "output", # OpenPype keys - should be removed "asset", # folder[name] @@ -796,6 +795,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if value is not None: repre_context[key] = value + # Keep only username + # NOTE This is to avoid storing all user attributes and data + # to representation + if "user" not in repre_context: + repre_context["user"] = { + "name": template_data["user"]["name"] + } + # Use previous representation's id if there is a name match existing = existing_repres_by_name.get(repre["name"].lower()) repre_id = None diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 90e6f15568..a591cfe880 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -89,7 +89,6 @@ class IntegrateHeroVersion( "family", "representation", "username", - "user", "output" ] # QUESTION/TODO this process should happen on server if crashed due to @@ -364,6 +363,14 @@ class IntegrateHeroVersion( if value is not None: repre_context[key] = value + # Keep only username + # NOTE This is to avoid storing all user attributes and data + # to representation + if "user" not in repre_context: + repre_context["user"] = { + "name": anatomy_data["user"]["name"] + } + # Prepare new repre repre_entity = copy.deepcopy(repre_info["representation"]) repre_entity.pop("id", None) diff --git a/client/ayon_core/scripts/otio_burnin.py b/client/ayon_core/scripts/otio_burnin.py index 77eeecaff6..bd94225979 100644 --- a/client/ayon_core/scripts/otio_burnin.py +++ b/client/ayon_core/scripts/otio_burnin.py @@ -6,7 +6,12 @@ import json import tempfile from string import Formatter -import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins +try: + from otio_burnins_adapter import ffmpeg_burnins +except ImportError: + import opentimelineio_contrib.adapters.ffmpeg_burnins as ffmpeg_burnins +from PIL import ImageFont + from ayon_core.lib import ( get_ffmpeg_tool_args, get_ffmpeg_codec_args, @@ -36,6 +41,39 @@ TIMECODE_KEY = "{timecode}" SOURCE_TIMECODE_KEY = "{source_timecode}" +def _drawtext(align, resolution, text, options): + """ + :rtype: {'x': int, 'y': int} + """ + x_pos = "0" + if align in (ffmpeg_burnins.TOP_CENTERED, ffmpeg_burnins.BOTTOM_CENTERED): + x_pos = "w/2-tw/2" + + elif align in (ffmpeg_burnins.TOP_RIGHT, ffmpeg_burnins.BOTTOM_RIGHT): + ifont = ImageFont.truetype(options["font"], options["font_size"]) + if hasattr(ifont, "getbbox"): + left, top, right, bottom = ifont.getbbox(text) + box_size = right - left, bottom - top + else: + box_size = ifont.getsize(text) + x_pos = resolution[0] - (box_size[0] + options["x_offset"]) + elif align in (ffmpeg_burnins.TOP_LEFT, ffmpeg_burnins.BOTTOM_LEFT): + x_pos = options["x_offset"] + + if align in ( + ffmpeg_burnins.TOP_CENTERED, + ffmpeg_burnins.TOP_RIGHT, + ffmpeg_burnins.TOP_LEFT + ): + y_pos = "%d" % options["y_offset"] + else: + y_pos = "h-text_h-%d" % (options["y_offset"]) + return {"x": x_pos, "y": y_pos} + + +ffmpeg_burnins._drawtext = _drawtext + + def _get_ffprobe_data(source): """Reimplemented from otio burnins to be able use full path to ffprobe :param str source: source media file diff --git a/client/ayon_core/tools/common_models/__init__.py b/client/ayon_core/tools/common_models/__init__.py index ec69e20b64..77cc2dfb0f 100644 --- a/client/ayon_core/tools/common_models/__init__.py +++ b/client/ayon_core/tools/common_models/__init__.py @@ -10,6 +10,7 @@ from .projects import ( PROJECTS_MODEL_SENDER, FolderTypeItem, TaskTypeItem, + ProductTypeIconMapping, ) from .hierarchy import ( FolderItem, @@ -34,6 +35,7 @@ __all__ = ( "PROJECTS_MODEL_SENDER", "FolderTypeItem", "TaskTypeItem", + "ProductTypeIconMapping", "FolderItem", "TaskItem", diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 034947de3a..250c3b020d 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -2,7 +2,7 @@ from __future__ import annotations import contextlib from abc import ABC, abstractmethod -from typing import Dict, Any +from typing import Any, Optional from dataclasses import dataclass import ayon_api @@ -51,7 +51,7 @@ class StatusItem: self.icon: str = icon self.state: str = state - def to_data(self) -> Dict[str, Any]: + def to_data(self) -> dict[str, Any]: return { "name": self.name, "color": self.color, @@ -125,16 +125,24 @@ class TaskTypeItem: icon (str): Icon name in MaterialIcons ("fiber_new"). """ - def __init__(self, name, short, icon): + def __init__( + self, + name: str, + short: str, + icon: str, + color: Optional[str], + ): self.name = name self.short = short self.icon = icon + self.color = color def to_data(self): return { "name": self.name, "short": self.short, "icon": self.icon, + "color": self.color, } @classmethod @@ -147,6 +155,7 @@ class TaskTypeItem: name=task_type_data["name"], short=task_type_data["shortName"], icon=task_type_data["icon"], + color=task_type_data.get("color"), ) @@ -218,6 +227,54 @@ class ProjectItem: return cls(**data) +class ProductTypeIconMapping: + def __init__( + self, + default: Optional[dict[str, str]] = None, + definitions: Optional[list[dict[str, str]]] = None, + ): + self._default = default or {} + self._definitions = definitions or [] + + self._default_def = None + self._definitions_by_name = None + + def get_icon( + self, + product_base_type: Optional[str] = None, + product_type: Optional[str] = None, + ) -> dict[str, str]: + defs = self._get_defs_by_name() + icon = defs.get(product_type) + if icon is None: + icon = defs.get(product_base_type) + if icon is None: + icon = self._get_default_def() + return icon.copy() + + def _get_default_def(self) -> dict[str, str]: + if self._default_def is None: + self._default_def = { + "type": "material-symbols", + "name": self._default.get("icon", "deployed_code"), + "color": self._default.get("color", "#cccccc"), + } + + return self._default_def + + def _get_defs_by_name(self) -> dict[str, dict[str, str]]: + if self._definitions_by_name is None: + self._definitions_by_name = { + product_base_type_def["name"]: { + "type": "material-symbols", + "name": product_base_type_def.get("icon", "deployed_code"), + "color": product_base_type_def.get("color", "#cccccc"), + } + for product_base_type_def in self._definitions + } + return self._definitions_by_name + + def _get_project_items_from_entitiy( projects: list[dict[str, Any]] ) -> list[ProjectItem]: @@ -242,6 +299,9 @@ class ProjectsModel(object): self._projects_by_name = NestedCacheItem( levels=1, default_factory=list ) + self._product_type_icons_mapping = NestedCacheItem( + levels=1, default_factory=ProductTypeIconMapping + ) self._project_statuses_cache = {} self._folder_types_cache = {} self._task_types_cache = {} @@ -255,6 +315,7 @@ class ProjectsModel(object): self._task_types_cache = {} self._projects_cache.reset() self._projects_by_name.reset() + self._product_type_icons_mapping.reset() def refresh(self): """Refresh project items. @@ -390,6 +451,27 @@ class ProjectsModel(object): self._task_type_items_getter, ) + def get_product_type_icons_mapping( + self, project_name: Optional[str] + ) -> ProductTypeIconMapping: + cache = self._product_type_icons_mapping[project_name] + if cache.is_valid: + return cache.get_data() + + project_entity = self.get_project_entity(project_name) + icons_mapping = ProductTypeIconMapping() + if project_entity: + product_base_types = ( + project_entity["config"].get("productBaseTypes", {}) + ) + icons_mapping = ProductTypeIconMapping( + product_base_types.get("default"), + product_base_types.get("definitions") + ) + + cache.update_data(icons_mapping) + return icons_mapping + def _get_project_items( self, project_name, sender, item_type, cache_obj, getter ): diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 1d7dafd62f..a94500116b 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -4,6 +4,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional, Any +from ayon_core.addon import AddonsManager from ayon_core.tools.common_models import ( ProjectItem, FolderItem, @@ -20,6 +21,7 @@ class WebactionContext: project_name: str folder_id: str task_id: str + workfile_id: str addon_name: str addon_version: str @@ -33,7 +35,7 @@ class ActionItem: 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 + variant_label (Optional[str]): 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 @@ -56,6 +58,15 @@ class ActionItem: addon_version: Optional[str] = None +@dataclass +class WorkfileItem: + workfile_id: str + filename: str + exists: bool + icon: Optional[str] + version: Optional[int] + + class AbstractLauncherCommon(ABC): @abstractmethod def register_event_callback(self, topic, callback): @@ -85,12 +96,16 @@ class AbstractLauncherBackend(AbstractLauncherCommon): pass + @abstractmethod + def get_addons_manager(self) -> AddonsManager: + pass + @abstractmethod def get_project_settings(self, project_name): """Project settings for current project. Args: - project_name (Union[str, None]): Project name. + project_name (Optional[str]): Project name. Returns: dict[str, Any]: Project settings. @@ -254,7 +269,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Selected project name. Returns: - Union[str, None]: Selected project name. + Optional[str]: Selected project name. """ pass @@ -264,7 +279,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Selected folder id. Returns: - Union[str, None]: Selected folder id. + Optional[str]: Selected folder id. """ pass @@ -274,7 +289,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Selected task id. Returns: - Union[str, None]: Selected task id. + Optional[str]: Selected task id. """ pass @@ -284,7 +299,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Selected task name. Returns: - Union[str, None]: Selected task name. + Optional[str]: Selected task name. """ pass @@ -302,7 +317,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): } Returns: - dict[str, Union[str, None]]: Selected context. + dict[str, Optional[str]]: Selected context. """ pass @@ -312,7 +327,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Change selected folder. Args: - project_name (Union[str, None]): Project nameor None if no project + project_name (Optional[str]): Project nameor None if no project is selected. """ @@ -323,7 +338,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Change selected folder. Args: - folder_id (Union[str, None]): Folder id or None if no folder + folder_id (Optional[str]): Folder id or None if no folder is selected. """ @@ -336,14 +351,24 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """Change selected task. Args: - task_id (Union[str, None]): Task id or None if no task + task_id (Optional[str]): Task id or None if no task is selected. - task_name (Union[str, None]): Task name or None if no task + task_name (Optional[str]): Task name or None if no task is selected. """ pass + @abstractmethod + def set_selected_workfile(self, workfile_id: Optional[str]): + """Change selected workfile. + + Args: + workfile_id (Optional[str]): Workfile id or None. + + """ + pass + # Actions @abstractmethod def get_action_items( @@ -351,13 +376,15 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): project_name: Optional[str], folder_id: Optional[str], task_id: Optional[str], + workfile_id: Optional[str], ) -> list[ActionItem]: """Get action items for given context. Args: - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. + project_name (Optional[str]): Project name. + folder_id (Optional[str]): Folder id. + task_id (Optional[str]): Task id. + workfile_id (Optional[str]): Workfile id. Returns: list[ActionItem]: List of action items that should be shown @@ -373,14 +400,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): project_name: Optional[str], folder_id: Optional[str], task_id: Optional[str], + workfile_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. + project_name (Optional[str]): Project name. + folder_id (Optional[str]): Folder id. + task_id (Optional[str]): Task id. + workfile_id (Optional[str]): Task id. """ pass @@ -465,3 +494,21 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon): """ pass + + @abstractmethod + def get_workfile_items( + self, + project_name: Optional[str], + task_id: Optional[str], + ) -> list[WorkfileItem]: + """Get workfile items for a given context. + + Args: + project_name (Optional[str]): Project name. + task_id (Optional[str]): Task id. + + Returns: + list[WorkfileItem]: List of workfile items. + + """ + pass diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 58d22453be..85b362f9d7 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,10 +1,21 @@ +from typing import Optional + from ayon_core.lib import Logger, get_ayon_username from ayon_core.lib.events import QueuedEventSystem +from ayon_core.addon import AddonsManager 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 -from .models import LauncherSelectionModel, ActionsModel +from .abstract import ( + AbstractLauncherFrontEnd, + AbstractLauncherBackend, + WorkfileItem, +) +from .models import ( + LauncherSelectionModel, + ActionsModel, + WorkfilesModel, +) NOT_SET = object() @@ -17,12 +28,15 @@ class BaseLauncherController( self._event_system = None self._log = None + self._addons_manager = None + self._username = NOT_SET self._selection_model = LauncherSelectionModel(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) self._actions_model = ActionsModel(self) + self._workfiles_model = WorkfilesModel(self) @property def log(self): @@ -59,6 +73,11 @@ class BaseLauncherController( def register_event_callback(self, topic, callback): self.event_system.add_callback(topic, callback) + def get_addons_manager(self) -> AddonsManager: + if self._addons_manager is None: + self._addons_manager = AddonsManager() + return self._addons_manager + # Entity items for UI def get_project_items(self, sender=None): return self._projects_model.get_project_items(sender) @@ -125,6 +144,9 @@ class BaseLauncherController( def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) + def set_selected_workfile(self, workfile_id): + self._selection_model.set_selected_workfile(workfile_id) + def get_selected_context(self): return { "project_name": self.get_selected_project_name(), @@ -133,10 +155,24 @@ class BaseLauncherController( "task_name": self.get_selected_task_name(), } + # Workfiles + def get_workfile_items( + self, + project_name: Optional[str], + task_id: Optional[str], + ) -> list[WorkfileItem]: + return self._workfiles_model.get_workfile_items( + project_name, + task_id, + ) + # Actions - def get_action_items(self, project_name, folder_id, task_id): + def get_action_items( + self, project_name, folder_id, task_id, workfile_id + ): return self._actions_model.get_action_items( - project_name, folder_id, task_id) + project_name, folder_id, task_id, workfile_id + ) def trigger_action( self, @@ -144,12 +180,14 @@ class BaseLauncherController( project_name, folder_id, task_id, + workfile_id, ): self._actions_model.trigger_action( identifier, project_name, folder_id, task_id, + workfile_id, ) def trigger_webaction(self, context, action_label, form_data=None): @@ -186,6 +224,8 @@ class BaseLauncherController( self._projects_model.reset() # Refresh actions self._actions_model.refresh() + # Reset workfiles model + self._workfiles_model.reset() self._emit_event("controller.refresh.actions.finished") diff --git a/client/ayon_core/tools/launcher/models/__init__.py b/client/ayon_core/tools/launcher/models/__init__.py index 1bc60c85f0..efc0de96ca 100644 --- a/client/ayon_core/tools/launcher/models/__init__.py +++ b/client/ayon_core/tools/launcher/models/__init__.py @@ -1,8 +1,10 @@ from .actions import ActionsModel from .selection import LauncherSelectionModel +from .workfiles import WorkfilesModel __all__ = ( "ActionsModel", "LauncherSelectionModel", + "WorkfilesModel", ) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 51fbe72143..709ae2e9a8 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -15,7 +15,6 @@ from ayon_core.lib import ( get_settings_variant, run_detached_ayon_launcher_process, ) -from ayon_core.addon import AddonsManager from ayon_core.pipeline.actions import ( discover_launcher_actions, LauncherActionSelection, @@ -104,8 +103,6 @@ class ActionsModel: levels=2, default_factory=list, lifetime=20, ) - self._addons_manager = None - self._variant = get_settings_variant() @staticmethod @@ -131,19 +128,28 @@ class ActionsModel: self._get_action_objects() self._controller.emit_event("actions.refresh.finished") - 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], + workfile_id: Optional[str], + ) -> list[ActionItem]: """Get actions for project. Args: - project_name (Union[str, None]): Project name. - folder_id (Union[str, None]): Folder id. - task_id (Union[str, None]): Task id. + project_name (Optional[str]): Project name. + folder_id (Optional[str]): Folder id. + task_id (Optional[str]): Task id. + workfile_id (Optional[str]): Workfile id. Returns: list[ActionItem]: List of actions. """ - selection = self._prepare_selection(project_name, folder_id, task_id) + selection = self._prepare_selection( + project_name, folder_id, task_id, workfile_id + ) output = [] action_items = self._get_action_items(project_name) for identifier, action in self._get_action_objects().items(): @@ -159,8 +165,11 @@ class ActionsModel: project_name, folder_id, task_id, + workfile_id, ): - selection = self._prepare_selection(project_name, folder_id, task_id) + selection = self._prepare_selection( + project_name, folder_id, task_id, workfile_id + ) failed = False error_message = None action_label = identifier @@ -202,11 +211,15 @@ class ActionsModel: identifier = context.identifier folder_id = context.folder_id task_id = context.task_id + workfile_id = context.workfile_id project_name = context.project_name addon_name = context.addon_name addon_version = context.addon_version - if task_id: + if workfile_id: + entity_type = "workfile" + entity_ids.append(workfile_id) + elif task_id: entity_type = "task" entity_ids.append(task_id) elif folder_id: @@ -272,6 +285,7 @@ class ActionsModel: "project_name": project_name, "folder_id": folder_id, "task_id": task_id, + "workfile_id": workfile_id, "addon_name": addon_name, "addon_version": addon_version, }) @@ -282,7 +296,10 @@ class ActionsModel: def get_action_config_values(self, context: WebactionContext): selection = self._prepare_selection( - context.project_name, context.folder_id, context.task_id + context.project_name, + context.folder_id, + context.task_id, + context.workfile_id, ) if not selection.is_project_selected: return {} @@ -309,7 +326,10 @@ class ActionsModel: def set_action_config_values(self, context, values): selection = self._prepare_selection( - context.project_name, context.folder_id, context.task_id + context.project_name, + context.folder_id, + context.task_id, + context.workfile_id, ) if not selection.is_project_selected: return {} @@ -333,12 +353,9 @@ class ActionsModel: exc_info=True ) - def _get_addons_manager(self): - if self._addons_manager is None: - self._addons_manager = AddonsManager() - return self._addons_manager - - def _prepare_selection(self, project_name, folder_id, task_id): + def _prepare_selection( + self, project_name, folder_id, task_id, workfile_id + ): project_entity = None if project_name: project_entity = self._controller.get_project_entity(project_name) @@ -347,6 +364,7 @@ class ActionsModel: project_name, folder_id, task_id, + workfile_id, project_entity=project_entity, project_settings=project_settings, ) @@ -355,7 +373,12 @@ class ActionsModel: entity_type = None entity_id = None entity_subtypes = [] - if selection.is_task_selected: + if selection.is_workfile_selected: + entity_type = "workfile" + entity_id = selection.workfile_id + entity_subtypes = [] + + elif selection.is_task_selected: entity_type = "task" entity_id = selection.task_entity["id"] entity_subtypes = [selection.task_entity["taskType"]] @@ -400,7 +423,7 @@ class ActionsModel: try: # 'variant' query is supported since AYON backend 1.10.4 - query = urlencode({"variant": self._variant}) + query = urlencode({"variant": self._variant, "mode": "all"}) response = ayon_api.post( f"actions/list?{query}", **request_data ) @@ -542,7 +565,7 @@ class ActionsModel: # NOTE We don't need to register the paths, but that would # require to change discovery logic and deprecate all functions # related to registering and discovering launcher actions. - addons_manager = self._get_addons_manager() + addons_manager = self._controller.get_addons_manager() actions_paths = addons_manager.collect_launcher_action_paths() for path in actions_paths: if path and os.path.exists(path): diff --git a/client/ayon_core/tools/launcher/models/selection.py b/client/ayon_core/tools/launcher/models/selection.py index b156d2084c..9d5ad47d89 100644 --- a/client/ayon_core/tools/launcher/models/selection.py +++ b/client/ayon_core/tools/launcher/models/selection.py @@ -1,26 +1,37 @@ -class LauncherSelectionModel(object): +from __future__ import annotations + +import typing +from typing import Optional + +if typing.TYPE_CHECKING: + from ayon_core.tools.launcher.abstract import AbstractLauncherBackend + + +class LauncherSelectionModel: """Model handling selection changes. Triggering events: - "selection.project.changed" - "selection.folder.changed" - "selection.task.changed" + - "selection.workfile.changed" """ event_source = "launcher.selection.model" - def __init__(self, controller): + def __init__(self, controller: AbstractLauncherBackend) -> None: self._controller = controller self._project_name = None self._folder_id = None self._task_name = None self._task_id = None + self._workfile_id = None - def get_selected_project_name(self): + def get_selected_project_name(self) -> Optional[str]: return self._project_name - def set_selected_project(self, project_name): + def set_selected_project(self, project_name: Optional[str]) -> None: if project_name == self._project_name: return @@ -31,10 +42,10 @@ class LauncherSelectionModel(object): self.event_source ) - def get_selected_folder_id(self): + def get_selected_folder_id(self) -> Optional[str]: return self._folder_id - def set_selected_folder(self, folder_id): + def set_selected_folder(self, folder_id: Optional[str]) -> None: if folder_id == self._folder_id: return @@ -48,13 +59,15 @@ class LauncherSelectionModel(object): self.event_source ) - def get_selected_task_name(self): + def get_selected_task_name(self) -> Optional[str]: return self._task_name - def get_selected_task_id(self): + def get_selected_task_id(self) -> Optional[str]: return self._task_id - def set_selected_task(self, task_id, task_name): + def set_selected_task( + self, task_id: Optional[str], task_name: Optional[str] + ) -> None: if task_id == self._task_id: return @@ -70,3 +83,23 @@ class LauncherSelectionModel(object): }, self.event_source ) + + def get_selected_workfile(self) -> Optional[str]: + return self._workfile_id + + def set_selected_workfile(self, workfile_id: Optional[str]) -> None: + if workfile_id == self._workfile_id: + return + + self._workfile_id = workfile_id + self._controller.emit_event( + "selection.workfile.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": self._task_name, + "task_id": self._task_id, + "workfile_id": workfile_id, + }, + self.event_source + ) diff --git a/client/ayon_core/tools/launcher/models/workfiles.py b/client/ayon_core/tools/launcher/models/workfiles.py new file mode 100644 index 0000000000..649a87353c --- /dev/null +++ b/client/ayon_core/tools/launcher/models/workfiles.py @@ -0,0 +1,102 @@ +import os +from typing import Optional, Any + +import ayon_api + +from ayon_core.lib import ( + Logger, + NestedCacheItem, +) +from ayon_core.pipeline import Anatomy +from ayon_core.tools.launcher.abstract import ( + WorkfileItem, + AbstractLauncherBackend, +) + + +class WorkfilesModel: + def __init__(self, controller: AbstractLauncherBackend): + self._controller = controller + + self._log = Logger.get_logger(self.__class__.__name__) + + self._host_icons = None + self._workfile_items = NestedCacheItem( + levels=2, default_factory=list, lifetime=60, + ) + + def reset(self) -> None: + self._workfile_items.reset() + + def get_workfile_items( + self, + project_name: Optional[str], + task_id: Optional[str], + ) -> list[WorkfileItem]: + if not project_name or not task_id: + return [] + + cache = self._workfile_items[project_name][task_id] + if cache.is_valid: + return cache.get_data() + + project_entity = self._controller.get_project_entity(project_name) + anatomy = Anatomy(project_name, project_entity=project_entity) + items = [] + for workfile_entity in ayon_api.get_workfiles_info( + project_name, task_ids={task_id}, fields={"id", "path", "data"} + ): + rootless_path = workfile_entity["path"] + exists = False + try: + path = anatomy.fill_root(rootless_path) + exists = os.path.exists(path) + except Exception: + self._log.warning( + "Failed to fill root for workfile path", + exc_info=True, + ) + workfile_data = workfile_entity["data"] + host_name = workfile_data.get("host_name") + version = workfile_data.get("version") + + items.append(WorkfileItem( + workfile_id=workfile_entity["id"], + filename=os.path.basename(rootless_path), + exists=exists, + icon=self._get_host_icon(host_name), + version=version, + )) + cache.update_data(items) + return items + + def _get_host_icon( + self, host_name: Optional[str] + ) -> Optional[dict[str, Any]]: + if self._host_icons is None: + host_icons = {} + try: + host_icons = self._get_host_icons() + except Exception: + self._log.warning( + "Failed to get host icons", + exc_info=True, + ) + self._host_icons = host_icons + return self._host_icons.get(host_name) + + def _get_host_icons(self) -> dict[str, Any]: + addons_manager = self._controller.get_addons_manager() + applications_addon = addons_manager["applications"] + apps_manager = applications_addon.get_applications_manager() + output = {} + for app_group in apps_manager.app_groups.values(): + host_name = app_group.host_name + icon_filename = app_group.icon + if not host_name or not icon_filename: + continue + icon_url = applications_addon.get_app_icon_url( + icon_filename, server=True + ) + output[host_name] = icon_url + return output diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 51cb8e73bc..31b303ca2b 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -136,6 +136,10 @@ class ActionsQtModel(QtGui.QStandardItemModel): "selection.task.changed", self._on_selection_task_changed, ) + controller.register_event_callback( + "selection.workfile.changed", + self._on_selection_workfile_changed, + ) self._controller = controller @@ -146,6 +150,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._selected_project_name = None self._selected_folder_id = None self._selected_task_id = None + self._selected_workfile_id = None def get_selected_project_name(self): return self._selected_project_name @@ -156,6 +161,9 @@ class ActionsQtModel(QtGui.QStandardItemModel): def get_selected_task_id(self): return self._selected_task_id + def get_selected_workfile_id(self): + return self._selected_workfile_id + def get_group_items(self, action_id): return self._groups_by_id[action_id] @@ -194,6 +202,7 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._selected_project_name, self._selected_folder_id, self._selected_task_id, + self._selected_workfile_id, ) if not items: self._clear_items() @@ -286,18 +295,28 @@ class ActionsQtModel(QtGui.QStandardItemModel): self._selected_project_name = event["project_name"] self._selected_folder_id = None self._selected_task_id = None + self._selected_workfile_id = None self.refresh() def _on_selection_folder_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = event["folder_id"] self._selected_task_id = None + self._selected_workfile_id = None self.refresh() def _on_selection_task_changed(self, event): self._selected_project_name = event["project_name"] self._selected_folder_id = event["folder_id"] self._selected_task_id = event["task_id"] + self._selected_workfile_id = None + self.refresh() + + def _on_selection_workfile_changed(self, event): + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + self._selected_workfile_id = event["workfile_id"] self.refresh() @@ -578,9 +597,6 @@ class ActionMenuPopup(QtWidgets.QWidget): 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) @@ -970,10 +986,11 @@ class ActionsWidget(QtWidgets.QWidget): event["project_name"], event["folder_id"], event["task_id"], + event["workfile_id"], event["addon_name"], event["addon_version"], ), - event["action_label"], + event["full_label"], form_data, ) @@ -1050,24 +1067,26 @@ class ActionsWidget(QtWidgets.QWidget): project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() + workfile_id = self._model.get_selected_workfile_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 + identifier=action_id, + project_name=project_name, + folder_id=folder_id, + task_id=task_id, + workfile_id=workfile_id, + addon_name=action_item.addon_name, + addon_version=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 + action_id, project_name, folder_id, task_id, workfile_id ) if index is None: @@ -1087,11 +1106,13 @@ class ActionsWidget(QtWidgets.QWidget): project_name = self._model.get_selected_project_name() folder_id = self._model.get_selected_folder_id() task_id = self._model.get_selected_task_id() + workfile_id = self._model.get_selected_workfile_id() context = WebactionContext( - action_id, + identifier=action_id, project_name=project_name, folder_id=folder_id, task_id=task_id, + workfile_id=workfile_id, addon_name=action_item.addon_name, addon_version=action_item.addon_version, ) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 65efdc27ac..47388d9685 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -12,6 +12,8 @@ from ayon_core.tools.utils import ( ) from ayon_core.tools.utils.lib import checkstate_int_to_enum +from .workfiles_page import WorkfilesPage + class HierarchyPage(QtWidgets.QWidget): def __init__(self, controller, parent): @@ -73,10 +75,15 @@ class HierarchyPage(QtWidgets.QWidget): # - Tasks widget tasks_widget = TasksWidget(controller, content_body) + # - Third page - Workfiles + workfiles_page = WorkfilesPage(controller, content_body) + content_body.addWidget(folders_widget) content_body.addWidget(tasks_widget) - content_body.setStretchFactor(0, 100) - content_body.setStretchFactor(1, 65) + content_body.addWidget(workfiles_page) + content_body.setStretchFactor(0, 120) + content_body.setStretchFactor(1, 85) + content_body.setStretchFactor(2, 220) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -99,6 +106,7 @@ class HierarchyPage(QtWidgets.QWidget): self._my_tasks_checkbox = my_tasks_checkbox self._folders_widget = folders_widget self._tasks_widget = tasks_widget + self._workfiles_page = workfiles_page self._project_name = None @@ -117,6 +125,7 @@ class HierarchyPage(QtWidgets.QWidget): def refresh(self): self._folders_widget.refresh() self._tasks_widget.refresh() + self._workfiles_page.refresh() self._on_my_tasks_checkbox_state_changed( self._my_tasks_checkbox.checkState() ) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 819e141d59..ad2fd2d3c2 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -177,7 +177,7 @@ class LauncherWindow(QtWidgets.QWidget): self._page_slide_anim = page_slide_anim hierarchy_page.setVisible(not self._is_on_projects_page) - self.resize(520, 740) + self.resize(920, 740) def showEvent(self, event): super().showEvent(event) diff --git a/client/ayon_core/tools/launcher/ui/workfiles_page.py b/client/ayon_core/tools/launcher/ui/workfiles_page.py new file mode 100644 index 0000000000..1ea223031e --- /dev/null +++ b/client/ayon_core/tools/launcher/ui/workfiles_page.py @@ -0,0 +1,175 @@ +from typing import Optional + +import ayon_api +from qtpy import QtCore, QtWidgets, QtGui + +from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd + +VERSION_ROLE = QtCore.Qt.UserRole + 1 +WORKFILE_ID_ROLE = QtCore.Qt.UserRole + 2 + + +class WorkfilesModel(QtGui.QStandardItemModel): + refreshed = QtCore.Signal() + + def __init__(self, controller: AbstractLauncherFrontEnd) -> None: + super().__init__() + + self.setColumnCount(1) + self.setHeaderData(0, QtCore.Qt.Horizontal, "Workfiles") + + controller.register_event_callback( + "selection.project.changed", + self._on_selection_project_changed, + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_selection_folder_changed, + ) + controller.register_event_callback( + "selection.task.changed", + self._on_selection_task_changed, + ) + + self._controller = controller + self._selected_project_name = None + self._selected_folder_id = None + self._selected_task_id = None + + self._transparent_icon = None + + self._cached_icons = {} + + def refresh(self) -> None: + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + workfile_items = self._controller.get_workfile_items( + self._selected_project_name, self._selected_task_id + ) + new_items = [] + for workfile_item in workfile_items: + icon = self._get_icon(workfile_item.icon) + item = QtGui.QStandardItem(workfile_item.filename) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(workfile_item.version, VERSION_ROLE) + item.setData(workfile_item.workfile_id, WORKFILE_ID_ROLE) + flags = QtCore.Qt.NoItemFlags + if workfile_item.exists: + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + item.setFlags(flags) + new_items.append(item) + + if not new_items: + title = "< No workfiles >" + if not self._selected_project_name: + title = "< Select a project >" + elif not self._selected_folder_id: + title = "< Select a folder >" + elif not self._selected_task_id: + title = "< Select a task >" + item = QtGui.QStandardItem(title) + item.setFlags(QtCore.Qt.NoItemFlags) + new_items.append(item) + root_item.appendRows(new_items) + + self.refreshed.emit() + + def _on_selection_project_changed(self, event) -> None: + self._selected_project_name = event["project_name"] + self._selected_folder_id = None + self._selected_task_id = None + self.refresh() + + def _on_selection_folder_changed(self, event) -> None: + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = None + self.refresh() + + def _on_selection_task_changed(self, event) -> None: + self._selected_project_name = event["project_name"] + self._selected_folder_id = event["folder_id"] + self._selected_task_id = event["task_id"] + self.refresh() + + def _get_transparent_icon(self) -> QtGui.QIcon: + if self._transparent_icon is None: + self._transparent_icon = get_qt_icon({ + "type": "transparent", "size": 256 + }) + return self._transparent_icon + + def _get_icon(self, icon_url: Optional[str]) -> QtGui.QIcon: + if icon_url is None: + return self._get_transparent_icon() + icon = self._cached_icons.get(icon_url) + if icon is not None: + return icon + + base_url = ayon_api.get_base_url() + if icon_url.startswith(base_url): + icon_def = { + "type": "ayon_url", + "url": icon_url[len(base_url) + 1:], + } + else: + icon_def = { + "type": "url", + "url": icon_url, + } + + icon = get_qt_icon(icon_def) + if icon is None: + icon = self._get_transparent_icon() + self._cached_icons[icon_url] = icon + return icon + + +class WorkfilesView(QtWidgets.QTreeView): + def drawBranches(self, painter, rect, index): + return + + +class WorkfilesPage(QtWidgets.QWidget): + def __init__( + self, + controller: AbstractLauncherFrontEnd, + parent: QtWidgets.QWidget, + ) -> None: + super().__init__(parent) + + workfiles_view = WorkfilesView(self) + workfiles_view.setIndentation(0) + workfiles_model = WorkfilesModel(controller) + workfiles_proxy = QtCore.QSortFilterProxyModel() + workfiles_proxy.setSourceModel(workfiles_model) + + workfiles_view.setModel(workfiles_proxy) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(workfiles_view, 1) + + workfiles_view.selectionModel().selectionChanged.connect( + self._on_selection_changed + ) + workfiles_model.refreshed.connect(self._on_refresh) + + self._controller = controller + self._workfiles_view = workfiles_view + self._workfiles_model = workfiles_model + self._workfiles_proxy = workfiles_proxy + + def refresh(self) -> None: + self._workfiles_model.refresh() + + def _on_refresh(self) -> None: + self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder) + + def _on_selection_changed(self, selected, _deselected) -> None: + workfile_id = None + for index in selected.indexes(): + workfile_id = index.data(WORKFILE_ID_ROLE) + self._controller.set_selected_workfile(workfile_id) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5ab7e78212..9c7934d2db 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -9,7 +9,11 @@ from ayon_core.lib.attribute_definitions import ( deserialize_attr_defs, serialize_attr_defs, ) -from ayon_core.tools.common_models import TaskItem, TagItem +from ayon_core.tools.common_models import ( + TaskItem, + TagItem, + ProductTypeIconMapping, +) class ProductTypeItem: @@ -78,7 +82,6 @@ class ProductItem: product_type (str): Product type. product_name (str): Product name. product_icon (dict[str, Any]): Product icon definition. - product_type_icon (dict[str, Any]): Product type icon definition. product_in_scene (bool): Is product in scene (only when used in DCC). group_name (str): Group name. folder_id (str): Folder id. @@ -93,8 +96,6 @@ class ProductItem: 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, @@ -106,8 +107,6 @@ class ProductItem: 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 @@ -121,8 +120,6 @@ class ProductItem: "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, @@ -499,8 +496,8 @@ class BackendLoaderController(_BaseLoaderController): topic (str): Event topic name. data (Optional[dict[str, Any]]): Event data. source (Optional[str]): Event source. - """ + """ pass @abstractmethod @@ -509,8 +506,20 @@ class BackendLoaderController(_BaseLoaderController): Returns: set[str]: Set of loaded product ids. - """ + """ + pass + + @abstractmethod + def get_product_type_icons_mapping( + self, project_name: Optional[str] + ) -> ProductTypeIconMapping: + """Product type icons mapping. + + Returns: + ProductTypeIconMapping: Product type icons mapping. + + """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7ba42a0981..9f159bfb21 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import uuid +from typing import Optional import ayon_api @@ -16,6 +17,7 @@ from ayon_core.tools.common_models import ( HierarchyModel, ThumbnailsModel, TagItem, + ProductTypeIconMapping, ) from .abstract import ( @@ -198,6 +200,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): project_name, sender ) + def get_product_type_icons_mapping( + self, project_name: Optional[str] + ) -> ProductTypeIconMapping: + return self._projects_model.get_product_type_icons_mapping( + project_name + ) + def get_folder_items(self, project_name, sender=None): return self._hierarchy_model.get_folder_items(project_name, sender) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 87e2406c81..7915a75bcf 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -9,9 +9,9 @@ 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.common_models import ProductTypeIconMapping from ayon_core.tools.loader.abstract import ( ProductTypeItem, ProductBaseTypeItem, @@ -21,8 +21,11 @@ from ayon_core.tools.loader.abstract import ( ) if TYPE_CHECKING: - from ayon_api.typing import ProductBaseTypeDict, ProductDict, VersionDict - + from ayon_api.typing import ( + ProductBaseTypeDict, + ProductDict, + VersionDict, + ) PRODUCTS_MODEL_SENDER = "products.model" @@ -84,42 +87,18 @@ def version_item_from_entity(version): def product_item_from_entity( product_entity: ProductDict, version_entities, - product_type_items_by_name: dict[str, ProductTypeItem], - product_base_type_items_by_name: dict[str, ProductBaseTypeItem], folder_label, + icons_mapping, product_in_scene, ): product_attribs = product_entity["attrib"] group = product_attribs.get("productGroup") product_type = product_entity["productType"] - product_type_item = product_type_items_by_name.get(product_type) - # NOTE This is needed for cases when products were not created on server - # using api functions. In that case product type item may not be - # available and we need to create a default. - if product_type_item is None: - product_type_item = create_default_product_type_item(product_type) - # Cache the item for future use - product_type_items_by_name[product_type] = product_type_item - 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", - "color": get_default_entity_icon_color(), - } + product_icon = icons_mapping.get_icon( + product_base_type, product_type + ) version_items = { version_entity["id"]: version_item_from_entity(version_entity) for version_entity in version_entities @@ -131,8 +110,6 @@ def product_item_from_entity( 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"], @@ -141,22 +118,8 @@ def product_item_from_entity( ) -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"] - icon = { - "type": "awesome-font", - "name": "fa.folder", - "color": "#0091B2", - } - # TODO implement checked logic - return ProductTypeItem(product_type_data["name"], icon) - - def product_base_type_item_from_data( - product_base_type_data: ProductBaseTypeDict + product_base_type_data: ProductBaseTypeDict ) -> ProductBaseTypeItem: """Create product base type item from data. @@ -174,34 +137,8 @@ def product_base_type_item_from_data( } 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", - "color": "#0091B2", - } - 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) + icon=icon + ) class ProductsModel: @@ -247,7 +184,9 @@ class ProductsModel: self._product_items_cache.reset() self._repre_items_cache.reset() - def get_product_type_items(self, project_name): + def get_product_type_items( + self, project_name: Optional[str] + ) -> list[ProductTypeItem]: """Product type items for project. Args: @@ -255,25 +194,33 @@ class ProductsModel: Returns: list[ProductTypeItem]: Product type items. - """ + """ if not project_name: return [] cache = self._product_type_items_cache[project_name] if not cache.is_valid: + icons_mapping = self._get_product_type_icons(project_name) product_types = ayon_api.get_project_product_types(project_name) cache.update_data([ - product_type_item_from_data(product_type) + ProductTypeItem( + product_type["name"], + icons_mapping.get_icon(product_type=product_type["name"]), + ) for product_type in product_types ]) return cache.get_data() def get_product_base_type_items( - self, - project_name: Optional[str]) -> list[ProductBaseTypeItem]: + self, project_name: Optional[str] + ) -> list[ProductBaseTypeItem]: """Product base type items for the project. + Notes: + This will be used for filtering product types in UI when + product base types are fully implemented. + Args: project_name (optional, str): Project name. @@ -286,6 +233,7 @@ class ProductsModel: cache = self._product_base_type_items_cache[project_name] if not cache.is_valid: + icons_mapping = self._get_product_type_icons(project_name) product_base_types = [] # TODO add temp implementation here when it is actually # implemented and available on server. @@ -294,7 +242,10 @@ class ProductsModel: project_name ) cache.update_data([ - product_base_type_item_from_data(product_base_type) + ProductBaseTypeItem( + product_base_type["name"], + icons_mapping.get_icon(product_base_type["name"]), + ) for product_base_type in product_base_types ]) return cache.get_data() @@ -511,6 +462,11 @@ class ProductsModel: PRODUCTS_MODEL_SENDER ) + def _get_product_type_icons( + self, project_name: Optional[str] + ) -> ProductTypeIconMapping: + return self._controller.get_product_type_icons_mapping(project_name) + def _get_product_items_by_id(self, project_name, product_ids): product_item_by_id = self._product_item_by_id[project_name] missing_product_ids = set() @@ -524,7 +480,7 @@ class ProductsModel: output.update( self._query_product_items_by_ids( - project_name, missing_product_ids + project_name, product_ids=missing_product_ids ) ) return output @@ -553,36 +509,18 @@ class ProductsModel: 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) - 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) for version in versions: versions_by_product_id[version["productId"]].append(version) - product_type_items_by_name = { - product_type_item.name: product_type_item - for product_type_item in product_type_items - } - - 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] = {} + icons_mapping = self._get_product_type_icons(project_name) for product in products: product_id = product["id"] folder_id = product["folderId"] @@ -595,9 +533,8 @@ class ProductsModel: product_item = product_item_from_entity( product, versions, - product_type_items_by_name, - product_base_type_items_by_name, folder_item.label, + icons_mapping, product_id in loaded_product_ids, ) output[product_id] = product_item diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index f3e5271f51..79ed197d83 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -17,7 +17,6 @@ PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6 PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7 PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8 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 @@ -228,10 +227,7 @@ class ProductsModel(QtGui.QStandardItemModel): return super().data(index, role) if role == QtCore.Qt.DecorationRole: - if col == 1: - role = PRODUCT_TYPE_ICON_ROLE - else: - return None + return None if ( role == VERSION_NAME_EDIT_ROLE @@ -455,7 +451,6 @@ class ProductsModel(QtGui.QStandardItemModel): model_item = QtGui.QStandardItem(product_item.product_name) model_item.setEditable(False) icon = get_qt_icon(product_item.product_icon) - product_type_icon = get_qt_icon(product_item.product_type_icon) model_item.setColumnCount(self.columnCount()) model_item.setData(icon, QtCore.Qt.DecorationRole) model_item.setData(product_id, PRODUCT_ID_ROLE) @@ -464,7 +459,6 @@ class ProductsModel(QtGui.QStandardItemModel): 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) self._product_items_by_id[product_id] = product_item diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 1e46e7e52c..033ddab0ef 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1147,6 +1147,8 @@ class LogItemMessage(QtWidgets.QTextEdit): QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Maximum ) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) document = self.document() document.documentLayout().documentSizeChanged.connect( self._adjust_minimum_size diff --git a/client/ayon_core/tools/publisher/widgets/tasks_model.py b/client/ayon_core/tools/publisher/widgets/tasks_model.py index 8bfa81116a..1803e46c5f 100644 --- a/client/ayon_core/tools/publisher/widgets/tasks_model.py +++ b/client/ayon_core/tools/publisher/widgets/tasks_model.py @@ -146,19 +146,19 @@ class TasksModel(QtGui.QStandardItemModel): self._controller.get_current_project_name() ) } - icon_name_by_task_name = {} + type_item_by_task_name = {} for task_items in task_items_by_folder_path.values(): for task_item in task_items: task_name = task_item.name if ( task_name not in new_task_names - or task_name in icon_name_by_task_name + or task_name in type_item_by_task_name ): continue task_type_name = task_item.task_type task_type_item = task_type_items.get(task_type_name) if task_type_item: - icon_name_by_task_name[task_name] = task_type_item.icon + type_item_by_task_name[task_name] = task_type_item for task_name in new_task_names: item = self._items_by_name.get(task_name) @@ -171,13 +171,18 @@ class TasksModel(QtGui.QStandardItemModel): if not task_name: continue - icon_name = icon_name_by_task_name.get(task_name) - icon = None + icon = icon_name = icon_color = None + task_type_item = type_item_by_task_name.get(task_name) + if task_type_item is not None: + icon_name = task_type_item.icon + icon_color = task_type_item.color if icon_name: + if not icon_color: + icon_color = get_default_entity_icon_color() icon = get_qt_icon({ "type": "material-symbols", "name": icon_name, - "color": get_default_entity_icon_color(), + "color": icon_color, }) if icon is None: icon = default_icon diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ef49838152..2adc708cf3 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,7 +5,7 @@ import itertools import sys import traceback import uuid -from typing import Optional, Dict +from typing import Optional, Any import ayon_api from ayon_api.utils import create_entity_id @@ -225,8 +225,8 @@ class ProjectPushRepreItem: but filenames are not template based. Args: - repre_entity (Dict[str, Ant]): Representation entity. - roots (Dict[str, str]): Project roots (based on project anatomy). + repre_entity (dict[str, Ant]): Representation entity. + roots (dict[str, str]): Project roots (based on project anatomy). """ def __init__(self, repre_entity, roots): @@ -482,6 +482,8 @@ class ProjectPushItemProcess: self._log_info("Destination project was found") self._fill_or_create_destination_folder() self._log_info("Destination folder was determined") + self._fill_or_create_destination_task() + self._log_info("Destination task was determined") self._determine_product_type() self._determine_publish_template_name() self._determine_product_name() @@ -650,10 +652,10 @@ class ProjectPushItemProcess: def _create_folder( self, - src_folder_entity, - project_entity, - parent_folder_entity, - folder_name + src_folder_entity: dict[str, Any], + project_entity: dict[str, Any], + parent_folder_entity: dict[str, Any], + folder_name: str ): parent_id = None if parent_folder_entity: @@ -702,10 +704,14 @@ class ProjectPushItemProcess: if new_folder_name != folder_name: folder_label = folder_name - # TODO find out how to define folder type + src_folder_type = src_folder_entity["folderType"] + dst_folder_type = self._get_dst_folder_type( + project_entity, + src_folder_type + ) folder_entity = new_folder_entity( folder_name, - "Folder", + dst_folder_type, parent_id=parent_id, attribs=new_folder_attrib ) @@ -727,10 +733,25 @@ class ProjectPushItemProcess: folder_entity["path"] = "/".join([parent_path, folder_name]) return folder_entity + def _get_dst_folder_type( + self, + project_entity: dict[str, Any], + src_folder_type: str + ) -> str: + """Get new folder type.""" + for folder_type in project_entity["folderTypes"]: + if folder_type["name"].lower() == src_folder_type.lower(): + return folder_type["name"] + + self._status.set_failed( + f"'{src_folder_type}' folder type is not configured in " + f"project Anatomy." + ) + raise PushToProjectError(self._status.fail_reason) + def _fill_or_create_destination_folder(self): dst_project_name = self._item.dst_project_name dst_folder_id = self._item.dst_folder_id - dst_task_name = self._item.dst_task_name new_folder_name = self._item.new_folder_name if not dst_folder_id and not new_folder_name: self._status.set_failed( @@ -761,9 +782,11 @@ class ProjectPushItemProcess: new_folder_name ) self._folder_entity = folder_entity - if not dst_task_name: - self._task_info = {} - return + + def _fill_or_create_destination_task(self): + folder_entity = self._folder_entity + dst_task_name = self._item.dst_task_name + dst_project_name = self._item.dst_project_name folder_path = folder_entity["path"] folder_tasks = { @@ -772,6 +795,20 @@ class ProjectPushItemProcess: dst_project_name, folder_ids=[folder_entity["id"]] ) } + + if not dst_task_name: + src_task_info = self._get_src_task_info() + if not src_task_info: # really no task selected nor on source + self._task_info = {} + return + + dst_task_name = src_task_info["name"] + if dst_task_name.lower() not in folder_tasks: + task_info = self._make_sure_task_exists( + folder_entity, src_task_info + ) + folder_tasks[dst_task_name.lower()] = task_info + task_info = folder_tasks.get(dst_task_name.lower()) if not task_info: self._status.set_failed( @@ -790,7 +827,10 @@ class ProjectPushItemProcess: task_type["name"]: task_type for task_type in self._project_entity["taskTypes"] } - task_type_info = task_types_by_name.get(task_type_name, {}) + task_type_info = copy.deepcopy( + task_types_by_name.get(task_type_name, {}) + ) + task_type_info.pop("name") # do not overwrite real task name task_info.update(task_type_info) self._task_info = task_info @@ -925,8 +965,8 @@ class ProjectPushItemProcess: version = get_versioning_start( project_name, self.host_name, - task_name=self._task_info["name"], - task_type=self._task_info["taskType"], + task_name=self._task_info.get("name"), + task_type=self._task_info.get("taskType"), product_type=product_type, product_name=product_entity["name"], ) @@ -950,10 +990,16 @@ class ProjectPushItemProcess: existing_version_entity["attrib"].update(dst_attrib) self._version_entity = existing_version_entity return + copied_tags = self._get_transferable_tags(src_version_entity) + copied_status = self._get_transferable_status(src_version_entity) version_entity = new_version_entity( version, product_id, + author=src_version_entity["author"], + status=copied_status, + tags=copied_tags, + task_id=self._task_info.get("id"), attribs=dst_attrib, thumbnail_id=thumbnail_id, ) @@ -962,6 +1008,47 @@ class ProjectPushItemProcess: ) self._version_entity = version_entity + def _make_sure_task_exists( + self, + folder_entity: dict[str, Any], + task_info: dict[str, Any], + ) -> dict[str, Any]: + """Creates destination task from source task information""" + project_name = self._item.dst_project_name + found_task_type = False + src_task_type = task_info["taskType"] + for task_type in self._project_entity["taskTypes"]: + if task_type["name"].lower() == src_task_type.lower(): + found_task_type = True + break + + if not found_task_type: + self._status.set_failed( + f"'{src_task_type}' task type is not configured in " + "project Anatomy." + ) + + raise PushToProjectError(self._status.fail_reason) + + task_info = self._operations.create_task( + project_name, + task_info["name"], + folder_id=folder_entity["id"], + task_type=src_task_type, + attrib=task_info["attrib"], + ) + self._task_info = task_info.data + return self._task_info + + def _get_src_task_info(self): + src_version_entity = self._src_version_entity + if not src_version_entity["taskId"]: + return None + src_task = ayon_api.get_task_by_id( + self._item.src_project_name, src_version_entity["taskId"] + ) + return src_task + def _integrate_representations(self): try: self._real_integrate_representations() @@ -1197,18 +1284,42 @@ class ProjectPushItemProcess: if context_value and isinstance(context_value, dict): for context_sub_key in context_value.keys(): value_to_update = formatting_data.get(context_key, {}).get( - context_sub_key) + context_sub_key + ) if value_to_update: - repre_context[context_key][ - context_sub_key] = value_to_update + repre_context[context_key][context_sub_key] = ( + value_to_update + ) else: value_to_update = formatting_data.get(context_key) if value_to_update: repre_context[context_key] = value_to_update if "task" not in formatting_data: - repre_context.pop("task") + repre_context.pop("task", None) return repre_context + def _get_transferable_tags(self, src_version_entity): + """Copy over only tags present in destination project""" + dst_project_tags = [ + tag["name"] for tag in self._project_entity["tags"] + ] + copied_tags = [] + for src_tag in src_version_entity["tags"]: + if src_tag in dst_project_tags: + copied_tags.append(src_tag) + return copied_tags + + def _get_transferable_status(self, src_version_entity): + """Copy over status, first status if not matching found""" + dst_project_statuses = { + status["name"]: status + for status in self._project_entity["statuses"] + } + copied_status = dst_project_statuses.get(src_version_entity["status"]) + if copied_status: + return copied_status["name"] + return None + class IntegrateModel: def __init__(self, controller): @@ -1281,6 +1392,6 @@ class IntegrateModel: return item.integrate() - def get_items(self) -> Dict[str, ProjectPushItemProcess]: + def get_items(self) -> dict[str, ProjectPushItemProcess]: """Returns dict of all ProjectPushItemProcess items """ return self._process_items diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index 45f76a54ac..606c9e7298 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -1,3 +1,5 @@ +from typing import Optional + import ayon_api from ayon_core.lib.events import QueuedEventSystem @@ -6,7 +8,11 @@ from ayon_core.pipeline import ( registered_host, get_current_context, ) -from ayon_core.tools.common_models import HierarchyModel, ProjectsModel +from ayon_core.tools.common_models import ( + HierarchyModel, + ProjectsModel, + ProductTypeIconMapping, +) from .models import SiteSyncModel, ContainersModel @@ -93,6 +99,13 @@ class SceneInventoryController: project_name, None ) + def get_product_type_icons_mapping( + self, project_name: Optional[str] + ) -> ProductTypeIconMapping: + return self._projects_model.get_product_type_icons_mapping( + project_name + ) + # Containers methods def get_containers(self): return self._containers_model.get_containers() diff --git a/client/ayon_core/tools/sceneinventory/delegates.py b/client/ayon_core/tools/sceneinventory/delegates.py index 6f91587613..9bc4294fda 100644 --- a/client/ayon_core/tools/sceneinventory/delegates.py +++ b/client/ayon_core/tools/sceneinventory/delegates.py @@ -1,10 +1,14 @@ from qtpy import QtWidgets, QtCore, QtGui -from .model import VERSION_LABEL_ROLE +from ayon_core.tools.utils import get_qt_icon + +from .model import VERSION_LABEL_ROLE, CONTAINER_VERSION_LOCKED_ROLE class VersionDelegate(QtWidgets.QStyledItemDelegate): """A delegate that display version integer formatted as version string.""" + _locked_icon = None + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -45,10 +49,35 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): QtWidgets.QStyle.PM_FocusFrameHMargin, option, option.widget ) + 1 + text_rect_f = text_rect.adjusted( + text_margin, 0, - text_margin, 0 + ) + painter.drawText( - text_rect.adjusted(text_margin, 0, - text_margin, 0), + text_rect_f, option.displayAlignment, text ) + if index.data(CONTAINER_VERSION_LOCKED_ROLE) is True: + icon = self._get_locked_icon() + size = max(text_rect_f.height() // 2, 16) + margin = (text_rect_f.height() - size) // 2 + + icon_rect = QtCore.QRect( + text_rect_f.right() - size, + text_rect_f.top() + margin, + size, + size + ) + icon.paint(painter, icon_rect) painter.restore() + + def _get_locked_icon(cls): + if cls._locked_icon is None: + cls._locked_icon = get_qt_icon({ + "type": "material-symbols", + "name": "lock", + "color": "white", + }) + return cls._locked_icon diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index 885553acaf..27211165bf 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -37,6 +37,7 @@ REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23 # containers inbetween refresh. ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24 PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 25 +CONTAINER_VERSION_LOCKED_ROLE = QtCore.Qt.UserRole + 26 class InventoryModel(QtGui.QStandardItemModel): @@ -214,9 +215,6 @@ class InventoryModel(QtGui.QStandardItemModel): group_icon = qtawesome.icon( "fa.object-group", color=self._default_icon_color ) - product_type_icon = qtawesome.icon( - "fa.folder", color="#0091B2" - ) group_item_font = QtGui.QFont() group_item_font.setBold(True) @@ -294,6 +292,10 @@ class InventoryModel(QtGui.QStandardItemModel): item.setData(container_item.object_name, OBJECT_NAME_ROLE) item.setData(True, IS_CONTAINER_ITEM_ROLE) item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE) + item.setData( + container_item.version_locked, + CONTAINER_VERSION_LOCKED_ROLE + ) container_model_items.append(item) progress = progress_by_id[repre_id] @@ -303,7 +305,7 @@ class InventoryModel(QtGui.QStandardItemModel): remote_site_progress = "{}%".format( max(progress["remote_site"], 0) * 100 ) - + product_type_icon = get_qt_icon(repre_info.product_type_icon) group_item = QtGui.QStandardItem() group_item.setColumnCount(root_item.columnCount()) group_item.setData(group_name, QtCore.Qt.DisplayRole) diff --git a/client/ayon_core/tools/sceneinventory/models/containers.py b/client/ayon_core/tools/sceneinventory/models/containers.py index f841f87c8e..0e19f381cd 100644 --- a/client/ayon_core/tools/sceneinventory/models/containers.py +++ b/client/ayon_core/tools/sceneinventory/models/containers.py @@ -95,7 +95,8 @@ class ContainerItem: namespace, object_name, item_id, - project_name + project_name, + version_locked, ): self.representation_id = representation_id self.loader_name = loader_name @@ -103,6 +104,7 @@ class ContainerItem: self.namespace = namespace self.item_id = item_id self.project_name = project_name + self.version_locked = version_locked @classmethod def from_container_data(cls, current_project_name, container): @@ -114,7 +116,8 @@ class ContainerItem: item_id=uuid.uuid4().hex, project_name=container.get( "project_name", current_project_name - ) + ), + version_locked=container.get("version_locked", False), ) @@ -126,6 +129,7 @@ class RepresentationInfo: product_id, product_name, product_type, + product_type_icon, product_group, version_id, representation_name, @@ -135,6 +139,7 @@ class RepresentationInfo: self.product_id = product_id self.product_name = product_name self.product_type = product_type + self.product_type_icon = product_type_icon self.product_group = product_group self.version_id = version_id self.representation_name = representation_name @@ -153,7 +158,17 @@ class RepresentationInfo: @classmethod def new_invalid(cls): - return cls(None, None, None, None, None, None, None, None) + return cls( + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) class VersionItem: @@ -229,6 +244,9 @@ class ContainersModel: def get_representation_info_items(self, project_name, representation_ids): output = {} missing_repre_ids = set() + icons_mapping = self._controller.get_product_type_icons_mapping( + project_name + ) for repre_id in representation_ids: try: uuid.UUID(repre_id) @@ -253,6 +271,7 @@ class ContainersModel: "product_id": None, "product_name": None, "product_type": None, + "product_type_icon": None, "product_group": None, "version_id": None, "representation_name": None, @@ -265,10 +284,17 @@ class ContainersModel: kwargs["folder_id"] = folder["id"] kwargs["folder_path"] = folder["path"] if product: + product_type = product["productType"] + product_base_type = product.get("productBaseType") + icon = icons_mapping.get_icon( + product_base_type=product_base_type, + product_type=product_type, + ) group = product["attrib"]["productGroup"] kwargs["product_id"] = product["id"] kwargs["product_name"] = product["name"] kwargs["product_type"] = product["productType"] + kwargs["product_type_icon"] = icon kwargs["product_group"] = group if version: kwargs["version_id"] = version["id"] diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index fdd1bdbe75..22bc170230 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -17,6 +17,7 @@ from ayon_core.tools.utils.lib import ( format_version, preserve_expanded_rows, preserve_selection, + get_qt_icon, ) from ayon_core.tools.utils.delegates import StatusDelegate @@ -46,7 +47,7 @@ class SceneInventoryView(QtWidgets.QTreeView): hierarchy_view_changed = QtCore.Signal(bool) def __init__(self, controller, parent): - super(SceneInventoryView, self).__init__(parent=parent) + super().__init__(parent=parent) # view settings self.setIndentation(12) @@ -524,7 +525,14 @@ class SceneInventoryView(QtWidgets.QTreeView): submenu = QtWidgets.QMenu("Actions", self) for action in custom_actions: color = action.color or DEFAULT_COLOR - icon = qtawesome.icon("fa.%s" % action.icon, color=color) + icon_def = action.icon + if not isinstance(action.icon, dict): + icon_def = { + "type": "awesome-font", + "name": icon_def, + "color": color, + } + icon = get_qt_icon(icon_def) action_item = QtWidgets.QAction(icon, action.label, submenu) action_item.triggered.connect( partial( @@ -622,7 +630,7 @@ class SceneInventoryView(QtWidgets.QTreeView): if isinstance(result, (list, set)): self._select_items_by_action(result) - if isinstance(result, dict): + elif isinstance(result, dict): self._select_items_by_action( result["objectNames"], result["options"] ) diff --git a/client/ayon_core/tools/utils/delegates.py b/client/ayon_core/tools/utils/delegates.py index 1cc18b5722..059fc1da0e 100644 --- a/client/ayon_core/tools/utils/delegates.py +++ b/client/ayon_core/tools/utils/delegates.py @@ -186,8 +186,15 @@ class StatusDelegate(QtWidgets.QStyledItemDelegate): ) fm = QtGui.QFontMetrics(option.font) if text_rect.width() < fm.width(text): - text = self._get_status_short_name(index) - if text_rect.width() < fm.width(text): + short_text = self._get_status_short_name(index) + if short_text: + text = short_text + + text = fm.elidedText( + text, QtCore.Qt.ElideRight, text_rect.width() + ) + # Allow at least one character + if len(text) < 2: text = "" fg_color = self._get_status_color(index) diff --git a/client/ayon_core/tools/utils/dialogs.py b/client/ayon_core/tools/utils/dialogs.py index 5dd0ddd54e..6dc3cf1d8b 100644 --- a/client/ayon_core/tools/utils/dialogs.py +++ b/client/ayon_core/tools/utils/dialogs.py @@ -41,7 +41,7 @@ class ScrollMessageBox(QtWidgets.QDialog): """ def __init__(self, icon, title, messages, cancelable=False): - super(ScrollMessageBox, self).__init__() + super().__init__() self.setWindowTitle(title) self.icon = icon @@ -49,8 +49,6 @@ class ScrollMessageBox(QtWidgets.QDialog): self.setWindowFlags(QtCore.Qt.WindowTitleHint) - layout = QtWidgets.QVBoxLayout(self) - scroll_widget = QtWidgets.QScrollArea(self) scroll_widget.setWidgetResizable(True) content_widget = QtWidgets.QWidget(self) @@ -63,14 +61,8 @@ class ScrollMessageBox(QtWidgets.QDialog): content_layout.addWidget(label_widget) message_len = max(message_len, len(message)) - # guess size of scrollable area - # WARNING: 'desktop' method probably won't work in PySide6 - desktop = QtWidgets.QApplication.desktop() - max_width = desktop.availableGeometry().width() - scroll_widget.setMinimumWidth( - min(max_width, message_len * 6) - ) - layout.addWidget(scroll_widget) + # Set minimum width + scroll_widget.setMinimumWidth(360) buttons = QtWidgets.QDialogButtonBox.Ok if cancelable: @@ -86,7 +78,9 @@ class ScrollMessageBox(QtWidgets.QDialog): btn.clicked.connect(self._on_copy_click) btn_box.addButton(btn, QtWidgets.QDialogButtonBox.NoRole) - layout.addWidget(btn_box) + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(scroll_widget, 1) + main_layout.addWidget(btn_box, 0) def _on_copy_click(self): clipboard = QtWidgets.QApplication.clipboard() @@ -104,7 +98,7 @@ class SimplePopup(QtWidgets.QDialog): on_clicked = QtCore.Signal() def __init__(self, parent=None, *args, **kwargs): - super(SimplePopup, self).__init__(parent=parent, *args, **kwargs) + super().__init__(parent=parent, *args, **kwargs) # Set default title self.setWindowTitle("Popup") @@ -161,7 +155,7 @@ class SimplePopup(QtWidgets.QDialog): geo = self._calculate_window_geometry() self.setGeometry(geo) - return super(SimplePopup, self).showEvent(event) + return super().showEvent(event) def _on_clicked(self): """Callback for when the 'show' button is clicked. @@ -228,9 +222,7 @@ class PopupUpdateKeys(SimplePopup): on_clicked_state = QtCore.Signal(bool) def __init__(self, parent=None, *args, **kwargs): - super(PopupUpdateKeys, self).__init__( - parent=parent, *args, **kwargs - ) + super().__init__(parent=parent, *args, **kwargs) layout = self.layout() diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index 744eb6060a..d77ce1e1f4 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -234,10 +234,11 @@ class TasksQtModel(QtGui.QStandardItemModel): ) icon = None if task_type_item is not None: + color = task_type_item.color or get_default_entity_icon_color() icon = get_qt_icon({ "type": "material-symbols", "name": task_type_item.icon, - "color": get_default_entity_icon_color() + "color": color, }) if icon is None: diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index de2c42c91f..4b787ff830 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -418,7 +418,7 @@ class ExpandingTextEdit(QtWidgets.QTextEdit): """QTextEdit which does not have sroll area but expands height.""" def __init__(self, parent=None): - super(ExpandingTextEdit, self).__init__(parent) + super().__init__(parent) size_policy = self.sizePolicy() size_policy.setHeightForWidth(True) @@ -441,14 +441,18 @@ class ExpandingTextEdit(QtWidgets.QTextEdit): margins = self.contentsMargins() document_width = 0 - if width >= margins.left() + margins.right(): - document_width = width - margins.left() - margins.right() + margins_size = margins.left() + margins.right() + if width >= margins_size: + document_width = width - margins_size document = self.document().clone() document.setTextWidth(document_width) return math.ceil( - margins.top() + document.size().height() + margins.bottom() + margins.top() + + document.size().height() + + margins.bottom() + + 2 ) def sizeHint(self): diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 3f96f0bb15..00362ea866 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -358,9 +358,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget): if not self._host_is_valid: return - self._folders_widget.set_project_name( - self._controller.get_current_project_name() - ) + self._project_name = self._controller.get_current_project_name() + self._folders_widget.set_project_name(self._project_name) def _on_save_as_finished(self, event): if event["failed"]: diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf deleted file mode 100644 index 26f767e075..0000000000 Binary files a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.ttf and /dev/null differ diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints similarity index 93% rename from client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints rename to client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints index d5ede9bf32..ec2d854772 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.codepoints +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.codepoints @@ -85,12 +85,13 @@ account_circle_off f7b3 account_tree e97a action_key f502 activity_zone e1e6 +acupuncture f2c4 acute e4cb ad e65a ad_group e65b ad_group_off eae5 ad_off f7b2 -ad_units ef39 +ad_units f2eb adaptive_audio_mic f4cc adaptive_audio_mic_off f4cb adb e60e @@ -127,7 +128,7 @@ add_row_below f422 add_shopping_cart e854 add_task f23a add_to_drive e65c -add_to_home_screen e1fe +add_to_home_screen f2b9 add_to_photos e39d add_to_queue e05c add_triangle f48e @@ -208,10 +209,36 @@ amp_stories ea13 analytics ef3e anchor f1cd android e859 +android_cell_4_bar ef06 +android_cell_4_bar_alert ef09 +android_cell_4_bar_off ef08 +android_cell_4_bar_plus ef07 +android_cell_5_bar ef02 +android_cell_5_bar_alert ef05 +android_cell_5_bar_off ef04 +android_cell_5_bar_plus ef03 +android_cell_dual_4_bar ef0d +android_cell_dual_4_bar_alert ef0f +android_cell_dual_4_bar_plus ef0e +android_cell_dual_5_bar ef0a +android_cell_dual_5_bar_alert ef0c +android_cell_dual_5_bar_plus ef0b +android_wifi_3_bar ef16 +android_wifi_3_bar_alert ef1b +android_wifi_3_bar_lock ef1a +android_wifi_3_bar_off ef19 +android_wifi_3_bar_plus ef18 +android_wifi_3_bar_question ef17 +android_wifi_4_bar ef10 +android_wifi_4_bar_alert ef15 +android_wifi_4_bar_lock ef14 +android_wifi_4_bar_off ef13 +android_wifi_4_bar_plus ef12 +android_wifi_4_bar_question ef11 animated_images f49a animation e71c announcement e87f -aod efda +aod f2e6 aod_tablet f89f aod_watch f6ac apartment ea40 @@ -219,14 +246,15 @@ api f1b7 apk_document f88e apk_install f88f app_badging f72f -app_blocking ef3f -app_promo e981 +app_blocking f2e5 +app_promo f2cd app_registration ef40 -app_settings_alt ef41 -app_shortcut eae4 +app_settings_alt f2d9 +app_shortcut f2df apparel ef7b approval e982 approval_delegation f84a +approval_delegation_off f2c5 apps e5c3 apps_outage e7cc aq f55a @@ -265,6 +293,9 @@ arrow_range f69b arrow_right e5df arrow_right_alt e941 arrow_selector_tool f82f +arrow_shape_up eef6 +arrow_shape_up_stack eef7 +arrow_shape_up_stack_2 eef8 arrow_split ea04 arrow_top_left f72e arrow_top_right f72d @@ -287,6 +318,7 @@ aspect_ratio e85b assessment f0cc assignment e85d assignment_add f848 +assignment_globe eeec assignment_ind e85e assignment_late e85f assignment_return e860 @@ -336,6 +368,7 @@ auto_read_pause f219 auto_read_play f216 auto_schedule e214 auto_stories e666 +auto_stories_off f267 auto_timer ef7f auto_towing e71e auto_transmission f53f @@ -352,6 +385,7 @@ av_timer e01b avc f4af avg_pace f6bb avg_time f813 +award_meal f241 award_star f612 azm f6ec baby_changing_station f19b @@ -370,6 +404,7 @@ backup e864 backup_table ef43 badge ea67 badge_critical_battery f156 +badminton f2a8 bakery_dining ea53 balance eaf6 balcony e58f @@ -382,9 +417,11 @@ barcode_reader f85c barcode_scanner e70c barefoot f871 batch_prediction f0f5 +bath_bedrock f286 bath_outdoor f6fb bath_private f6fa bath_public_large f6f9 +bath_soak f2a0 bathroom efdd bathtub ea41 battery_0_bar ebdc @@ -410,6 +447,19 @@ battery_android_5 f308 battery_android_6 f307 battery_android_alert f306 battery_android_bolt f305 +battery_android_frame_1 f257 +battery_android_frame_2 f256 +battery_android_frame_3 f255 +battery_android_frame_4 f254 +battery_android_frame_5 f253 +battery_android_frame_6 f252 +battery_android_frame_alert f251 +battery_android_frame_bolt f250 +battery_android_frame_full f24f +battery_android_frame_plus f24e +battery_android_frame_question f24d +battery_android_frame_share f24c +battery_android_frame_shield f24b battery_android_full f304 battery_android_plus f303 battery_android_question f302 @@ -449,6 +499,7 @@ bedroom_parent efe2 bedtime f159 bedtime_off eb76 beenhere e52d +beer_meal f285 bento f1f4 bia f6eb bid_landscape e678 @@ -490,7 +541,7 @@ book_3 f53d book_4 f53c book_5 f53b book_6 f3df -book_online f217 +book_online f2e4 book_ribbon f3e7 bookmark e8e7 bookmark_add e598 @@ -537,6 +588,7 @@ breaking_news ea08 breaking_news_alt_1 f0ba breastfeeding f856 brick f388 +briefcase_meal f246 brightness_1 e3fa brightness_2 f036 brightness_3 e3a8 @@ -564,6 +616,7 @@ brush e3ae bubble ef83 bubble_chart e6dd bubbles f64e +bucket_check ef2a bug_report e868 build f8cd build_circle ef48 @@ -586,7 +639,11 @@ cake_add f85b calculate ea5f calendar_add_on ef85 calendar_apps_script f0bb +calendar_check f243 calendar_clock f540 +calendar_lock f242 +calendar_meal f296 +calendar_meal_2 f240 calendar_month ebcc calendar_today e935 calendar_view_day e936 @@ -607,10 +664,10 @@ call_to_action e06c camera e3af camera_alt e412 camera_enhance e8fc -camera_front e3b1 +camera_front f2c9 camera_indoor efe9 camera_outdoor efea -camera_rear e3b2 +camera_rear f2c8 camera_roll e3b3 camera_video f7a6 cameraswitch efeb @@ -628,7 +685,9 @@ car_crash ebf2 car_defrost_left f344 car_defrost_low_left f343 car_defrost_low_right f342 +car_defrost_mid_left f278 car_defrost_mid_low_left f341 +car_defrost_mid_low_right f277 car_defrost_mid_right f340 car_defrost_right f33f car_fan_low_left f33e @@ -674,17 +733,21 @@ center_focus_strong e3b4 center_focus_weak e3b5 chair efed chair_alt efee +chair_counter f29f +chair_fireplace f29e +chair_umbrella f29d chalet e585 change_circle e2e7 change_history e86b charger e2ae -charging_station f19d +charging_station f2e3 chart_data e473 chat e0c9 chat_add_on f0f3 chat_apps_script f0bd chat_bubble e0cb chat_bubble_outline e0cb +chat_dashed eeed chat_error f7ac chat_info f52b chat_paste_go f6bd @@ -695,6 +758,7 @@ check_box_outline_blank e835 check_circle f0be check_circle_filled f0be check_circle_outline f0be +check_circle_unread f27e check_in_out f6f6 check_indeterminate_small f88a check_small f88b @@ -707,13 +771,22 @@ checkroom f19e cheer f6a8 chef_hat f357 chess f5e7 +chess_bishop f261 +chess_bishop_2 f262 +chess_king f25f +chess_king_2 f260 +chess_knight f25e chess_pawn f3b6 +chess_pawn_2 f25d +chess_queen f25c +chess_rook f25b chevron_backward f46b chevron_forward f46a chevron_left e5cb chevron_right e5cc child_care eb41 child_friendly eb42 +child_hat ef30 chip_extraction f821 chips e993 chrome_reader_mode e86d @@ -839,6 +912,7 @@ control_camera e074 control_point e3ba control_point_duplicate e3bb controller_gen e83d +conversation ef2f conversion_path f0c1 conversion_path_off f7b4 convert_to_text f41f @@ -984,13 +1058,13 @@ detector_status e1e8 developer_board e30d developer_board_off e4ff developer_guide e99e -developer_mode e1b0 +developer_mode f2e2 developer_mode_tv e874 device_band f2f5 device_hub e335 device_reset e8b3 device_thermostat e1ff -device_unknown e339 +device_unknown f2e1 devices e326 devices_fold ebde devices_fold_2 f406 @@ -1004,10 +1078,14 @@ dialer_sip e0bb dialogs e99f dialpad e0bc diamond ead5 +diamond_shine f2b2 dictionary f539 difference eb7d digital_out_of_home f1de digital_wellbeing ef86 +dine_heart f29c +dine_in f295 +dine_lamp f29b dining eff4 dinner_dining ea57 directions e52e @@ -1057,7 +1135,7 @@ do_not_disturb_on f08f do_not_disturb_on_total_silence effb do_not_step f19f do_not_touch f1b0 -dock e30e +dock f2e0 dock_to_bottom f7e6 dock_to_left f7e5 dock_to_right f7e4 @@ -1112,6 +1190,8 @@ drive_file_move_rtl e9a1 drive_file_rename_outline e9a2 drive_folder_upload e9a3 drive_fusiontable e678 +drone f25a +drone_2 f259 dropdown e9a4 dropper_eye f351 dry f1b3 @@ -1139,8 +1219,8 @@ ecg f80f ecg_heart f6e9 eco ea35 eda f6e8 -edgesensor_high f005 -edgesensor_low f006 +edgesensor_high f2ef +edgesensor_low f2ee edit f097 edit_arrow_down f380 edit_arrow_up f37f @@ -1266,6 +1346,8 @@ extension e87b extension_off e4f5 eye_tracking f4c9 eyeglasses f6ee +eyeglasses_2 f2c7 +eyeglasses_2_sound f265 face f008 face_2 f8da face_3 f8db @@ -1285,6 +1367,7 @@ fact_check f0c5 factory ebbc falling f60d familiar_face_and_zone e21c +family_group eef2 family_history e0ad family_home eb26 family_link eb19 @@ -1379,6 +1462,7 @@ fit_screen ea10 fit_width f779 fitness_center eb43 fitness_tracker f463 +fitness_trackers eef1 flag f0c6 flag_2 f40f flag_check f3d8 @@ -1515,6 +1599,8 @@ forward_media f6f4 forward_to_inbox f187 foundation f200 fragrance f345 +frame_bug eeef +frame_exclamation eeee frame_inspect f772 frame_person f8a6 frame_person_mic f4d5 @@ -1541,8 +1627,10 @@ gallery_thumbnail f86f gamepad e30f games e30f garage f011 +garage_check f28d garage_door e714 garage_home e82d +garage_money f28c garden_cart f8a9 gas_meter ec19 gastroenterology e0f1 @@ -1621,9 +1709,12 @@ h_plus_mobiledata f019 h_plus_mobiledata_badge f7df hail e9b1 hallway e6f8 +hanami_dango f23f hand_bones f894 hand_gesture ef9c hand_gesture_off f3f3 +hand_meal f294 +hand_package f293 handheld_controller f4c6 handshake ebcb handwriting_recognition eb02 @@ -1655,6 +1746,7 @@ headset_off e33a healing e3f3 health_and_beauty ef9d health_and_safety e1d5 +health_cross f2c3 health_metrics f6e2 heap_snapshot_large f76e heap_snapshot_multiple f76d @@ -1662,11 +1754,14 @@ heap_snapshot_thumbnail f76c hearing e023 hearing_aid f464 hearing_aid_disabled f3b0 +hearing_aid_disabled_left f2ec +hearing_aid_left f2ed hearing_disabled f104 heart_broken eac2 heart_check f60a heart_minus f883 heart_plus f884 +heart_smile f292 heat f537 heat_pump ec18 heat_pump_balance e27e @@ -1682,6 +1777,7 @@ hexagon eb39 hide ef9e hide_image f022 hide_source f023 +high_chair f29a high_density f79c high_quality e024 high_res f54b @@ -1770,6 +1866,7 @@ iframe_off f71c image e3f4 image_arrow_up f317 image_aspect_ratio e3f5 +image_inset f247 image_not_supported f116 image_search e43f imagesearch_roller e9b4 @@ -1815,7 +1912,7 @@ insert_photo e3f4 insert_text f827 insights f092 install_desktop eb71 -install_mobile eb72 +install_mobile f2cd instant_mix e026 integration_instructions ef54 interactive_space f7ff @@ -1830,6 +1927,8 @@ ios_share e6b8 iron e583 iso e3f6 jamboard_kiosk e9b5 +japanese_curry f284 +japanese_flag f283 javascript eb7c join f84f join_full f84f @@ -1838,6 +1937,7 @@ join_left eaf2 join_right eaea joystick f5ee jump_to_element f719 +kanji_alcohol f23e kayaking e50c kebab_dining e842 keep f026 @@ -2065,9 +2165,11 @@ magnification_small f83c magnify_docked f7d6 magnify_fullscreen f7d5 mail e159 +mail_asterisk eef4 mail_lock ec0a mail_off f48b mail_outline e159 +mail_shield f249 male e58e man e4eb man_2 f8e1 @@ -2079,6 +2181,8 @@ manage_search f02f manga f5e3 manufacturing e726 map e55b +map_pin_heart f298 +map_pin_review f297 map_search f3ca maps_home_work f030 maps_ugc ef58 @@ -2097,11 +2201,14 @@ markunread_mailbox e89b masked_transitions e72e masked_transitions_add f42b masks f218 +massage f2c2 match_case f6f1 match_case_off f36f match_word f6f0 matter e907 maximize e930 +meal_dinner f23d +meal_lunch f23c measuring_tape f6af media_bluetooth_off f031 media_bluetooth_on f032 @@ -2120,6 +2227,7 @@ memory_alt f7a3 menstrual_health f6e1 menu e5d2 menu_book ea19 +menu_book_2 f291 menu_open e9bd merge eb98 merge_type e252 @@ -2151,17 +2259,57 @@ mist e188 mitre f547 mixture_med e4c8 mms e618 -mobile_friendly e200 +mobile e7ba +mobile_2 f2db +mobile_3 f2da +mobile_alert f2d3 +mobile_arrow_down f2cd +mobile_arrow_right f2d2 +mobile_arrow_up_right f2b9 +mobile_block f2e5 +mobile_camera f44e +mobile_camera_front f2c9 +mobile_camera_rear f2c8 +mobile_cancel f2ea +mobile_cast f2cc +mobile_charge f2e3 +mobile_chat f79f +mobile_check f073 +mobile_code f2e2 +mobile_dots f2d0 +mobile_friendly f073 +mobile_gear f2d9 mobile_hand f323 mobile_hand_left f313 mobile_hand_left_off f312 mobile_hand_off f314 +mobile_info f2dc +mobile_landscape ed3e +mobile_layout f2bf +mobile_lock_landscape f2d8 +mobile_lock_portrait f2be mobile_loupe f322 +mobile_menu f2d1 mobile_off e201 -mobile_screen_share e0e7 +mobile_question f2e1 +mobile_rotate f2d5 +mobile_rotate_lock f2d6 +mobile_screen_share f2df mobile_screensaver f321 +mobile_sensor_hi f2ef +mobile_sensor_lo f2ee +mobile_share f2df +mobile_share_stack f2de +mobile_sound f2e8 mobile_sound_2 f318 +mobile_sound_off f7aa mobile_speaker f320 +mobile_text f2eb +mobile_text_2 f2e6 +mobile_theft f2a9 +mobile_ticket f2e4 +mobile_vibrate f2cb +mobile_wrench f2b0 mobiledata_off f034 mode f097 mode_comment e253 @@ -2186,6 +2334,7 @@ money e57d money_bag f3ee money_off f038 money_off_csred f038 +money_range f245 monitor ef5b monitor_heart eaa2 monitor_weight f039 @@ -2199,6 +2348,7 @@ mood_bad e7f3 moon_stars f34f mop e28d moped eb28 +moped_package f28b more e619 more_down f196 more_horiz e5d3 @@ -2220,6 +2370,7 @@ motion_sensor_idle e783 motion_sensor_urgent e78e motorcycle e91b mountain_flag f5e2 +mountain_steam f282 mouse e323 mouse_lock f490 mouse_lock_off f48f @@ -2241,6 +2392,7 @@ movie_edit f840 movie_filter e43a movie_info e02d movie_off f499 +movie_speaker f2a3 moving e501 moving_beds e73d moving_ministry e73e @@ -2252,6 +2404,7 @@ multiple_airports efab multiple_stop f1b9 museum ea36 music_cast eb1a +music_history f2c1 music_note e405 music_note_add f391 music_off e440 @@ -2288,6 +2441,11 @@ nest_display f124 nest_display_max f125 nest_doorbell_visitor f8bd nest_eco_leaf f8be +nest_farsight_cool f27d +nest_farsight_dual f27c +nest_farsight_eco f27b +nest_farsight_heat f27a +nest_farsight_seasonal f279 nest_farsight_weather f8bf nest_found_savings f8c0 nest_gale_wifi f579 @@ -2356,7 +2514,7 @@ night_sight_max f6c3 nightlife ea62 nightlight f03d nightlight_round f03d -nights_stay ea46 +nights_stay f174 no_accounts f03e no_adult_content f8fe no_backpack f237 @@ -2410,8 +2568,9 @@ odt e6e9 offline_bolt e932 offline_pin e90a offline_pin_off f4d0 -offline_share e9c5 +offline_share f2de oil_barrel ec15 +okonomiyaki f281 on_device_training ebfd on_hub_device e6c3 oncology e114 @@ -2424,7 +2583,7 @@ open_in_full f1ce open_in_new e89e open_in_new_down f70f open_in_new_off e4f6 -open_in_phone e702 +open_in_phone f2d2 open_jam efae open_run f4b7 open_with e89f @@ -2461,10 +2620,12 @@ pacemaker e656 package e48f package_2 f569 padding e9c8 +padel f2a7 page_control e731 page_footer f383 page_header f384 page_info f614 +page_menu_ios eefb pageless f509 pages e7f9 pageview e8a0 @@ -2481,10 +2642,15 @@ panorama_photosphere e9c9 panorama_vertical e40e panorama_wide_angle e40f paragliding e50f +parent_child_dining f22d park ea63 +parking_meter f28a +parking_sign f289 +parking_valet f288 partly_cloudy_day f172 partly_cloudy_night f174 partner_exchange f7f9 +partner_heart ef2e partner_reports efaf party_mode e7fa passkey f87f @@ -2499,6 +2665,8 @@ pause_circle_filled e1a2 pause_circle_outline e1a2 pause_presentation e0ea payment e8a1 +payment_arrow_down f2c0 +payment_card f2a1 payments ef63 pedal_bike eb29 pediatrics e11d @@ -2514,12 +2682,13 @@ people ea21 people_alt ea21 people_outline ea21 percent eb58 +percent_discount f244 performance_max e51a pergola e203 perm_camera_mic e8a2 perm_contact_calendar e8a3 perm_data_setting e8a4 -perm_device_information e8a5 +perm_device_information f2dc perm_identity f0d3 perm_media e8a7 perm_phone_msg e8a8 @@ -2539,6 +2708,7 @@ person_celebrate f7fe person_check f565 person_edit f4fa person_filled f0d3 +person_heart f290 person_off e510 person_outline f0d3 person_pin e55a @@ -2561,24 +2731,24 @@ pets e91d phishing ead7 phone f0d4 phone_alt f0d4 -phone_android e324 +phone_android f2db phone_bluetooth_speaker e61b phone_callback e649 phone_disabled e9cc phone_enabled e9cd phone_forwarded e61c phone_in_talk e61d -phone_iphone e325 +phone_iphone f2da phone_locked e61e phone_missed e61f phone_paused e620 phonelink e326 -phonelink_erase e0db -phonelink_lock e0dc -phonelink_off e327 -phonelink_ring e0dd +phonelink_erase f2ea +phonelink_lock f2be +phonelink_off f7a5 +phonelink_ring f2e8 phonelink_ring_off f7aa -phonelink_setup ef41 +phonelink_setup f2d9 photo e432 photo_album e411 photo_auto_merge f530 @@ -2596,6 +2766,7 @@ php eb8f physical_therapy e11e piano e521 piano_off e520 +pickleball f2a6 picture_as_pdf e415 picture_in_picture e8aa picture_in_picture_alt e911 @@ -2626,6 +2797,7 @@ pivot_table_chart e9ce place f1db place_item f1f0 plagiarism ea5a +plane_contrails f2ac planet f387 planner_banner_ad_pt e692 planner_review e694 @@ -2637,6 +2809,8 @@ play_lesson f047 play_music e6ee play_pause f137 play_shapes f7fc +playground f28e +playground_2 f28f playing_cards f5dc playlist_add e03b playlist_add_check e065 @@ -2818,6 +2992,7 @@ report_problem f083 request_page f22c request_quote f1b6 reset_brightness f482 +reset_exposure f266 reset_focus f481 reset_image f824 reset_iso f480 @@ -2830,6 +3005,7 @@ reset_wrench f56c resize f707 respiratory_rate e127 responsive_layout e9da +rest_area f22a restart_alt f053 restaurant e56c restaurant_menu e561 @@ -2913,11 +3089,11 @@ science_off f542 scooter f471 score e269 scoreboard ebd0 -screen_lock_landscape e1be -screen_lock_portrait e1bf -screen_lock_rotation e1c0 +screen_lock_landscape f2d8 +screen_lock_portrait f2be +screen_lock_rotation f2d6 screen_record f679 -screen_rotation e1c1 +screen_rotation f2d5 screen_rotation_alt ebee screen_rotation_up f678 screen_search_desktop ef70 @@ -2941,6 +3117,7 @@ search e8b6 search_activity f3e5 search_check f800 search_check_2 f469 +search_gear eefa search_hands_free e696 search_insights f4bc search_off ea76 @@ -2952,9 +3129,9 @@ seat_vent_left f32d seat_vent_right f32c security e32a security_key f503 -security_update f072 +security_update f2cd security_update_good f073 -security_update_warning f074 +security_update_warning f2d3 segment e94b select f74d select_all e162 @@ -2970,7 +3147,7 @@ send e163 send_and_archive ea0c send_money e8b7 send_time_extension eadb -send_to_mobile f05c +send_to_mobile f2d2 sensor_door f1b5 sensor_occupied ec10 sensor_window f1b4 @@ -3005,7 +3182,7 @@ settings_b_roll f625 settings_backup_restore e8ba settings_bluetooth e8bb settings_brightness e8bd -settings_cell e8bc +settings_cell f2d1 settings_cinematic_blur f624 settings_ethernet e8be settings_heart f522 @@ -3022,6 +3199,7 @@ settings_phone e8c5 settings_photo_camera f834 settings_power e8c6 settings_remote e8c7 +settings_seating ef2d settings_slow_motion f623 settings_suggest f05e settings_system_daydream e1c3 @@ -3042,6 +3220,7 @@ share_location f05f share_off f6cb share_reviews f8a4 share_windows f613 +shaved_ice f225 sheets_rtl f823 shelf_auto_hide f703 shelf_position f702 @@ -3052,6 +3231,7 @@ shield_locked f592 shield_moon eaa9 shield_person f650 shield_question f529 +shield_toggle f2ad shield_watch f30f shield_with_heart e78f shield_with_house e78d @@ -3081,6 +3261,7 @@ shutter_speed_minus f57d sick f220 side_navigation e9e2 sign_language ebe5 +sign_language_2 f258 signal_cellular_0_bar f0a8 signal_cellular_1_bar f0a9 signal_cellular_2_bar f0aa @@ -3140,9 +3321,9 @@ smart_card_reader f4a5 smart_card_reader_off f4a6 smart_display f06a smart_outlet e844 -smart_screen f06b +smart_screen f2d0 smart_toy f06c -smartphone e32c +smartphone e7ba smartphone_camera f44e smb_share f74b smoke_free eb4a @@ -3157,9 +3338,11 @@ snowing_heavy f61c snowmobile e503 snowshoeing e514 soap f1b2 +soba ef36 social_distance e1cb social_leaderboard f6a0 solar_power ec0f +solo_dining ef35 sort e164 sort_by_alpha e053 sos ebf7 @@ -3283,10 +3466,10 @@ stat_3 e69a stat_minus_1 e69b stat_minus_2 e69c stat_minus_3 e69d -stay_current_landscape e0d3 -stay_current_portrait e0d4 -stay_primary_landscape e0d5 -stay_primary_portrait e0d6 +stay_current_landscape ed3e +stay_current_portrait e7ba +stay_primary_landscape ed3e +stay_primary_portrait f2d3 steering_wheel_heat f32b step f6fe step_into f701 @@ -3340,6 +3523,7 @@ subtitles e048 subtitles_gear f355 subtitles_off ef72 subway e56f +subway_walk f287 summarize f071 sunny e81a sunny_snowing e819 @@ -3395,11 +3579,12 @@ sync_disabled e628 sync_lock eaee sync_problem e629 sync_saved_locally f820 +sync_saved_locally_off f264 syringe e133 -system_security_update f072 +system_security_update f2cd system_security_update_good f073 -system_security_update_warning f074 -system_update f072 +system_security_update_warning f2d3 +system_update f2cd system_update_alt e8d7 tab e8d8 tab_close f745 @@ -3421,9 +3606,11 @@ table_convert f3c7 table_edit f3c6 table_eye f466 table_lamp e1f2 +table_large f299 table_restaurant eac6 table_rows f101 table_rows_narrow f73f +table_sign ef2c table_view f1be tablet e32f tablet_android e330 @@ -3434,13 +3621,15 @@ tactic f564 tag e9ef tag_faces ea22 takeout_dining ea74 +takeout_dining_2 ef34 tamper_detection_off e82e tamper_detection_on f8c8 -tap_and_play e62b +tap_and_play f2cc tapas f1e9 target e719 task f075 task_alt e2e6 +tatami_seat ef33 taunt f69f taxi_alert ef74 team_dashboard e013 @@ -3507,6 +3696,7 @@ thumb_up_filled f577 thumb_up_off f577 thumb_up_off_alt f577 thumbnail_bar f734 +thumbs_up_double eefc thumbs_up_down e8dd thunderstorm ebdb tibia f89b @@ -3519,9 +3709,11 @@ time_to_leave eff7 timelapse e422 timeline e922 timer e425 +timer_1 f2af timer_10 e423 timer_10_alt_1 efbf timer_10_select f07a +timer_2 f2ae timer_3 e424 timer_3_alt_1 efc0 timer_3_select f07b @@ -3544,6 +3736,7 @@ toggle_on e9f6 token ea25 toll e8e0 tonality e427 +tonality_2 f2b4 toolbar e9f7 tools_flat_head f8cb tools_installation_kit e2ab @@ -3593,6 +3786,7 @@ transition_fade f50c transition_push f50b transition_slide f50a translate e8e2 +translate_indic f263 transportation e21d travel ef93 travel_explore e2db @@ -3637,6 +3831,7 @@ two_wheeler e9f9 type_specimen f8f0 u_turn_left eba1 u_turn_right eba2 +udon ef32 ulna_radius f89d ulna_radius_alt f89e umbrella f1ad @@ -3693,7 +3888,7 @@ vertical_distribute e076 vertical_shades ec0e vertical_shades_closed ec0d vertical_split e949 -vibration e62d +vibration f2cb video_call e070 video_camera_back f07f video_camera_back_add f40c @@ -3738,6 +3933,7 @@ view_stream e8f2 view_timeline eb85 view_week e8f3 vignette e435 +vignette_2 f2b3 villa e586 visibility e8f4 visibility_lock f653 @@ -3780,7 +3976,9 @@ warning f083 warning_amber f083 warning_off f7ad wash f1b1 +washoku f280 watch e334 +watch_arrow f2ca watch_button_press f6aa watch_check f468 watch_later efd6 @@ -3867,6 +4065,7 @@ window f088 window_closed e77e window_open e78c window_sensor e2bb +windshield_defrost_auto f248 windshield_defrost_front f32a windshield_defrost_rear f329 windshield_heat_front f328 @@ -3888,7 +4087,9 @@ wrap_text e25b wrist f69c wrong_location ef78 wysiwyg f1c3 +yakitori ef31 yard f089 +yoshoku f27f your_trips eb2b youtube_activity f85a youtube_searched_for e8fa diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json similarity index 93% rename from client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json rename to client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json index 2eb48b234b..a9a95206e6 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined-Regular.json +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.json @@ -86,12 +86,13 @@ "account_tree": 59770, "action_key": 62722, "activity_zone": 57830, + "acupuncture": 62148, "acute": 58571, "ad": 58970, "ad_group": 58971, "ad_group_off": 60133, "ad_off": 63410, - "ad_units": 61241, + "ad_units": 62187, "adaptive_audio_mic": 62668, "adaptive_audio_mic_off": 62667, "adb": 58894, @@ -128,7 +129,7 @@ "add_shopping_cart": 59476, "add_task": 62010, "add_to_drive": 58972, - "add_to_home_screen": 57854, + "add_to_home_screen": 62137, "add_to_photos": 58269, "add_to_queue": 57436, "add_triangle": 62606, @@ -209,10 +210,36 @@ "analytics": 61246, "anchor": 61901, "android": 59481, + "android_cell_4_bar": 61190, + "android_cell_4_bar_alert": 61193, + "android_cell_4_bar_off": 61192, + "android_cell_4_bar_plus": 61191, + "android_cell_5_bar": 61186, + "android_cell_5_bar_alert": 61189, + "android_cell_5_bar_off": 61188, + "android_cell_5_bar_plus": 61187, + "android_cell_dual_4_bar": 61197, + "android_cell_dual_4_bar_alert": 61199, + "android_cell_dual_4_bar_plus": 61198, + "android_cell_dual_5_bar": 61194, + "android_cell_dual_5_bar_alert": 61196, + "android_cell_dual_5_bar_plus": 61195, + "android_wifi_3_bar": 61206, + "android_wifi_3_bar_alert": 61211, + "android_wifi_3_bar_lock": 61210, + "android_wifi_3_bar_off": 61209, + "android_wifi_3_bar_plus": 61208, + "android_wifi_3_bar_question": 61207, + "android_wifi_4_bar": 61200, + "android_wifi_4_bar_alert": 61205, + "android_wifi_4_bar_lock": 61204, + "android_wifi_4_bar_off": 61203, + "android_wifi_4_bar_plus": 61202, + "android_wifi_4_bar_question": 61201, "animated_images": 62618, "animation": 59164, "announcement": 59519, - "aod": 61402, + "aod": 62182, "aod_tablet": 63647, "aod_watch": 63148, "apartment": 59968, @@ -220,14 +247,15 @@ "apk_document": 63630, "apk_install": 63631, "app_badging": 63279, - "app_blocking": 61247, - "app_promo": 59777, + "app_blocking": 62181, + "app_promo": 62157, "app_registration": 61248, - "app_settings_alt": 61249, - "app_shortcut": 60132, + "app_settings_alt": 62169, + "app_shortcut": 62175, "apparel": 61307, "approval": 59778, "approval_delegation": 63562, + "approval_delegation_off": 62149, "apps": 58819, "apps_outage": 59340, "aq": 62810, @@ -266,6 +294,9 @@ "arrow_right": 58847, "arrow_right_alt": 59713, "arrow_selector_tool": 63535, + "arrow_shape_up": 61174, + "arrow_shape_up_stack": 61175, + "arrow_shape_up_stack_2": 61176, "arrow_split": 59908, "arrow_top_left": 63278, "arrow_top_right": 63277, @@ -288,6 +319,7 @@ "assessment": 61644, "assignment": 59485, "assignment_add": 63560, + "assignment_globe": 61164, "assignment_ind": 59486, "assignment_late": 59487, "assignment_return": 59488, @@ -337,6 +369,7 @@ "auto_read_play": 61974, "auto_schedule": 57876, "auto_stories": 58982, + "auto_stories_off": 62055, "auto_timer": 61311, "auto_towing": 59166, "auto_transmission": 62783, @@ -353,6 +386,7 @@ "avc": 62639, "avg_pace": 63163, "avg_time": 63507, + "award_meal": 62017, "award_star": 62994, "azm": 63212, "baby_changing_station": 61851, @@ -371,6 +405,7 @@ "backup_table": 61251, "badge": 60007, "badge_critical_battery": 61782, + "badminton": 62120, "bakery_dining": 59987, "balance": 60150, "balcony": 58767, @@ -383,9 +418,11 @@ "barcode_scanner": 59148, "barefoot": 63601, "batch_prediction": 61685, + "bath_bedrock": 62086, "bath_outdoor": 63227, "bath_private": 63226, "bath_public_large": 63225, + "bath_soak": 62112, "bathroom": 61405, "bathtub": 59969, "battery_0_bar": 60380, @@ -411,6 +448,19 @@ "battery_android_6": 62215, "battery_android_alert": 62214, "battery_android_bolt": 62213, + "battery_android_frame_1": 62039, + "battery_android_frame_2": 62038, + "battery_android_frame_3": 62037, + "battery_android_frame_4": 62036, + "battery_android_frame_5": 62035, + "battery_android_frame_6": 62034, + "battery_android_frame_alert": 62033, + "battery_android_frame_bolt": 62032, + "battery_android_frame_full": 62031, + "battery_android_frame_plus": 62030, + "battery_android_frame_question": 62029, + "battery_android_frame_share": 62028, + "battery_android_frame_shield": 62027, "battery_android_full": 62212, "battery_android_plus": 62211, "battery_android_question": 62210, @@ -450,6 +500,7 @@ "bedtime": 61785, "bedtime_off": 60278, "beenhere": 58669, + "beer_meal": 62085, "bento": 61940, "bia": 63211, "bid_landscape": 59000, @@ -491,7 +542,7 @@ "book_4": 62780, "book_5": 62779, "book_6": 62431, - "book_online": 61975, + "book_online": 62180, "book_ribbon": 62439, "bookmark": 59623, "bookmark_add": 58776, @@ -538,6 +589,7 @@ "breaking_news_alt_1": 61626, "breastfeeding": 63574, "brick": 62344, + "briefcase_meal": 62022, "brightness_1": 58362, "brightness_2": 61494, "brightness_3": 58280, @@ -565,6 +617,7 @@ "bubble": 61315, "bubble_chart": 59101, "bubbles": 63054, + "bucket_check": 61226, "bug_report": 59496, "build": 63693, "build_circle": 61256, @@ -587,7 +640,11 @@ "calculate": 59999, "calendar_add_on": 61317, "calendar_apps_script": 61627, + "calendar_check": 62019, "calendar_clock": 62784, + "calendar_lock": 62018, + "calendar_meal": 62102, + "calendar_meal_2": 62016, "calendar_month": 60364, "calendar_today": 59701, "calendar_view_day": 59702, @@ -608,10 +665,10 @@ "camera": 58287, "camera_alt": 58386, "camera_enhance": 59644, - "camera_front": 58289, + "camera_front": 62153, "camera_indoor": 61417, "camera_outdoor": 61418, - "camera_rear": 58290, + "camera_rear": 62152, "camera_roll": 58291, "camera_video": 63398, "cameraswitch": 61419, @@ -629,7 +686,9 @@ "car_defrost_left": 62276, "car_defrost_low_left": 62275, "car_defrost_low_right": 62274, + "car_defrost_mid_left": 62072, "car_defrost_mid_low_left": 62273, + "car_defrost_mid_low_right": 62071, "car_defrost_mid_right": 62272, "car_defrost_right": 62271, "car_fan_low_left": 62270, @@ -675,17 +734,21 @@ "center_focus_weak": 58293, "chair": 61421, "chair_alt": 61422, + "chair_counter": 62111, + "chair_fireplace": 62110, + "chair_umbrella": 62109, "chalet": 58757, "change_circle": 58087, "change_history": 59499, "charger": 58030, - "charging_station": 61853, + "charging_station": 62179, "chart_data": 58483, "chat": 57545, "chat_add_on": 61683, "chat_apps_script": 61629, "chat_bubble": 57547, "chat_bubble_outline": 57547, + "chat_dashed": 61165, "chat_error": 63404, "chat_info": 62763, "chat_paste_go": 63165, @@ -696,6 +759,7 @@ "check_circle": 61630, "check_circle_filled": 61630, "check_circle_outline": 61630, + "check_circle_unread": 62078, "check_in_out": 63222, "check_indeterminate_small": 63626, "check_small": 63627, @@ -708,13 +772,22 @@ "cheer": 63144, "chef_hat": 62295, "chess": 62951, + "chess_bishop": 62049, + "chess_bishop_2": 62050, + "chess_king": 62047, + "chess_king_2": 62048, + "chess_knight": 62046, "chess_pawn": 62390, + "chess_pawn_2": 62045, + "chess_queen": 62044, + "chess_rook": 62043, "chevron_backward": 62571, "chevron_forward": 62570, "chevron_left": 58827, "chevron_right": 58828, "child_care": 60225, "child_friendly": 60226, + "child_hat": 61232, "chip_extraction": 63521, "chips": 59795, "chrome_reader_mode": 59501, @@ -840,6 +913,7 @@ "control_point": 58298, "control_point_duplicate": 58299, "controller_gen": 59453, + "conversation": 61231, "conversion_path": 61633, "conversion_path_off": 63412, "convert_to_text": 62495, @@ -985,13 +1059,13 @@ "developer_board": 58125, "developer_board_off": 58623, "developer_guide": 59806, - "developer_mode": 57776, + "developer_mode": 62178, "developer_mode_tv": 59508, "device_band": 62197, "device_hub": 58165, "device_reset": 59571, "device_thermostat": 57855, - "device_unknown": 58169, + "device_unknown": 62177, "devices": 58150, "devices_fold": 60382, "devices_fold_2": 62470, @@ -1005,10 +1079,14 @@ "dialogs": 59807, "dialpad": 57532, "diamond": 60117, + "diamond_shine": 62130, "dictionary": 62777, "difference": 60285, "digital_out_of_home": 61918, "digital_wellbeing": 61318, + "dine_heart": 62108, + "dine_in": 62101, + "dine_lamp": 62107, "dining": 61428, "dinner_dining": 59991, "directions": 58670, @@ -1058,7 +1136,7 @@ "do_not_disturb_on_total_silence": 61435, "do_not_step": 61855, "do_not_touch": 61872, - "dock": 58126, + "dock": 62176, "dock_to_bottom": 63462, "dock_to_left": 63461, "dock_to_right": 63460, @@ -1113,6 +1191,8 @@ "drive_file_rename_outline": 59810, "drive_folder_upload": 59811, "drive_fusiontable": 59000, + "drone": 62042, + "drone_2": 62041, "dropdown": 59812, "dropper_eye": 62289, "dry": 61875, @@ -1140,8 +1220,8 @@ "ecg_heart": 63209, "eco": 59957, "eda": 63208, - "edgesensor_high": 61445, - "edgesensor_low": 61446, + "edgesensor_high": 62191, + "edgesensor_low": 62190, "edit": 61591, "edit_arrow_down": 62336, "edit_arrow_up": 62335, @@ -1267,6 +1347,8 @@ "extension_off": 58613, "eye_tracking": 62665, "eyeglasses": 63214, + "eyeglasses_2": 62151, + "eyeglasses_2_sound": 62053, "face": 61448, "face_2": 63706, "face_3": 63707, @@ -1286,6 +1368,7 @@ "factory": 60348, "falling": 62989, "familiar_face_and_zone": 57884, + "family_group": 61170, "family_history": 57517, "family_home": 60198, "family_link": 60185, @@ -1380,6 +1463,7 @@ "fit_width": 63353, "fitness_center": 60227, "fitness_tracker": 62563, + "fitness_trackers": 61169, "flag": 61638, "flag_2": 62479, "flag_check": 62424, @@ -1516,6 +1600,8 @@ "forward_to_inbox": 61831, "foundation": 61952, "fragrance": 62277, + "frame_bug": 61167, + "frame_exclamation": 61166, "frame_inspect": 63346, "frame_person": 63654, "frame_person_mic": 62677, @@ -1542,8 +1628,10 @@ "gamepad": 58127, "games": 58127, "garage": 61457, + "garage_check": 62093, "garage_door": 59156, "garage_home": 59437, + "garage_money": 62092, "garden_cart": 63657, "gas_meter": 60441, "gastroenterology": 57585, @@ -1622,9 +1710,12 @@ "h_plus_mobiledata_badge": 63455, "hail": 59825, "hallway": 59128, + "hanami_dango": 62015, "hand_bones": 63636, "hand_gesture": 61340, "hand_gesture_off": 62451, + "hand_meal": 62100, + "hand_package": 62099, "handheld_controller": 62662, "handshake": 60363, "handwriting_recognition": 60162, @@ -1656,6 +1747,7 @@ "healing": 58355, "health_and_beauty": 61341, "health_and_safety": 57813, + "health_cross": 62147, "health_metrics": 63202, "heap_snapshot_large": 63342, "heap_snapshot_multiple": 63341, @@ -1663,11 +1755,14 @@ "hearing": 57379, "hearing_aid": 62564, "hearing_aid_disabled": 62384, + "hearing_aid_disabled_left": 62188, + "hearing_aid_left": 62189, "hearing_disabled": 61700, "heart_broken": 60098, "heart_check": 62986, "heart_minus": 63619, "heart_plus": 63620, + "heart_smile": 62098, "heat": 62775, "heat_pump": 60440, "heat_pump_balance": 57982, @@ -1683,6 +1778,7 @@ "hide": 61342, "hide_image": 61474, "hide_source": 61475, + "high_chair": 62106, "high_density": 63388, "high_quality": 57380, "high_res": 62795, @@ -1771,6 +1867,7 @@ "image": 58356, "image_arrow_up": 62231, "image_aspect_ratio": 58357, + "image_inset": 62023, "image_not_supported": 61718, "image_search": 58431, "imagesearch_roller": 59828, @@ -1816,7 +1913,7 @@ "insert_text": 63527, "insights": 61586, "install_desktop": 60273, - "install_mobile": 60274, + "install_mobile": 62157, "instant_mix": 57382, "integration_instructions": 61268, "interactive_space": 63487, @@ -1831,6 +1928,8 @@ "iron": 58755, "iso": 58358, "jamboard_kiosk": 59829, + "japanese_curry": 62084, + "japanese_flag": 62083, "javascript": 60284, "join": 63567, "join_full": 63567, @@ -1839,6 +1938,7 @@ "join_right": 60138, "joystick": 62958, "jump_to_element": 63257, + "kanji_alcohol": 62014, "kayaking": 58636, "kebab_dining": 59458, "keep": 61478, @@ -2066,9 +2166,11 @@ "magnify_docked": 63446, "magnify_fullscreen": 63445, "mail": 57689, + "mail_asterisk": 61172, "mail_lock": 60426, "mail_off": 62603, "mail_outline": 57689, + "mail_shield": 62025, "male": 58766, "man": 58603, "man_2": 63713, @@ -2080,6 +2182,8 @@ "manga": 62947, "manufacturing": 59174, "map": 58715, + "map_pin_heart": 62104, + "map_pin_review": 62103, "map_search": 62410, "maps_home_work": 61488, "maps_ugc": 61272, @@ -2098,11 +2202,14 @@ "masked_transitions": 59182, "masked_transitions_add": 62507, "masks": 61976, + "massage": 62146, "match_case": 63217, "match_case_off": 62319, "match_word": 63216, "matter": 59655, "maximize": 59696, + "meal_dinner": 62013, + "meal_lunch": 62012, "measuring_tape": 63151, "media_bluetooth_off": 61489, "media_bluetooth_on": 61490, @@ -2121,6 +2228,7 @@ "menstrual_health": 63201, "menu": 58834, "menu_book": 59929, + "menu_book_2": 62097, "menu_open": 59837, "merge": 60312, "merge_type": 57938, @@ -2152,17 +2260,57 @@ "mitre": 62791, "mixture_med": 58568, "mms": 58904, - "mobile_friendly": 57856, + "mobile": 59322, + "mobile_2": 62171, + "mobile_3": 62170, + "mobile_alert": 62163, + "mobile_arrow_down": 62157, + "mobile_arrow_right": 62162, + "mobile_arrow_up_right": 62137, + "mobile_block": 62181, + "mobile_camera": 62542, + "mobile_camera_front": 62153, + "mobile_camera_rear": 62152, + "mobile_cancel": 62186, + "mobile_cast": 62156, + "mobile_charge": 62179, + "mobile_chat": 63391, + "mobile_check": 61555, + "mobile_code": 62178, + "mobile_dots": 62160, + "mobile_friendly": 61555, + "mobile_gear": 62169, "mobile_hand": 62243, "mobile_hand_left": 62227, "mobile_hand_left_off": 62226, "mobile_hand_off": 62228, + "mobile_info": 62172, + "mobile_landscape": 60734, + "mobile_layout": 62143, + "mobile_lock_landscape": 62168, + "mobile_lock_portrait": 62142, "mobile_loupe": 62242, + "mobile_menu": 62161, "mobile_off": 57857, - "mobile_screen_share": 57575, + "mobile_question": 62177, + "mobile_rotate": 62165, + "mobile_rotate_lock": 62166, + "mobile_screen_share": 62175, "mobile_screensaver": 62241, + "mobile_sensor_hi": 62191, + "mobile_sensor_lo": 62190, + "mobile_share": 62175, + "mobile_share_stack": 62174, + "mobile_sound": 62184, "mobile_sound_2": 62232, + "mobile_sound_off": 63402, "mobile_speaker": 62240, + "mobile_text": 62187, + "mobile_text_2": 62182, + "mobile_theft": 62121, + "mobile_ticket": 62180, + "mobile_vibrate": 62155, + "mobile_wrench": 62128, "mobiledata_off": 61492, "mode": 61591, "mode_comment": 57939, @@ -2187,6 +2335,7 @@ "money_bag": 62446, "money_off": 61496, "money_off_csred": 61496, + "money_range": 62021, "monitor": 61275, "monitor_heart": 60066, "monitor_weight": 61497, @@ -2200,6 +2349,7 @@ "moon_stars": 62287, "mop": 57997, "moped": 60200, + "moped_package": 62091, "more": 58905, "more_down": 61846, "more_horiz": 58835, @@ -2221,6 +2371,7 @@ "motion_sensor_urgent": 59278, "motorcycle": 59675, "mountain_flag": 62946, + "mountain_steam": 62082, "mouse": 58147, "mouse_lock": 62608, "mouse_lock_off": 62607, @@ -2242,6 +2393,7 @@ "movie_filter": 58426, "movie_info": 57389, "movie_off": 62617, + "movie_speaker": 62115, "moving": 58625, "moving_beds": 59197, "moving_ministry": 59198, @@ -2253,6 +2405,7 @@ "multiple_stop": 61881, "museum": 59958, "music_cast": 60186, + "music_history": 62145, "music_note": 58373, "music_note_add": 62353, "music_off": 58432, @@ -2289,6 +2442,11 @@ "nest_display_max": 61733, "nest_doorbell_visitor": 63677, "nest_eco_leaf": 63678, + "nest_farsight_cool": 62077, + "nest_farsight_dual": 62076, + "nest_farsight_eco": 62075, + "nest_farsight_heat": 62074, + "nest_farsight_seasonal": 62073, "nest_farsight_weather": 63679, "nest_found_savings": 63680, "nest_gale_wifi": 62841, @@ -2357,7 +2515,7 @@ "nightlife": 60002, "nightlight": 61501, "nightlight_round": 61501, - "nights_stay": 59974, + "nights_stay": 61812, "no_accounts": 61502, "no_adult_content": 63742, "no_backpack": 62007, @@ -2411,8 +2569,9 @@ "offline_bolt": 59698, "offline_pin": 59658, "offline_pin_off": 62672, - "offline_share": 59845, + "offline_share": 62174, "oil_barrel": 60437, + "okonomiyaki": 62081, "on_device_training": 60413, "on_hub_device": 59075, "oncology": 57620, @@ -2425,7 +2584,7 @@ "open_in_new": 59550, "open_in_new_down": 63247, "open_in_new_off": 58614, - "open_in_phone": 59138, + "open_in_phone": 62162, "open_jam": 61358, "open_run": 62647, "open_with": 59551, @@ -2462,10 +2621,12 @@ "package": 58511, "package_2": 62825, "padding": 59848, + "padel": 62119, "page_control": 59185, "page_footer": 62339, "page_header": 62340, "page_info": 62996, + "page_menu_ios": 61179, "pageless": 62729, "pages": 59385, "pageview": 59552, @@ -2482,10 +2643,15 @@ "panorama_vertical": 58382, "panorama_wide_angle": 58383, "paragliding": 58639, + "parent_child_dining": 61997, "park": 60003, + "parking_meter": 62090, + "parking_sign": 62089, + "parking_valet": 62088, "partly_cloudy_day": 61810, "partly_cloudy_night": 61812, "partner_exchange": 63481, + "partner_heart": 61230, "partner_reports": 61359, "party_mode": 59386, "passkey": 63615, @@ -2500,6 +2666,8 @@ "pause_circle_outline": 57762, "pause_presentation": 57578, "payment": 59553, + "payment_arrow_down": 62144, + "payment_card": 62113, "payments": 61283, "pedal_bike": 60201, "pediatrics": 57629, @@ -2515,12 +2683,13 @@ "people_alt": 59937, "people_outline": 59937, "percent": 60248, + "percent_discount": 62020, "performance_max": 58650, "pergola": 57859, "perm_camera_mic": 59554, "perm_contact_calendar": 59555, "perm_data_setting": 59556, - "perm_device_information": 59557, + "perm_device_information": 62172, "perm_identity": 61651, "perm_media": 59559, "perm_phone_msg": 59560, @@ -2540,6 +2709,7 @@ "person_check": 62821, "person_edit": 62714, "person_filled": 61651, + "person_heart": 62096, "person_off": 58640, "person_outline": 61651, "person_pin": 58714, @@ -2562,24 +2732,24 @@ "phishing": 60119, "phone": 61652, "phone_alt": 61652, - "phone_android": 58148, + "phone_android": 62171, "phone_bluetooth_speaker": 58907, "phone_callback": 58953, "phone_disabled": 59852, "phone_enabled": 59853, "phone_forwarded": 58908, "phone_in_talk": 58909, - "phone_iphone": 58149, + "phone_iphone": 62170, "phone_locked": 58910, "phone_missed": 58911, "phone_paused": 58912, "phonelink": 58150, - "phonelink_erase": 57563, - "phonelink_lock": 57564, - "phonelink_off": 58151, - "phonelink_ring": 57565, + "phonelink_erase": 62186, + "phonelink_lock": 62142, + "phonelink_off": 63397, + "phonelink_ring": 62184, "phonelink_ring_off": 63402, - "phonelink_setup": 61249, + "phonelink_setup": 62169, "photo": 58418, "photo_album": 58385, "photo_auto_merge": 62768, @@ -2597,6 +2767,7 @@ "physical_therapy": 57630, "piano": 58657, "piano_off": 58656, + "pickleball": 62118, "picture_as_pdf": 58389, "picture_in_picture": 59562, "picture_in_picture_alt": 59665, @@ -2627,6 +2798,7 @@ "place": 61915, "place_item": 61936, "plagiarism": 59994, + "plane_contrails": 62124, "planet": 62343, "planner_banner_ad_pt": 59026, "planner_review": 59028, @@ -2638,6 +2810,8 @@ "play_music": 59118, "play_pause": 61751, "play_shapes": 63484, + "playground": 62094, + "playground_2": 62095, "playing_cards": 62940, "playlist_add": 57403, "playlist_add_check": 57445, @@ -2819,6 +2993,7 @@ "request_page": 61996, "request_quote": 61878, "reset_brightness": 62594, + "reset_exposure": 62054, "reset_focus": 62593, "reset_image": 63524, "reset_iso": 62592, @@ -2831,6 +3006,7 @@ "resize": 63239, "respiratory_rate": 57639, "responsive_layout": 59866, + "rest_area": 61994, "restart_alt": 61523, "restaurant": 58732, "restaurant_menu": 58721, @@ -2914,11 +3090,11 @@ "scooter": 62577, "score": 57961, "scoreboard": 60368, - "screen_lock_landscape": 57790, - "screen_lock_portrait": 57791, - "screen_lock_rotation": 57792, + "screen_lock_landscape": 62168, + "screen_lock_portrait": 62142, + "screen_lock_rotation": 62166, "screen_record": 63097, - "screen_rotation": 57793, + "screen_rotation": 62165, "screen_rotation_alt": 60398, "screen_rotation_up": 63096, "screen_search_desktop": 61296, @@ -2942,6 +3118,7 @@ "search_activity": 62437, "search_check": 63488, "search_check_2": 62569, + "search_gear": 61178, "search_hands_free": 59030, "search_insights": 62652, "search_off": 60022, @@ -2953,9 +3130,9 @@ "seat_vent_right": 62252, "security": 58154, "security_key": 62723, - "security_update": 61554, + "security_update": 62157, "security_update_good": 61555, - "security_update_warning": 61556, + "security_update_warning": 62163, "segment": 59723, "select": 63309, "select_all": 57698, @@ -2971,7 +3148,7 @@ "send_and_archive": 59916, "send_money": 59575, "send_time_extension": 60123, - "send_to_mobile": 61532, + "send_to_mobile": 62162, "sensor_door": 61877, "sensor_occupied": 60432, "sensor_window": 61876, @@ -3006,7 +3183,7 @@ "settings_backup_restore": 59578, "settings_bluetooth": 59579, "settings_brightness": 59581, - "settings_cell": 59580, + "settings_cell": 62161, "settings_cinematic_blur": 63012, "settings_ethernet": 59582, "settings_heart": 62754, @@ -3023,6 +3200,7 @@ "settings_photo_camera": 63540, "settings_power": 59590, "settings_remote": 59591, + "settings_seating": 61229, "settings_slow_motion": 63011, "settings_suggest": 61534, "settings_system_daydream": 57795, @@ -3043,6 +3221,7 @@ "share_off": 63179, "share_reviews": 63652, "share_windows": 62995, + "shaved_ice": 61989, "sheets_rtl": 63523, "shelf_auto_hide": 63235, "shelf_position": 63234, @@ -3053,6 +3232,7 @@ "shield_moon": 60073, "shield_person": 63056, "shield_question": 62761, + "shield_toggle": 62125, "shield_watch": 62223, "shield_with_heart": 59279, "shield_with_house": 59277, @@ -3082,6 +3262,7 @@ "sick": 61984, "side_navigation": 59874, "sign_language": 60389, + "sign_language_2": 62040, "signal_cellular_0_bar": 61608, "signal_cellular_1_bar": 61609, "signal_cellular_2_bar": 61610, @@ -3141,9 +3322,9 @@ "smart_card_reader_off": 62630, "smart_display": 61546, "smart_outlet": 59460, - "smart_screen": 61547, + "smart_screen": 62160, "smart_toy": 61548, - "smartphone": 58156, + "smartphone": 59322, "smartphone_camera": 62542, "smb_share": 63307, "smoke_free": 60234, @@ -3158,9 +3339,11 @@ "snowmobile": 58627, "snowshoeing": 58644, "soap": 61874, + "soba": 61238, "social_distance": 57803, "social_leaderboard": 63136, "solar_power": 60431, + "solo_dining": 61237, "sort": 57700, "sort_by_alpha": 57427, "sos": 60407, @@ -3284,10 +3467,10 @@ "stat_minus_1": 59035, "stat_minus_2": 59036, "stat_minus_3": 59037, - "stay_current_landscape": 57555, - "stay_current_portrait": 57556, - "stay_primary_landscape": 57557, - "stay_primary_portrait": 57558, + "stay_current_landscape": 60734, + "stay_current_portrait": 59322, + "stay_primary_landscape": 60734, + "stay_primary_portrait": 62163, "steering_wheel_heat": 62251, "step": 63230, "step_into": 63233, @@ -3341,6 +3524,7 @@ "subtitles_gear": 62293, "subtitles_off": 61298, "subway": 58735, + "subway_walk": 62087, "summarize": 61553, "sunny": 59418, "sunny_snowing": 59417, @@ -3396,11 +3580,12 @@ "sync_lock": 60142, "sync_problem": 58921, "sync_saved_locally": 63520, + "sync_saved_locally_off": 62052, "syringe": 57651, - "system_security_update": 61554, + "system_security_update": 62157, "system_security_update_good": 61555, - "system_security_update_warning": 61556, - "system_update": 61554, + "system_security_update_warning": 62163, + "system_update": 62157, "system_update_alt": 59607, "tab": 59608, "tab_close": 63301, @@ -3422,9 +3607,11 @@ "table_edit": 62406, "table_eye": 62566, "table_lamp": 57842, + "table_large": 62105, "table_restaurant": 60102, "table_rows": 61697, "table_rows_narrow": 63295, + "table_sign": 61228, "table_view": 61886, "tablet": 58159, "tablet_android": 58160, @@ -3435,13 +3622,15 @@ "tag": 59887, "tag_faces": 59938, "takeout_dining": 60020, + "takeout_dining_2": 61236, "tamper_detection_off": 59438, "tamper_detection_on": 63688, - "tap_and_play": 58923, + "tap_and_play": 62156, "tapas": 61929, "target": 59161, "task": 61557, "task_alt": 58086, + "tatami_seat": 61235, "taunt": 63135, "taxi_alert": 61300, "team_dashboard": 57363, @@ -3508,6 +3697,7 @@ "thumb_up_off": 62839, "thumb_up_off_alt": 62839, "thumbnail_bar": 63284, + "thumbs_up_double": 61180, "thumbs_up_down": 59613, "thunderstorm": 60379, "tibia": 63643, @@ -3520,9 +3710,11 @@ "timelapse": 58402, "timeline": 59682, "timer": 58405, + "timer_1": 62127, "timer_10": 58403, "timer_10_alt_1": 61375, "timer_10_select": 61562, + "timer_2": 62126, "timer_3": 58404, "timer_3_alt_1": 61376, "timer_3_select": 61563, @@ -3545,6 +3737,7 @@ "token": 59941, "toll": 59616, "tonality": 58407, + "tonality_2": 62132, "toolbar": 59895, "tools_flat_head": 63691, "tools_installation_kit": 58027, @@ -3594,6 +3787,7 @@ "transition_push": 62731, "transition_slide": 62730, "translate": 59618, + "translate_indic": 62051, "transportation": 57885, "travel": 61331, "travel_explore": 58075, @@ -3638,6 +3832,7 @@ "type_specimen": 63728, "u_turn_left": 60321, "u_turn_right": 60322, + "udon": 61234, "ulna_radius": 63645, "ulna_radius_alt": 63646, "umbrella": 61869, @@ -3694,7 +3889,7 @@ "vertical_shades": 60430, "vertical_shades_closed": 60429, "vertical_split": 59721, - "vibration": 58925, + "vibration": 62155, "video_call": 57456, "video_camera_back": 61567, "video_camera_back_add": 62476, @@ -3739,6 +3934,7 @@ "view_timeline": 60293, "view_week": 59635, "vignette": 58421, + "vignette_2": 62131, "villa": 58758, "visibility": 59636, "visibility_lock": 63059, @@ -3781,7 +3977,9 @@ "warning_amber": 61571, "warning_off": 63405, "wash": 61873, + "washoku": 62080, "watch": 58164, + "watch_arrow": 62154, "watch_button_press": 63146, "watch_check": 62568, "watch_later": 61398, @@ -3868,6 +4066,7 @@ "window_closed": 59262, "window_open": 59276, "window_sensor": 58043, + "windshield_defrost_auto": 62024, "windshield_defrost_front": 62250, "windshield_defrost_rear": 62249, "windshield_heat_front": 62248, @@ -3889,7 +4088,9 @@ "wrist": 63132, "wrong_location": 61304, "wysiwyg": 61891, + "yakitori": 61233, "yard": 61577, + "yoshoku": 62079, "your_trips": 60203, "youtube_activity": 63578, "youtube_searched_for": 59642, diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf new file mode 100644 index 0000000000..1b940f6148 Binary files /dev/null and b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf differ diff --git a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py index 581b37c213..6da4c6986b 100644 --- a/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py +++ b/client/ayon_core/vendor/python/qtmaterialsymbols/resources/__init__.py @@ -5,32 +5,12 @@ CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) def get_font_filepath( - font_name: Optional[str] = "MaterialSymbolsOutlined-Regular" + font_name: Optional[str] = "MaterialSymbolsOutlined" ) -> str: return os.path.join(CURRENT_DIR, f"{font_name}.ttf") def get_mapping_filepath( - font_name: Optional[str] = "MaterialSymbolsOutlined-Regular" + font_name: Optional[str] = "MaterialSymbolsOutlined" ) -> 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 f2aa94020f..8e0834b8da 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.5.3+dev" +__version__ = "1.6.6+dev" diff --git a/mkdocs.yml b/mkdocs.yml index 8e4c2663bc..a3b89b5455 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,12 +11,12 @@ theme: - media: "(prefers-color-scheme: dark)" scheme: slate toggle: - icon: material/toggle-switch-off-outline + icon: material/weather-sunny name: Switch to light mode - media: "(prefers-color-scheme: light)" scheme: default toggle: - icon: material/toggle-switch + icon: material/weather-night name: Switch to dark mode logo: img/ay-symbol-blackw-full.png favicon: img/favicon.ico diff --git a/mkdocs_requirements.txt b/mkdocs_requirements.txt new file mode 100644 index 0000000000..829d02951a --- /dev/null +++ b/mkdocs_requirements.txt @@ -0,0 +1,9 @@ +mkdocs-material >= 9.6.7 +mkdocs-autoapi >= 0.4.0 +mkdocstrings-python >= 1.16.2 +mkdocs-minify-plugin >= 0.8.0 +markdown-checklist >= 0.4.4 +mdx-gh-links >= 0.4 +pymdown-extensions >= 10.14.3 +mike >= 2.1.3 +mkdocstrings-shell >= 1.0.2 diff --git a/package.py b/package.py index 4393b7be40..5fa4d165d2 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.5.3+dev" +version = "1.6.6+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index ee6c35b50b..73b9a4a916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.5.3+dev" +version = "1.6.6+dev" description = "" authors = ["Ynput Team "] readme = "README.md" @@ -27,17 +27,6 @@ codespell = "^2.2.6" semver = "^3.0.2" mypy = "^1.14.0" mock = "^5.0.0" -tomlkit = "^0.13.2" -requests = "^2.32.3" -mkdocs-material = "^9.6.7" -mkdocs-autoapi = "^0.4.0" -mkdocstrings-python = "^1.16.2" -mkdocs-minify-plugin = "^0.8.0" -markdown-checklist = "^0.4.4" -mdx-gh-links = "^0.4" -pymdown-extensions = "^10.14.3" -mike = "^2.1.3" -mkdocstrings-shell = "^1.0.2" nxtools = "^1.6" [tool.poetry.group.test.dependencies] diff --git a/server/settings/tools.py b/server/settings/tools.py index 815ef40f8e..f40c7c3627 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -454,7 +454,7 @@ DEFAULT_TOOLS_VALUES = { "hosts": [], "task_types": [], "tasks": [], - "template": "{product[type]}{Task[name]}{Variant}" + "template": "{product[type]}{Task[name]}{Variant}<_{Aov}>" }, { "product_types": [ 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 6a74df7f43..ed441edc63 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 @@ -246,75 +246,75 @@ def test_multiple_review_clips_no_gap(): 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 ' + ' -i color=c=black:s=1920x1080 -tune ' '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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2203 -pix_fmt rgba C:/result/output.%04d.png' ] @@ -348,12 +348,12 @@ def test_multiple_review_clips_with_gap(): '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080: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 ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1091 -pix_fmt rgba C:/result/output.%04d.png' ]