mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 12:54:40 +01:00
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:
commit
c1b262138d
107 changed files with 6093 additions and 2480 deletions
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
8
client/ayon_core/lib/_compatibility.py
Normal file
8
client/ayon_core/lib/_compatibility.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
client/ayon_core/pipeline/actions/__init__.py
Normal file
62
client/ayon_core/pipeline/actions/__init__.py
Normal 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",
|
||||
)
|
||||
108
client/ayon_core/pipeline/actions/inventory.py
Normal file
108
client/ayon_core/pipeline/actions/inventory.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
864
client/ayon_core/pipeline/actions/loader.py
Normal file
864
client/ayon_core/pipeline/actions/loader.py
Normal 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)
|
||||
60
client/ayon_core/pipeline/actions/structures.py
Normal file
60
client/ayon_core/pipeline/actions/structures.py
Normal 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)
|
||||
100
client/ayon_core/pipeline/actions/utils.py
Normal file
100
client/ayon_core/pipeline/actions/utils.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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_()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
122
client/ayon_core/plugins/loader/copy_file.py
Normal file
122
client/ayon_core/plugins/loader/copy_file.py
Normal 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,
|
||||
)
|
||||
388
client/ayon_core/plugins/loader/delete_old_versions.py
Normal file
388
client/ayon_core/plugins/loader/delete_old_versions.py
Normal 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
|
||||
|
|
@ -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
|
||||
))
|
||||
|
|
@ -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(
|
||||
360
client/ayon_core/plugins/loader/open_file.py
Normal file
360
client/ayon_core/plugins/loader/open_file.py
Normal 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,
|
||||
)
|
||||
69
client/ayon_core/plugins/loader/push_to_project.py
Normal file
69
client/ayon_core/plugins/loader/push_to_project.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
BIN
client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf
vendored
Normal file
BIN
client/ayon_core/vendor/python/qtmaterialsymbols/resources/MaterialSymbolsOutlined.ttf
vendored
Normal file
Binary file not shown.
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue