Merge branch 'enhancement/collect_scene_loaded_versions_add_more_hosts' of https://github.com/BigRoy/ayon-core into enhancement/collect_scene_loaded_versions_add_more_hosts

This commit is contained in:
Roy Nieterau 2025-11-16 22:42:02 +01:00
commit c1b262138d
107 changed files with 6093 additions and 2480 deletions

View file

@ -35,6 +35,14 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.6.9
- 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5
- 1.6.4
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- 1.5.3

View file

@ -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,79 @@ 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")
# If AYON launcher <1.4.0 was used
if not studio_bundle_name:
studio_bundle_name = project_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 +218,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 +236,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,7 +254,6 @@ 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 addon_dir:
@ -308,28 +262,20 @@ def _load_ayon_addons(log):
)
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
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:
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:
@ -368,24 +314,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
@ -403,20 +347,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)
@ -424,7 +369,7 @@ class AYONAddon(ABC):
self.initialize(settings)
@property
def id(self):
def id(self) -> str:
"""Random id of addon object.
Returns:
@ -437,7 +382,7 @@ class AYONAddon(ABC):
@property
@abstractmethod
def name(self):
def name(self) -> str:
"""Addon name.
Returns:
@ -447,7 +392,7 @@ class AYONAddon(ABC):
pass
@property
def version(self):
def version(self) -> str:
"""Addon version.
Todo:
@ -466,7 +411,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
@ -478,7 +423,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:
@ -489,7 +434,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
@ -510,7 +455,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.
@ -521,20 +466,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
@ -545,7 +482,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
@ -554,7 +491,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
@ -585,15 +522,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)
@ -620,29 +563,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:
@ -656,18 +605,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.
@ -678,7 +629,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)
@ -686,7 +637,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:
@ -699,7 +650,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()
@ -780,7 +731,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`.
@ -789,7 +740,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)
@ -808,7 +759,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:
@ -831,7 +782,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.
@ -890,7 +841,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 = []
@ -899,12 +850,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`
@ -935,7 +888,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:
@ -950,16 +903,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
@ -967,37 +920,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:
@ -1005,21 +958,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():
@ -1030,21 +983,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

View file

@ -185,6 +185,15 @@ class IPluginPaths(AYONInterface):
"""
return self._get_plugin_paths_by_type("inventory")
def get_loader_action_plugin_paths(self) -> list[str]:
"""Receive loader action plugin paths.
Returns:
list[str]: Paths to loader action plugins.
"""
return []
class ITrayAddon(AYONInterface):
"""Addon has special procedures when used in Tray tool.

View file

@ -1,11 +1,4 @@
from enum import Enum
class StrEnum(str, Enum):
"""A string-based Enum class that allows for string comparison."""
def __str__(self) -> str:
return self.value
from ayon_core.lib import StrEnum
class ContextChangeReason(StrEnum):

View file

@ -2,6 +2,7 @@
# flake8: noqa E402
"""AYON lib functions."""
from ._compatibility import StrEnum
from .local_settings import (
IniSettingRegistry,
JSONSettingRegistry,
@ -11,6 +12,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 +75,7 @@ from .log import (
)
from .path_templates import (
DefaultKeysDict,
TemplateUnsolved,
StringTemplate,
FormatObject,
@ -140,6 +143,8 @@ from .ayon_info import (
terminal = Terminal
__all__ = [
"StrEnum",
"IniSettingRegistry",
"JSONSettingRegistry",
"AYONSecureRegistry",
@ -148,6 +153,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 +234,7 @@ __all__ = [
"get_version_from_path",
"get_last_version_from_path",
"DefaultKeysDict",
"TemplateUnsolved",
"StringTemplate",
"FormatObject",

View file

@ -0,0 +1,8 @@
from enum import Enum
class StrEnum(str, Enum):
"""A string-based Enum class that allows for string comparison."""
def __str__(self) -> str:
return self.value

View file

@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef):
if value is None:
return copy.deepcopy(self.default)
return list(self._item_values.intersection(value))
return [
v
for v in value
if v in self._item_values
]
def is_value_valid(self, value: Any) -> bool:
"""Check if item is available in possible values."""

View file

@ -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"]

View file

@ -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 "<Optional:{}>".format("".join([str(p) for p in self._parts]))
joined_parts = "".join([str(p) for p in self._parts])
return f"<Optional:{joined_parts}>"
def format(
self,
data: Dict[str, Any],
data: dict[str, Any],
result: TemplatePartResult,
) -> TemplatePartResult:
new_result = TemplatePartResult(True)

View file

@ -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]")

View file

@ -110,6 +110,15 @@ def deprecated(new_destination):
return _decorator(func)
class MissingRGBAChannelsError(ValueError):
"""Raised when we can't find channels to use as RGBA for conversion in
input media.
This may be other channels than solely RGBA, like Z-channel. The error is
raised when no matching 'reviewable' channel was found.
"""
def get_transcode_temp_directory():
"""Creates temporary folder for transcoding.
@ -388,6 +397,10 @@ def get_review_info_by_layer_name(channel_names):
...
]
This tries to find suitable outputs good for review purposes, by
searching for channel names like RGBA, but also XYZ, Z, N, AR, AG, AB
channels.
Args:
channel_names (list[str]): List of channel names.
@ -396,7 +409,6 @@ def get_review_info_by_layer_name(channel_names):
"""
layer_names_order = []
rgba_by_layer_name = collections.defaultdict(dict)
channels_by_layer_name = collections.defaultdict(dict)
for channel_name in channel_names:
@ -405,42 +417,95 @@ def get_review_info_by_layer_name(channel_names):
if "." in channel_name:
layer_name, last_part = channel_name.rsplit(".", 1)
channels_by_layer_name[layer_name][channel_name] = last_part
if last_part.lower() not in {
"r", "red",
"g", "green",
"b", "blue",
"a", "alpha"
# R, G, B, A or X, Y, Z, N, AR, AG, AB, RED, GREEN, BLUE, ALPHA
channel = last_part.upper()
if channel not in {
# Detect RGBA channels
"R", "G", "B", "A",
# Support fully written out rgba channel names
"RED", "GREEN", "BLUE", "ALPHA",
# Allow detecting of x, y and z channels, and normal channels
"X", "Y", "Z", "N",
# red, green and blue alpha/opacity, for colored mattes
"AR", "AG", "AB"
}:
continue
if layer_name not in layer_names_order:
layer_names_order.append(layer_name)
# R, G, B or A
channel = last_part[0].upper()
rgba_by_layer_name[layer_name][channel] = channel_name
# Put empty layer to the beginning of the list
channels_by_layer_name[layer_name][channel] = channel_name
# 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, "")
def _sort(_layer_name: str) -> int:
# Prioritize "" layer name
# Prioritize layers with RGB channels
if _layer_name == "rgba":
return 0
if _layer_name == "":
return 1
channels = channels_by_layer_name[_layer_name]
if all(channel in channels for channel in "RGB"):
return 2
return 10
layer_names_order.sort(key=_sort)
output = []
for layer_name in layer_names_order:
rgba_layer_info = rgba_by_layer_name[layer_name]
red = rgba_layer_info.get("R")
green = rgba_layer_info.get("G")
blue = rgba_layer_info.get("B")
if not red or not green or not blue:
channel_info = channels_by_layer_name[layer_name]
alpha = channel_info.get("A")
# RGB channels
if all(channel in channel_info for channel in "RGB"):
rgb = "R", "G", "B"
# RGB channels using fully written out channel names
elif all(
channel in channel_info
for channel in ("RED", "GREEN", "BLUE")
):
rgb = "RED", "GREEN", "BLUE"
alpha = channel_info.get("ALPHA")
# XYZ channels (position pass)
elif all(channel in channel_info for channel in "XYZ"):
rgb = "X", "Y", "Z"
# Colored mattes (as defined in OpenEXR Channel Name standards)
elif all(channel in channel_info for channel in ("AR", "AG", "AB")):
rgb = "AR", "AG", "AB"
# Luminance channel (as defined in OpenEXR Channel Name standards)
elif "Y" in channel_info:
rgb = "Y", "Y", "Y"
# Has only Z channel (Z-depth layer)
elif "Z" in channel_info:
rgb = "Z", "Z", "Z"
# Has only A channel (Alpha layer)
elif "A" in channel_info:
rgb = "A", "A", "A"
alpha = None
else:
# No reviewable channels found
continue
red = channel_info[rgb[0]]
green = channel_info[rgb[1]]
blue = channel_info[rgb[2]]
output.append({
"name": layer_name,
"review_channels": {
"R": red,
"G": green,
"B": blue,
"A": rgba_layer_info.get("A"),
"A": alpha,
}
})
return output
@ -1464,8 +1529,9 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
review_channels = get_convert_rgb_channels(channel_names)
if review_channels is None:
raise ValueError(
"Couldn't find channels that can be used for conversion."
raise MissingRGBAChannelsError(
"Couldn't find channels that can be used for conversion "
f"among channels: {channel_names}."
)
red, green, blue, alpha = review_channels
@ -1479,7 +1545,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
channels_arg += ",A={}".format(float(alpha_default))
input_channels.append("A")
input_channels_str = ",".join(input_channels)
# Make sure channels are unique, but preserve order to avoid oiiotool crash
input_channels_str = ",".join(list(dict.fromkeys(input_channels)))
subimages = oiio_input_info.get("subimages")
input_arg = "-i"
@ -1519,12 +1586,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
@ -1551,11 +1633,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

View file

@ -0,0 +1,62 @@
from .structures import (
ActionForm,
)
from .utils import (
webaction_fields_to_attribute_defs,
)
from .loader import (
LoaderSelectedType,
LoaderActionResult,
LoaderActionItem,
LoaderActionPlugin,
LoaderActionSelection,
LoaderActionsContext,
SelectionEntitiesCache,
LoaderSimpleActionPlugin,
)
from .launcher import (
LauncherAction,
LauncherActionSelection,
discover_launcher_actions,
register_launcher_action,
register_launcher_action_path,
)
from .inventory import (
InventoryAction,
discover_inventory_actions,
register_inventory_action,
register_inventory_action_path,
deregister_inventory_action,
deregister_inventory_action_path,
)
__all__ = (
"ActionForm",
"webaction_fields_to_attribute_defs",
"LoaderSelectedType",
"LoaderActionResult",
"LoaderActionItem",
"LoaderActionPlugin",
"LoaderActionSelection",
"LoaderActionsContext",
"SelectionEntitiesCache",
"LoaderSimpleActionPlugin",
"LauncherAction",
"LauncherActionSelection",
"discover_launcher_actions",
"register_launcher_action",
"register_launcher_action_path",
"InventoryAction",
"discover_inventory_actions",
"register_inventory_action",
"register_inventory_action_path",
"deregister_inventory_action",
"deregister_inventory_action_path",
)

View file

@ -0,0 +1,108 @@
import logging
from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline.load.utils import get_representation_path_from_context
class InventoryAction:
"""A custom action for the scene inventory tool
If registered the action will be visible in the Right Mouse Button menu
under the submenu "Actions".
"""
label = None
icon = None
color = None
order = 0
log = logging.getLogger("InventoryAction")
log.propagate = True
@staticmethod
def is_compatible(container):
"""Override function in a custom class
This method is specifically used to ensure the action can operate on
the container.
Args:
container(dict): the data of a loaded asset, see host.ls()
Returns:
bool
"""
return bool(container.get("objectName"))
def process(self, containers):
"""Override function in a custom class
This method will receive all containers even those which are
incompatible. It is advised to create a small filter along the lines
of this example:
valid_containers = filter(self.is_compatible(c) for c in containers)
The return value will need to be a True-ish value to trigger
the data_changed signal in order to refresh the view.
You can return a list of container names to trigger GUI to select
treeview items.
You can return a dict to carry extra GUI options. For example:
{
"objectNames": [container names...],
"options": {"mode": "toggle",
"clear": False}
}
Currently workable GUI options are:
- clear (bool): Clear current selection before selecting by action.
Default `True`.
- mode (str): selection mode, use one of these:
"select", "deselect", "toggle". Default is "select".
Args:
containers (list): list of dictionaries
Return:
bool, list or dict
"""
return True
@classmethod
def filepath_from_context(cls, context):
return get_representation_path_from_context(context)
def discover_inventory_actions():
actions = discover(InventoryAction)
filtered_actions = []
for action in actions:
if action is not InventoryAction:
filtered_actions.append(action)
return filtered_actions
def register_inventory_action(plugin):
return register_plugin(InventoryAction, plugin)
def deregister_inventory_action(plugin):
deregister_plugin(InventoryAction, plugin)
def register_inventory_action_path(path):
return register_plugin_path(InventoryAction, path)
def deregister_inventory_action_path(path):
return deregister_plugin_path(InventoryAction, path)

View file

@ -8,12 +8,8 @@ from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from .load.utils import get_representation_path_from_context
class LauncherActionSelection:
"""Object helper to pass selection to actions.
@ -390,79 +386,6 @@ class LauncherAction(object):
pass
class InventoryAction(object):
"""A custom action for the scene inventory tool
If registered the action will be visible in the Right Mouse Button menu
under the submenu "Actions".
"""
label = None
icon = None
color = None
order = 0
log = logging.getLogger("InventoryAction")
log.propagate = True
@staticmethod
def is_compatible(container):
"""Override function in a custom class
This method is specifically used to ensure the action can operate on
the container.
Args:
container(dict): the data of a loaded asset, see host.ls()
Returns:
bool
"""
return bool(container.get("objectName"))
def process(self, containers):
"""Override function in a custom class
This method will receive all containers even those which are
incompatible. It is advised to create a small filter along the lines
of this example:
valid_containers = filter(self.is_compatible(c) for c in containers)
The return value will need to be a True-ish value to trigger
the data_changed signal in order to refresh the view.
You can return a list of container names to trigger GUI to select
treeview items.
You can return a dict to carry extra GUI options. For example:
{
"objectNames": [container names...],
"options": {"mode": "toggle",
"clear": False}
}
Currently workable GUI options are:
- clear (bool): Clear current selection before selecting by action.
Default `True`.
- mode (str): selection mode, use one of these:
"select", "deselect", "toggle". Default is "select".
Args:
containers (list): list of dictionaries
Return:
bool, list or dict
"""
return True
@classmethod
def filepath_from_context(cls, context):
return get_representation_path_from_context(context)
# Launcher action
def discover_launcher_actions():
return discover(LauncherAction)
@ -473,30 +396,3 @@ def register_launcher_action(plugin):
def register_launcher_action_path(path):
return register_plugin_path(LauncherAction, path)
# Inventory action
def discover_inventory_actions():
actions = discover(InventoryAction)
filtered_actions = []
for action in actions:
if action is not InventoryAction:
filtered_actions.append(action)
return filtered_actions
def register_inventory_action(plugin):
return register_plugin(InventoryAction, plugin)
def deregister_inventory_action(plugin):
deregister_plugin(InventoryAction, plugin)
def register_inventory_action_path(path):
return register_plugin_path(InventoryAction, path)
def deregister_inventory_action_path(path):
return deregister_plugin_path(InventoryAction, path)

View file

@ -0,0 +1,864 @@
"""API for actions for loader tool.
Even though the api is meant for the loader tool, the api should be possible
to use in a standalone way out of the loader tool.
To use add actions, make sure your addon does inherit from
'IPluginPaths' and implements 'get_loader_action_plugin_paths' which
returns paths to python files with loader actions.
The plugin is used to collect available actions for the given context and to
execute them. Selection is defined with 'LoaderActionSelection' object
that also contains a cache of entities and project anatomy.
Implementing 'get_action_items' allows the plugin to define what actions
are shown and available for the selection. Because for a single selection
can be shown multiple actions with the same action identifier, the action
items also have 'data' attribute which can be used to store additional
data for the action (they have to be json-serializable).
The action is triggered by calling the 'execute_action' method. Which takes
the action identifier, the selection, the additional data from the action
item and form values from the form if any.
Using 'LoaderActionResult' as the output of 'execute_action' can trigger to
show a message in UI or to show an additional form ('ActionForm')
which would retrigger the action with the values from the form on
submitting. That allows handling of multistep actions.
It is also recommended that the plugin does override the 'identifier'
attribute. The identifier has to be unique across all plugins.
Class name is used by default.
The selection wrapper currently supports the following types of entity types:
- version
- representation
It is planned to add 'folder' and 'task' selection in the future.
NOTE: It is possible to trigger 'execute_action' without ever calling
'get_action_items', that can be handy in automations.
The whole logic is wrapped into 'LoaderActionsContext'. It takes care of
the discovery of plugins and wraps the collection and execution of
action items. Method 'execute_action' on context also requires plugin
identifier.
The flow of the logic is (in the loader tool):
1. User selects entities in the UI.
2. Right-click the selected entities.
3. Use 'LoaderActionsContext' to collect items using 'get_action_items'.
4. Show a menu (with submenus) in the UI.
5. If a user selects an action, the action is triggered using
'execute_action'.
5a. If the action returns 'LoaderActionResult', show a 'message' if it is
filled and show a form dialog if 'form' is filled.
5b. If the user submitted the form, trigger the action again with the
values from the form and repeat from 5a.
"""
from __future__ import annotations
import os
import collections
import copy
import logging
from abc import ABC, abstractmethod
import typing
from typing import Optional, Any, Callable
from dataclasses import dataclass
import ayon_api
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import StrEnum, Logger
from ayon_core.host import AbstractHost
from ayon_core.addon import AddonsManager, IPluginPaths
from ayon_core.settings import get_studio_settings, get_project_settings
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.plugin_discover import discover_plugins
from .structures import ActionForm
if typing.TYPE_CHECKING:
from typing import Union
DataBaseType = Union[str, int, float, bool]
DataType = dict[str, Union[DataBaseType, list[DataBaseType]]]
_PLACEHOLDER = object()
class LoaderSelectedType(StrEnum):
"""Selected entity type."""
# folder = "folder"
# task = "task"
version = "version"
representation = "representation"
class SelectionEntitiesCache:
"""Cache of entities used as helper in the selection wrapper.
It is possible to get entities based on ids with helper methods to get
entities, their parents or their children's entities.
The goal is to avoid multiple API calls for the same entity in multiple
action plugins.
The cache is based on the selected project. Entities are fetched
if are not in cache yet.
"""
def __init__(
self,
project_name: str,
project_entity: Optional[dict[str, Any]] = None,
folders_by_id: Optional[dict[str, dict[str, Any]]] = None,
tasks_by_id: Optional[dict[str, dict[str, Any]]] = None,
products_by_id: Optional[dict[str, dict[str, Any]]] = None,
versions_by_id: Optional[dict[str, dict[str, Any]]] = None,
representations_by_id: Optional[dict[str, dict[str, Any]]] = None,
task_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
product_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
version_ids_by_product_id: Optional[dict[str, set[str]]] = None,
representation_ids_by_version_id: Optional[dict[str, set[str]]] = None,
):
self._project_name = project_name
self._project_entity = project_entity
self._folders_by_id = folders_by_id or {}
self._tasks_by_id = tasks_by_id or {}
self._products_by_id = products_by_id or {}
self._versions_by_id = versions_by_id or {}
self._representations_by_id = representations_by_id or {}
self._task_ids_by_folder_id = task_ids_by_folder_id or {}
self._product_ids_by_folder_id = product_ids_by_folder_id or {}
self._version_ids_by_product_id = version_ids_by_product_id or {}
self._representation_ids_by_version_id = (
representation_ids_by_version_id or {}
)
def get_project(self) -> dict[str, Any]:
"""Get project entity"""
if self._project_entity is None:
self._project_entity = ayon_api.get_project(self._project_name)
return copy.deepcopy(self._project_entity)
def get_folders(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
folder_ids,
self._folders_by_id,
"folder_ids",
ayon_api.get_folders,
)
def get_tasks(
self, task_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
task_ids,
self._tasks_by_id,
"task_ids",
ayon_api.get_tasks,
)
def get_products(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
product_ids,
self._products_by_id,
"product_ids",
ayon_api.get_products,
)
def get_versions(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
version_ids,
self._versions_by_id,
"version_ids",
ayon_api.get_versions,
)
def get_representations(
self, representation_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
representation_ids,
self._representations_by_id,
"representation_ids",
ayon_api.get_representations,
)
def get_folders_tasks(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
task_ids = self._fill_parent_children_ids(
folder_ids,
"folderId",
"folder_ids",
self._task_ids_by_folder_id,
ayon_api.get_tasks,
)
return self.get_tasks(task_ids)
def get_folders_products(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
product_ids = self._get_folders_products_ids(folder_ids)
return self.get_products(product_ids)
def get_tasks_versions(
self, task_ids: set[str]
) -> list[dict[str, Any]]:
folder_ids = {
task["folderId"]
for task in self.get_tasks(task_ids)
}
product_ids = self._get_folders_products_ids(folder_ids)
output = []
for version in self.get_products_versions(product_ids):
task_id = version["taskId"]
if task_id in task_ids:
output.append(version)
return output
def get_products_versions(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
version_ids = self._fill_parent_children_ids(
product_ids,
"productId",
"product_ids",
self._version_ids_by_product_id,
ayon_api.get_versions,
)
return self.get_versions(version_ids)
def get_versions_representations(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
repre_ids = self._fill_parent_children_ids(
version_ids,
"versionId",
"version_ids",
self._representation_ids_by_version_id,
ayon_api.get_representations,
)
return self.get_representations(repre_ids)
def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]:
folder_ids = {
task["folderId"]
for task in self.get_tasks(task_ids)
}
return self.get_folders(folder_ids)
def get_products_folders(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
folder_ids = {
product["folderId"]
for product in self.get_products(product_ids)
}
return self.get_folders(folder_ids)
def get_versions_products(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
product_ids = {
version["productId"]
for version in self.get_versions(version_ids)
}
return self.get_products(product_ids)
def get_versions_tasks(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
task_ids = {
version["taskId"]
for version in self.get_versions(version_ids)
if version["taskId"]
}
return self.get_tasks(task_ids)
def get_representations_versions(
self, representation_ids: set[str]
) -> list[dict[str, Any]]:
version_ids = {
repre["versionId"]
for repre in self.get_representations(representation_ids)
}
return self.get_versions(version_ids)
def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]:
return self._fill_parent_children_ids(
folder_ids,
"folderId",
"folder_ids",
self._product_ids_by_folder_id,
ayon_api.get_products,
)
def _fill_parent_children_ids(
self,
entity_ids: set[str],
parent_key: str,
filter_attr: str,
parent_mapping: dict[str, set[str]],
getter: Callable,
) -> set[str]:
if not entity_ids:
return set()
children_ids = set()
missing_ids = set()
for entity_id in entity_ids:
_children_ids = parent_mapping.get(entity_id)
if _children_ids is None:
missing_ids.add(entity_id)
else:
children_ids.update(_children_ids)
if missing_ids:
entities_by_parent_id = collections.defaultdict(set)
for entity in getter(
self._project_name,
fields={"id", parent_key},
**{filter_attr: missing_ids},
):
child_id = entity["id"]
children_ids.add(child_id)
entities_by_parent_id[entity[parent_key]].add(child_id)
for entity_id in missing_ids:
parent_mapping[entity_id] = entities_by_parent_id[entity_id]
return children_ids
def _get_entities(
self,
entity_ids: set[str],
cache_var: dict[str, Any],
filter_arg: str,
getter: Callable,
) -> list[dict[str, Any]]:
if not entity_ids:
return []
output = []
missing_ids: set[str] = set()
for entity_id in entity_ids:
entity = cache_var.get(entity_id)
if entity_id not in cache_var:
missing_ids.add(entity_id)
cache_var[entity_id] = None
elif entity:
output.append(entity)
if missing_ids:
for entity in getter(
self._project_name,
**{filter_arg: missing_ids}
):
output.append(entity)
cache_var[entity["id"]] = entity
return output
class LoaderActionSelection:
"""Selection of entities for loader actions.
Selection tells action plugins what exactly is selected in the tool and
which ids.
Contains entity cache which can be used to get entities by their ids. Or
to get project settings and anatomy.
"""
def __init__(
self,
project_name: str,
selected_ids: set[str],
selected_type: LoaderSelectedType,
*,
project_anatomy: Optional[Anatomy] = None,
project_settings: Optional[dict[str, Any]] = None,
entities_cache: Optional[SelectionEntitiesCache] = None,
):
self._project_name = project_name
self._selected_ids = selected_ids
self._selected_type = selected_type
self._project_anatomy = project_anatomy
self._project_settings = project_settings
if entities_cache is None:
entities_cache = SelectionEntitiesCache(project_name)
self._entities_cache = entities_cache
def get_entities_cache(self) -> SelectionEntitiesCache:
return self._entities_cache
def get_project_name(self) -> str:
return self._project_name
def get_selected_ids(self) -> set[str]:
return set(self._selected_ids)
def get_selected_type(self) -> str:
return self._selected_type
def get_project_settings(self) -> dict[str, Any]:
if self._project_settings is None:
self._project_settings = get_project_settings(self._project_name)
return copy.deepcopy(self._project_settings)
def get_project_anatomy(self) -> Anatomy:
if self._project_anatomy is None:
self._project_anatomy = Anatomy(
self._project_name,
project_entity=self.get_entities_cache().get_project(),
)
return self._project_anatomy
project_name = property(get_project_name)
selected_ids = property(get_selected_ids)
selected_type = property(get_selected_type)
project_settings = property(get_project_settings)
project_anatomy = property(get_project_anatomy)
entities = property(get_entities_cache)
# --- Helper methods ---
def versions_selected(self) -> bool:
"""Selected entity type is version.
Returns:
bool: True if selected entity type is version.
"""
return self._selected_type == LoaderSelectedType.version
def representations_selected(self) -> bool:
"""Selected entity type is representation.
Returns:
bool: True if selected entity type is representation.
"""
return self._selected_type == LoaderSelectedType.representation
def get_selected_version_entities(self) -> list[dict[str, Any]]:
"""Retrieve selected version entities.
An empty list is returned if 'version' is not the selected
entity type.
Returns:
list[dict[str, Any]]: List of selected version entities.
"""
if self.versions_selected():
return self.entities.get_versions(self.selected_ids)
return []
def get_selected_representation_entities(self) -> list[dict[str, Any]]:
"""Retrieve selected representation entities.
An empty list is returned if 'representation' is not the selected
entity type.
Returns:
list[dict[str, Any]]: List of selected representation entities.
"""
if self.representations_selected():
return self.entities.get_representations(self.selected_ids)
return []
@dataclass
class LoaderActionItem:
"""Item of loader action.
Action plugins return these items as possible actions to run for a given
context.
Because the action item can be related to a specific entity
and not the whole selection, they also have to define the entity type
and ids to be executed on.
Attributes:
label (str): Text shown in UI.
order (int): Order of the action in UI.
group_label (Optional[str]): Label of the group to which the action
belongs.
icon (Optional[dict[str, Any]): Icon definition.
data (Optional[DataType]): Action item data.
identifier (Optional[str]): Identifier of the plugin which
created the action item. Is filled automatically. Is not changed
if is filled -> can lead to different plugin.
"""
label: str
order: int = 0
group_label: Optional[str] = None
icon: Optional[dict[str, Any]] = None
data: Optional[DataType] = None
# Is filled automatically
identifier: str = None
@dataclass
class LoaderActionResult:
"""Result of loader action execution.
Attributes:
message (Optional[str]): Message to show in UI.
success (bool): If the action was successful. Affects color of
the message.
form (Optional[ActionForm]): Form to show in UI.
form_values (Optional[dict[str, Any]]): Values for the form. Can be
used if the same form is re-shown e.g. because a user forgot to
fill a required field.
"""
message: Optional[str] = None
success: bool = True
form: Optional[ActionForm] = None
form_values: Optional[dict[str, Any]] = None
def to_json_data(self) -> dict[str, Any]:
form = self.form
if form is not None:
form = form.to_json_data()
return {
"message": self.message,
"success": self.success,
"form": form,
"form_values": self.form_values,
}
@classmethod
def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult":
form = data["form"]
if form is not None:
data["form"] = ActionForm.from_json_data(form)
return LoaderActionResult(**data)
class LoaderActionPlugin(ABC):
"""Plugin for loader actions.
Plugin is responsible for getting action items and executing actions.
"""
_log: Optional[logging.Logger] = None
enabled: bool = True
def __init__(self, context: "LoaderActionsContext") -> None:
self._context = context
self.apply_settings(context.get_studio_settings())
def apply_settings(self, studio_settings: dict[str, Any]) -> None:
"""Apply studio settings to the plugin.
Args:
studio_settings (dict[str, Any]): Studio settings.
"""
pass
@property
def log(self) -> logging.Logger:
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@property
def identifier(self) -> str:
"""Identifier of the plugin.
Returns:
str: Plugin identifier.
"""
return self.__class__.__name__
@property
def host_name(self) -> Optional[str]:
"""Name of the current host."""
return self._context.get_host_name()
@abstractmethod
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
"""Action items for the selection.
Args:
selection (LoaderActionSelection): Selection.
Returns:
list[LoaderActionItem]: Action items.
"""
pass
@abstractmethod
def execute_action(
self,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Execute an action.
Args:
selection (LoaderActionSelection): Selection wrapper. Can be used
to get entities or get context of original selection.
data (Optional[DataType]): Additional action item data.
form_values (dict[str, Any]): Attribute values.
Returns:
Optional[LoaderActionResult]: Result of the action execution.
"""
pass
class LoaderActionsContext:
"""Wrapper for loader actions and their logic.
Takes care about the public api of loader actions and internal logic like
discovery and initialization of plugins.
"""
def __init__(
self,
studio_settings: Optional[dict[str, Any]] = None,
addons_manager: Optional[AddonsManager] = None,
host: Optional[AbstractHost] = _PLACEHOLDER,
) -> None:
self._log = Logger.get_logger(self.__class__.__name__)
self._addons_manager = addons_manager
self._host = host
# Attributes that are re-cached on reset
self._studio_settings = studio_settings
self._plugins = None
def reset(
self, studio_settings: Optional[dict[str, Any]] = None
) -> None:
"""Reset context cache.
Reset plugins and studio settings to reload them.
Notes:
Does not reset the cache of AddonsManger because there should not
be a reason to do so.
"""
self._studio_settings = studio_settings
self._plugins = None
def get_addons_manager(self) -> AddonsManager:
if self._addons_manager is None:
self._addons_manager = AddonsManager(
settings=self.get_studio_settings()
)
return self._addons_manager
def get_host(self) -> Optional[AbstractHost]:
"""Get current host integration.
Returns:
Optional[AbstractHost]: Host integration. Can be None if host
integration is not registered -> probably not used in the
host integration process.
"""
if self._host is _PLACEHOLDER:
from ayon_core.pipeline import registered_host
self._host = registered_host()
return self._host
def get_host_name(self) -> Optional[str]:
host = self.get_host()
if host is None:
return None
return host.name
def get_studio_settings(self) -> dict[str, Any]:
if self._studio_settings is None:
self._studio_settings = get_studio_settings()
return copy.deepcopy(self._studio_settings)
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
"""Collect action items from all plugins for given selection.
Args:
selection (LoaderActionSelection): Selection wrapper.
"""
output = []
for plugin_id, plugin in self._get_plugins().items():
try:
for action_item in plugin.get_action_items(selection):
if action_item.identifier is None:
action_item.identifier = plugin_id
output.append(action_item)
except Exception:
self._log.warning(
"Failed to get action items for"
f" plugin '{plugin.identifier}'",
exc_info=True,
)
return output
def execute_action(
self,
identifier: str,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Trigger action execution.
Args:
identifier (str): Identifier of the plugin.
selection (LoaderActionSelection): Selection wrapper. Can be used
to get what is selected in UI and to get access to entity
cache.
data (Optional[DataType]): Additional action item data.
form_values (dict[str, Any]): Form values related to action.
Usually filled if action returned response with form.
"""
plugins_by_id = self._get_plugins()
plugin = plugins_by_id[identifier]
return plugin.execute_action(
selection,
data,
form_values,
)
def _get_plugins(self) -> dict[str, LoaderActionPlugin]:
if self._plugins is None:
addons_manager = self.get_addons_manager()
all_paths = [
os.path.join(AYON_CORE_ROOT, "plugins", "loader")
]
for addon in addons_manager.addons:
if not isinstance(addon, IPluginPaths):
continue
paths = addon.get_loader_action_plugin_paths()
if paths:
all_paths.extend(paths)
result = discover_plugins(LoaderActionPlugin, all_paths)
result.log_report()
plugins = {}
for cls in result.plugins:
try:
plugin = cls(self)
if not plugin.enabled:
continue
plugin_id = plugin.identifier
if plugin_id not in plugins:
plugins[plugin_id] = plugin
continue
self._log.warning(
f"Duplicated plugins identifier found '{plugin_id}'."
)
except Exception:
self._log.warning(
f"Failed to initialize plugin '{cls.__name__}'",
exc_info=True,
)
self._plugins = plugins
return self._plugins
class LoaderSimpleActionPlugin(LoaderActionPlugin):
"""Simple action plugin.
This action will show exactly one action item defined by attributes
on the class.
Attributes:
label: Label of the action item.
order: Order of the action item.
group_label: Label of the group to which the action belongs.
icon: Icon definition shown next to label.
"""
label: Optional[str] = None
order: int = 0
group_label: Optional[str] = None
icon: Optional[dict[str, Any]] = None
@abstractmethod
def is_compatible(self, selection: LoaderActionSelection) -> bool:
"""Check if plugin is compatible with selection.
Args:
selection (LoaderActionSelection): Selection information.
Returns:
bool: True if plugin is compatible with selection.
"""
pass
@abstractmethod
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Process action based on selection.
Args:
selection (LoaderActionSelection): Selection information.
form_values (dict[str, Any]): Values from a form if there are any.
Returns:
Optional[LoaderActionResult]: Result of the action.
"""
pass
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
if self.is_compatible(selection):
label = self.label or self.__class__.__name__
return [
LoaderActionItem(
label=label,
order=self.order,
group_label=self.group_label,
icon=self.icon,
)
]
return []
def execute_action(
self,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
return self.execute_simple_action(selection, form_values)

View file

@ -0,0 +1,60 @@
from dataclasses import dataclass
from typing import Optional, Any
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
serialize_attr_defs,
deserialize_attr_defs,
)
@dataclass
class ActionForm:
"""Form for loader action.
If an action needs to collect information from a user before or during of
the action execution, it can return a response with a form. When the
form is submitted, a new execution of the action is triggered.
It is also possible to just show a label message without the submit
button to make sure the user has seen the message.
Attributes:
title (str): Title of the form -> title of the window.
fields (list[AbstractAttrDef]): Fields of the form.
submit_label (Optional[str]): Label of the submit button. Is hidden
if is set to None.
submit_icon (Optional[dict[str, Any]]): Icon definition of the submit
button.
cancel_label (Optional[str]): Label of the cancel button. Is hidden
if is set to None. User can still close the window tho.
cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel
button.
"""
title: str
fields: list[AbstractAttrDef]
submit_label: Optional[str] = "Submit"
submit_icon: Optional[dict[str, Any]] = None
cancel_label: Optional[str] = "Cancel"
cancel_icon: Optional[dict[str, Any]] = None
def to_json_data(self) -> dict[str, Any]:
fields = self.fields
if fields is not None:
fields = serialize_attr_defs(fields)
return {
"title": self.title,
"fields": fields,
"submit_label": self.submit_label,
"submit_icon": self.submit_icon,
"cancel_label": self.cancel_label,
"cancel_icon": self.cancel_icon,
}
@classmethod
def from_json_data(cls, data: dict[str, Any]) -> "ActionForm":
fields = data["fields"]
if fields is not None:
data["fields"] = deserialize_attr_defs(fields)
return cls(**data)

View file

@ -0,0 +1,100 @@
from __future__ import annotations
import uuid
from typing import Any
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
UILabelDef,
BoolDef,
TextDef,
NumberDef,
EnumDef,
HiddenDef,
)
def webaction_fields_to_attribute_defs(
fields: list[dict[str, Any]]
) -> list[AbstractAttrDef]:
"""Helper function to convert fields definition from webactions form.
Convert form fields to attribute definitions to be able to display them
using attribute definitions.
Args:
fields (list[dict[str, Any]]): Fields from webaction form.
Returns:
list[AbstractAttrDef]: Converted attribute definitions.
"""
attr_defs = []
for field in fields:
field_type = field["type"]
attr_def = None
if field_type == "label":
label = field.get("value")
if label is None:
label = field.get("text")
attr_def = UILabelDef(
label, key=uuid.uuid4().hex
)
elif field_type == "boolean":
value = field["value"]
if isinstance(value, str):
value = value.lower() == "true"
attr_def = BoolDef(
field["name"],
default=value,
label=field.get("label"),
)
elif field_type == "text":
attr_def = TextDef(
field["name"],
default=field.get("value"),
label=field.get("label"),
placeholder=field.get("placeholder"),
multiline=field.get("multiline", False),
regex=field.get("regex"),
# syntax=field["syntax"],
)
elif field_type in ("integer", "float"):
value = field.get("value")
if isinstance(value, str):
if field_type == "integer":
value = int(value)
else:
value = float(value)
attr_def = NumberDef(
field["name"],
default=value,
label=field.get("label"),
decimals=0 if field_type == "integer" else 5,
# placeholder=field.get("placeholder"),
minimum=field.get("min"),
maximum=field.get("max"),
)
elif field_type in ("select", "multiselect"):
attr_def = EnumDef(
field["name"],
items=field["options"],
default=field.get("value"),
label=field.get("label"),
multiselection=field_type == "multiselect",
)
elif field_type == "hidden":
attr_def = HiddenDef(
field["name"],
default=field.get("value"),
)
if attr_def is None:
print(f"Unknown config field type: {field_type}")
attr_def = UILabelDef(
f"Unknown field type '{field_type}",
key=uuid.uuid4().hex
)
attr_defs.append(attr_def)
return attr_defs

View file

@ -137,6 +137,7 @@ class AttributeValues:
if value is None:
continue
converted_value = attr_def.convert_value(value)
# QUESTION Could we just use converted value all the time?
if converted_value == value:
self._data[attr_def.key] = value
@ -245,11 +246,11 @@ class AttributeValues:
def _update(self, value):
changes = {}
for key, value in dict(value).items():
if key in self._data and self._data.get(key) == value:
for key, key_value in dict(value).items():
if key in self._data and self._data.get(key) == key_value:
continue
self._data[key] = value
changes[key] = value
self._data[key] = key_value
changes[key] = key_value
return changes
def _pop(self, key, default):

View file

@ -202,7 +202,8 @@ def is_clip_from_media_sequence(otio_clip):
def remap_range_on_file_sequence(otio_clip, otio_range):
"""
""" Remap the provided range on a file sequence clip.
Args:
otio_clip (otio.schema.Clip): The OTIO clip to check.
otio_range (otio.schema.TimeRange): The trim range to apply.
@ -249,7 +250,11 @@ def remap_range_on_file_sequence(otio_clip, otio_range):
if (
is_clip_from_media_sequence(otio_clip)
and available_range_start_frame == media_ref.start_frame
and conformed_src_in.to_frames() < media_ref.start_frame
# source range should be included in available range from media
# using round instead of conformed_src_in.to_frames() to avoid
# any precision issue with frame rate.
and round(conformed_src_in.value) < media_ref.start_frame
):
media_in = otio.opentime.RationalTime(
0, rate=available_range_rate

View file

@ -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"):
@ -1044,7 +1045,9 @@ def get_resources(project_name, version_entity, extension=None):
filtered.append(repre_entity)
representation = filtered[0]
directory = get_representation_path(representation)
directory = get_representation_path(
project_name, representation
)
print("Source: ", directory)
resources = sorted(
[

View file

@ -25,8 +25,8 @@ from .utils import (
get_loader_identifier,
get_loaders_by_name,
get_representation_path_from_context,
get_representation_path,
get_representation_path_from_context,
get_representation_path_with_anatomy,
is_compatible_loader,
@ -85,8 +85,8 @@ __all__ = (
"get_loader_identifier",
"get_loaders_by_name",
"get_representation_path_from_context",
"get_representation_path",
"get_representation_path_from_context",
"get_representation_path_with_anatomy",
"is_compatible_loader",

View file

@ -1,11 +1,15 @@
from __future__ import annotations
import os
import uuid
import platform
import warnings
import logging
import inspect
import collections
import numbers
from typing import Optional, Union, Any
import copy
from functools import wraps
from typing import Optional, Union, Any, overload
import ayon_api
@ -14,9 +18,8 @@ from ayon_core.lib import (
StringTemplate,
TemplateUnsolved,
)
from ayon_core.pipeline import (
Anatomy,
)
from ayon_core.lib.path_templates import TemplateResult
from ayon_core.pipeline import Anatomy
log = logging.getLogger(__name__)
@ -644,15 +647,15 @@ def get_representation_path_from_context(context):
representation = context["representation"]
project_entity = context.get("project")
root = None
if (
project_entity
and project_entity["name"] != get_current_project_name()
):
anatomy = Anatomy(project_entity["name"])
root = anatomy.roots
return get_representation_path(representation, root)
if project_entity:
project_name = project_entity["name"]
else:
project_name = get_current_project_name()
return get_representation_path(
project_name,
representation,
project_entity=project_entity,
)
def get_representation_path_with_anatomy(repre_entity, anatomy):
@ -671,139 +674,248 @@ def get_representation_path_with_anatomy(repre_entity, anatomy):
anatomy (Anatomy): Project anatomy object.
Returns:
Union[None, TemplateResult]: None if path can't be received
TemplateResult: Resolved representation path.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
return get_representation_path(
anatomy.project_name,
repre_entity,
anatomy=anatomy,
)
def get_representation_path_with_roots(
representation: dict[str, Any],
roots: dict[str, str],
) -> Optional[TemplateResult]:
"""Get filename from representation with custom root.
Args:
representation(dict): Representation entity.
roots (dict[str, str]): Roots to use.
Returns:
Optional[TemplateResult]: Resolved representation path.
"""
try:
template = representation["attrib"]["template"]
except KeyError:
return None
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = roots
path = StringTemplate.format_strict_template(
template, context
)
except (TemplateUnsolved, KeyError):
# Template references unavailable data
return None
return path.normalized()
def _backwards_compatibility_repre_path(func):
"""Wrapper handling backwards compatibility of 'get_representation_path'.
Allows 'get_representation_path' to support old and new signatures of the
function. The old signature supported passing in representation entity
and optional roots. The new signature requires the project name
to be passed. In case custom roots should be used, a dedicated function
'get_representation_path_with_roots' is available.
The wrapper handles passed arguments, and based on kwargs and types
of the arguments will call the function which relates to
the arguments.
The function is also marked with an attribute 'version' so other addons
can check if the function is using the new signature or is using
the old signature. That should allow addons to adapt to new signature.
>>> if getattr(get_representation_path, "version", None) == 2:
>>> path = get_representation_path(project_name, repre_entity)
>>> else:
>>> path = get_representation_path(repre_entity)
The plan to remove backwards compatibility is 1.1.2026.
"""
# Add an attribute to the function so addons can check if the new variant
# of the function is available.
# >>> getattr(get_representation_path, "version", None) == 2
# >>> True
setattr(func, "version", 2)
@wraps(func)
def inner(*args, **kwargs):
from ayon_core.pipeline import get_current_project_name
# Decide which variant of the function based on passed arguments
# will be used.
if args:
arg_1 = args[0]
if isinstance(arg_1, str):
return func(*args, **kwargs)
elif "project_name" in kwargs:
return func(*args, **kwargs)
warnings.warn(
(
"Used deprecated variant of 'get_representation_path'."
" Please change used arguments signature to follow"
" new definition. Will be removed 1.1.2026."
),
DeprecationWarning,
stacklevel=2,
)
# Find out which arguments were passed
if args:
representation = args[0]
else:
representation = kwargs.get("representation")
if len(args) > 1:
roots = args[1]
else:
roots = kwargs.get("root")
if roots is not None:
return get_representation_path_with_roots(
representation, roots
)
project_name = (
representation["context"].get("project", {}).get("name")
)
if project_name is None:
project_name = get_current_project_name()
return func(project_name, representation)
return inner
@overload
def get_representation_path(
representation: dict[str, Any],
root: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""DEPRECATED Get filled representation path.
Use 'get_representation_path' using the new function signature.
Args:
representation (dict[str, Any]): Representation entity.
root (Optional[dict[str, Any]): Roots to fill the path.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
pass
@overload
def get_representation_path(
project_name: str,
repre_entity: dict[str, Any],
*,
anatomy: Optional[Anatomy] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""Get filled representation path.
Args:
project_name (str): Project name.
repre_entity (dict[str, Any]): Representation entity.
anatomy (Optional[Anatomy]): Project anatomy.
project_entity (Optional[dict[str, Any]): Project entity. Is used to
initialize Anatomy and is not needed if 'anatomy' is passed in.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
pass
@_backwards_compatibility_repre_path
def get_representation_path(
project_name: str,
repre_entity: dict[str, Any],
*,
anatomy: Optional[Anatomy] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""Get filled representation path.
Args:
project_name (str): Project name.
repre_entity (dict[str, Any]): Representation entity.
anatomy (Optional[Anatomy]): Project anatomy.
project_entity (Optional[dict[str, Any]): Project entity. Is used to
initialize Anatomy and is not needed if 'anatomy' is passed in.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
if anatomy is None:
anatomy = Anatomy(project_name, project_entity=project_entity)
try:
template = repre_entity["attrib"]["template"]
except KeyError:
raise InvalidRepresentationContext((
"Representation document does not"
" contain template in data ('data.template')"
))
except KeyError as exc:
raise InvalidRepresentationContext(
"Failed to receive template from representation entity."
) from exc
try:
context = repre_entity["context"]
context = copy.deepcopy(repre_entity["context"])
_fix_representation_context_compatibility(context)
context["root"] = anatomy.roots
path = StringTemplate.format_strict_template(template, context)
except TemplateUnsolved as exc:
raise InvalidRepresentationContext((
"Couldn't resolve representation template with available data."
" Reason: {}".format(str(exc))
))
raise InvalidRepresentationContext(
"Failed to resolve representation template with available data."
) from exc
return path.normalized()
def get_representation_path(representation, root=None):
"""Get filename from representation document
There are three ways of getting the path from representation which are
tried in following sequence until successful.
1. Get template from representation['data']['template'] and data from
representation['context']. Then format template with the data.
2. Get template from project['config'] and format it with default data set
3. Get representation['data']['path'] and use it directly
Args:
representation(dict): representation document from the database
Returns:
str: fullpath of the representation
"""
if root is None:
from ayon_core.pipeline import get_current_project_name, Anatomy
anatomy = Anatomy(get_current_project_name())
return get_representation_path_with_anatomy(
representation, anatomy
)
def path_from_representation():
try:
template = representation["attrib"]["template"]
except KeyError:
return None
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = root
path = StringTemplate.format_strict_template(
template, context
)
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
except (TemplateUnsolved, KeyError):
# Template references unavailable data
return None
if not path:
return path
normalized_path = os.path.normpath(path)
if os.path.exists(normalized_path):
return normalized_path
return path
def path_from_data():
if "path" not in representation["attrib"]:
return None
path = representation["attrib"]["path"]
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
if os.path.exists(path):
return os.path.normpath(path)
dir_path, file_name = os.path.split(path)
if not os.path.exists(dir_path):
return None
base_name, ext = os.path.splitext(file_name)
file_name_items = None
if "#" in base_name:
file_name_items = [part for part in base_name.split("#") if part]
elif "%" in base_name:
file_name_items = base_name.split("%")
if not file_name_items:
return None
filename_start = file_name_items[0]
for _file in os.listdir(dir_path):
if _file.startswith(filename_start) and _file.endswith(ext):
return os.path.normpath(path)
return (
path_from_representation() or path_from_data()
)
def get_representation_path_by_names(
project_name: str,
folder_path: str,
product_name: str,
version_name: str,
representation_name: str,
anatomy: Optional[Anatomy] = None) -> Optional[str]:
project_name: str,
folder_path: str,
product_name: str,
version_name: Union[int, str],
representation_name: str,
anatomy: Optional[Anatomy] = None
) -> Optional[TemplateResult]:
"""Get (latest) filepath for representation for folder and product.
See `get_representation_by_names` for more details.
@ -820,22 +932,21 @@ def get_representation_path_by_names(
representation_name
)
if not representation:
return
return None
if not anatomy:
anatomy = Anatomy(project_name)
if representation:
path = get_representation_path_with_anatomy(representation, anatomy)
return str(path).replace("\\", "/")
return get_representation_path(
project_name,
representation,
anatomy=anatomy,
)
def get_representation_by_names(
project_name: str,
folder_path: str,
product_name: str,
version_name: Union[int, str],
representation_name: str,
project_name: str,
folder_path: str,
product_name: str,
version_name: Union[int, str],
representation_name: str,
) -> Optional[dict]:
"""Get representation entity for asset and subset.
@ -852,7 +963,7 @@ def get_representation_by_names(
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields=["id"])
if not folder_entity:
return
return None
if isinstance(product_name, dict) and "name" in product_name:
# Allow explicitly passing subset document
@ -864,7 +975,7 @@ def get_representation_by_names(
folder_id=folder_entity["id"],
fields=["id"])
if not product_entity:
return
return None
if version_name == "hero":
version_entity = ayon_api.get_hero_version_by_product_id(
@ -876,7 +987,7 @@ def get_representation_by_names(
version_entity = ayon_api.get_version_by_name(
project_name, version_name, product_id=product_entity["id"])
if not version_entity:
return
return None
return ayon_api.get_representation_by_name(
project_name, representation_name, version_id=version_entity["id"])

View file

@ -1,6 +1,9 @@
from __future__ import annotations
import os
import inspect
import traceback
from typing import Optional
from ayon_core.lib import Logger
from ayon_core.lib.python_module_tools import (
@ -96,6 +99,70 @@ class DiscoverResult:
log.info(report)
def discover_plugins(
base_class: type,
paths: Optional[list[str]] = None,
classes: Optional[list[type]] = None,
ignored_classes: Optional[list[type]] = None,
allow_duplicates: bool = True,
):
"""Find and return subclasses of `superclass`
Args:
base_class (type): Class which determines discovered subclasses.
paths (Optional[list[str]]): List of paths to look for plug-ins.
classes (Optional[list[str]]): List of classes to filter.
ignored_classes (list[type]): List of classes that won't be added to
the output plugins.
allow_duplicates (bool): Validate class name duplications.
Returns:
DiscoverResult: Object holding successfully
discovered plugins, ignored plugins, plugins with missing
abstract implementation and duplicated plugin.
"""
ignored_classes = ignored_classes or []
paths = paths or []
classes = classes or []
result = DiscoverResult(base_class)
all_plugins = list(classes)
for path in paths:
modules, crashed = modules_from_path(path)
for (filepath, exc_info) in crashed:
result.crashed_file_paths[filepath] = exc_info
for item in modules:
filepath, module = item
result.add_module(module)
all_plugins.extend(classes_from_module(base_class, module))
if base_class not in ignored_classes:
ignored_classes.append(base_class)
plugin_names = set()
for cls in all_plugins:
if cls in ignored_classes:
result.ignored_plugins.add(cls)
continue
if inspect.isabstract(cls):
result.abstract_plugins.append(cls)
continue
if not allow_duplicates:
class_name = cls.__name__
if class_name in plugin_names:
result.duplicated_plugins.append(cls)
continue
plugin_names.add(class_name)
result.plugins.append(cls)
return result
class PluginDiscoverContext(object):
"""Store and discover registered types nad registered paths to types.
@ -141,58 +208,17 @@ class PluginDiscoverContext(object):
Union[DiscoverResult, list[Any]]: Object holding successfully
discovered plugins, ignored plugins, plugins with missing
abstract implementation and duplicated plugin.
"""
if not ignore_classes:
ignore_classes = []
result = DiscoverResult(superclass)
plugin_names = set()
registered_classes = self._registered_plugins.get(superclass) or []
registered_paths = self._registered_plugin_paths.get(superclass) or []
for cls in registered_classes:
if cls is superclass or cls in ignore_classes:
result.ignored_plugins.add(cls)
continue
if inspect.isabstract(cls):
result.abstract_plugins.append(cls)
continue
class_name = cls.__name__
if class_name in plugin_names:
result.duplicated_plugins.append(cls)
continue
plugin_names.add(class_name)
result.plugins.append(cls)
# Include plug-ins from registered paths
for path in registered_paths:
modules, crashed = modules_from_path(path)
for item in crashed:
filepath, exc_info = item
result.crashed_file_paths[filepath] = exc_info
for item in modules:
filepath, module = item
result.add_module(module)
for cls in classes_from_module(superclass, module):
if cls is superclass or cls in ignore_classes:
result.ignored_plugins.add(cls)
continue
if inspect.isabstract(cls):
result.abstract_plugins.append(cls)
continue
if not allow_duplicates:
class_name = cls.__name__
if class_name in plugin_names:
result.duplicated_plugins.append(cls)
continue
plugin_names.add(class_name)
result.plugins.append(cls)
result = discover_plugins(
superclass,
paths=registered_paths,
classes=registered_classes,
ignored_classes=ignore_classes,
allow_duplicates=allow_duplicates,
)
# Store in memory last result to keep in memory loaded modules
self._last_discovered_results[superclass] = result

View file

@ -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)

View file

@ -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(

View file

@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC):
self._loaders_by_name = get_loaders_by_name()
return self._loaders_by_name
def get_linked_folder_entities(self, link_type: Optional[str]):
def get_linked_folder_entities(
self,
link_type: Optional[str],
folder_path_regex: Optional[str],
):
if not link_type:
return []
project_name = self.project_name
@ -317,7 +321,11 @@ class AbstractTemplateBuilder(ABC):
if link["entityType"] == "folder"
}
return list(get_folders(project_name, folder_ids=linked_folder_ids))
return list(get_folders(
project_name,
folder_path_regex=folder_path_regex,
folder_ids=linked_folder_ids,
))
def _collect_creators(self):
self._creators_by_name = {
@ -1638,7 +1646,10 @@ class PlaceholderLoadMixin(object):
linked_folder_entity["id"]
for linked_folder_entity in (
self.builder.get_linked_folder_entities(
link_type=link_type))
link_type=link_type,
folder_path_regex=folder_path_regex
)
)
]
if not folder_ids:

View file

@ -1,34 +0,0 @@
from ayon_core.style import get_default_entity_icon_color
from ayon_core.pipeline import load
class CopyFile(load.LoaderPlugin):
"""Copy the published file to be pasted at the desired location"""
representations = {"*"}
product_types = {"*"}
label = "Copy File"
order = 10
icon = "copy"
color = get_default_entity_icon_color()
def load(self, context, name=None, namespace=None, data=None):
path = self.filepath_from_context(context)
self.log.info("Added copy to clipboard: {0}".format(path))
self.copy_file_to_clipboard(path)
@staticmethod
def copy_file_to_clipboard(path):
from qtpy import QtCore, QtWidgets
clipboard = QtWidgets.QApplication.clipboard()
assert clipboard, "Must have running QApplication instance"
# Build mime data for clipboard
data = QtCore.QMimeData()
url = QtCore.QUrl.fromLocalFile(path)
data.setUrls([url])
# Set to Clipboard
clipboard.setMimeData(data)

View file

@ -1,29 +0,0 @@
import os
from ayon_core.pipeline import load
class CopyFilePath(load.LoaderPlugin):
"""Copy published file path to clipboard"""
representations = {"*"}
product_types = {"*"}
label = "Copy File Path"
order = 20
icon = "clipboard"
color = "#999999"
def load(self, context, name=None, namespace=None, data=None):
path = self.filepath_from_context(context)
self.log.info("Added file path to clipboard: {0}".format(path))
self.copy_path_to_clipboard(path)
@staticmethod
def copy_path_to_clipboard(path):
from qtpy import QtWidgets
clipboard = QtWidgets.QApplication.clipboard()
assert clipboard, "Must have running QApplication instance"
# Set to Clipboard
clipboard.setText(os.path.normpath(path))

View file

@ -75,6 +75,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin):
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.WindowStaysOnTopHint
)
msgBox.exec_()

View file

@ -1,477 +0,0 @@
import collections
import os
import uuid
from typing import List, Dict, Any
import clique
import ayon_api
from ayon_api.operations import OperationsSession
import qargparse
from qtpy import QtWidgets, QtCore
from ayon_core import style
from ayon_core.lib import format_file_size
from ayon_core.pipeline import load, Anatomy
from ayon_core.pipeline.load import (
get_representation_path_with_anatomy,
InvalidRepresentationContext,
)
class DeleteOldVersions(load.ProductLoaderPlugin):
"""Deletes specific number of old version"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = ["*"]
product_types = {"*"}
tool_names = ["library_loader"]
label = "Delete Old Versions"
order = 35
icon = "trash"
color = "#d8d8d8"
options = [
qargparse.Integer(
"versions_to_keep", default=2, min=0, help="Versions to keep:"
),
qargparse.Boolean(
"remove_publish_folder", help="Remove publish folder:"
)
]
requires_confirmation = True
def delete_whole_dir_paths(self, dir_paths, delete=True):
size = 0
for dir_path in dir_paths:
# Delete all files and folders in dir path
for root, dirs, files in os.walk(dir_path, topdown=False):
for name in files:
file_path = os.path.join(root, name)
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
for name in dirs:
if delete:
os.rmdir(os.path.join(root, name))
if not delete:
continue
# Delete even the folder and it's parents folders if they are empty
while True:
if not os.path.exists(dir_path):
dir_path = os.path.dirname(dir_path)
continue
if len(os.listdir(dir_path)) != 0:
break
os.rmdir(os.path.join(dir_path))
return size
def path_from_representation(self, representation, anatomy):
try:
context = representation["context"]
except KeyError:
return (None, None)
try:
path = get_representation_path_with_anatomy(
representation, anatomy
)
except InvalidRepresentationContext:
return (None, None)
sequence_path = None
if "frame" in context:
context["frame"] = self.sequence_splitter
sequence_path = get_representation_path_with_anatomy(
representation, anatomy
)
if sequence_path:
sequence_path = sequence_path.normalized()
return (path.normalized(), sequence_path)
def delete_only_repre_files(self, dir_paths, file_paths, delete=True):
size = 0
for dir_id, dir_path in dir_paths.items():
dir_files = os.listdir(dir_path)
collections, remainders = clique.assemble(dir_files)
for file_path, seq_path in file_paths[dir_id]:
file_path_base = os.path.split(file_path)[1]
# Just remove file if `frame` key was not in context or
# filled path is in remainders (single file sequence)
if not seq_path or file_path_base in remainders:
if not os.path.exists(file_path):
self.log.debug(
"File was not found: {}".format(file_path)
)
continue
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
if file_path_base in remainders:
remainders.remove(file_path_base)
continue
seq_path_base = os.path.split(seq_path)[1]
head, tail = seq_path_base.split(self.sequence_splitter)
final_col = None
for collection in collections:
if head != collection.head or tail != collection.tail:
continue
final_col = collection
break
if final_col is not None:
# Fill full path to head
final_col.head = os.path.join(dir_path, final_col.head)
for _file_path in final_col:
if os.path.exists(_file_path):
size += os.path.getsize(_file_path)
if delete:
os.remove(_file_path)
self.log.debug(
"Removed file: {}".format(_file_path)
)
_seq_path = final_col.format("{head}{padding}{tail}")
self.log.debug("Removed files: {}".format(_seq_path))
collections.remove(final_col)
elif os.path.exists(file_path):
size += os.path.getsize(file_path)
if delete:
os.remove(file_path)
self.log.debug("Removed file: {}".format(file_path))
else:
self.log.debug(
"File was not found: {}".format(file_path)
)
# Delete as much as possible parent folders
if not delete:
return size
for dir_path in dir_paths.values():
while True:
if not os.path.exists(dir_path):
dir_path = os.path.dirname(dir_path)
continue
if len(os.listdir(dir_path)) != 0:
break
self.log.debug("Removed folder: {}".format(dir_path))
os.rmdir(dir_path)
return size
def message(self, text):
msgBox = QtWidgets.QMessageBox()
msgBox.setText(text)
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
)
msgBox.exec_()
def _confirm_delete(self,
contexts: List[Dict[str, Any]],
versions_to_keep: int) -> bool:
"""Prompt user for a deletion confirmation"""
contexts_list = "\n".join(sorted(
"- {folder[name]} > {product[name]}".format_map(context)
for context in contexts
))
num_contexts = len(contexts)
s = "s" if num_contexts > 1 else ""
text = (
"Are you sure you want to delete versions?\n\n"
f"This will keep only the last {versions_to_keep} "
f"versions for the {num_contexts} selected product{s}."
)
informative_text = "Warning: This will delete files from disk"
detailed_text = (
f"Keep only {versions_to_keep} versions for:\n{contexts_list}"
)
messagebox = QtWidgets.QMessageBox()
messagebox.setIcon(QtWidgets.QMessageBox.Warning)
messagebox.setWindowTitle("Delete Old Versions")
messagebox.setText(text)
messagebox.setInformativeText(informative_text)
messagebox.setDetailedText(detailed_text)
messagebox.setStandardButtons(
QtWidgets.QMessageBox.Yes
| QtWidgets.QMessageBox.Cancel
)
messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel)
messagebox.setStyleSheet(style.load_stylesheet())
messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
return messagebox.exec_() == QtWidgets.QMessageBox.Yes
def get_data(self, context, versions_count):
product_entity = context["product"]
folder_entity = context["folder"]
project_name = context["project"]["name"]
anatomy = Anatomy(project_name, project_entity=context["project"])
version_fields = ayon_api.get_default_fields_for_type("version")
version_fields.add("tags")
versions = list(ayon_api.get_versions(
project_name,
product_ids=[product_entity["id"]],
active=None,
hero=False,
fields=version_fields
))
self.log.debug(
"Version Number ({})".format(len(versions))
)
versions_by_parent = collections.defaultdict(list)
for ent in versions:
versions_by_parent[ent["productId"]].append(ent)
def sort_func(ent):
return int(ent["version"])
all_last_versions = []
for _parent_id, _versions in versions_by_parent.items():
for idx, version in enumerate(
sorted(_versions, key=sort_func, reverse=True)
):
if idx >= versions_count:
break
all_last_versions.append(version)
self.log.debug("Collected versions ({})".format(len(versions)))
# Filter latest versions
for version in all_last_versions:
versions.remove(version)
# Update versions_by_parent without filtered versions
versions_by_parent = collections.defaultdict(list)
for ent in versions:
versions_by_parent[ent["productId"]].append(ent)
# Filter already deleted versions
versions_to_pop = []
for version in versions:
if "deleted" in version["tags"]:
versions_to_pop.append(version)
for version in versions_to_pop:
msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format(
folder_entity["path"],
product_entity["name"],
version["version"]
)
self.log.debug((
"Skipping version. Already tagged as inactive. < {} >"
).format(msg))
versions.remove(version)
version_ids = [ent["id"] for ent in versions]
self.log.debug(
"Filtered versions to delete ({})".format(len(version_ids))
)
if not version_ids:
msg = "Skipping processing. Nothing to delete on {}/{}".format(
folder_entity["path"], product_entity["name"]
)
self.log.info(msg)
print(msg)
return
repres = list(ayon_api.get_representations(
project_name, version_ids=version_ids
))
self.log.debug(
"Collected representations to remove ({})".format(len(repres))
)
dir_paths = {}
file_paths_by_dir = collections.defaultdict(list)
for repre in repres:
file_path, seq_path = self.path_from_representation(
repre, anatomy
)
if file_path is None:
self.log.debug((
"Could not format path for represenation \"{}\""
).format(str(repre)))
continue
dir_path = os.path.dirname(file_path)
dir_id = None
for _dir_id, _dir_path in dir_paths.items():
if _dir_path == dir_path:
dir_id = _dir_id
break
if dir_id is None:
dir_id = uuid.uuid4()
dir_paths[dir_id] = dir_path
file_paths_by_dir[dir_id].append([file_path, seq_path])
dir_ids_to_pop = []
for dir_id, dir_path in dir_paths.items():
if os.path.exists(dir_path):
continue
dir_ids_to_pop.append(dir_id)
# Pop dirs from both dictionaries
for dir_id in dir_ids_to_pop:
dir_paths.pop(dir_id)
paths = file_paths_by_dir.pop(dir_id)
# TODO report of missing directories?
paths_msg = ", ".join([
"'{}'".format(path[0].replace("\\", "/")) for path in paths
])
self.log.debug((
"Folder does not exist. Deleting its files skipped: {}"
).format(paths_msg))
return {
"dir_paths": dir_paths,
"file_paths_by_dir": file_paths_by_dir,
"versions": versions,
"folder": folder_entity,
"product": product_entity,
"archive_product": versions_count == 0
}
def main(self, project_name, data, remove_publish_folder):
# Size of files.
size = 0
if not data:
return size
if remove_publish_folder:
size = self.delete_whole_dir_paths(data["dir_paths"].values())
else:
size = self.delete_only_repre_files(
data["dir_paths"], data["file_paths_by_dir"]
)
op_session = OperationsSession()
for version in data["versions"]:
orig_version_tags = version["tags"]
version_tags = list(orig_version_tags)
changes = {}
if "deleted" not in version_tags:
version_tags.append("deleted")
changes["tags"] = version_tags
if version["active"]:
changes["active"] = False
if not changes:
continue
op_session.update_entity(
project_name, "version", version["id"], changes
)
op_session.commit()
return size
def load(self, contexts, name=None, namespace=None, options=None):
# Get user options
versions_to_keep = 2
remove_publish_folder = False
if options:
versions_to_keep = options.get(
"versions_to_keep", versions_to_keep
)
remove_publish_folder = options.get(
"remove_publish_folder", remove_publish_folder
)
# Because we do not want this run by accident we will add an extra
# user confirmation
if (
self.requires_confirmation
and not self._confirm_delete(contexts, versions_to_keep)
):
return
try:
size = 0
for count, context in enumerate(contexts):
data = self.get_data(context, versions_to_keep)
if not data:
continue
project_name = context["project"]["name"]
size += self.main(project_name, data, remove_publish_folder)
print("Progressing {}/{}".format(count + 1, len(contexts)))
msg = "Total size of files: {}".format(format_file_size(size))
self.log.info(msg)
self.message(msg)
except Exception:
self.log.error("Failed to delete versions.", exc_info=True)
class CalculateOldVersions(DeleteOldVersions):
"""Calculate file size of old versions"""
label = "Calculate Old Versions"
order = 30
tool_names = ["library_loader"]
options = [
qargparse.Integer(
"versions_to_keep", default=2, min=0, help="Versions to keep:"
),
qargparse.Boolean(
"remove_publish_folder", help="Remove publish folder:"
)
]
requires_confirmation = False
def main(self, project_name, data, remove_publish_folder):
size = 0
if not data:
return size
if remove_publish_folder:
size = self.delete_whole_dir_paths(
data["dir_paths"].values(), delete=False
)
else:
size = self.delete_only_repre_files(
data["dir_paths"], data["file_paths_by_dir"], delete=False
)
return size

View file

@ -1,36 +0,0 @@
import sys
import os
import subprocess
from ayon_core.pipeline import load
def open(filepath):
"""Open file with system default executable"""
if sys.platform.startswith('darwin'):
subprocess.call(('open', filepath))
elif os.name == 'nt':
os.startfile(filepath)
elif os.name == 'posix':
subprocess.call(('xdg-open', filepath))
class OpenFile(load.LoaderPlugin):
"""Open Image Sequence or Video with system default"""
product_types = {"render2d"}
representations = {"*"}
label = "Open"
order = -10
icon = "play-circle"
color = "orange"
def load(self, context, name, namespace, data):
path = self.filepath_from_context(context)
if not os.path.exists(path):
raise RuntimeError("File not found: {}".format(path))
self.log.info("Opening : {}".format(path))
open(path)

View file

@ -1,56 +0,0 @@
import os
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import get_ayon_launcher_args, run_detached_process
from ayon_core.pipeline import load
from ayon_core.pipeline.load import LoadError
class PushToProject(load.ProductLoaderPlugin):
"""Export selected versions to different project"""
is_multiple_contexts_compatible = True
representations = {"*"}
product_types = {"*"}
label = "Push to project"
order = 35
icon = "send"
color = "#d8d8d8"
def load(self, contexts, name=None, namespace=None, options=None):
filtered_contexts = [
context
for context in contexts
if context.get("project") and context.get("version")
]
if not filtered_contexts:
raise LoadError("Nothing to push for your selection")
folder_ids = set(
context["folder"]["id"]
for context in filtered_contexts
)
if len(folder_ids) > 1:
raise LoadError("Please select products from single folder")
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
"tools",
"push_to_project",
"main.py"
)
project_name = filtered_contexts[0]["project"]["name"]
version_ids = {
context["version"]["id"]
for context in filtered_contexts
}
args = get_ayon_launcher_args(
push_tool_script_path,
"--project", project_name,
"--versions", ",".join(version_ids)
)
run_detached_process(args)

View file

@ -0,0 +1,122 @@
import os
import collections
from typing import Optional, Any
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.actions import (
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
class CopyFileActionPlugin(LoaderActionPlugin):
"""Copy published file path to clipboard"""
identifier = "core.copy-action"
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
repres = []
if selection.selected_type == "representation":
repres = selection.entities.get_representations(
selection.selected_ids
)
if selection.selected_type == "version":
repres = selection.entities.get_versions_representations(
selection.selected_ids
)
output = []
if not repres:
return output
repre_ids_by_name = collections.defaultdict(set)
for repre in repres:
repre_ids_by_name[repre["name"]].add(repre["id"])
for repre_name, repre_ids in repre_ids_by_name.items():
repre_id = next(iter(repre_ids), None)
if not repre_id:
continue
output.append(
LoaderActionItem(
label=repre_name,
order=32,
group_label="Copy file path",
data={
"representation_id": repre_id,
"action": "copy-path",
},
icon={
"type": "material-symbols",
"name": "content_copy",
"color": "#999999",
}
)
)
output.append(
LoaderActionItem(
label=repre_name,
order=33,
group_label="Copy file",
data={
"representation_id": repre_id,
"action": "copy-file",
},
icon={
"type": "material-symbols",
"name": "file_copy",
"color": "#999999",
}
)
)
return output
def execute_action(
self,
selection: LoaderActionSelection,
data: dict,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
from qtpy import QtWidgets, QtCore
action = data["action"]
repre_id = data["representation_id"]
repre = next(iter(selection.entities.get_representations({repre_id})))
path = get_representation_path_with_anatomy(
repre, selection.get_project_anatomy()
)
self.log.info(f"Added file path to clipboard: {path}")
clipboard = QtWidgets.QApplication.clipboard()
if not clipboard:
return LoaderActionResult(
"Failed to copy file path to clipboard.",
success=False,
)
if action == "copy-path":
# Set to Clipboard
clipboard.setText(os.path.normpath(path))
return LoaderActionResult(
"Path stored to clipboard...",
success=True,
)
# Build mime data for clipboard
data = QtCore.QMimeData()
url = QtCore.QUrl.fromLocalFile(path)
data.setUrls([url])
# Set to Clipboard
clipboard.setMimeData(data)
return LoaderActionResult(
"File added to clipboard...",
success=True,
)

View file

@ -0,0 +1,388 @@
from __future__ import annotations
import os
import collections
import json
import shutil
from typing import Optional, Any
from ayon_api.operations import OperationsSession
from ayon_core.lib import (
format_file_size,
AbstractAttrDef,
NumberDef,
BoolDef,
TextDef,
UILabelDef,
)
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.actions import (
ActionForm,
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
class DeleteOldVersions(LoaderActionPlugin):
"""Deletes specific number of old version"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
requires_confirmation = True
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
# Do not show in hosts
if self.host_name is not None:
return []
versions = selection.get_selected_version_entities()
if not versions:
return []
product_ids = {
version["productId"]
for version in versions
}
return [
LoaderActionItem(
label="Delete Versions",
order=35,
data={
"product_ids": list(product_ids),
"action": "delete-versions",
},
icon={
"type": "material-symbols",
"name": "delete",
"color": "#d8d8d8",
}
),
LoaderActionItem(
label="Calculate Versions size",
order=34,
data={
"product_ids": list(product_ids),
"action": "calculate-versions-size",
},
icon={
"type": "material-symbols",
"name": "auto_delete",
"color": "#d8d8d8",
}
)
]
def execute_action(
self,
selection: LoaderActionSelection,
data: dict[str, Any],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
step = form_values.get("step")
action = data["action"]
versions_to_keep = form_values.get("versions_to_keep")
remove_publish_folder = form_values.get("remove_publish_folder")
if step is None:
return self._first_step(
action,
versions_to_keep,
remove_publish_folder,
)
if versions_to_keep is None:
versions_to_keep = 2
if remove_publish_folder is None:
remove_publish_folder = False
product_ids = data["product_ids"]
if step == "prepare-data":
return self._prepare_data_step(
action,
versions_to_keep,
remove_publish_folder,
product_ids,
selection,
)
if step == "delete-versions":
return self._delete_versions_step(
selection.project_name, form_values
)
return None
def _first_step(
self,
action: str,
versions_to_keep: Optional[int],
remove_publish_folder: Optional[bool],
) -> LoaderActionResult:
fields: list[AbstractAttrDef] = [
TextDef(
"step",
visible=False,
),
NumberDef(
"versions_to_keep",
label="Versions to keep",
minimum=0,
default=2,
),
]
if action == "delete-versions":
fields.append(
BoolDef(
"remove_publish_folder",
label="Remove publish folder",
default=False,
)
)
form_values = {
key: value
for key, value in (
("remove_publish_folder", remove_publish_folder),
("versions_to_keep", versions_to_keep),
)
if value is not None
}
form_values["step"] = "prepare-data"
return LoaderActionResult(
form=ActionForm(
title="Delete Old Versions",
fields=fields,
),
form_values=form_values
)
def _prepare_data_step(
self,
action: str,
versions_to_keep: int,
remove_publish_folder: bool,
entity_ids: set[str],
selection: LoaderActionSelection,
):
versions_by_product_id = collections.defaultdict(list)
for version in selection.entities.get_products_versions(entity_ids):
# Keep hero version
if versions_to_keep != 0 and version["version"] < 0:
continue
versions_by_product_id[version["productId"]].append(version)
versions_to_delete = []
for product_id, versions in versions_by_product_id.items():
if versions_to_keep == 0:
versions_to_delete.extend(versions)
continue
if len(versions) <= versions_to_keep:
continue
versions.sort(key=lambda v: v["version"])
for _ in range(versions_to_keep):
if not versions:
break
versions.pop(-1)
versions_to_delete.extend(versions)
self.log.debug(
f"Collected versions to delete ({len(versions_to_delete)})"
)
version_ids = {
version["id"]
for version in versions_to_delete
}
if not version_ids:
return LoaderActionResult(
message="Skipping. Nothing to delete.",
success=False,
)
project = selection.entities.get_project()
anatomy = Anatomy(project["name"], project_entity=project)
repres = selection.entities.get_versions_representations(version_ids)
self.log.debug(
f"Collected representations to remove ({len(repres)})"
)
filepaths_by_repre_id = {}
repre_ids_by_version_id = {
version_id: []
for version_id in version_ids
}
for repre in repres:
repre_ids_by_version_id[repre["versionId"]].append(repre["id"])
filepaths_by_repre_id[repre["id"]] = [
anatomy.fill_root(repre_file["path"])
for repre_file in repre["files"]
]
size = 0
for filepaths in filepaths_by_repre_id.values():
for filepath in filepaths:
if os.path.exists(filepath):
size += os.path.getsize(filepath)
if action == "calculate-versions-size":
return LoaderActionResult(
message="Calculated size",
success=True,
form=ActionForm(
title="Calculated versions size",
fields=[
UILabelDef(
f"Total size of files: {format_file_size(size)}"
),
],
submit_label=None,
cancel_label="Close",
),
)
form, form_values = self._get_delete_form(
size,
remove_publish_folder,
list(version_ids),
repre_ids_by_version_id,
filepaths_by_repre_id,
)
return LoaderActionResult(
form=form,
form_values=form_values
)
def _delete_versions_step(
self, project_name: str, form_values: dict[str, Any]
) -> LoaderActionResult:
delete_data = json.loads(form_values["delete_data"])
remove_publish_folder = form_values["remove_publish_folder"]
if form_values["delete_value"].lower() != "delete":
size = delete_data["size"]
form, form_values = self._get_delete_form(
size,
remove_publish_folder,
delete_data["version_ids"],
delete_data["repre_ids_by_version_id"],
delete_data["filepaths_by_repre_id"],
True,
)
return LoaderActionResult(
form=form,
form_values=form_values,
)
version_ids = delete_data["version_ids"]
repre_ids_by_version_id = delete_data["repre_ids_by_version_id"]
filepaths_by_repre_id = delete_data["filepaths_by_repre_id"]
op_session = OperationsSession()
total_versions = len(version_ids)
try:
for version_idx, version_id in enumerate(version_ids):
self.log.info(
f"Progressing version {version_idx + 1}/{total_versions}"
)
for repre_id in repre_ids_by_version_id[version_id]:
for filepath in filepaths_by_repre_id[repre_id]:
publish_folder = os.path.dirname(filepath)
if remove_publish_folder:
if os.path.exists(publish_folder):
shutil.rmtree(
publish_folder, ignore_errors=True
)
continue
if os.path.exists(filepath):
os.remove(filepath)
op_session.delete_entity(
project_name, "representation", repre_id
)
op_session.delete_entity(
project_name, "version", version_id
)
self.log.info("All done")
except Exception:
self.log.error("Failed to delete versions.", exc_info=True)
return LoaderActionResult(
message="Failed to delete versions.",
success=False,
)
finally:
op_session.commit()
return LoaderActionResult(
message="Deleted versions",
success=True,
)
def _get_delete_form(
self,
size: int,
remove_publish_folder: bool,
version_ids: list[str],
repre_ids_by_version_id: dict[str, list[str]],
filepaths_by_repre_id: dict[str, list[str]],
repeated: bool = False,
) -> tuple[ActionForm, dict[str, Any]]:
versions_len = len(repre_ids_by_version_id)
fields = [
UILabelDef(
f"Going to delete {versions_len} versions<br/>"
f"- total size of files: {format_file_size(size)}<br/>"
),
UILabelDef("Are you sure you want to continue?"),
TextDef(
"delete_value",
placeholder="Type 'delete' to confirm...",
),
]
if repeated:
fields.append(UILabelDef(
"*Please fill in '**delete**' to confirm deletion.*"
))
fields.extend([
TextDef(
"delete_data",
visible=False,
),
TextDef(
"step",
visible=False,
),
BoolDef(
"remove_publish_folder",
label="Remove publish folder",
default=False,
visible=False,
)
])
form = ActionForm(
title="Delete versions",
submit_label="Delete",
cancel_label="Close",
fields=fields,
)
form_values = {
"delete_data": json.dumps({
"size": size,
"version_ids": version_ids,
"repre_ids_by_version_id": repre_ids_by_version_id,
"filepaths_by_repre_id": filepaths_by_repre_id,
}),
"step": "delete-versions",
"remove_publish_folder": remove_publish_folder,
}
return form, form_values

View file

@ -1,5 +1,6 @@
import platform
from collections import defaultdict
from typing import Optional, Any
import ayon_api
from qtpy import QtWidgets, QtCore, QtGui
@ -10,7 +11,12 @@ from ayon_core.lib import (
collect_frames,
get_datetime_data,
)
from ayon_core.pipeline import load, Anatomy
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.delivery import (
get_format_dict,
@ -20,43 +26,72 @@ from ayon_core.pipeline.delivery import (
)
class Delivery(load.ProductLoaderPlugin):
"""Export selected versions to folder structure from Template"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = {"*"}
product_types = {"*"}
tool_names = ["library_loader"]
class DeliveryAction(LoaderSimpleActionPlugin):
identifier = "core.delivery"
label = "Deliver Versions"
order = 35
icon = "upload"
color = "#d8d8d8"
icon = {
"type": "material-symbols",
"name": "upload",
"color": "#d8d8d8",
}
def message(self, text):
msgBox = QtWidgets.QMessageBox()
msgBox.setText(text)
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
def is_compatible(self, selection: LoaderActionSelection) -> bool:
if self.host_name is not None:
return False
if not selection.selected_ids:
return False
return (
selection.versions_selected()
or selection.representations_selected()
)
msgBox.exec_()
def load(self, contexts, name=None, namespace=None, options=None):
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
version_ids = set()
if selection.selected_type == "representation":
versions = selection.entities.get_representations_versions(
selection.selected_ids
)
version_ids = {version["id"] for version in versions}
if selection.selected_type == "version":
version_ids = set(selection.selected_ids)
if not version_ids:
return LoaderActionResult(
message="No versions found in your selection",
success=False,
)
try:
dialog = DeliveryOptionsDialog(contexts, self.log)
# TODO run the tool in subprocess
dialog = DeliveryOptionsDialog(
selection.project_name, version_ids, self.log
)
dialog.exec_()
except Exception:
self.log.error("Failed to deliver versions.", exc_info=True)
return LoaderActionResult()
class DeliveryOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
def __init__(self, contexts, log=None, parent=None):
super(DeliveryOptionsDialog, self).__init__(parent=parent)
def __init__(
self,
project_name,
version_ids,
log=None,
parent=None,
):
super().__init__(parent=parent)
self.setWindowTitle("AYON - Deliver versions")
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
@ -70,13 +105,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
self.setStyleSheet(style.load_stylesheet())
project_name = contexts[0]["project"]["name"]
self.anatomy = Anatomy(project_name)
self._representations = None
self.log = log
self.currently_uploaded = 0
self._set_representations(project_name, contexts)
self._set_representations(project_name, version_ids)
dropdown = QtWidgets.QComboBox()
self.templates = self._get_templates(self.anatomy)
@ -316,9 +350,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
return templates
def _set_representations(self, project_name, contexts):
version_ids = {context["version"]["id"] for context in contexts}
def _set_representations(self, project_name, version_ids):
repres = list(ayon_api.get_representations(
project_name, version_ids=version_ids
))

View file

@ -2,11 +2,10 @@ import logging
import os
from pathlib import Path
from collections import defaultdict
from typing import Any, Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_api import get_representations
from ayon_core.pipeline import load, Anatomy
from ayon_core import resources, style
from ayon_core.lib.transcoding import (
IMAGE_EXTENSIONS,
@ -16,9 +15,16 @@ from ayon_core.lib import (
get_ffprobe_data,
is_oiio_supported,
)
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.tools.utils import show_message_dialog
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
OTIO = None
FRAME_SPLITTER = "__frame_splitter__"
@ -30,34 +36,99 @@ def _import_otio():
OTIO = opentimelineio
class ExportOTIO(load.ProductLoaderPlugin):
"""Export selected versions to OpenTimelineIO."""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
representations = {"*"}
product_types = {"*"}
tool_names = ["library_loader"]
class ExportOTIO(LoaderSimpleActionPlugin):
identifier = "core.export-otio"
label = "Export OTIO"
group_label = None
order = 35
icon = "save"
color = "#d8d8d8"
icon = {
"type": "material-symbols",
"name": "save",
"color": "#d8d8d8",
}
def load(self, contexts, name=None, namespace=None, options=None):
def is_compatible(
self, selection: LoaderActionSelection
) -> bool:
# Don't show in hosts
if self.host_name is not None:
return False
return selection.versions_selected()
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
_import_otio()
version_ids = set(selection.selected_ids)
versions_by_id = {
version["id"]: version
for version in selection.entities.get_versions(version_ids)
}
product_ids = {
version["productId"]
for version in versions_by_id.values()
}
products_by_id = {
product["id"]: product
for product in selection.entities.get_products(product_ids)
}
folder_ids = {
product["folderId"]
for product in products_by_id.values()
}
folder_by_id = {
folder["id"]: folder
for folder in selection.entities.get_folders(folder_ids)
}
repre_entities = selection.entities.get_versions_representations(
version_ids
)
version_path_by_id = {}
for version in versions_by_id.values():
version_id = version["id"]
product_id = version["productId"]
product = products_by_id[product_id]
folder_id = product["folderId"]
folder = folder_by_id[folder_id]
version_path_by_id[version_id] = "/".join([
folder["path"],
product["name"],
version["name"]
])
try:
dialog = ExportOTIOOptionsDialog(contexts, self.log)
# TODO this should probably trigger a subprocess?
dialog = ExportOTIOOptionsDialog(
selection.project_name,
versions_by_id,
repre_entities,
version_path_by_id,
self.log
)
dialog.exec_()
except Exception:
self.log.error("Failed to export OTIO.", exc_info=True)
return LoaderActionResult()
class ExportOTIOOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
def __init__(self, contexts, log=None, parent=None):
def __init__(
self,
project_name,
versions_by_id,
repre_entities,
version_path_by_id,
log=None,
parent=None
):
# Not all hosts have OpenTimelineIO available.
self.log = log
@ -73,30 +144,14 @@ class ExportOTIOOptionsDialog(QtWidgets.QDialog):
| QtCore.Qt.WindowMinimizeButtonHint
)
project_name = contexts[0]["project"]["name"]
versions_by_id = {
context["version"]["id"]: context["version"]
for context in contexts
}
repre_entities = list(get_representations(
project_name, version_ids=set(versions_by_id)
))
version_by_representation_id = {
repre_entity["id"]: versions_by_id[repre_entity["versionId"]]
for repre_entity in repre_entities
}
version_path_by_id = {}
representations_by_version_id = {}
for context in contexts:
version_id = context["version"]["id"]
if version_id in version_path_by_id:
continue
representations_by_version_id[version_id] = []
version_path_by_id[version_id] = "/".join([
context["folder"]["path"],
context["product"]["name"],
context["version"]["name"]
])
representations_by_version_id = {
version_id: []
for version_id in versions_by_id
}
for repre_entity in repre_entities:
representations_by_version_id[repre_entity["versionId"]].append(

View file

@ -0,0 +1,360 @@
import os
import sys
import subprocess
import platform
import collections
import ctypes
from typing import Optional, Any, Callable
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.actions import (
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
WINDOWS_USER_REG_PATH = (
r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts"
r"\{ext}\UserChoice"
)
class _Cache:
"""Cache extensions information.
Notes:
The cache is cleared when loader tool is refreshed so it might be
moved to other place which is not cleared on refresh.
"""
supported_exts: set[str] = set()
unsupported_exts: set[str] = set()
@classmethod
def is_supported(cls, ext: str) -> bool:
return ext in cls.supported_exts
@classmethod
def already_checked(cls, ext: str) -> bool:
return (
ext in cls.supported_exts
or ext in cls.unsupported_exts
)
@classmethod
def set_ext_support(cls, ext: str, supported: bool) -> None:
if supported:
cls.supported_exts.add(ext)
else:
cls.unsupported_exts.add(ext)
def _extension_has_assigned_app_windows(ext: str) -> bool:
import winreg
progid = None
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
WINDOWS_USER_REG_PATH.format(ext=ext),
) as k:
progid, _ = winreg.QueryValueEx(k, "ProgId")
except OSError:
pass
if progid:
return True
try:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k:
progid = winreg.QueryValueEx(k, None)[0]
except OSError:
pass
return bool(progid)
def _linux_find_desktop_file(desktop: str) -> Optional[str]:
for dirpath in (
os.path.expanduser("~/.local/share/applications"),
"/usr/share/applications",
"/usr/local/share/applications",
):
path = os.path.join(dirpath, desktop)
if os.path.isfile(path):
return path
return None
def _extension_has_assigned_app_linux(ext: str) -> bool:
import mimetypes
mime, _ = mimetypes.guess_type(f"file{ext}")
if not mime:
return False
try:
# xdg-mime query default <mime>
desktop = subprocess.check_output(
["xdg-mime", "query", "default", mime],
text=True
).strip() or None
except Exception:
desktop = None
if not desktop:
return False
desktop_path = _linux_find_desktop_file(desktop)
if not desktop_path:
return False
if desktop_path and os.path.isfile(desktop_path):
return True
return False
def _extension_has_assigned_app_macos(ext: str) -> bool:
# Uses CoreServices/LaunchServices and Uniform Type Identifiers via
# ctypes.
# Steps: ext -> UTI -> default handler bundle id for role 'all'.
cf = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
)
ls = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreServices.framework/Frameworks"
"/LaunchServices.framework/LaunchServices"
)
# CFType/CFString helpers
CFStringRef = ctypes.c_void_p
CFAllocatorRef = ctypes.c_void_p
CFIndex = ctypes.c_long
kCFStringEncodingUTF8 = 0x08000100
cf.CFStringCreateWithCString.argtypes = [
CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32
]
cf.CFStringCreateWithCString.restype = CFStringRef
cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32]
cf.CFStringGetCStringPtr.restype = ctypes.c_char_p
cf.CFStringGetCString.argtypes = [
CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32
]
cf.CFStringGetCString.restype = ctypes.c_bool
cf.CFRelease.argtypes = [ctypes.c_void_p]
cf.CFRelease.restype = None
try:
UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreServices.framework/CoreServices"
).UTTypeCreatePreferredIdentifierForTag
except OSError:
# Fallback path (older systems)
UTTypeCreatePreferredIdentifierForTag = (
ls.UTTypeCreatePreferredIdentifierForTag
)
UTTypeCreatePreferredIdentifierForTag.argtypes = [
CFStringRef, CFStringRef, CFStringRef
]
UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef
LSRolesMask = ctypes.c_uint
kLSRolesAll = 0xFFFFFFFF
ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [
CFStringRef, LSRolesMask
]
ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef
def cfstr(py_s: str) -> CFStringRef:
return cf.CFStringCreateWithCString(
None, py_s.encode("utf-8"), kCFStringEncodingUTF8
)
def to_pystr(cf_s: CFStringRef) -> Optional[str]:
if not cf_s:
return None
# Try fast pointer
ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8)
if ptr:
return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
# Fallback buffer
buf_size = 1024
buf = ctypes.create_string_buffer(buf_size)
ok = cf.CFStringGetCString(
cf_s, buf, buf_size, kCFStringEncodingUTF8
)
if ok:
return buf.value.decode("utf-8")
return None
# Convert extension (without dot) to UTI
tag_class = cfstr("public.filename-extension")
tag_value = cfstr(ext.lstrip("."))
uti_ref = UTTypeCreatePreferredIdentifierForTag(
tag_class, tag_value, None
)
# Clean up temporary CFStrings
for ref in (tag_class, tag_value):
if ref:
cf.CFRelease(ref)
bundle_id = None
if uti_ref:
# Get default handler for the UTI
default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType(
uti_ref, kLSRolesAll
)
bundle_id = to_pystr(default_bundle_ref)
if default_bundle_ref:
cf.CFRelease(default_bundle_ref)
cf.CFRelease(uti_ref)
return bundle_id is not None
def _filter_supported_exts(
extensions: set[str], test_func: Callable
) -> set[str]:
filtered_exs: set[str] = set()
for ext in extensions:
if not _Cache.already_checked(ext):
_Cache.set_ext_support(ext, test_func(ext))
if _Cache.is_supported(ext):
filtered_exs.add(ext)
return filtered_exs
def filter_supported_exts(extensions: set[str]) -> set[str]:
if not extensions:
return set()
platform_name = platform.system().lower()
if platform_name == "windows":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_windows
)
if platform_name == "linux":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_linux
)
if platform_name == "darwin":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_macos
)
return set()
def open_file(filepath: str) -> None:
"""Open file with system default executable"""
if sys.platform.startswith("darwin"):
subprocess.call(("open", filepath))
elif os.name == "nt":
os.startfile(filepath)
elif os.name == "posix":
subprocess.call(("xdg-open", filepath))
class OpenFileAction(LoaderActionPlugin):
"""Open Image Sequence or Video with system default"""
identifier = "core.open-file"
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
repres = []
if selection.selected_type == "representation":
repres = selection.entities.get_representations(
selection.selected_ids
)
if selection.selected_type == "version":
repres = selection.entities.get_versions_representations(
selection.selected_ids
)
if not repres:
return []
repres_by_ext = collections.defaultdict(list)
for repre in repres:
repre_context = repre.get("context")
if not repre_context:
continue
ext = repre_context.get("ext")
if not ext:
path = repre["attrib"].get("path")
if path:
ext = os.path.splitext(path)[1]
if ext:
ext = ext.lower()
if not ext.startswith("."):
ext = f".{ext}"
repres_by_ext[ext.lower()].append(repre)
if not repres_by_ext:
return []
filtered_exts = filter_supported_exts(set(repres_by_ext))
repre_ids_by_name = collections.defaultdict(set)
for ext in filtered_exts:
for repre in repres_by_ext[ext]:
repre_ids_by_name[repre["name"]].add(repre["id"])
return [
LoaderActionItem(
label=repre_name,
group_label="Open file",
order=30,
data={"representation_ids": list(repre_ids)},
icon={
"type": "material-symbols",
"name": "file_open",
"color": "#ffffff",
}
)
for repre_name, repre_ids in repre_ids_by_name.items()
]
def execute_action(
self,
selection: LoaderActionSelection,
data: dict[str, Any],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
path = None
repre_path = None
repre_ids = data["representation_ids"]
for repre in selection.entities.get_representations(repre_ids):
repre_path = get_representation_path_with_anatomy(
repre, selection.get_project_anatomy()
)
if os.path.exists(repre_path):
path = repre_path
break
if path is None:
if repre_path is None:
return LoaderActionResult(
"Failed to fill representation path...",
success=False,
)
return LoaderActionResult(
"File to open was not found...",
success=False,
)
self.log.info(f"Opening: {path}")
open_file(path)
return LoaderActionResult(
"File was opened...",
success=True,
)

View file

@ -0,0 +1,69 @@
import os
from typing import Optional, Any
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import get_ayon_launcher_args, run_detached_process
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
class PushToProject(LoaderSimpleActionPlugin):
identifier = "core.push-to-project"
label = "Push to project"
order = 35
icon = {
"type": "material-symbols",
"name": "send",
"color": "#d8d8d8",
}
def is_compatible(
self, selection: LoaderActionSelection
) -> bool:
if not selection.versions_selected():
return False
version_ids = set(selection.selected_ids)
product_ids = {
product["id"]
for product in selection.entities.get_versions_products(
version_ids
)
}
folder_ids = {
folder["id"]
for folder in selection.entities.get_products_folders(
product_ids
)
}
if len(folder_ids) == 1:
return True
return False
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
"tools",
"push_to_project",
"main.py"
)
args = get_ayon_launcher_args(
push_tool_script_path,
"--project", selection.project_name,
"--versions", ",".join(selection.selected_ids)
)
run_detached_process(args)
return LoaderActionResult(
message="Push to project tool opened...",
success=True,
)

View file

@ -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

View file

@ -52,7 +52,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
context, self.__class__
):
# Skip instances that already have audio filled
if instance.data.get("audio"):
if "audio" in instance.data:
self.log.debug(
"Skipping Audio collection. It is already collected"
)

View file

@ -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?

View file

@ -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:

View file

@ -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

View file

@ -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")

View file

@ -11,6 +11,7 @@ from ayon_core.lib import (
is_oiio_supported,
)
from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert,
)
@ -111,7 +112,17 @@ class ExtractOIIOTranscode(publish.Extractor):
self.log.warning("Config file doesn't exist, skipping")
continue
# Get representation files to convert
if isinstance(repre["files"], list):
repre_files_to_convert = copy.deepcopy(repre["files"])
else:
repre_files_to_convert = [repre["files"]]
# Process each output definition
for output_def in profile_output_defs:
# Local copy to avoid accidental mutable changes
files_to_convert = list(repre_files_to_convert)
output_name = output_def["name"]
new_repre = copy.deepcopy(repre)
@ -122,11 +133,6 @@ class ExtractOIIOTranscode(publish.Extractor):
)
new_repre["stagingDir"] = new_staging_dir
if isinstance(new_repre["files"], list):
files_to_convert = copy.deepcopy(new_repre["files"])
else:
files_to_convert = [new_repre["files"]]
output_extension = output_def["extension"]
output_extension = output_extension.replace('.', '')
self._rename_in_representation(new_repre,
@ -168,30 +174,49 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"]
["additional_command_args"])
files_to_convert = self._translate_to_sequence(
files_to_convert)
self.log.debug("Files to convert: {}".format(files_to_convert))
for file_name in files_to_convert:
sequence_files = self._translate_to_sequence(files_to_convert)
self.log.debug("Files to convert: {}".format(sequence_files))
missing_rgba_review_channels = False
for file_name in sequence_files:
if isinstance(file_name, clique.Collection):
# Convert to filepath that can be directly converted
# by oiio like `frame.1001-1025%04d.exr`
file_name: str = file_name.format(
"{head}{range}{padding}{tail}"
)
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
try:
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
)
except MissingRGBAChannelsError as exc:
missing_rgba_review_channels = True
self.log.error(exc)
self.log.error(
"Skipping OIIO Transcode. Unknown RGBA channels"
f" for colorspace conversion in file: {input_path}"
)
break
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
)
if missing_rgba_review_channels:
# Stop processing this representation
break
# cleanup temporary transcoded files
for file_name in new_repre["files"]:
@ -217,11 +242,11 @@ class ExtractOIIOTranscode(publish.Extractor):
added_review = True
# If there is only 1 file outputted then convert list to
# string, cause that'll indicate that its not a sequence.
# string, because that'll indicate that it is not a sequence.
if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0]
# If the source representation has "review" tag, but its not
# If the source representation has "review" tag, but it's not
# part of the output definition tags, then both the
# representations will be transcoded in ExtractReview and
# their outputs will clash in integration.
@ -271,42 +296,34 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert):
"""Returns original list or list with filename formatted in single
sequence format.
"""Returns original list or a clique.Collection of a sequence.
Uses clique to find frame sequence, in this case it merges all frames
into sequence format (FRAMESTART-FRAMEEND#) and returns it.
If sequence not found, it returns original list
Uses clique to find frame sequence Collection.
If sequence not found, it returns original list.
Args:
files_to_convert (list): list of file names
Returns:
(list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr]
list[str | clique.Collection]: List of filepaths or a list
of Collections (usually one, unless there are holes)
"""
pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble(
files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True)
if collections:
if len(collections) > 1:
raise ValueError(
"Too many collections {}".format(collections))
collection = collections[0]
frames = list(collection.indexes)
if collection.holes().indexes:
return files_to_convert
# Get the padding from the collection
# This is the number of digits used in the frame numbers
padding = collection.padding
frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding)
file_name = "{}{}{}".format(collection.head, frame_str,
collection.tail)
files_to_convert = [file_name]
# TODO: Technically oiiotool supports holes in the sequence as well
# using the dedicated --frames argument to specify the frames.
# We may want to use that too so conversions of sequences with
# holes will perform faster as well.
# Separate the collection so that we have no holes/gaps per
# collection.
return collection.separate()
return files_to_convert

View file

@ -1,12 +1,83 @@
import collections
import hashlib
import os
import tempfile
import uuid
from pathlib import Path
import pyblish
from ayon_core.lib import get_ffmpeg_tool_args, run_subprocess
from ayon_core.lib import (
get_ffmpeg_tool_args,
run_subprocess
)
def get_audio_instances(context):
"""Return only instances which are having audio in families
Args:
context (pyblish.context): context of publisher
Returns:
list: list of selected instances
"""
audio_instances = []
for instance in context:
if not instance.data.get("parent_instance_id"):
continue
if (
instance.data["productType"] == "audio"
or instance.data.get("reviewAudio")
):
audio_instances.append(instance)
return audio_instances
def map_instances_by_parent_id(context):
"""Create a mapping of instances by their parent id
Args:
context (pyblish.context): context of publisher
Returns:
dict: mapping of instances by their parent id
"""
instances_by_parent_id = collections.defaultdict(list)
for instance in context:
parent_instance_id = instance.data.get("parent_instance_id")
if not parent_instance_id:
continue
instances_by_parent_id[parent_instance_id].append(instance)
return instances_by_parent_id
class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin):
"""Collect audio instance attribute"""
order = pyblish.api.CollectorOrder
label = "Collect Audio Instance Attribute"
def process(self, context):
audio_instances = get_audio_instances(context)
# no need to continue if no audio instances found
if not audio_instances:
return
# create mapped instances by parent id
instances_by_parent_id = map_instances_by_parent_id(context)
# distribute audio related attribute
for audio_instance in audio_instances:
parent_instance_id = audio_instance.data["parent_instance_id"]
for sibl_instance in instances_by_parent_id[parent_instance_id]:
# exclude the same audio instance
if sibl_instance.id == audio_instance.id:
continue
self.log.info(
"Adding audio to Sibling instance: "
f"{sibl_instance.data['label']}"
)
sibl_instance.data["audio"] = None
class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
@ -19,7 +90,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
order = pyblish.api.ExtractorOrder - 0.44
label = "Extract OTIO Audio Tracks"
hosts = ["hiero", "resolve", "flame"]
temp_dir_path = None
def process(self, context):
"""Convert otio audio track's content to audio representations
@ -28,13 +100,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
context (pyblish.Context): context of publisher
"""
# split the long audio file to peces devided by isntances
audio_instances = self.get_audio_instances(context)
self.log.debug("Audio instances: {}".format(len(audio_instances)))
audio_instances = get_audio_instances(context)
if len(audio_instances) < 1:
self.log.info("No audio instances available")
# no need to continue if no audio instances found
if not audio_instances:
return
self.log.debug("Audio instances: {}".format(len(audio_instances)))
# get sequence
otio_timeline = context.data["otioTimeline"]
@ -44,8 +117,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
if not audio_inputs:
return
# temp file
audio_temp_fpath = self.create_temp_file("audio")
# Convert all available audio into single file for trimming
audio_temp_fpath = self.create_temp_file("timeline_audio_track")
# create empty audio with longest duration
empty = self.create_empty(audio_inputs)
@ -59,19 +132,25 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
# remove empty
os.remove(empty["mediaPath"])
# create mapped instances by parent id
instances_by_parent_id = map_instances_by_parent_id(context)
# cut instance framerange and add to representations
self.add_audio_to_instances(audio_temp_fpath, audio_instances)
self.add_audio_to_instances(
audio_temp_fpath, audio_instances, instances_by_parent_id)
# remove full mixed audio file
os.remove(audio_temp_fpath)
def add_audio_to_instances(self, audio_file, instances):
def add_audio_to_instances(
self, audio_file, audio_instances, instances_by_parent_id):
created_files = []
for inst in instances:
name = inst.data["folderPath"]
for audio_instance in audio_instances:
folder_path = audio_instance.data["folderPath"]
file_suffix = folder_path.replace("/", "-")
recycling_file = [f for f in created_files if name in f]
audio_clip = inst.data["otioClip"]
recycling_file = [f for f in created_files if file_suffix in f]
audio_clip = audio_instance.data["otioClip"]
audio_range = audio_clip.range_in_parent()
duration = audio_range.duration.to_frames()
@ -84,68 +163,70 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
start_sec = relative_start_time.to_seconds()
duration_sec = audio_range.duration.to_seconds()
# temp audio file
audio_fpath = self.create_temp_file(name)
# shot related audio file
shot_audio_fpath = self.create_temp_file(file_suffix)
cmd = get_ffmpeg_tool_args(
"ffmpeg",
"-ss", str(start_sec),
"-t", str(duration_sec),
"-i", audio_file,
audio_fpath
shot_audio_fpath
)
# run subprocess
self.log.debug("Executing: {}".format(" ".join(cmd)))
run_subprocess(cmd, logger=self.log)
else:
audio_fpath = recycling_file.pop()
if "audio" in (
inst.data["families"] + [inst.data["productType"]]
):
# add generated audio file to created files for recycling
if shot_audio_fpath not in created_files:
created_files.append(shot_audio_fpath)
else:
shot_audio_fpath = recycling_file.pop()
# audio file needs to be published as representation
if audio_instance.data["productType"] == "audio":
# create empty representation attr
if "representations" not in inst.data:
inst.data["representations"] = []
if "representations" not in audio_instance.data:
audio_instance.data["representations"] = []
# add to representations
inst.data["representations"].append({
"files": os.path.basename(audio_fpath),
audio_instance.data["representations"].append({
"files": os.path.basename(shot_audio_fpath),
"name": "wav",
"ext": "wav",
"stagingDir": os.path.dirname(audio_fpath),
"stagingDir": os.path.dirname(shot_audio_fpath),
"frameStart": 0,
"frameEnd": duration
})
elif "reviewAudio" in inst.data.keys():
audio_attr = inst.data.get("audio") or []
# audio file needs to be reviewable too
elif "reviewAudio" in audio_instance.data.keys():
audio_attr = audio_instance.data.get("audio") or []
audio_attr.append({
"filename": audio_fpath,
"filename": shot_audio_fpath,
"offset": 0
})
inst.data["audio"] = audio_attr
audio_instance.data["audio"] = audio_attr
# add generated audio file to created files for recycling
if audio_fpath not in created_files:
created_files.append(audio_fpath)
def get_audio_instances(self, context):
"""Return only instances which are having audio in families
Args:
context (pyblish.context): context of publisher
Returns:
list: list of selected instances
"""
return [
_i for _i in context
# filter only those with audio product type or family
# and also with reviewAudio data key
if bool("audio" in (
_i.data.get("families", []) + [_i.data["productType"]])
) or _i.data.get("reviewAudio")
]
# Make sure if the audio instance is having siblink instances
# which needs audio for reviewable media so it is also added
# to its instance data
# Retrieve instance data from parent instance shot instance.
parent_instance_id = audio_instance.data["parent_instance_id"]
for sibl_instance in instances_by_parent_id[parent_instance_id]:
# exclude the same audio instance
if sibl_instance.id == audio_instance.id:
continue
self.log.info(
"Adding audio to Sibling instance: "
f"{sibl_instance.data['label']}"
)
audio_attr = sibl_instance.data.get("audio") or []
audio_attr.append({
"filename": shot_audio_fpath,
"offset": 0
})
sibl_instance.data["audio"] = audio_attr
def get_audio_track_items(self, otio_timeline):
"""Get all audio clips form OTIO audio tracks
@ -321,19 +402,23 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
os.remove(filters_tmp_filepath)
def create_temp_file(self, name):
def create_temp_file(self, file_suffix):
"""Create temp wav file
Args:
name (str): name to be used in file name
file_suffix (str): name to be used in file name
Returns:
str: temp fpath
"""
name = name.replace("/", "_")
return os.path.normpath(
tempfile.mktemp(
prefix="pyblish_tmp_{}_".format(name),
suffix=".wav"
)
)
extension = ".wav"
# get 8 characters
hash = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()[:8]
file_name = f"{hash}_{file_suffix}{extension}"
if not self.temp_dir_path:
audio_temp_dir_path = tempfile.mkdtemp(prefix="AYON_audio_")
self.temp_dir_path = Path(audio_temp_dir_path)
self.temp_dir_path.mkdir(parents=True, exist_ok=True)
return (self.temp_dir_path / file_name).as_posix()

View file

@ -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:

View file

@ -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.
@ -360,14 +361,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not filtered_output_defs:
self.log.debug((
"Repre: {} - All output definitions were filtered"
" out by single frame filter. Skipping"
" out by single frame filter. Skipped."
).format(repre["name"]))
continue
# Skip if file is not set
if first_input_path is None:
self.log.warning((
"Representation \"{}\" have empty files. Skipped."
"Representation \"{}\" has empty files. Skipped."
).format(repre["name"]))
continue
@ -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)

View file

@ -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,12 @@ from ayon_core.lib import (
path_to_subprocess_arg,
run_subprocess,
)
from ayon_core.lib.transcoding import oiio_color_convert
from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
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 +216,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 +231,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 +241,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 +393,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,
@ -455,9 +466,59 @@ 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)
try:
input_arg, channels_arg = get_oiio_input_and_channel_args(
input_info
)
except MissingRGBAChannelsError:
self.log.debug(
"Unable to find relevant reviewable channel for thumbnail "
"creation"
)
return False
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 {}

View file

@ -1,6 +1,7 @@
from operator import attrgetter
import dataclasses
import os
import platform
from typing import Any, Dict, List
import pyblish.api
@ -179,6 +180,8 @@ def get_instance_uri_path(
# Ensure `None` for now is also a string
path = str(path)
if platform.system().lower() == "windows":
path = path.replace("\\", "/")
return path

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.addWidget(attrs_widget, 0)
main_layout.addStretch(1)
main_layout.addWidget(btns_widget, 0)

View file

@ -182,6 +182,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
layout.deleteLater()
new_layout = QtWidgets.QGridLayout()
new_layout.setContentsMargins(0, 0, 0, 0)
new_layout.setColumnStretch(0, 0)
new_layout.setColumnStretch(1, 1)
self.setLayout(new_layout)
@ -210,12 +211,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
if not attr_def.visible:
continue
col_num = 0
expand_cols = 2
if attr_def.is_value_def and attr_def.is_label_horizontal:
expand_cols = 1
col_num = 2 - expand_cols
if attr_def.is_value_def and attr_def.label:
label_widget = AttributeDefinitionsLabel(
attr_def.id, attr_def.label, self
@ -233,9 +230,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
| QtCore.Qt.AlignVCenter
)
layout.addWidget(
label_widget, row, 0, 1, expand_cols
label_widget, row, col_num, 1, 1
)
if not attr_def.is_label_horizontal:
if attr_def.is_label_horizontal:
col_num += 1
expand_cols = 1
else:
row += 1
if attr_def.is_value_def:

View file

@ -1,11 +1,13 @@
from __future__ import annotations
import json
import contextlib
from abc import ABC, abstractmethod
from typing import Any, Optional
from dataclasses import dataclass
import ayon_api
from ayon_api.graphql_queries import projects_graphql_query
from ayon_core.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem, NestedCacheItem
@ -275,7 +277,7 @@ class ProductTypeIconMapping:
return self._definitions_by_name
def _get_project_items_from_entitiy(
def _get_project_items_from_entity(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
"""
@ -290,6 +292,7 @@ def _get_project_items_from_entitiy(
return [
ProjectItem.from_entity(project)
for project in projects
if project["active"]
]
@ -538,8 +541,32 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
return self._projects_cache.get_data()
def _fetch_graphql_projects(self) -> list[dict[str, Any]]:
"""Fetch projects using GraphQl.
This method was added because ayon_api had a bug in 'get_projects'.
Returns:
list[dict[str, Any]]: List of projects.
"""
api = ayon_api.get_server_api_connection()
query = projects_graphql_query({"name", "active", "library", "data"})
projects = []
for parsed_data in query.continuous_query(api):
for project in parsed_data["projects"]:
project_data = project["data"]
if project_data is None:
project["data"] = {}
elif isinstance(project_data, str):
project["data"] = json.loads(project_data)
projects.append(project)
return projects
def _query_projects(self) -> list[ProjectItem]:
projects = ayon_api.get_projects(fields=["name", "active", "library"])
projects = self._fetch_graphql_projects()
user = ayon_api.get_user()
pinned_projects = (
user
@ -548,7 +575,7 @@ class ProjectsModel(object):
.get("pinnedProjects")
) or []
pinned_projects = set(pinned_projects)
project_items = _get_project_items_from_entitiy(list(projects))
project_items = _get_project_items_from_entity(list(projects))
for project in project_items:
project.is_pinned = project.name in pinned_projects
return project_items

View file

@ -1,10 +1,13 @@
import json
import collections
from typing import Optional
import ayon_api
from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict
from ayon_core.lib import NestedCacheItem
from ayon_core.lib import NestedCacheItem, get_ayon_username
NOT_SET = object()
# --- Implementation that should be in ayon-python-api ---
@ -105,9 +108,18 @@ class UserItem:
class UsersModel:
def __init__(self, controller):
self._current_username = NOT_SET
self._controller = controller
self._users_cache = NestedCacheItem(default_factory=list)
def get_current_username(self) -> Optional[str]:
if self._current_username is NOT_SET:
self._current_username = get_ayon_username()
return self._current_username
def reset(self) -> None:
self._users_cache.reset()
def get_user_items(self, project_name):
"""Get user items.

View file

@ -1,10 +1,14 @@
from typing import Optional
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib import Logger
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 ayon_core.tools.common_models import (
ProjectsModel,
HierarchyModel,
UsersModel,
)
from .abstract import (
AbstractLauncherFrontEnd,
@ -30,13 +34,12 @@ class BaseLauncherController(
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)
self._users_model = UsersModel(self)
@property
def log(self):
@ -209,6 +212,7 @@ class BaseLauncherController(
self._projects_model.reset()
self._hierarchy_model.reset()
self._users_model.reset()
self._actions_model.refresh()
self._projects_model.refresh()
@ -229,8 +233,10 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished")
def get_my_tasks_entity_ids(self, project_name: str):
username = self._get_my_username()
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
@ -238,10 +244,5 @@ class BaseLauncherController(
project_name, assignees
)
def _get_my_username(self):
if self._username is NOT_SET:
self._username = get_ayon_username()
return self._username
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")

View file

@ -1,22 +1,12 @@
import time
import uuid
import collections
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.lib import Logger
from ayon_core.lib.attribute_definitions import (
UILabelDef,
EnumDef,
TextDef,
BoolDef,
NumberDef,
HiddenDef,
)
from ayon_core.pipeline.actions import webaction_fields_to_attribute_defs
from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
get_qt_icon,
)
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.launcher.abstract import WebactionContext
@ -1173,74 +1163,7 @@ class ActionsWidget(QtWidgets.QWidget):
float - 'label', 'value', 'placeholder', 'min', 'max'
"""
attr_defs = []
for config_field in config_fields:
field_type = config_field["type"]
attr_def = None
if field_type == "label":
label = config_field.get("value")
if label is None:
label = config_field.get("text")
attr_def = UILabelDef(
label, key=uuid.uuid4().hex
)
elif field_type == "boolean":
value = config_field["value"]
if isinstance(value, str):
value = value.lower() == "true"
attr_def = BoolDef(
config_field["name"],
default=value,
label=config_field.get("label"),
)
elif field_type == "text":
attr_def = TextDef(
config_field["name"],
default=config_field.get("value"),
label=config_field.get("label"),
placeholder=config_field.get("placeholder"),
multiline=config_field.get("multiline", False),
regex=config_field.get("regex"),
# syntax=config_field["syntax"],
)
elif field_type in ("integer", "float"):
value = config_field.get("value")
if isinstance(value, str):
if field_type == "integer":
value = int(value)
else:
value = float(value)
attr_def = NumberDef(
config_field["name"],
default=value,
label=config_field.get("label"),
decimals=0 if field_type == "integer" else 5,
# placeholder=config_field.get("placeholder"),
minimum=config_field.get("min"),
maximum=config_field.get("max"),
)
elif field_type in ("select", "multiselect"):
attr_def = EnumDef(
config_field["name"],
items=config_field["options"],
default=config_field.get("value"),
label=config_field.get("label"),
multiselection=field_type == "multiselect",
)
elif field_type == "hidden":
attr_def = HiddenDef(
config_field["name"],
default=config_field.get("value"),
)
if attr_def is None:
print(f"Unknown config field type: {field_type}")
attr_def = UILabelDef(
f"Unknown field type '{field_type}",
key=uuid.uuid4().hex
)
attr_defs.append(attr_def)
attr_defs = webaction_fields_to_attribute_defs(config_fields)
dialog = AttributeDefinitionsDialog(
attr_defs,

View file

@ -2,19 +2,47 @@ import qtawesome
from qtpy import QtWidgets, QtCore
from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget
from .workfiles_page import WorkfilesPage
class LauncherFoldersWidget(FoldersWidget):
focused_in = QtCore.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._folders_view.installEventFilter(self)
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focused_in.emit()
return False
class LauncherTasksWidget(TasksWidget):
focused_in = QtCore.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._tasks_view.installEventFilter(self)
def deselect(self):
sel_model = self._tasks_view.selectionModel()
sel_model.clearSelection()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focused_in.emit()
return False
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super().__init__(parent)
@ -46,34 +74,15 @@ class HierarchyPage(QtWidgets.QWidget):
content_body.setOrientation(QtCore.Qt.Horizontal)
# - filters
filters_widget = QtWidgets.QWidget(self)
folders_filter_text = PlaceholderLineEdit(filters_widget)
folders_filter_text.setPlaceholderText("Filter folders...")
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget)
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(filters_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
filters_layout = QtWidgets.QHBoxLayout(filters_widget)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.addWidget(folders_filter_text, 1)
filters_layout.addWidget(my_tasks_label, 0)
filters_layout.addWidget(my_tasks_checkbox, 0)
filters_widget = FoldersFiltersWidget(self)
# - Folders widget
folders_widget = FoldersWidget(controller, content_body)
folders_widget = LauncherFoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
folders_widget.set_deselectable(True)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
tasks_widget = LauncherTasksWidget(controller, content_body)
# - Third page - Workfiles
workfiles_page = WorkfilesPage(controller, content_body)
@ -93,17 +102,18 @@ class HierarchyPage(QtWidgets.QWidget):
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
my_tasks_checkbox.stateChanged.connect(
filters_widget.text_changed.connect(self._on_filter_text_changed)
filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
folders_widget.focused_in.connect(self._on_folders_focus)
tasks_widget.focused_in.connect(self._on_tasks_focus)
self._is_visible = False
self._controller = controller
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._workfiles_page = workfiles_page
@ -126,9 +136,6 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -139,11 +146,10 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, state):
def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
@ -151,3 +157,9 @@ class HierarchyPage(QtWidgets.QWidget):
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)
def _on_folders_focus(self):
self._workfiles_page.deselect()
def _on_tasks_focus(self):
self._workfiles_page.deselect()

View file

@ -3,7 +3,7 @@ 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.utils import get_qt_icon, DeselectableTreeView
from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
VERSION_ROLE = QtCore.Qt.UserRole + 1
@ -127,7 +127,7 @@ class WorkfilesModel(QtGui.QStandardItemModel):
return icon
class WorkfilesView(QtWidgets.QTreeView):
class WorkfilesView(DeselectableTreeView):
def drawBranches(self, painter, rect, index):
return
@ -165,6 +165,10 @@ class WorkfilesPage(QtWidgets.QWidget):
def refresh(self) -> None:
self._workfiles_model.refresh()
def deselect(self):
sel_model = self._workfiles_view.selectionModel()
sel_model.clearSelection()
def _on_refresh(self) -> None:
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)

View file

@ -316,43 +316,34 @@ class ActionItem:
Args:
identifier (str): Action identifier.
label (str): Action label.
icon (dict[str, Any]): Action icon definition.
tooltip (str): Action tooltip.
group_label (Optional[str]): Group label.
icon (Optional[dict[str, Any]]): Action icon definition.
tooltip (Optional[str]): Action tooltip.
order (int): Action order.
data (Optional[dict[str, Any]]): Additional action data.
options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]):
Action options. Note: 'qargparse' is considered as deprecated.
order (int): Action order.
project_name (str): Project name.
folder_ids (list[str]): Folder ids.
product_ids (list[str]): Product ids.
version_ids (list[str]): Version ids.
representation_ids (list[str]): Representation ids.
"""
"""
def __init__(
self,
identifier,
label,
icon,
tooltip,
options,
order,
project_name,
folder_ids,
product_ids,
version_ids,
representation_ids,
identifier: str,
label: str,
group_label: Optional[str],
icon: Optional[dict[str, Any]],
tooltip: Optional[str],
order: int,
data: Optional[dict[str, Any]],
options: Optional[list],
):
self.identifier = identifier
self.label = label
self.group_label = group_label
self.icon = icon
self.tooltip = tooltip
self.options = options
self.data = data
self.order = order
self.project_name = project_name
self.folder_ids = folder_ids
self.product_ids = product_ids
self.version_ids = version_ids
self.representation_ids = representation_ids
self.options = options
def _options_to_data(self):
options = self.options
@ -364,30 +355,26 @@ class ActionItem:
# future development of detached UI tools it would be better to be
# prepared for it.
raise NotImplementedError(
"{}.to_data is not implemented. Use Attribute definitions"
" from 'ayon_core.lib' instead of 'qargparse'.".format(
self.__class__.__name__
)
f"{self.__class__.__name__}.to_data is not implemented."
" Use Attribute definitions from 'ayon_core.lib'"
" instead of 'qargparse'."
)
def to_data(self):
def to_data(self) -> dict[str, Any]:
options = self._options_to_data()
return {
"identifier": self.identifier,
"label": self.label,
"group_label": self.group_label,
"icon": self.icon,
"tooltip": self.tooltip,
"options": options,
"order": self.order,
"project_name": self.project_name,
"folder_ids": self.folder_ids,
"product_ids": self.product_ids,
"version_ids": self.version_ids,
"representation_ids": self.representation_ids,
"data": self.data,
"options": options,
}
@classmethod
def from_data(cls, data):
def from_data(cls, data) -> "ActionItem":
options = data["options"]
if options:
options = deserialize_attr_defs(options)
@ -666,6 +653,21 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
@abstractmethod
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, list[str]]: Folder and task ids.
"""
pass
@abstractmethod
def get_available_tags_by_entity_type(
self, project_name: str
@ -990,43 +992,35 @@ class FrontendLoaderController(_BaseLoaderController):
# Load action items
@abstractmethod
def get_versions_action_items(self, project_name, version_ids):
def get_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
) -> list[ActionItem]:
"""Action items for versions selection.
Args:
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
entity_ids (set[str]): Entity ids.
entity_type (str): Entity type.
Returns:
list[ActionItem]: List of action items.
"""
pass
@abstractmethod
def get_representations_action_items(
self, project_name, representation_ids
):
"""Action items for representations selection.
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
Returns:
list[ActionItem]: List of action items.
"""
pass
@abstractmethod
def trigger_action_item(
self,
identifier,
options,
project_name,
version_ids,
representation_ids
identifier: str,
project_name: str,
selected_ids: set[str],
selected_entity_type: str,
data: Optional[dict[str, Any]],
options: dict[str, Any],
form_values: dict[str, Any],
):
"""Trigger action item.
@ -1044,13 +1038,15 @@ class FrontendLoaderController(_BaseLoaderController):
}
Args:
identifier (str): Action identifier.
options (dict[str, Any]): Action option values from UI.
identifier (sttr): Plugin identifier.
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
representation_ids (Iterable[str]): Representation ids.
"""
selected_ids (set[str]): Selected entity ids.
selected_entity_type (str): Selected entity type.
data (Optional[dict[str, Any]]): Additional action item data.
options (dict[str, Any]): Action option values from UI.
form_values (dict[str, Any]): Action form values from UI.
"""
pass
@abstractmethod

View file

@ -2,13 +2,17 @@ from __future__ import annotations
import logging
import uuid
from typing import Optional
from typing import Optional, Any
import ayon_api
from ayon_core.settings import get_project_settings
from ayon_core.pipeline import get_current_host_name
from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles
from ayon_core.lib import (
NestedCacheItem,
CacheItem,
filter_profiles,
)
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.pipeline import Anatomy, get_current_context
from ayon_core.host import ILoadHost
@ -18,12 +22,14 @@ from ayon_core.tools.common_models import (
ThumbnailsModel,
TagItem,
ProductTypeIconMapping,
UsersModel,
)
from .abstract import (
BackendLoaderController,
FrontendLoaderController,
ProductTypesFilter
ProductTypesFilter,
ActionItem,
)
from .models import (
SelectionModel,
@ -32,6 +38,8 @@ from .models import (
SiteSyncModel
)
NOT_SET = object()
class ExpectedSelection:
def __init__(self, controller):
@ -124,6 +132,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._loader_actions_model = LoaderActionsModel(self)
self._thumbnails_model = ThumbnailsModel()
self._sitesync_model = SiteSyncModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -160,6 +169,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._projects_model.reset()
self._thumbnails_model.reset()
self._sitesync_model.reset()
self._users_model.reset()
self._projects_model.refresh()
@ -235,6 +245,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
output[folder_id] = label
return output
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
def get_available_tags_by_entity_type(
self, project_name: str
) -> dict[str, list[str]]:
@ -296,45 +317,47 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
project_name, product_ids, group_name
)
def get_versions_action_items(self, project_name, version_ids):
return self._loader_actions_model.get_versions_action_items(
project_name, version_ids)
def get_representations_action_items(
self, project_name, representation_ids):
action_items = (
self._loader_actions_model.get_representations_action_items(
project_name, representation_ids)
def get_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
) -> list[ActionItem]:
action_items = self._loader_actions_model.get_action_items(
project_name, entity_ids, entity_type
)
action_items.extend(self._sitesync_model.get_sitesync_action_items(
project_name, representation_ids)
site_sync_items = self._sitesync_model.get_sitesync_action_items(
project_name, entity_ids, entity_type
)
action_items.extend(site_sync_items)
return action_items
def trigger_action_item(
self,
identifier,
options,
project_name,
version_ids,
representation_ids
identifier: str,
project_name: str,
selected_ids: set[str],
selected_entity_type: str,
data: Optional[dict[str, Any]],
options: dict[str, Any],
form_values: dict[str, Any],
):
if self._sitesync_model.is_sitesync_action(identifier):
self._sitesync_model.trigger_action_item(
identifier,
project_name,
representation_ids
data,
)
return
self._loader_actions_model.trigger_action_item(
identifier,
options,
project_name,
version_ids,
representation_ids
identifier=identifier,
project_name=project_name,
selected_ids=selected_ids,
selected_entity_type=selected_entity_type,
data=data,
options=options,
form_values=form_values,
)
# Selection model wrappers
@ -476,20 +499,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def is_standard_projects_filter_enabled(self):
return self._host is not None
def _get_project_anatomy(self, project_name):
if not project_name:
return None
cache = self._project_anatomy_cache[project_name]
if not cache.is_valid:
cache.update_data(Anatomy(project_name))
return cache.get_data()
def _create_event_system(self):
return QueuedEventSystem()
def _emit_event(self, topic, data=None):
self._event_system.emit(topic, data or {}, "controller")
def get_product_types_filter(self):
output = ProductTypesFilter(
is_allow_list=False,
@ -545,3 +554,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
product_types=profile["filter_product_types"]
)
return output
def _create_event_system(self):
return QueuedEventSystem()
def _emit_event(self, topic, data=None):
self._event_system.emit(topic, data or {}, "controller")
def _get_project_anatomy(self, project_name):
if not project_name:
return None
cache = self._project_anatomy_cache[project_name]
if not cache.is_valid:
cache.update_data(Anatomy(project_name))
return cache.get_data()

View file

@ -5,10 +5,16 @@ import traceback
import inspect
import collections
import uuid
from typing import Optional, Callable, Any
import ayon_api
from ayon_core.lib import NestedCacheItem
from ayon_core.lib import NestedCacheItem, Logger
from ayon_core.pipeline.actions import (
LoaderActionsContext,
LoaderActionSelection,
SelectionEntitiesCache,
)
from ayon_core.pipeline.load import (
discover_loader_plugins,
ProductLoaderPlugin,
@ -23,6 +29,7 @@ from ayon_core.pipeline.load import (
from ayon_core.tools.loader.abstract import ActionItem
ACTIONS_MODEL_SENDER = "actions.model"
LOADER_PLUGIN_ID = "__loader_plugin__"
NOT_SET = object()
@ -44,6 +51,7 @@ class LoaderActionsModel:
loaders_cache_lifetime = 30
def __init__(self, controller):
self._log = Logger.get_logger(self.__class__.__name__)
self._controller = controller
self._current_context_project = NOT_SET
self._loaders_by_identifier = NestedCacheItem(
@ -52,6 +60,15 @@ class LoaderActionsModel:
levels=1, lifetime=self.loaders_cache_lifetime)
self._repre_loaders = NestedCacheItem(
levels=1, lifetime=self.loaders_cache_lifetime)
self._loader_actions = LoaderActionsContext()
self._projects_cache = NestedCacheItem(levels=1, lifetime=60)
self._folders_cache = NestedCacheItem(levels=2, lifetime=300)
self._tasks_cache = NestedCacheItem(levels=2, lifetime=300)
self._products_cache = NestedCacheItem(levels=2, lifetime=300)
self._versions_cache = NestedCacheItem(levels=2, lifetime=1200)
self._representations_cache = NestedCacheItem(levels=2, lifetime=1200)
self._repre_parents_cache = NestedCacheItem(levels=2, lifetime=1200)
def reset(self):
"""Reset the model with all cached items."""
@ -60,64 +77,58 @@ class LoaderActionsModel:
self._loaders_by_identifier.reset()
self._product_loaders.reset()
self._repre_loaders.reset()
self._loader_actions.reset()
def get_versions_action_items(self, project_name, version_ids):
"""Get action items for given version ids.
self._folders_cache.reset()
self._tasks_cache.reset()
self._products_cache.reset()
self._versions_cache.reset()
self._representations_cache.reset()
self._repre_parents_cache.reset()
Args:
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
def get_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
) -> list[ActionItem]:
version_context_by_id = {}
repre_context_by_id = {}
if entity_type == "representation":
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_representations(project_name, entity_ids)
Returns:
list[ActionItem]: List of action items.
"""
if entity_type == "version":
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_versions(project_name, entity_ids)
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_versions(
project_name,
version_ids
)
return self._get_action_items_for_contexts(
action_items = self._get_action_items_for_contexts(
project_name,
version_context_by_id,
repre_context_by_id
)
def get_representations_action_items(
self, project_name, representation_ids
):
"""Get action items for given representation ids.
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
Returns:
list[ActionItem]: List of action items.
"""
(
product_context_by_id,
repre_context_by_id
) = self._contexts_for_representations(
action_items.extend(self._get_loader_action_items(
project_name,
representation_ids
)
return self._get_action_items_for_contexts(
project_name,
product_context_by_id,
repre_context_by_id
)
entity_ids,
entity_type,
version_context_by_id,
repre_context_by_id,
))
return action_items
def trigger_action_item(
self,
identifier,
options,
project_name,
version_ids,
representation_ids
identifier: str,
project_name: str,
selected_ids: set[str],
selected_entity_type: str,
data: Optional[dict[str, Any]],
options: dict[str, Any],
form_values: dict[str, Any],
):
"""Trigger action by identifier.
@ -128,15 +139,21 @@ class LoaderActionsModel:
happened.
Args:
identifier (str): Loader identifier.
options (dict[str, Any]): Loader option values.
identifier (str): Plugin identifier.
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
representation_ids (Iterable[str]): Representation ids.
"""
selected_ids (set[str]): Selected entity ids.
selected_entity_type (str): Selected entity type.
data (Optional[dict[str, Any]]): Additional action item data.
options (dict[str, Any]): Loader option values.
form_values (dict[str, Any]): Form values.
"""
event_data = {
"identifier": identifier,
"project_name": project_name,
"selected_ids": list(selected_ids),
"selected_entity_type": selected_entity_type,
"data": data,
"id": uuid.uuid4().hex,
}
self._controller.emit_event(
@ -144,24 +161,60 @@ class LoaderActionsModel:
event_data,
ACTIONS_MODEL_SENDER,
)
loader = self._get_loader_by_identifier(project_name, identifier)
if representation_ids is not None:
error_info = self._trigger_representation_loader(
loader,
options,
project_name,
representation_ids,
if identifier != LOADER_PLUGIN_ID:
result = None
crashed = False
try:
result = self._loader_actions.execute_action(
identifier=identifier,
selection=LoaderActionSelection(
project_name,
selected_ids,
selected_entity_type,
),
data=data,
form_values=form_values,
)
except Exception:
crashed = True
self._log.warning(
f"Failed to execute action '{identifier}'",
exc_info=True,
)
event_data["result"] = result
event_data["crashed"] = crashed
self._controller.emit_event(
"loader.action.finished",
event_data,
ACTIONS_MODEL_SENDER,
)
elif version_ids is not None:
return
loader = self._get_loader_by_identifier(
project_name, data["loader"]
)
entity_type = data["entity_type"]
entity_ids = data["entity_ids"]
if entity_type == "version":
error_info = self._trigger_version_loader(
loader,
options,
project_name,
version_ids,
entity_ids,
)
elif entity_type == "representation":
error_info = self._trigger_representation_loader(
loader,
options,
project_name,
entity_ids,
)
else:
raise NotImplementedError(
"Invalid arguments to trigger action item")
f"Invalid entity type '{entity_type}' to trigger action item"
)
event_data["error_info"] = error_info
self._controller.emit_event(
@ -276,28 +329,26 @@ class LoaderActionsModel:
self,
loader,
contexts,
project_name,
folder_ids=None,
product_ids=None,
version_ids=None,
representation_ids=None,
entity_ids,
entity_type,
repre_name=None,
):
label = self._get_action_label(loader)
if repre_name:
label = "{} ({})".format(label, repre_name)
label = f"{label} ({repre_name})"
return ActionItem(
get_loader_identifier(loader),
LOADER_PLUGIN_ID,
data={
"entity_ids": entity_ids,
"entity_type": entity_type,
"loader": get_loader_identifier(loader),
},
label=label,
group_label=None,
icon=self._get_action_icon(loader),
tooltip=self._get_action_tooltip(loader),
options=loader.get_options(contexts),
order=loader.order,
project_name=project_name,
folder_ids=folder_ids,
product_ids=product_ids,
version_ids=version_ids,
representation_ids=representation_ids,
options=loader.get_options(contexts),
)
def _get_loaders(self, project_name):
@ -351,15 +402,6 @@ class LoaderActionsModel:
loaders_by_identifier = loaders_by_identifier_c.get_data()
return loaders_by_identifier.get(identifier)
def _actions_sorter(self, action_item):
"""Sort the Loaders by their order and then their name.
Returns:
tuple[int, str]: Sort keys.
"""
return action_item.order, action_item.label
def _contexts_for_versions(self, project_name, version_ids):
"""Get contexts for given version ids.
@ -385,8 +427,8 @@ class LoaderActionsModel:
if not project_name and not version_ids:
return version_context_by_id, repre_context_by_id
version_entities = ayon_api.get_versions(
project_name, version_ids=version_ids
version_entities = self._get_versions(
project_name, version_ids
)
version_entities_by_id = {}
version_entities_by_product_id = collections.defaultdict(list)
@ -397,18 +439,18 @@ class LoaderActionsModel:
version_entities_by_product_id[product_id].append(version_entity)
_product_ids = set(version_entities_by_product_id.keys())
_product_entities = ayon_api.get_products(
project_name, product_ids=_product_ids
_product_entities = self._get_products(
project_name, _product_ids
)
product_entities_by_id = {p["id"]: p for p in _product_entities}
_folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
_folder_entities = ayon_api.get_folders(
project_name, folder_ids=_folder_ids
_folder_entities = self._get_folders(
project_name, _folder_ids
)
folder_entities_by_id = {f["id"]: f for f in _folder_entities}
project_entity = ayon_api.get_project(project_name)
project_entity = self._get_project(project_name)
for version_id, version_entity in version_entities_by_id.items():
product_id = version_entity["productId"]
@ -422,8 +464,15 @@ class LoaderActionsModel:
"version": version_entity,
}
repre_entities = ayon_api.get_representations(
project_name, version_ids=version_ids)
all_repre_ids = set()
for repre_ids in self._get_repre_ids_by_version_ids(
project_name, version_ids
).values():
all_repre_ids |= repre_ids
repre_entities = self._get_representations(
project_name, all_repre_ids
)
for repre_entity in repre_entities:
version_id = repre_entity["versionId"]
version_entity = version_entities_by_id[version_id]
@ -459,49 +508,54 @@ class LoaderActionsModel:
Returns:
tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
representation contexts.
"""
product_context_by_id = {}
"""
version_context_by_id = {}
repre_context_by_id = {}
if not project_name and not repre_ids:
return product_context_by_id, repre_context_by_id
return version_context_by_id, repre_context_by_id
repre_entities = list(ayon_api.get_representations(
project_name, representation_ids=repre_ids
))
repre_entities = self._get_representations(
project_name, repre_ids
)
version_ids = {r["versionId"] for r in repre_entities}
version_entities = ayon_api.get_versions(
project_name, version_ids=version_ids
version_entities = self._get_versions(
project_name, version_ids
)
version_entities_by_id = {
v["id"]: v for v in version_entities
}
product_ids = {v["productId"] for v in version_entities_by_id.values()}
product_entities = ayon_api.get_products(
project_name, product_ids=product_ids
product_entities = self._get_products(
project_name, product_ids
)
product_entities_by_id = {
p["id"]: p for p in product_entities
}
folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
folder_entities = ayon_api.get_folders(
project_name, folder_ids=folder_ids
folder_entities = self._get_folders(
project_name, folder_ids
)
folder_entities_by_id = {
f["id"]: f for f in folder_entities
}
project_entity = ayon_api.get_project(project_name)
project_entity = self._get_project(project_name)
for product_id, product_entity in product_entities_by_id.items():
version_context_by_id = {}
for version_id, version_entity in version_entities_by_id.items():
product_id = version_entity["productId"]
product_entity = product_entities_by_id[product_id]
folder_id = product_entity["folderId"]
folder_entity = folder_entities_by_id[folder_id]
product_context_by_id[product_id] = {
version_context_by_id[version_id] = {
"project": project_entity,
"folder": folder_entity,
"product": product_entity,
"version": version_entity,
}
for repre_entity in repre_entities:
@ -519,7 +573,125 @@ class LoaderActionsModel:
"version": version_entity,
"representation": repre_entity,
}
return product_context_by_id, repre_context_by_id
return version_context_by_id, repre_context_by_id
def _get_project(self, project_name: str) -> dict[str, Any]:
cache = self._projects_cache[project_name]
if not cache.is_valid:
cache.update_data(ayon_api.get_project(project_name))
return cache.get_data()
def _get_folders(
self, project_name: str, folder_ids: set[str]
) -> list[dict[str, Any]]:
"""Get folders by ids."""
return self._get_entities(
project_name,
folder_ids,
self._folders_cache,
ayon_api.get_folders,
"folder_ids",
)
def _get_products(
self, project_name: str, product_ids: set[str]
) -> list[dict[str, Any]]:
"""Get products by ids."""
return self._get_entities(
project_name,
product_ids,
self._products_cache,
ayon_api.get_products,
"product_ids",
)
def _get_versions(
self, project_name: str, version_ids: set[str]
) -> list[dict[str, Any]]:
"""Get versions by ids."""
return self._get_entities(
project_name,
version_ids,
self._versions_cache,
ayon_api.get_versions,
"version_ids",
)
def _get_representations(
self, project_name: str, representation_ids: set[str]
) -> list[dict[str, Any]]:
"""Get representations by ids."""
return self._get_entities(
project_name,
representation_ids,
self._representations_cache,
ayon_api.get_representations,
"representation_ids",
)
def _get_repre_ids_by_version_ids(
self, project_name: str, version_ids: set[str]
) -> dict[str, set[str]]:
output = {}
if not version_ids:
return output
project_cache = self._repre_parents_cache[project_name]
missing_ids = set()
for version_id in version_ids:
cache = project_cache[version_id]
if cache.is_valid:
output[version_id] = cache.get_data()
else:
missing_ids.add(version_id)
if missing_ids:
repre_cache = self._representations_cache[project_name]
repres_by_parent_id = collections.defaultdict(list)
for repre in ayon_api.get_representations(
project_name, version_ids=missing_ids
):
version_id = repre["versionId"]
repre_cache[repre["id"]].update_data(repre)
repres_by_parent_id[version_id].append(repre)
for version_id, repres in repres_by_parent_id.items():
repre_ids = {
repre["id"]
for repre in repres
}
output[version_id] = set(repre_ids)
project_cache[version_id].update_data(repre_ids)
return output
def _get_entities(
self,
project_name: str,
entity_ids: set[str],
cache: NestedCacheItem,
getter: Callable,
filter_arg: str,
) -> list[dict[str, Any]]:
entities = []
if not entity_ids:
return entities
missing_ids = set()
project_cache = cache[project_name]
for entity_id in entity_ids:
entity_cache = project_cache[entity_id]
if entity_cache.is_valid:
entities.append(entity_cache.get_data())
else:
missing_ids.add(entity_id)
if missing_ids:
for entity in getter(project_name, **{filter_arg: missing_ids}):
entities.append(entity)
entity_id = entity["id"]
project_cache[entity_id].update_data(entity)
return entities
def _get_action_items_for_contexts(
self,
@ -557,51 +729,137 @@ class LoaderActionsModel:
if not filtered_repre_contexts:
continue
repre_ids = set()
repre_version_ids = set()
repre_product_ids = set()
repre_folder_ids = set()
for repre_context in filtered_repre_contexts:
repre_ids.add(repre_context["representation"]["id"])
repre_product_ids.add(repre_context["product"]["id"])
repre_version_ids.add(repre_context["version"]["id"])
repre_folder_ids.add(repre_context["folder"]["id"])
repre_ids = {
repre_context["representation"]["id"]
for repre_context in filtered_repre_contexts
}
item = self._create_loader_action_item(
loader,
repre_contexts,
project_name=project_name,
folder_ids=repre_folder_ids,
product_ids=repre_product_ids,
version_ids=repre_version_ids,
representation_ids=repre_ids,
repre_ids,
"representation",
repre_name=repre_name,
)
action_items.append(item)
# Product Loaders.
version_ids = set(version_context_by_id.keys())
product_folder_ids = set()
product_ids = set()
for product_context in version_context_by_id.values():
product_ids.add(product_context["product"]["id"])
product_folder_ids.add(product_context["folder"]["id"])
version_ids = set(version_context_by_id.keys())
version_contexts = list(version_context_by_id.values())
for loader in product_loaders:
item = self._create_loader_action_item(
loader,
version_contexts,
project_name=project_name,
folder_ids=product_folder_ids,
product_ids=product_ids,
version_ids=version_ids,
version_ids,
"version",
)
action_items.append(item)
action_items.sort(key=self._actions_sorter)
return action_items
def _get_loader_action_items(
self,
project_name: str,
entity_ids: set[str],
entity_type: str,
version_context_by_id: dict[str, dict[str, Any]],
repre_context_by_id: dict[str, dict[str, Any]],
) -> list[ActionItem]:
"""
Args:
project_name (str): Project name.
entity_ids (set[str]): Selected entity ids.
entity_type (str): Selected entity type.
version_context_by_id (dict[str, dict[str, Any]]): Version context
by id.
repre_context_by_id (dict[str, dict[str, Any]]): Representation
context by id.
Returns:
list[ActionItem]: List of action items.
"""
entities_cache = self._prepare_entities_cache(
project_name,
entity_type,
version_context_by_id,
repre_context_by_id,
)
selection = LoaderActionSelection(
project_name,
entity_ids,
entity_type,
entities_cache=entities_cache
)
items = []
for action in self._loader_actions.get_action_items(selection):
items.append(ActionItem(
action.identifier,
label=action.label,
group_label=action.group_label,
icon=action.icon,
tooltip=None, # action.tooltip,
order=action.order,
data=action.data,
options=None, # action.options,
))
return items
def _prepare_entities_cache(
self,
project_name: str,
entity_type: str,
version_context_by_id: dict[str, dict[str, Any]],
repre_context_by_id: dict[str, dict[str, Any]],
):
project_entity = None
folders_by_id = {}
products_by_id = {}
versions_by_id = {}
representations_by_id = {}
for context in version_context_by_id.values():
if project_entity is None:
project_entity = context["project"]
folder_entity = context["folder"]
product_entity = context["product"]
version_entity = context["version"]
folders_by_id[folder_entity["id"]] = folder_entity
products_by_id[product_entity["id"]] = product_entity
versions_by_id[version_entity["id"]] = version_entity
for context in repre_context_by_id.values():
repre_entity = context["representation"]
representations_by_id[repre_entity["id"]] = repre_entity
# Mapping has to be for all child entities which is available for
# representations only if version is selected
representation_ids_by_version_id = {}
if entity_type == "version":
representation_ids_by_version_id = {
version_id: set()
for version_id in versions_by_id
}
for context in repre_context_by_id.values():
repre_entity = context["representation"]
v_id = repre_entity["versionId"]
representation_ids_by_version_id[v_id].add(repre_entity["id"])
return SelectionEntitiesCache(
project_name,
project_entity=project_entity,
folders_by_id=folders_by_id,
products_by_id=products_by_id,
versions_by_id=versions_by_id,
representations_by_id=representations_by_id,
representation_ids_by_version_id=representation_ids_by_version_id,
)
def _trigger_version_loader(
self,
loader,
@ -634,12 +892,12 @@ class LoaderActionsModel:
project_name, version_ids=version_ids
))
product_ids = {v["productId"] for v in version_entities}
product_entities = ayon_api.get_products(
project_name, product_ids=product_ids
product_entities = self._get_products(
project_name, product_ids
)
product_entities_by_id = {p["id"]: p for p in product_entities}
folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
folder_entities = ayon_api.get_folders(
folder_entities = self._get_folders(
project_name, folder_ids=folder_ids
)
folder_entities_by_id = {f["id"]: f for f in folder_entities}

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import collections
from typing import Any
from ayon_api import (
get_representations,
@ -246,26 +247,32 @@ class SiteSyncModel:
output[repre_id] = repre_cache.get_data()
return output
def get_sitesync_action_items(self, project_name, representation_ids):
def get_sitesync_action_items(
self, project_name, entity_ids, entity_type
):
"""
Args:
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
entity_ids (set[str]): Selected entity ids.
entity_type (str): Selected entity type.
Returns:
list[ActionItem]: Actions that can be shown in loader.
"""
if entity_type != "representation":
return []
if not self.is_sitesync_enabled(project_name):
return []
repres_status = self.get_representations_sync_status(
project_name, representation_ids
project_name, entity_ids
)
repre_ids_per_identifier = collections.defaultdict(set)
for repre_id in representation_ids:
for repre_id in entity_ids:
repre_status = repres_status[repre_id]
local_status, remote_status = repre_status
@ -293,36 +300,32 @@ class SiteSyncModel:
return action_items
def is_sitesync_action(self, identifier):
def is_sitesync_action(self, identifier: str) -> bool:
"""Should be `identifier` handled by SiteSync.
Args:
identifier (str): Action identifier.
identifier (str): Plugin identifier.
Returns:
bool: Should action be handled by SiteSync.
"""
return identifier in {
UPLOAD_IDENTIFIER,
DOWNLOAD_IDENTIFIER,
REMOVE_IDENTIFIER,
}
"""
return identifier == "sitesync.loader.action"
def trigger_action_item(
self,
identifier,
project_name,
representation_ids
project_name: str,
data: dict[str, Any],
):
"""Resets status for site_name or remove local files.
Args:
identifier (str): Action identifier.
project_name (str): Project name.
representation_ids (Iterable[str]): Representation ids.
"""
data (dict[str, Any]): Action item data.
"""
representation_ids = data["representation_ids"]
action_identifier = data["action_identifier"]
active_site = self.get_active_site(project_name)
remote_site = self.get_remote_site(project_name)
@ -346,17 +349,17 @@ class SiteSyncModel:
for repre_id in representation_ids:
repre_entity = repre_entities_by_id.get(repre_id)
product_type = product_type_by_repre_id[repre_id]
if identifier == DOWNLOAD_IDENTIFIER:
if action_identifier == DOWNLOAD_IDENTIFIER:
self._add_site(
project_name, repre_entity, active_site, product_type
)
elif identifier == UPLOAD_IDENTIFIER:
elif action_identifier == UPLOAD_IDENTIFIER:
self._add_site(
project_name, repre_entity, remote_site, product_type
)
elif identifier == REMOVE_IDENTIFIER:
elif action_identifier == REMOVE_IDENTIFIER:
self._sitesync_addon.remove_site(
project_name,
repre_id,
@ -476,27 +479,27 @@ class SiteSyncModel:
self,
project_name,
representation_ids,
identifier,
action_identifier,
label,
tooltip,
icon_name
):
return ActionItem(
identifier,
label,
"sitesync.loader.action",
label=label,
group_label=None,
icon={
"type": "awesome-font",
"name": icon_name,
"color": "#999999"
},
tooltip=tooltip,
options={},
order=1,
project_name=project_name,
folder_ids=[],
product_ids=[],
version_ids=[],
representation_ids=representation_ids,
data={
"representation_ids": representation_ids,
"action_identifier": action_identifier,
},
options=None,
)
def _add_site(self, project_name, repre_entity, site_name, product_type):

View file

@ -1,6 +1,7 @@
import uuid
from typing import Optional, Any
from qtpy import QtWidgets, QtGui
from qtpy import QtWidgets, QtGui, QtCore
import qtawesome
from ayon_core.lib.attribute_definitions import AbstractAttrDef
@ -11,9 +12,29 @@ from ayon_core.tools.utils.widgets import (
OptionDialog,
)
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.loader.abstract import ActionItem
def show_actions_menu(action_items, global_point, one_item_selected, parent):
def _actions_sorter(item: tuple[ActionItem, str, str]):
"""Sort the Loaders by their order and then their name.
Returns:
tuple[int, str]: Sort keys.
"""
action_item, group_label, label = item
if group_label is None:
group_label = label
label = ""
return action_item.order, group_label, label
def show_actions_menu(
action_items: list[ActionItem],
global_point: QtCore.QPoint,
one_item_selected: bool,
parent: QtWidgets.QWidget,
) -> tuple[Optional[ActionItem], Optional[dict[str, Any]]]:
selected_action_item = None
selected_options = None
@ -26,8 +47,16 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
menu = OptionalMenu(parent)
action_items_by_id = {}
action_items_with_labels = []
for action_item in action_items:
action_items_with_labels.append(
(action_item, action_item.group_label, action_item.label)
)
group_menu_by_label = {}
action_items_by_id = {}
for item in sorted(action_items_with_labels, key=_actions_sorter):
action_item, _, _ = item
item_id = uuid.uuid4().hex
action_items_by_id[item_id] = action_item
item_options = action_item.options
@ -50,7 +79,18 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
action.setData(item_id)
menu.addAction(action)
group_label = action_item.group_label
if group_label:
group_menu = group_menu_by_label.get(group_label)
if group_menu is None:
group_menu = OptionalMenu(group_label, menu)
if icon is not None:
group_menu.setIcon(icon)
menu.addMenu(group_menu)
group_menu_by_label[group_label] = group_menu
group_menu.addAction(action)
else:
menu.addAction(action)
action = menu.exec_(global_point)
if action is not None:

View file

@ -1,11 +1,11 @@
from typing import Optional
import qtpy
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
)
from ayon_core.style import get_objected_colors
from ayon_core.tools.utils import DeselectableTreeView
from ayon_core.tools.utils.folders_widget import FoldersProxyModel
from ayon_core.tools.utils import (
FoldersQtModel,
@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
QtWidgets.QAbstractItemView.ExtendedSelection)
folders_model = LoaderFoldersModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel()
folders_proxy_model = FoldersProxyModel()
folders_proxy_model.setSourceModel(folders_model)
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
if name:
self._folders_view.expandAll()
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
folder_ids (list[str]): The list of folder ids.
"""
self._folders_proxy_model.set_folder_ids_filter(folder_ids)
def set_merged_products_selection(self, items):
"""

View file

@ -420,8 +420,9 @@ class ProductsWidget(QtWidgets.QWidget):
if version_id is not None:
version_ids.add(version_id)
action_items = self._controller.get_versions_action_items(
project_name, version_ids)
action_items = self._controller.get_action_items(
project_name, version_ids, "version"
)
# Prepare global point where to show the menu
global_point = self._products_view.mapToGlobal(point)
@ -437,11 +438,13 @@ class ProductsWidget(QtWidgets.QWidget):
return
self._controller.trigger_action_item(
action_item.identifier,
options,
action_item.project_name,
version_ids=action_item.version_ids,
representation_ids=action_item.representation_ids,
identifier=action_item.identifier,
project_name=project_name,
selected_ids=version_ids,
selected_entity_type="version",
data=action_item.data,
options=options,
form_values={},
)
def _on_selection_change(self):

View file

@ -384,8 +384,8 @@ class RepresentationsWidget(QtWidgets.QWidget):
def _on_context_menu(self, point):
repre_ids = self._get_selected_repre_ids()
action_items = self._controller.get_representations_action_items(
self._selected_project_name, repre_ids
action_items = self._controller.get_action_items(
self._selected_project_name, repre_ids, "representation"
)
global_point = self._repre_view.mapToGlobal(point)
result = show_actions_menu(
@ -399,9 +399,11 @@ class RepresentationsWidget(QtWidgets.QWidget):
return
self._controller.trigger_action_item(
action_item.identifier,
options,
action_item.project_name,
version_ids=action_item.version_ids,
representation_ids=action_item.representation_ids,
identifier=action_item.identifier,
project_name=self._selected_project_name,
selected_ids=repre_ids,
selected_entity_type="representation",
data=action_item.data,
options=options,
form_values={},
)

View file

@ -1,11 +1,11 @@
import collections
import hashlib
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TasksQtModel,
TASKS_MODEL_SENDER_NAME,
@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import (
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
TasksProxyModel,
)
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
# Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--"
@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel):
return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
class LoaderTasksProxyModel(TasksProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
return True
return super().lessThan(left, right)
def filterAcceptsRow(self, row, parent_index):
source_index = self.sourceModel().index(row, 0, parent_index)
if source_index.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return True
return super().filterAcceptsRow(row, parent_index)
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget):
if name:
self._tasks_view.expandAll()
def set_task_ids_filter(self, task_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
task_ids (list[str]): The list of folder ids.
"""
self._tasks_proxy_model.set_task_ids_filter(task_ids)
def refresh(self):
self._tasks_model.refresh()

View file

@ -1,18 +1,24 @@
from __future__ import annotations
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.style import load_stylesheet
from ayon_core.pipeline.actions import LoaderActionResult
from ayon_core.tools.utils import (
PlaceholderLineEdit,
MessageOverlayObject,
ErrorMessageBox,
ThumbnailPainterWidget,
RefreshButton,
GoToCurrentButton,
ProjectsCombobox,
get_qt_icon,
FoldersFiltersWidget,
)
from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.utils.lib import center_window
from ayon_core.tools.utils import ProjectsCombobox
from ayon_core.tools.common_models import StatusItem
from ayon_core.tools.loader.abstract import ProductTypeItem
from ayon_core.tools.loader.control import LoaderController
@ -141,6 +147,8 @@ class LoaderWindow(QtWidgets.QWidget):
if controller is None:
controller = LoaderController()
overlay_object = MessageOverlayObject(self)
main_splitter = QtWidgets.QSplitter(self)
context_splitter = QtWidgets.QSplitter(main_splitter)
@ -170,15 +178,14 @@ class LoaderWindow(QtWidgets.QWidget):
context_top_layout.addWidget(go_to_current_btn, 0)
context_top_layout.addWidget(refresh_btn, 0)
folders_filter_input = PlaceholderLineEdit(context_widget)
folders_filter_input.setPlaceholderText("Folder name filter...")
filters_widget = FoldersFiltersWidget(context_widget)
folders_widget = LoaderFoldersWidget(controller, context_widget)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(context_top_widget, 0)
context_layout.addWidget(folders_filter_input, 0)
context_layout.addWidget(filters_widget, 0)
context_layout.addWidget(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget)
@ -247,9 +254,12 @@ class LoaderWindow(QtWidgets.QWidget):
projects_combobox.refreshed.connect(self._on_projects_refresh)
folders_widget.refreshed.connect(self._on_folders_refresh)
products_widget.refreshed.connect(self._on_products_refresh)
folders_filter_input.textChanged.connect(
filters_widget.text_changed.connect(
self._on_folder_filter_change
)
filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
search_bar.filter_changed.connect(self._on_filter_change)
product_group_checkbox.stateChanged.connect(
self._on_product_group_change
@ -294,6 +304,12 @@ class LoaderWindow(QtWidgets.QWidget):
"controller.reset.finished",
self._on_controller_reset_finish,
)
controller.register_event_callback(
"loader.action.finished",
self._on_loader_action_finished,
)
self._overlay_object = overlay_object
self._group_dialog = ProductGroupDialog(controller, self)
@ -303,7 +319,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._refresh_btn = refresh_btn
self._projects_combobox = projects_combobox
self._folders_filter_input = folders_filter_input
self._filters_widget = filters_widget
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -406,6 +422,20 @@ class LoaderWindow(QtWidgets.QWidget):
if self._reset_on_show:
self.refresh()
def _show_toast_message(
self,
message: str,
success: bool = True,
message_id: Optional[str] = None,
):
message_type = None
if not success:
message_type = "error"
self._overlay_object.add_message(
message, message_type, message_id=message_id
)
def _show_group_dialog(self):
project_name = self._projects_combobox.get_selected_project_name()
if not project_name:
@ -421,9 +451,21 @@ class LoaderWindow(QtWidgets.QWidget):
self._group_dialog.set_product_ids(project_name, product_ids)
self._group_dialog.show()
def _on_folder_filter_change(self, text):
def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._selected_project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)
def _on_product_group_change(self):
self._products_widget.set_enable_grouping(
self._product_group_checkbox.isChecked()
@ -494,6 +536,77 @@ class LoaderWindow(QtWidgets.QWidget):
box = LoadErrorMessageBox(error_info, self)
box.show()
def _on_loader_action_finished(self, event):
crashed = event["crashed"]
if crashed:
self._show_toast_message(
"Action failed",
success=False,
)
return
result: Optional[LoaderActionResult] = event["result"]
if result is None:
return
if result.message:
self._show_toast_message(
result.message, result.success
)
if result.form is None:
return
form = result.form
dialog = AttributeDefinitionsDialog(
form.fields,
title=form.title,
parent=self,
)
if result.form_values:
dialog.set_values(result.form_values)
submit_label = form.submit_label
submit_icon = form.submit_icon
cancel_label = form.cancel_label
cancel_icon = form.cancel_icon
if submit_icon:
submit_icon = get_qt_icon(submit_icon)
if cancel_icon:
cancel_icon = get_qt_icon(cancel_icon)
if submit_label:
dialog.set_submit_label(submit_label)
else:
dialog.set_submit_visible(False)
if submit_icon:
dialog.set_submit_icon(submit_icon)
if cancel_label:
dialog.set_cancel_label(cancel_label)
else:
dialog.set_cancel_visible(False)
if cancel_icon:
dialog.set_cancel_icon(cancel_icon)
dialog.setMinimumSize(300, 140)
result = dialog.exec_()
if result != QtWidgets.QDialog.Accepted:
return
form_values = dialog.get_values()
self._controller.trigger_action_item(
identifier=event["identifier"],
project_name=event["project_name"],
selected_ids=event["selected_ids"],
selected_entity_type=event["selected_entity_type"],
options={},
data=event["data"],
form_values=form_values,
)
def _on_project_selection_changed(self, event):
self._selected_project_name = event["project_name"]
self._update_filters()

View file

@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
"""Get folder id from folder path."""
pass
@abstractmethod
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, list[str]]: Folder and task ids.
"""
pass
# --- Create ---
@abstractmethod
def get_creator_items(self) -> Dict[str, "CreatorItem"]:

View file

@ -11,7 +11,11 @@ from ayon_core.pipeline import (
registered_host,
get_process_id,
)
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from ayon_core.tools.common_models import (
ProjectsModel,
HierarchyModel,
UsersModel,
)
from .models import (
PublishModel,
@ -101,6 +105,7 @@ class PublisherController(
# Cacher of avalon documents
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -317,6 +322,17 @@ class PublisherController(
return False
return True
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
# --- Publish specific callbacks ---
def get_context_title(self):
"""Get context title for artist shown at the top of main window."""
@ -359,6 +375,7 @@ class PublisherController(
self._emit_event("controller.reset.started")
self._hierarchy_model.reset()
self._users_model.reset()
# Publish part must be reset after plugins
self._create_model.reset()

View file

@ -1,5 +1,6 @@
import logging
import re
import copy
from typing import (
Union,
List,
@ -1098,7 +1099,7 @@ class CreateModel:
creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value):
creator_attributes[key] = value
creator_attributes[key] = copy.deepcopy(value)
def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value

View file

@ -202,7 +202,7 @@ class ContextCardWidget(CardWidget):
Is not visually under group widget and is always at the top of card view.
"""
def __init__(self, parent):
def __init__(self, parent: QtWidgets.QWidget):
super().__init__(parent)
self._id = CONTEXT_ID
@ -211,7 +211,7 @@ class ContextCardWidget(CardWidget):
icon_widget = PublishPixmapLabel(None, self)
icon_widget.setObjectName("ProductTypeIconLabel")
label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)
label_widget = QtWidgets.QLabel(f"<span>{CONTEXT_LABEL}</span>", self)
icon_layout = QtWidgets.QHBoxLayout()
icon_layout.setContentsMargins(5, 5, 5, 5)
@ -288,6 +288,8 @@ class InstanceCardWidget(CardWidget):
self._last_product_name = None
self._last_variant = None
self._last_label = None
self._last_folder_path = None
self._last_task_name = None
icon_widget = IconValuePixmapLabel(group_icon, self)
icon_widget.setObjectName("ProductTypeIconLabel")
@ -383,29 +385,54 @@ class InstanceCardWidget(CardWidget):
self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not valid)
@staticmethod
def _get_card_widget_sub_label(
folder_path: Optional[str],
task_name: Optional[str],
) -> str:
sublabel = ""
if folder_path:
folder_name = folder_path.rsplit("/", 1)[-1]
sublabel = f"<b>{folder_name}</b>"
if task_name:
sublabel += f" - <i>{task_name}</i>"
return sublabel
def _update_product_name(self):
variant = self.instance.variant
product_name = self.instance.product_name
label = self.instance.label
folder_path = self.instance.folder_path
task_name = self.instance.task_name
if (
variant == self._last_variant
and product_name == self._last_product_name
and label == self._last_label
and folder_path == self._last_folder_path
and task_name == self._last_task_name
):
return
self._last_variant = variant
self._last_product_name = product_name
self._last_label = label
self._last_folder_path = folder_path
self._last_task_name = task_name
# Make `variant` bold
label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts:
for part in found_parts:
replacement = "<b>{}</b>".format(part)
replacement = f"<b>{part}</b>"
label = label.replace(part, replacement)
label = f"<span>{label}</span>"
sublabel = self._get_card_widget_sub_label(folder_path, task_name)
if sublabel:
label += f"<br/><span style=\"font-size: 8pt;\">{sublabel}</span>"
self._label_widget.setText(label)
# HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag
@ -702,11 +729,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh(self):
"""Refresh instances in view based on CreatedContext."""
self._make_sure_context_widget_exists()
self._update_convertors_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
@ -814,6 +839,8 @@ class InstanceCardView(AbstractInstanceView):
widget.setVisible(False)
widget.deleteLater()
sorted_group_names.insert(0, CONTEXT_GROUP)
self._parent_id_by_id = parent_id_by_id
self._instance_ids_by_parent_id = instance_ids_by_parent_id
self._group_name_by_instance_id = group_by_instance_id
@ -881,7 +908,7 @@ class InstanceCardView(AbstractInstanceView):
context_info,
is_parent_active,
group_icon,
group_widget
group_widget,
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)

View file

@ -1,10 +1,14 @@
from qtpy import QtWidgets, QtCore
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.tools.utils import PlaceholderLineEdit, GoToCurrentButton
from ayon_core.tools.common_models import HierarchyExpectedSelection
from ayon_core.tools.utils import FoldersWidget, TasksWidget
from ayon_core.tools.utils import (
FoldersWidget,
TasksWidget,
FoldersFiltersWidget,
GoToCurrentButton,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_widget = QtWidgets.QWidget(self)
folder_filter_input = PlaceholderLineEdit(headers_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
filters_widget = FoldersFiltersWidget(headers_widget)
current_context_btn = GoToCurrentButton(headers_widget)
current_context_btn.setToolTip("Go to current context")
@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_layout = QtWidgets.QHBoxLayout(headers_widget)
headers_layout.setContentsMargins(0, 0, 0, 0)
headers_layout.addWidget(folder_filter_input, 1)
headers_layout.setSpacing(5)
headers_layout.addWidget(filters_widget, 1)
headers_layout.addWidget(current_context_btn, 0)
hierarchy_controller = CreateHierarchyController(controller)
@ -207,15 +211,16 @@ class CreateContextWidget(QtWidgets.QWidget):
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(headers_widget, 0)
main_layout.addSpacing(5)
main_layout.addWidget(folders_widget, 2)
main_layout.addWidget(tasks_widget, 1)
folders_widget.selection_changed.connect(self._on_folder_change)
tasks_widget.selection_changed.connect(self._on_task_change)
current_context_btn.clicked.connect(self._on_current_context_click)
folder_filter_input.textChanged.connect(self._on_folder_filter_change)
filters_widget.text_changed.connect(self._on_folder_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
self._folder_filter_input = folder_filter_input
self._current_context_btn = current_context_btn
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -303,5 +308,17 @@ class CreateContextWidget(QtWidgets.QWidget):
self._last_project_name, folder_id, task_name
)
def _on_folder_filter_change(self, text):
def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
def _on_my_tasks_change(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._last_project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -710,11 +710,13 @@ class CreateWidget(QtWidgets.QWidget):
def _on_first_show(self):
width = self.width()
part = int(width / 4)
rem_width = width - part
self._main_splitter_widget.setSizes([part, rem_width])
rem_width = rem_width - part
self._creators_splitter.setSizes([part, rem_width])
part = int(width / 9)
context_width = part * 3
create_sel_width = part * 2
rem_width = width - context_width
self._main_splitter_widget.setSizes([context_width, rem_width])
rem_width -= create_sel_width
self._creators_splitter.setSizes([create_sel_width, rem_width])
def showEvent(self, event):
super().showEvent(event)

View file

@ -1,7 +1,10 @@
from qtpy import QtWidgets
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.tools.utils import PlaceholderLineEdit, FoldersWidget
from ayon_core.tools.utils import (
FoldersWidget,
FoldersFiltersWidget,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -43,8 +46,7 @@ class FoldersDialog(QtWidgets.QDialog):
super().__init__(parent)
self.setWindowTitle("Select folder")
filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter folders..")
filters_widget = FoldersFiltersWidget(self)
folders_controller = FoldersDialogController(controller)
folders_widget = FoldersWidget(folders_controller, self)
@ -59,7 +61,8 @@ class FoldersDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0)
layout.setSpacing(5)
layout.addWidget(filters_widget, 0)
layout.addWidget(folders_widget, 1)
layout.addLayout(btns_layout, 0)
@ -68,12 +71,13 @@ class FoldersDialog(QtWidgets.QDialog):
)
folders_widget.double_clicked.connect(self._on_ok_clicked)
filter_input.textChanged.connect(self._on_filter_change)
filters_widget.text_changed.connect(self._on_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._controller = controller
self._filter_input = filter_input
self._filters_widget = filters_widget
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
@ -88,6 +92,49 @@ class FoldersDialog(QtWidgets.QDialog):
self._first_show = True
self._default_height = 500
self._project_name = None
def showEvent(self, event):
"""Refresh folders widget on show."""
super().showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
# Refresh on show
self.reset(False)
def reset(self, force=True):
"""Reset widget."""
if not force and not self._soft_reset_enabled:
return
self._project_name = self._controller.get_current_project_name()
if self._soft_reset_enabled:
self._soft_reset_enabled = False
self._folders_widget.set_project_name(self._project_name)
def get_selected_folder_path(self):
"""Get selected folder path."""
return self._selected_folder_path
def set_selected_folders(self, folder_paths: list[str]) -> None:
"""Change preselected folder before showing the dialog.
This also resets model and clean filter.
"""
self.reset(False)
self._filters_widget.set_text("")
self._filters_widget.set_my_tasks_checked(False)
folder_id = None
for folder_path in folder_paths:
folder_id = self._controller.get_folder_id_from_path(folder_path)
if folder_id:
break
if folder_id:
self._folders_widget.set_selected_folder(folder_id)
def _on_first_show(self):
center = self.rect().center()
size = self.size()
@ -103,27 +150,6 @@ class FoldersDialog(QtWidgets.QDialog):
# Change reset enabled so model is reset on show event
self._soft_reset_enabled = True
def showEvent(self, event):
"""Refresh folders widget on show."""
super().showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
# Refresh on show
self.reset(False)
def reset(self, force=True):
"""Reset widget."""
if not force and not self._soft_reset_enabled:
return
if self._soft_reset_enabled:
self._soft_reset_enabled = False
self._folders_widget.set_project_name(
self._controller.get_current_project_name()
)
def _on_filter_change(self, text):
"""Trigger change of filter of folders."""
self._folders_widget.set_name_filter(text)
@ -137,22 +163,11 @@ class FoldersDialog(QtWidgets.QDialog):
)
self.done(1)
def set_selected_folders(self, folder_paths):
"""Change preselected folder before showing the dialog.
This also resets model and clean filter.
"""
self.reset(False)
self._filter_input.setText("")
folder_id = None
for folder_path in folder_paths:
folder_id = self._controller.get_folder_id_from_path(folder_path)
if folder_id:
break
if folder_id:
self._folders_widget.set_selected_folder(folder_id)
def get_selected_folder_path(self):
"""Get selected folder path."""
return self._selected_folder_path
def _on_my_tasks_change(self, enabled: bool) -> None:
folder_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
folder_ids = entity_ids["folder_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)

View file

@ -678,13 +678,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._help_dialog.show()
window = self.window()
if hasattr(QtWidgets.QApplication, "desktop"):
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
screen = window.screen()
screen_geo = screen.geometry()
window_geo = window.geometry()
dialog_x = window_geo.x() + window_geo.width()

View file

@ -41,6 +41,7 @@ class PushToContextController:
self._process_item_id = None
self._use_original_name = False
self._version_up = False
self.set_source(project_name, version_ids)
@ -212,7 +213,7 @@ class PushToContextController:
self._user_values.variant,
comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name,
dst_version=1,
version_up=self._version_up,
use_original_name=self._use_original_name,
)
item_ids.append(item_id)
@ -229,6 +230,9 @@ class PushToContextController:
thread.start()
return item_ids
def set_version_up(self, state):
self._version_up = state
def wait_for_process_thread(self):
if self._process_thread is None:
return

View file

@ -3,9 +3,10 @@ import re
import copy
import itertools
import sys
import tempfile
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
@ -88,7 +89,7 @@ class ProjectPushItem:
variant,
comment,
new_folder_name,
dst_version,
version_up,
item_id=None,
use_original_name=False
):
@ -99,7 +100,7 @@ class ProjectPushItem:
self.dst_project_name = dst_project_name
self.dst_folder_id = dst_folder_id
self.dst_task_name = dst_task_name
self.dst_version = dst_version
self.version_up = version_up
self.variant = variant
self.new_folder_name = new_folder_name
self.comment = comment or ""
@ -117,7 +118,7 @@ class ProjectPushItem:
str(self.dst_folder_id),
str(self.new_folder_name),
str(self.dst_task_name),
str(self.dst_version),
str(self.version_up),
self.use_original_name
])
return self._repr_value
@ -132,7 +133,7 @@ class ProjectPushItem:
"dst_project_name": self.dst_project_name,
"dst_folder_id": self.dst_folder_id,
"dst_task_name": self.dst_task_name,
"dst_version": self.dst_version,
"version_up": self.version_up,
"variant": self.variant,
"comment": self.comment,
"new_folder_name": self.new_folder_name,
@ -225,8 +226,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 +483,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 +653,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,12 +705,19 @@ 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
)
new_thumbnail_id = self._create_new_folder_thumbnail(
project_entity, src_folder_entity)
folder_entity = new_folder_entity(
folder_name,
"Folder",
dst_folder_type,
parent_id=parent_id,
attribs=new_folder_attrib
attribs=new_folder_attrib,
thumbnail_id=new_thumbnail_id
)
if folder_label:
folder_entity["label"] = folder_label
@ -727,10 +737,59 @@ class ProjectPushItemProcess:
folder_entity["path"] = "/".join([parent_path, folder_name])
return folder_entity
def _create_new_folder_thumbnail(
self,
project_entity: dict[str, Any],
src_folder_entity: dict[str, Any]
) -> Optional[str]:
"""Copy thumbnail possibly set on folder.
Could be different from representation thumbnails, and it is only shown
when folder is selected.
"""
if not src_folder_entity["thumbnailId"]:
return None
thumbnail = ayon_api.get_folder_thumbnail(
self._item.src_project_name,
src_folder_entity["id"],
src_folder_entity["thumbnailId"]
)
if not thumbnail.id:
return None
with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
tmp_file.write(thumbnail.content)
temp_file_path = tmp_file.name
new_thumbnail_id = None
try:
new_thumbnail_id = ayon_api.create_thumbnail(
project_entity["name"], temp_file_path)
finally:
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
return new_thumbnail_id
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 +820,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 +833,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 +865,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
@ -870,10 +948,22 @@ class ProjectPushItemProcess:
self._product_entity = product_entity
return product_entity
src_attrib = self._src_product_entity["attrib"]
dst_attrib = {}
for key in {
"description",
"productGroup",
}:
value = src_attrib.get(key)
if value:
dst_attrib[key] = value
product_entity = new_product_entity(
product_name,
product_type,
folder_id,
attribs=dst_attrib
)
self._operations.create_entity(
project_name, "product", product_entity
@ -884,7 +974,7 @@ class ProjectPushItemProcess:
"""Make sure version document exits in database."""
project_name = self._item.dst_project_name
version = self._item.dst_version
version_up = self._item.version_up
src_version_entity = self._src_version_entity
product_entity = self._product_entity
product_id = product_entity["id"]
@ -912,27 +1002,29 @@ class ProjectPushItemProcess:
"description",
"intent",
}:
if key in src_attrib:
dst_attrib[key] = src_attrib[key]
value = src_attrib.get(key)
if value:
dst_attrib[key] = value
if version is None:
last_version_entity = ayon_api.get_last_version_by_product_id(
project_name, product_id
last_version_entity = ayon_api.get_last_version_by_product_id(
project_name, product_id
)
if last_version_entity is None:
dst_version = get_versioning_start(
project_name,
self.host_name,
task_name=self._task_info.get("name"),
task_type=self._task_info.get("taskType"),
product_type=product_type,
product_name=product_entity["name"],
)
if last_version_entity:
version = int(last_version_entity["version"]) + 1
else:
version = get_versioning_start(
project_name,
self.host_name,
task_name=self._task_info["name"],
task_type=self._task_info["taskType"],
product_type=product_type,
product_name=product_entity["name"],
)
else:
dst_version = int(last_version_entity["version"])
if version_up:
dst_version += 1
existing_version_entity = ayon_api.get_version_by_name(
project_name, version, product_id
project_name, dst_version, product_id
)
thumbnail_id = self._copy_version_thumbnail()
@ -950,10 +1042,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,
dst_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 +1060,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 +1336,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):
@ -1231,7 +1394,7 @@ class IntegrateModel:
variant,
comment,
new_folder_name,
dst_version,
version_up,
use_original_name
):
"""Create new item for integration.
@ -1245,7 +1408,7 @@ class IntegrateModel:
variant (str): Variant name.
comment (Union[str, None]): Comment.
new_folder_name (Union[str, None]): New folder name.
dst_version (int): Destination version number.
version_up (bool): Should destination product be versioned up
use_original_name (bool): If original product names should be used
Returns:
@ -1262,7 +1425,7 @@ class IntegrateModel:
variant,
comment=comment,
new_folder_name=new_folder_name,
dst_version=dst_version,
version_up=version_up,
use_original_name=use_original_name
)
process_item = ProjectPushItemProcess(self, item)
@ -1281,6 +1444,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

View file

@ -144,6 +144,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
variant_input.setPlaceholderText("< Variant >")
variant_input.setObjectName("ValidatedLineEdit")
version_up_checkbox = NiceCheckbox(True, parent=inputs_widget)
comment_input = PlaceholderLineEdit(inputs_widget)
comment_input.setPlaceholderText("< Publish comment >")
@ -153,7 +155,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_layout.addRow("New folder name", folder_name_input)
inputs_layout.addRow("Variant", variant_input)
inputs_layout.addRow(
"Use original product names", original_names_checkbox)
"Use original product names", original_names_checkbox
)
inputs_layout.addRow(
"Version up existing Product", version_up_checkbox
)
inputs_layout.addRow("Comment", comment_input)
main_splitter.addWidget(context_widget)
@ -209,8 +215,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
"Show error detail dialog to copy full error."
)
original_names_checkbox.setToolTip(
"Required for multi copy, doesn't allow changes "
"variant values."
"Required for multi copy, doesn't allow changes variant values."
)
version_up_checkbox.setToolTip(
"Version up existing product. If not selected version will be "
"updated."
)
overlay_close_btn = QtWidgets.QPushButton(
@ -259,6 +268,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
library_only_checkbox.stateChanged.connect(self._on_library_only_change)
original_names_checkbox.stateChanged.connect(
self._on_original_names_change)
version_up_checkbox.stateChanged.connect(
self._on_version_up_checkbox_change)
publish_btn.clicked.connect(self._on_select_click)
cancel_btn.clicked.connect(self._on_close_click)
@ -308,6 +319,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._folder_name_input = folder_name_input
self._comment_input = comment_input
self._use_original_names_checkbox = original_names_checkbox
self._library_only_checkbox = library_only_checkbox
self._publish_btn = publish_btn
@ -328,6 +340,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._new_folder_name_input_text = None
self._variant_input_text = None
self._comment_input_text = None
self._version_up_checkbox = version_up_checkbox
self._first_show = True
self._show_timer = show_timer
@ -344,6 +357,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
show_detail_btn.setVisible(False)
overlay_close_btn.setVisible(False)
overlay_try_btn.setVisible(False)
version_up_checkbox.setChecked(False)
# Support of public api function of controller
def set_source(self, project_name, version_ids):
@ -376,7 +390,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._invalidate_new_folder_name(
new_folder_name, user_values["is_new_folder_name_valid"]
)
self._controller._invalidate()
self._projects_combobox.refresh()
def _on_first_show(self):
@ -415,14 +428,18 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._comment_input_text = text
self._user_input_changed_timer.start()
def _on_library_only_change(self, state: int) -> None:
def _on_library_only_change(self) -> None:
"""Change toggle state, reset filter, recalculate dropdown"""
state = bool(state)
self._projects_combobox.set_standard_filter_enabled(state)
is_checked = self._library_only_checkbox.isChecked()
self._projects_combobox.set_standard_filter_enabled(is_checked)
def _on_original_names_change(self, state: int) -> None:
use_original_name = bool(state)
self._invalidate_use_original_names(use_original_name)
def _on_original_names_change(self) -> None:
is_checked = self._use_original_names_checkbox.isChecked()
self._invalidate_use_original_names(is_checked)
def _on_version_up_checkbox_change(self) -> None:
is_checked = self._version_up_checkbox.isChecked()
self._controller.set_version_up(is_checked)
def _on_user_input_timer(self):
folder_name_enabled = self._new_folder_name_enabled

View file

@ -76,6 +76,7 @@ from .folders_widget import (
FoldersQtModel,
FOLDERS_MODEL_SENDER_NAME,
SimpleFoldersWidget,
FoldersFiltersWidget,
)
from .tasks_widget import (
@ -160,6 +161,7 @@ __all__ = (
"FoldersQtModel",
"FOLDERS_MODEL_SENDER_NAME",
"SimpleFoldersWidget",
"FoldersFiltersWidget",
"TasksWidget",
"TasksQtModel",

View file

@ -1,4 +1,3 @@
import qtpy
from qtpy import QtWidgets, QtCore, QtGui
@ -6,7 +5,7 @@ class PickScreenColorWidget(QtWidgets.QWidget):
color_selected = QtCore.Signal(QtGui.QColor)
def __init__(self, parent=None):
super(PickScreenColorWidget, self).__init__(parent)
super().__init__(parent)
self.labels = []
self.magnification = 2
@ -53,7 +52,7 @@ class PickLabel(QtWidgets.QLabel):
close_session = QtCore.Signal()
def __init__(self, pick_widget):
super(PickLabel, self).__init__()
super().__init__()
self.setMouseTracking(True)
self.pick_widget = pick_widget
@ -74,14 +73,10 @@ class PickLabel(QtWidgets.QLabel):
self.show()
self.windowHandle().setScreen(screen_obj)
geo = screen_obj.geometry()
args = (
QtWidgets.QApplication.desktop().winId(),
pix = screen_obj.grabWindow(
self.winId(),
geo.x(), geo.y(), geo.width(), geo.height()
)
if qtpy.API in ("pyqt4", "pyside"):
pix = QtGui.QPixmap.grabWindow(*args)
else:
pix = screen_obj.grabWindow(*args)
if pix.width() > pix.height():
size = pix.height()

View file

@ -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()

View file

@ -15,6 +15,8 @@ from ayon_core.tools.common_models import (
from .models import RecursiveSortFilterProxyModel
from .views import TreeView
from .lib import RefreshThread, get_qt_icon
from .widgets import PlaceholderLineEdit
from .nice_checkbox import NiceCheckbox
FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
@ -343,6 +345,8 @@ class FoldersProxyModel(RecursiveSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
self._folder_ids_filter = None
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
@ -794,3 +798,47 @@ class SimpleFoldersWidget(FoldersWidget):
event (Event): Triggered event.
"""
pass
class FoldersFiltersWidget(QtWidgets.QWidget):
"""Helper widget for most commonly used filters in context selection."""
text_changed = QtCore.Signal(str)
my_tasks_changed = QtCore.Signal(bool)
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
folders_filter_input = PlaceholderLineEdit(self)
folders_filter_input.setPlaceholderText("Folder name filter...")
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", self)
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(self)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.addWidget(folders_filter_input, 1)
layout.addWidget(my_tasks_label, 0)
layout.addWidget(my_tasks_checkbox, 0)
folders_filter_input.textChanged.connect(self.text_changed)
my_tasks_checkbox.stateChanged.connect(self._on_my_tasks_change)
self._folders_filter_input = folders_filter_input
self._my_tasks_checkbox = my_tasks_checkbox
def set_text(self, text: str) -> None:
self._folders_filter_input.setText(text)
def set_my_tasks_checked(self, checked: bool) -> None:
self._my_tasks_checkbox.setChecked(checked)
def _on_my_tasks_change(self, _state: int) -> None:
self.my_tasks_changed.emit(self._my_tasks_checkbox.isChecked())

View file

@ -53,14 +53,8 @@ def checkstate_enum_to_int(state):
def center_window(window):
"""Move window to center of it's screen."""
if hasattr(QtWidgets.QApplication, "desktop"):
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
screen = window.screen()
screen_geo = screen.geometry()
geo = window.frameGeometry()
geo.moveCenter(screen_geo.center())

View file

@ -865,24 +865,26 @@ class OptionalMenu(QtWidgets.QMenu):
def mouseReleaseEvent(self, event):
"""Emit option clicked signal if mouse released on it"""
active = self.actionAt(event.pos())
if active and active.use_option:
if isinstance(active, OptionalAction) and active.use_option:
option = active.widget.option
if option.is_hovered(event.globalPos()):
option.clicked.emit()
super(OptionalMenu, self).mouseReleaseEvent(event)
super().mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
"""Add highlight to active action"""
active = self.actionAt(event.pos())
for action in self.actions():
action.set_highlight(action is active, event.globalPos())
super(OptionalMenu, self).mouseMoveEvent(event)
if isinstance(action, OptionalAction):
action.set_highlight(action is active, event.globalPos())
super().mouseMoveEvent(event)
def leaveEvent(self, event):
"""Remove highlight from all actions"""
for action in self.actions():
action.set_highlight(False)
super(OptionalMenu, self).leaveEvent(event)
if isinstance(action, OptionalAction):
action.set_highlight(False)
super().leaveEvent(event)
class OptionalAction(QtWidgets.QWidgetAction):
@ -894,7 +896,7 @@ class OptionalAction(QtWidgets.QWidgetAction):
"""
def __init__(self, label, icon, use_option, parent):
super(OptionalAction, self).__init__(parent)
super().__init__(parent)
self.label = label
self.icon = icon
self.use_option = use_option
@ -955,7 +957,7 @@ class OptionalActionWidget(QtWidgets.QWidget):
"""Main widget class for `OptionalAction`"""
def __init__(self, label, parent=None):
super(OptionalActionWidget, self).__init__(parent)
super().__init__(parent)
body_widget = QtWidgets.QWidget(self)
body_widget.setObjectName("OptionalActionBody")

View file

@ -1,8 +1,15 @@
from __future__ import annotations
import os
from abc import ABC, abstractmethod
import typing
from typing import Optional
from ayon_core.style import get_default_entity_icon_color
if typing.TYPE_CHECKING:
from ayon_core.host import PublishedWorkfileInfo
class FolderItem:
"""Item representing folder entity on a server.
@ -159,6 +166,17 @@ class WorkareaFilepathResult:
self.filepath = filepath
class PublishedWorkfileWrap:
"""Wrapper for workfile info that also contains version comment."""
def __init__(
self,
info: Optional[PublishedWorkfileInfo] = None,
comment: Optional[str] = None,
) -> None:
self.info = info
self.comment = comment
class AbstractWorkfilesCommon(ABC):
@abstractmethod
def is_host_valid(self):
@ -787,6 +805,25 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
"""
pass
@abstractmethod
def get_published_workfile_info(
self,
folder_id: Optional[str],
representation_id: Optional[str],
) -> PublishedWorkfileWrap:
"""Get published workfile info by representation ID.
Args:
folder_id (Optional[str]): Folder id.
representation_id (Optional[str]): Representation id.
Returns:
PublishedWorkfileWrap: Published workfile info or None
if not found.
"""
pass
@abstractmethod
def get_workfile_info(self, folder_id, task_id, rootless_path):
"""Workfile info from database.

View file

@ -1,4 +1,7 @@
from __future__ import annotations
import os
from typing import Optional
import ayon_api
@ -18,6 +21,7 @@ from ayon_core.tools.common_models import (
from .abstract import (
AbstractWorkfilesBackend,
AbstractWorkfilesFrontend,
PublishedWorkfileWrap,
)
from .models import SelectionModel, WorkfilesModel
@ -432,6 +436,15 @@ class BaseWorkfileController(
folder_id, task_id
)
def get_published_workfile_info(
self,
folder_id: Optional[str],
representation_id: Optional[str],
) -> PublishedWorkfileWrap:
return self._workfiles_model.get_published_workfile_info(
folder_id, representation_id
)
def get_workfile_info(self, folder_id, task_id, rootless_path):
return self._workfiles_model.get_workfile_info(
folder_id, task_id, rootless_path

View file

@ -17,6 +17,8 @@ class SelectionModel(object):
self._task_name = None
self._task_id = None
self._workfile_path = None
self._rootless_workfile_path = None
self._workfile_entity_id = None
self._representation_id = None
def get_selected_folder_id(self):
@ -62,39 +64,49 @@ class SelectionModel(object):
def get_selected_workfile_path(self):
return self._workfile_path
def get_selected_workfile_data(self):
return {
"project_name": self._controller.get_current_project_name(),
"path": self._workfile_path,
"rootless_path": self._rootless_workfile_path,
"folder_id": self._folder_id,
"task_name": self._task_name,
"task_id": self._task_id,
"workfile_entity_id": self._workfile_entity_id,
}
def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id
):
if path == self._workfile_path:
return
self._rootless_workfile_path = rootless_path
self._workfile_path = path
self._workfile_entity_id = workfile_entity_id
self._controller.emit_event(
"selection.workarea.changed",
{
"project_name": self._controller.get_current_project_name(),
"path": path,
"rootless_path": rootless_path,
"folder_id": self._folder_id,
"task_name": self._task_name,
"task_id": self._task_id,
"workfile_entity_id": workfile_entity_id,
},
self.get_selected_workfile_data(),
self.event_source
)
def get_selected_representation_id(self):
return self._representation_id
def get_selected_representation_data(self):
return {
"project_name": self._controller.get_current_project_name(),
"folder_id": self._folder_id,
"task_id": self._task_id,
"representation_id": self._representation_id,
}
def set_selected_representation_id(self, representation_id):
if representation_id == self._representation_id:
return
self._representation_id = representation_id
self._controller.emit_event(
"selection.representation.changed",
{
"project_name": self._controller.get_current_project_name(),
"representation_id": representation_id,
},
self.get_selected_representation_data(),
self.event_source
)

View file

@ -39,6 +39,7 @@ from ayon_core.pipeline.workfile import (
from ayon_core.pipeline.version_start import get_versioning_start
from ayon_core.tools.workfiles.abstract import (
WorkareaFilepathResult,
PublishedWorkfileWrap,
AbstractWorkfilesBackend,
)
@ -79,6 +80,7 @@ class WorkfilesModel:
# Published workfiles
self._repre_by_id = {}
self._version_comment_by_id = {}
self._published_workfile_items_cache = NestedCacheItem(
levels=1, default_factory=list
)
@ -95,6 +97,7 @@ class WorkfilesModel:
self._workarea_file_items_cache.reset()
self._repre_by_id = {}
self._version_comment_by_id = {}
self._published_workfile_items_cache.reset()
self._workfile_entities_by_task_id = {}
@ -552,13 +555,13 @@ class WorkfilesModel:
)
def get_published_file_items(
self, folder_id: str, task_id: str
self, folder_id: Optional[str], task_id: Optional[str]
) -> list[PublishedWorkfileInfo]:
"""Published workfiles for passed context.
Args:
folder_id (str): Folder id.
task_id (str): Task id.
folder_id (Optional[str]): Folder id.
task_id (Optional[str]): Task id.
Returns:
list[PublishedWorkfileInfo]: List of files for published workfiles.
@ -586,7 +589,7 @@ class WorkfilesModel:
version_entities = list(ayon_api.get_versions(
project_name,
product_ids=product_ids,
fields={"id", "author", "taskId"},
fields={"id", "author", "taskId", "attrib.comment"},
))
repre_entities = []
@ -600,6 +603,13 @@ class WorkfilesModel:
repre_entity["id"]: repre_entity
for repre_entity in repre_entities
})
# Map versions by representation ID for easy lookup
self._version_comment_by_id.update({
version_entity["id"]: version_entity["attrib"].get("comment")
for version_entity in version_entities
})
project_entity = self._controller.get_project_entity(project_name)
prepared_data = ListPublishedWorkfilesOptionalData(
@ -626,6 +636,34 @@ class WorkfilesModel:
]
return items
def get_published_workfile_info(
self,
folder_id: Optional[str],
representation_id: Optional[str],
) -> PublishedWorkfileWrap:
"""Get published workfile info by representation ID.
Args:
folder_id (Optional[str]): Folder id.
representation_id (Optional[str]): Representation id.
Returns:
PublishedWorkfileWrap: Published workfile info or None
if not found.
"""
if not representation_id:
return PublishedWorkfileWrap()
# Search through all cached published workfile items
for item in self.get_published_file_items(folder_id, None):
if item.representation_id == representation_id:
comment = self._get_published_workfile_version_comment(
representation_id
)
return PublishedWorkfileWrap(item, comment)
return PublishedWorkfileWrap()
@property
def _project_name(self) -> str:
return self._controller.get_current_project_name()
@ -642,6 +680,25 @@ class WorkfilesModel:
self._current_username = get_ayon_username()
return self._current_username
def _get_published_workfile_version_comment(
self, representation_id: str
) -> Optional[str]:
"""Get version comment for published workfile.
Args:
representation_id (str): Representation id.
Returns:
Optional[str]: Version comment or None.
"""
if not representation_id:
return None
repre = self._repre_by_id.get(representation_id)
if not repre:
return None
return self._version_comment_by_id.get(repre["versionId"])
# --- Host ---
def _open_workfile(self, folder_id: str, task_id: str, filepath: str):
# TODO move to workfiles pipeline

View file

@ -1,6 +1,7 @@
import datetime
from typing import Optional
from qtpy import QtWidgets, QtCore
from qtpy import QtCore, QtWidgets
def file_size_to_string(file_size):
@ -8,9 +9,9 @@ def file_size_to_string(file_size):
return "N/A"
size = 0
size_ending_mapping = {
"KB": 1024 ** 1,
"MB": 1024 ** 2,
"GB": 1024 ** 3
"KB": 1024**1,
"MB": 1024**2,
"GB": 1024**3,
}
ending = "B"
for _ending, _size in size_ending_mapping.items():
@ -70,7 +71,12 @@ class SidePanelWidget(QtWidgets.QWidget):
btn_description_save.clicked.connect(self._on_save_click)
controller.register_event_callback(
"selection.workarea.changed", self._on_selection_change
"selection.workarea.changed",
self._on_workarea_selection_change
)
controller.register_event_callback(
"selection.representation.changed",
self._on_representation_selection_change,
)
self._details_input = details_input
@ -82,12 +88,13 @@ class SidePanelWidget(QtWidgets.QWidget):
self._task_id = None
self._filepath = None
self._rootless_path = None
self._representation_id = None
self._orig_description = ""
self._controller = controller
self._set_context(None, None, None, None)
self._set_context(False, None, None)
def set_published_mode(self, published_mode):
def set_published_mode(self, published_mode: bool) -> None:
"""Change published mode.
Args:
@ -95,14 +102,37 @@ class SidePanelWidget(QtWidgets.QWidget):
"""
self._description_widget.setVisible(not published_mode)
# Clear the context when switching modes to avoid showing stale data
if published_mode:
self._set_publish_context(
self._folder_id,
self._task_id,
self._representation_id,
)
else:
self._set_workarea_context(
self._folder_id,
self._task_id,
self._rootless_path,
self._filepath,
)
def _on_selection_change(self, event):
def _on_workarea_selection_change(self, event):
folder_id = event["folder_id"]
task_id = event["task_id"]
filepath = event["path"]
rootless_path = event["rootless_path"]
self._set_context(folder_id, task_id, rootless_path, filepath)
self._set_workarea_context(
folder_id, task_id, rootless_path, filepath
)
def _on_representation_selection_change(self, event):
folder_id = event["folder_id"]
task_id = event["task_id"]
representation_id = event["representation_id"]
self._set_publish_context(folder_id, task_id, representation_id)
def _on_description_change(self):
text = self._description_input.toPlainText()
@ -118,85 +148,134 @@ class SidePanelWidget(QtWidgets.QWidget):
self._orig_description = description
self._btn_description_save.setEnabled(False)
def _set_context(self, folder_id, task_id, rootless_path, filepath):
def _set_workarea_context(
self,
folder_id: Optional[str],
task_id: Optional[str],
rootless_path: Optional[str],
filepath: Optional[str],
) -> None:
self._rootless_path = rootless_path
self._filepath = filepath
workfile_info = None
# Check if folder, task and file are selected
if folder_id and task_id and rootless_path:
workfile_info = self._controller.get_workfile_info(
folder_id, task_id, rootless_path
)
enabled = workfile_info is not None
self._details_input.setEnabled(enabled)
self._description_input.setEnabled(enabled)
self._btn_description_save.setEnabled(enabled)
self._folder_id = folder_id
self._task_id = task_id
self._filepath = filepath
self._rootless_path = rootless_path
# Disable inputs and remove texts if any required arguments are
# missing
if not enabled:
if workfile_info is None:
self._orig_description = ""
self._details_input.setPlainText("")
self._description_input.setPlainText("")
self._set_context(False, folder_id, task_id)
return
description = workfile_info.description
size_value = file_size_to_string(workfile_info.file_size)
self._set_context(
True,
folder_id,
task_id,
file_created=workfile_info.file_created,
file_modified=workfile_info.file_modified,
size_value=workfile_info.file_size,
created_by=workfile_info.created_by,
updated_by=workfile_info.updated_by,
)
description = workfile_info.description
self._orig_description = description
self._description_input.setPlainText(description)
def _set_publish_context(
self,
folder_id: Optional[str],
task_id: Optional[str],
representation_id: Optional[str],
) -> None:
self._representation_id = representation_id
published_workfile_wrap = self._controller.get_published_workfile_info(
folder_id,
representation_id,
)
info = published_workfile_wrap.info
comment = published_workfile_wrap.comment
if info is None:
self._set_context(False, folder_id, task_id)
return
self._set_context(
True,
folder_id,
task_id,
file_created=info.file_created,
file_modified=info.file_modified,
size_value=info.file_size,
created_by=info.author,
comment=comment,
)
def _set_context(
self,
is_valid: bool,
folder_id: Optional[str],
task_id: Optional[str],
*,
file_created: Optional[int] = None,
file_modified: Optional[int] = None,
size_value: Optional[int] = None,
created_by: Optional[str] = None,
updated_by: Optional[str] = None,
comment: Optional[str] = None,
) -> None:
self._folder_id = folder_id
self._task_id = task_id
self._details_input.setEnabled(is_valid)
self._description_input.setEnabled(is_valid)
self._btn_description_save.setEnabled(is_valid)
if not is_valid:
self._details_input.setPlainText("")
return
# Append html string
datetime_format = "%b %d %Y %H:%M:%S"
file_created = workfile_info.file_created
modification_time = workfile_info.file_modified
if file_created:
file_created = datetime.datetime.fromtimestamp(file_created)
if modification_time:
modification_time = datetime.datetime.fromtimestamp(
modification_time)
if file_modified:
file_modified = datetime.datetime.fromtimestamp(
file_modified
)
user_items_by_name = self._controller.get_user_items_by_name()
def convert_username(username):
user_item = user_items_by_name.get(username)
def convert_username(username_v):
user_item = user_items_by_name.get(username_v)
if user_item is not None and user_item.full_name:
return user_item.full_name
return username
return username_v
created_lines = []
if workfile_info.created_by:
created_lines.append(
convert_username(workfile_info.created_by)
)
if file_created:
created_lines.append(file_created.strftime(datetime_format))
lines = []
if size_value is not None:
size_value = file_size_to_string(size_value)
lines.append(f"<b>Size:</b><br/>{size_value}")
if created_lines:
created_lines.insert(0, "<b>Created:</b>")
# Add version comment for published workfiles
if comment:
lines.append(f"<b>Comment:</b><br/>{comment}")
modified_lines = []
if workfile_info.updated_by:
modified_lines.append(
convert_username(workfile_info.updated_by)
)
if modification_time:
modified_lines.append(
modification_time.strftime(datetime_format)
)
if modified_lines:
modified_lines.insert(0, "<b>Modified:</b>")
if created_by or file_created:
lines.append("<b>Created:</b>")
if created_by:
lines.append(convert_username(created_by))
if file_created:
lines.append(file_created.strftime(datetime_format))
lines = (
"<b>Size:</b>",
size_value,
"<br/>".join(created_lines),
"<br/>".join(modified_lines),
)
self._orig_description = description
self._description_input.setPlainText(description)
if updated_by or file_modified:
lines.append("<b>Modified:</b>")
if updated_by:
lines.append(convert_username(updated_by))
if file_modified:
lines.append(file_modified.strftime(datetime_format))
# Set as empty string
self._details_input.setPlainText("")

View file

@ -6,12 +6,11 @@ from ayon_core.tools.utils import (
FoldersWidget,
GoToCurrentButton,
MessageOverlayObject,
NiceCheckbox,
PlaceholderLineEdit,
RefreshButton,
TasksWidget,
FoldersFiltersWidget,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.workfiles.control import BaseWorkfileController
from .files_widget import FilesWidget
@ -69,7 +68,6 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._default_window_flags = flags
self._folders_widget = None
self._folder_filter_input = None
self._files_widget = None
@ -178,48 +176,33 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
col_widget = QtWidgets.QWidget(parent)
header_widget = QtWidgets.QWidget(col_widget)
folder_filter_input = PlaceholderLineEdit(header_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
filters_widget = FoldersFiltersWidget(header_widget)
go_to_current_btn = GoToCurrentButton(header_widget)
refresh_btn = RefreshButton(header_widget)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(filters_widget, 1)
header_layout.addWidget(go_to_current_btn, 0)
header_layout.addWidget(refresh_btn, 0)
folder_widget = FoldersWidget(
controller, col_widget, handle_expected_selection=True
)
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks")
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(folder_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(folder_filter_input, 1)
header_layout.addWidget(go_to_current_btn, 0)
header_layout.addWidget(refresh_btn, 0)
header_layout.addWidget(my_tasks_label, 0)
header_layout.addWidget(my_tasks_checkbox, 0)
col_layout = QtWidgets.QVBoxLayout(col_widget)
col_layout.setContentsMargins(0, 0, 0, 0)
col_layout.addWidget(header_widget, 0)
col_layout.addWidget(folder_widget, 1)
folder_filter_input.textChanged.connect(self._on_folder_filter_change)
go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
my_tasks_checkbox.stateChanged.connect(
filters_widget.text_changed.connect(self._on_folder_filter_change)
filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
self._folder_filter_input = folder_filter_input
self._folders_widget = folder_widget
return col_widget
@ -358,9 +341,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"]:
@ -404,11 +386,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
else:
self.close()
def _on_my_tasks_checkbox_state_changed(self, state):
def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.6.1+dev"
__version__ = "1.6.9+dev"

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