Merge branch 'develop' into chore/cleanup_plugin_code_cosmetics

This commit is contained in:
Roy Nieterau 2025-04-29 13:06:51 +02:00 committed by GitHub
commit 59543ae492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
194 changed files with 9256 additions and 10505 deletions

View file

@ -37,7 +37,7 @@ def _handle_error(
if process_context.headless:
if detail:
print(detail)
print(f"{10*'*'}\n{message}\n{10*'*'}")
print(f"{10 * '*'}\n{message}\n{10 * '*'}")
return
current_dir = os.path.dirname(os.path.abspath(__file__))

View file

@ -8,7 +8,6 @@ from pathlib import Path
import warnings
import click
import acre
from ayon_core import AYON_CORE_ROOT
from ayon_core.addon import AddonsManager
@ -18,7 +17,11 @@ from ayon_core.lib import (
is_running_from_build,
Logger,
)
from ayon_core.lib.env_tools import (
parse_env_variables_structure,
compute_env_variables_structure,
merge_env_variables,
)
@click.group(invoke_without_command=True)
@ -169,7 +172,6 @@ def contextselection(
main(output_path, project, folder, strict)
@main_cli.command(
context_settings=dict(
ignore_unknown_options=True,
@ -235,24 +237,19 @@ def version(build):
def _set_global_environments() -> None:
"""Set global AYON environments."""
general_env = get_general_environments()
# First resolve general environment
general_env = parse_env_variables_structure(get_general_environments())
# first resolve general environment because merge doesn't expect
# values to be list.
# TODO: switch to AYON environment functions
merged_env = acre.merge(
acre.compute(acre.parse(general_env), cleanup=False),
# Merge environments with current environments and update values
merged_env = merge_env_variables(
compute_env_variables_structure(general_env),
dict(os.environ)
)
env = acre.compute(
merged_env,
cleanup=False
)
env = compute_env_variables_structure(merged_env)
os.environ.clear()
os.environ.update(env)
# Hardcoded default values
os.environ["PYBLISH_GUI"] = "pyblish_pype"
# Change scale factor only if is not set
if "QT_AUTO_SCREEN_SCALE_FACTOR" not in os.environ:
os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
@ -263,8 +260,8 @@ def _set_addons_environments(addons_manager):
# Merge environments with current environments and update values
if module_envs := addons_manager.collect_global_environments():
parsed_envs = acre.parse(module_envs)
env = acre.merge(parsed_envs, dict(os.environ))
parsed_envs = parse_env_variables_structure(module_envs)
env = merge_env_variables(parsed_envs, dict(os.environ))
os.environ.clear()
os.environ.update(env)
@ -290,8 +287,6 @@ def main(*args, **kwargs):
split_paths = python_path.split(os.pathsep)
additional_paths = [
# add AYON tools for 'pyblish_pype'
os.path.join(AYON_CORE_ROOT, "tools"),
# add common AYON vendor
# (common for multiple Python interpreter versions)
os.path.join(AYON_CORE_ROOT, "vendor", "python")

View file

@ -26,10 +26,12 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"photoshop",
"tvpaint",
"substancepainter",
"substancedesigner",
"aftereffects",
"wrap",
"openrv",
"cinema4d"
"cinema4d",
"silhouette",
}
launch_types = {LaunchTypes.local}

View file

@ -1,12 +1,15 @@
from ayon_api import get_project, get_folder_by_path, get_task_by_name
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.anatomy import RootMissingEnv
from ayon_applications import PreLaunchHook
from ayon_applications.exceptions import ApplicationLaunchFailed
from ayon_applications.utils import (
EnvironmentPrepData,
prepare_app_environments,
prepare_context_environments
)
from ayon_core.pipeline import Anatomy
class GlobalHostDataHook(PreLaunchHook):
@ -67,9 +70,12 @@ class GlobalHostDataHook(PreLaunchHook):
self.data["project_entity"] = project_entity
# Anatomy
self.data["anatomy"] = Anatomy(
project_name, project_entity=project_entity
)
try:
self.data["anatomy"] = Anatomy(
project_name, project_entity=project_entity
)
except RootMissingEnv as exc:
raise ApplicationLaunchFailed(str(exc))
folder_path = self.data.get("folder_path")
if not folder_path:

View file

@ -10,6 +10,7 @@ class OCIOEnvHook(PreLaunchHook):
order = 0
hosts = {
"substancepainter",
"substancedesigner",
"fusion",
"blender",
"aftereffects",
@ -20,7 +21,8 @@ class OCIOEnvHook(PreLaunchHook):
"hiero",
"resolve",
"openrv",
"cinema4d"
"cinema4d",
"silhouette",
}
launch_types = set()

View file

@ -9,6 +9,7 @@ from .local_settings import (
AYONSettingsRegistry,
get_launcher_local_dir,
get_launcher_storage_dir,
get_addons_resources_dir,
get_local_site_id,
get_ayon_username,
)
@ -142,6 +143,7 @@ __all__ = [
"AYONSettingsRegistry",
"get_launcher_local_dir",
"get_launcher_storage_dir",
"get_addons_resources_dir",
"get_local_site_id",
"get_ayon_username",

View file

@ -22,12 +22,10 @@ import clique
if typing.TYPE_CHECKING:
from typing import Self, Tuple, Union, TypedDict, Pattern
class EnumItemDict(TypedDict):
label: str
value: Any
EnumItemsInputType = Union[
Dict[Any, str],
List[Tuple[Any, str]],
@ -35,7 +33,6 @@ if typing.TYPE_CHECKING:
List[EnumItemDict]
]
class FileDefItemDict(TypedDict):
directory: str
filenames: List[str]
@ -289,6 +286,7 @@ AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef)
# UI attribute definitions won't hold value
# -----------------------------------------
class UIDef(AbstractAttrDef):
is_value_def = False
@ -550,29 +548,38 @@ class EnumDef(AbstractAttrDef):
passed items or list of values for multiselection.
multiselection (Optional[bool]): If True, multiselection is allowed.
Output is list of selected items.
placeholder (Optional[str]): Placeholder for UI purposes, only for
multiselection enumeration.
"""
type = "enum"
type_attributes = [
"multiselection",
"placeholder",
]
def __init__(
self,
key: str,
items: "EnumItemsInputType",
default: "Union[str, List[Any]]" = None,
multiselection: Optional[bool] = False,
placeholder: Optional[str] = None,
**kwargs
):
if not items:
raise ValueError((
"Empty 'items' value. {} must have"
if multiselection is None:
multiselection = False
if not items and not multiselection:
raise ValueError(
f"Empty 'items' value. {self.__class__.__name__} must have"
" defined values on initialization."
).format(self.__class__.__name__))
)
items = self.prepare_enum_items(items)
item_values = [item["value"] for item in items]
item_values_set = set(item_values)
if multiselection is None:
multiselection = False
if multiselection:
if default is None:
@ -587,6 +594,7 @@ class EnumDef(AbstractAttrDef):
self.items: List["EnumItemDict"] = items
self._item_values: Set[Any] = item_values_set
self.multiselection: bool = multiselection
self.placeholder: Optional[str] = placeholder
def convert_value(self, value):
if not self.multiselection:
@ -612,7 +620,6 @@ class EnumDef(AbstractAttrDef):
def serialize(self):
data = super().serialize()
data["items"] = copy.deepcopy(self.items)
data["multiselection"] = self.multiselection
return data
@staticmethod

View file

@ -177,10 +177,12 @@ def initialize_ayon_connection(force=False):
return _new_get_last_versions(
con, *args, **kwargs
)
def _lv_by_pi_wrapper(*args, **kwargs):
return _new_get_last_version_by_product_id(
con, *args, **kwargs
)
def _lv_by_pn_wrapper(*args, **kwargs):
return _new_get_last_version_by_product_name(
con, *args, **kwargs

View file

@ -1,7 +1,34 @@
from __future__ import annotations
import os
import re
import platform
import typing
import collections
from string import Formatter
from typing import Optional
if typing.TYPE_CHECKING:
from typing import Union, Literal
PlatformName = Literal["windows", "linux", "darwin"]
EnvValue = Union[str, list[str], dict[str, str], dict[str, list[str]]]
def env_value_to_bool(env_key=None, value=None, default=False):
class CycleError(ValueError):
"""Raised when a cycle is detected in dynamic env variables compute."""
pass
class DynamicKeyClashError(Exception):
"""Raised when dynamic key clashes with an existing key."""
pass
def env_value_to_bool(
env_key: Optional[str] = None,
value: Optional[str] = None,
default: bool = False,
) -> bool:
"""Convert environment variable value to boolean.
Function is based on value of the environemt variable. Value is lowered
@ -11,6 +38,7 @@ def env_value_to_bool(env_key=None, value=None, default=False):
bool: If value match to one of ["true", "yes", "1"] result if True
but if value match to ["false", "no", "0"] result is False else
default value is returned.
"""
if value is None and env_key is None:
return default
@ -27,18 +55,23 @@ def env_value_to_bool(env_key=None, value=None, default=False):
return default
def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
def get_paths_from_environ(
env_key: Optional[str] = None,
env_value: Optional[str] = None,
return_first: bool = False,
) -> Optional[Union[str, list[str]]]:
"""Return existing paths from specific environment variable.
Args:
env_key (str): Environment key where should look for paths.
env_value (str): Value of environment variable. Argument `env_key` is
skipped if this argument is entered.
env_key (Optional[str]): Environment key where should look for paths.
env_value (Optional[str]): Value of environment variable.
Argument `env_key` is skipped if this argument is entered.
return_first (bool): Return first found value or return list of found
paths. `None` or empty list returned if nothing found.
Returns:
str, list, None: Result of found path/s.
Optional[Union[str, list[str]]]: Result of found path/s.
"""
existing_paths = []
if not env_key and not env_value:
@ -69,3 +102,225 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
return None
# Return all existing paths from environment variable
return existing_paths
def parse_env_variables_structure(
env: dict[str, EnvValue],
platform_name: Optional[PlatformName] = None
) -> dict[str, str]:
"""Parse environment for platform-specific values and paths as lists.
Args:
env (dict): The source environment to read.
platform_name (Optional[PlatformName]): Name of platform to parse for.
Defaults to current platform.
Returns:
dict: The flattened environment for a platform.
"""
if platform_name is None:
platform_name = platform.system().lower()
# Separator based on OS 'os.pathsep' is ';' on Windows and ':' on Unix
sep = ";" if platform_name == "windows" else ":"
result = {}
for variable, value in env.items():
# Platform specific values
if isinstance(value, dict):
value = value.get(platform_name)
# Allow to have lists as values in the tool data
if isinstance(value, (list, tuple)):
value = sep.join(value)
if not value:
continue
if not isinstance(value, str):
raise TypeError(f"Expected 'str' got '{type(value)}'")
result[variable] = value
return result
def _topological_sort(
dependencies: dict[str, set[str]]
) -> tuple[list[str], list[str]]:
"""Sort values subject to dependency constraints.
Args:
dependencies (dict[str, set[str]): Mapping of environment variable
keys to a set of keys they depend on.
Returns:
tuple[list[str], list[str]]: A tuple of two lists. The first list
contains the ordered keys in which order should be environment
keys filled, the second list contains the keys that would cause
cyclic fill of values.
"""
num_heads = collections.defaultdict(int) # num arrows pointing in
tails = collections.defaultdict(list) # list of arrows going out
heads = [] # unique list of heads in order first seen
for head, tail_values in dependencies.items():
for tail_value in tail_values:
num_heads[tail_value] += 1
if head not in tails:
heads.append(head)
tails[head].append(tail_value)
ordered = [head for head in heads if head not in num_heads]
for head in ordered:
for tail in tails[head]:
num_heads[tail] -= 1
if not num_heads[tail]:
ordered.append(tail)
cyclic = [tail for tail, heads in num_heads.items() if heads]
return ordered, cyclic
class _PartialFormatDict(dict):
"""This supports partial formatting.
Missing keys are replaced with the return value of __missing__.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._missing_template: str = "{{{key}}}"
def set_missing_template(self, template: str):
self._missing_template = template
def __missing__(self, key: str) -> str:
return self._missing_template.format(key=key)
def _partial_format(
value: str,
data: dict[str, str],
missing_template: Optional[str] = None,
) -> str:
"""Return string `s` formatted by `data` allowing a partial format
Arguments:
value (str): The string that will be formatted
data (dict): The dictionary used to format with.
missing_template (Optional[str]): The template to use when a key is
missing from the data. If `None`, the key will remain unformatted.
Example:
>>> _partial_format("{d} {a} {b} {c} {d}", {'b': "and", 'd': "left"})
'left {a} and {c} left'
"""
mapping = _PartialFormatDict(**data)
if missing_template is not None:
mapping.set_missing_template(missing_template)
formatter = Formatter()
try:
output = formatter.vformat(value, (), mapping)
except Exception:
r_token = re.compile(r"({.*?})")
output = value
for match in re.findall(r_token, value):
try:
output = re.sub(match, match.format(**data), output)
except (KeyError, ValueError, IndexError):
continue
return output
def compute_env_variables_structure(
env: dict[str, str],
fill_dynamic_keys: bool = True,
) -> dict[str, str]:
"""Compute the result from recursive dynamic environment.
Note: Keys that are not present in the data will remain unformatted as the
original keys. So they can be formatted against the current user
environment when merging. So {"A": "{key}"} will remain {key} if not
present in the dynamic environment.
"""
env = env.copy()
# Collect dependencies
dependencies = collections.defaultdict(set)
for key, value in env.items():
dependent_keys = re.findall("{(.+?)}", value)
for dependent_key in dependent_keys:
# Ignore reference to itself or key is not in env
if dependent_key != key and dependent_key in env:
dependencies[key].add(dependent_key)
ordered, cyclic = _topological_sort(dependencies)
# Check cycle
if cyclic:
raise CycleError(f"A cycle is detected on: {cyclic}")
# Format dynamic values
for key in reversed(ordered):
if key in env:
if not isinstance(env[key], str):
continue
data = env.copy()
data.pop(key) # format without itself
env[key] = _partial_format(env[key], data=data)
# Format dynamic keys
if fill_dynamic_keys:
formatted = {}
for key, value in env.items():
if not isinstance(value, str):
formatted[key] = value
continue
new_key = _partial_format(key, data=env)
if new_key in formatted:
raise DynamicKeyClashError(
f"Key clashes on: {new_key} (source: {key})"
)
formatted[new_key] = value
env = formatted
return env
def merge_env_variables(
src_env: dict[str, str],
dst_env: dict[str, str],
missing_template: Optional[str] = None,
) -> dict[str, str]:
"""Merge the tools environment with the 'current_env'.
This finalizes the join with a current environment by formatting the
remainder of dynamic variables with that from the current environment.
Remaining missing variables result in an empty value.
Args:
src_env (dict): The dynamic environment
dst_env (dict): The target environment variables mapping to merge
the dynamic environment into.
missing_template (str): Argument passed to '_partial_format' during
merging. `None` should keep missing keys unchanged.
Returns:
dict[str, str]: The resulting environment after the merge.
"""
result = dst_env.copy()
for key, value in src_env.items():
result[key] = _partial_format(
str(value), dst_env, missing_template
)
return result

View file

@ -108,21 +108,29 @@ def run_subprocess(*args, **kwargs):
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
)
# Escape parentheses for bash
# Escape special characters in certain shells
if (
kwargs.get("shell") is True
and len(args) == 1
and isinstance(args[0], str)
and os.getenv("SHELL") in ("/bin/bash", "/bin/sh")
):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg, )
# Escape parentheses for bash
if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"):
new_arg = (
args[0]
.replace("(", "\\(")
.replace(")", "\\)")
)
args = (new_arg,)
# Escape & on Windows in shell with `cmd.exe` using ^&
elif (
platform.system().lower() == "windows"
and os.getenv("COMSPEC").endswith("cmd.exe")
):
new_arg = args[0].replace("&", "^&")
args = (new_arg, )
# Get environents from kwarg or use current process environments if were
# Get environments from kwarg or use current process environments if were
# not passed.
env = kwargs.get("env") or os.environ
# Make sure environment contains only strings

View file

@ -9,7 +9,7 @@ from datetime import datetime
from abc import ABC, abstractmethod
from functools import lru_cache
import appdirs
import platformdirs
import ayon_api
_PLACEHOLDER = object()
@ -17,7 +17,7 @@ _PLACEHOLDER = object()
def _get_ayon_appdirs(*args):
return os.path.join(
appdirs.user_data_dir("AYON", "Ynput"),
platformdirs.user_data_dir("AYON", "Ynput"),
*args
)
@ -96,6 +96,30 @@ def get_launcher_local_dir(*subdirs: str) -> str:
return os.path.join(storage_dir, *subdirs)
def get_addons_resources_dir(addon_name: str, *args) -> str:
"""Get directory for storing resources for addons.
Some addons might need to store ad-hoc resources that are not part of
addon client package (e.g. because of size). Studio might define
dedicated directory to store them with 'AYON_ADDONS_RESOURCES_DIR'
environment variable. By default, is used 'addons_resources' in
launcher storage (might be shared across platforms).
Args:
addon_name (str): Addon name.
*args (str): Subfolders in resources directory.
Returns:
str: Path to resources directory.
"""
addons_resources_dir = os.getenv("AYON_ADDONS_RESOURCES_DIR")
if not addons_resources_dir:
addons_resources_dir = get_launcher_storage_dir("addons_resources")
return os.path.join(addons_resources_dir, addon_name, *args)
class AYONSecureRegistry:
"""Store information using keyring.

View file

@ -587,8 +587,8 @@ class FormattingPart:
if sub_key < 0:
sub_key = len(value) + sub_key
invalid = 0 > sub_key < len(data)
if invalid:
valid = 0 <= sub_key < len(value)
if not valid:
used_keys.append(sub_key)
missing_key = True
break

View file

@ -1,17 +0,0 @@
# Deprecated file
# - the file container 'WeakMethod' implementation for Python 2 which is not
# needed anymore.
import warnings
import weakref
WeakMethod = weakref.WeakMethod
warnings.warn(
(
"'ayon_core.lib.python_2_comp' is deprecated."
"Please use 'weakref.WeakMethod'."
),
DeprecationWarning,
stacklevel=2
)

View file

@ -1,6 +1,8 @@
"""Tools for working with python modules and classes."""
import os
import sys
import types
from typing import Optional
import importlib
import inspect
import logging
@ -8,13 +10,22 @@ import logging
log = logging.getLogger(__name__)
def import_filepath(filepath, module_name=None):
def import_filepath(
filepath: str,
module_name: Optional[str] = None,
sys_module_name: Optional[str] = None) -> types.ModuleType:
"""Import python file as python module.
Args:
filepath (str): Path to python file.
module_name (str): Name of loaded module. Only for Python 3. By default
is filled with filename of filepath.
sys_module_name (str): Name of module in `sys.modules` where to store
loaded module. By default is None so module is not added to
`sys.modules`.
Todo (antirotor): We should add the module to the sys.modules always but
we need to be careful about it and test it properly.
"""
if module_name is None:
@ -28,6 +39,9 @@ def import_filepath(filepath, module_name=None):
module_loader = importlib.machinery.SourceFileLoader(
module_name, filepath
)
# only add to sys.modules if requested
if sys_module_name:
sys.modules[sys_module_name] = module
module_loader.exec_module(module)
return module
@ -126,7 +140,8 @@ def classes_from_module(superclass, module):
return classes
def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None):
def import_module_from_dirpath(
dirpath, folder_name, dst_module_name=None):
"""Import passed directory as a python module.
Imported module can be assigned as a child attribute of already loaded
@ -193,7 +208,7 @@ def is_func_signature_supported(func, *args, **kwargs):
Notes:
This does NOT check if the function would work with passed arguments
only if they can be passed in. If function have *args, **kwargs
in paramaters, this will always return 'True'.
in parameters, this will always return 'True'.
Example:
>>> def my_function(my_number):

View file

@ -39,6 +39,7 @@ class Terminal:
"""
from ayon_core.lib import env_value_to_bool
log_no_colors = env_value_to_bool(
"AYON_LOG_NO_COLORS", default=None
)

View file

@ -53,7 +53,7 @@ IMAGE_EXTENSIONS = {
".kra", ".logluv", ".mng", ".miff", ".nrrd", ".ora",
".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf",
".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr",
".ras", ".rgbe", ".sgi", ".tga",
".ras", ".rgbe", ".sgi", ".sxr", ".tga",
".tif", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp",
".wbmp", ".webp", ".xr", ".xt", ".xbm", ".xcf", ".xpm", ".xwd"
}

View file

@ -162,7 +162,7 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None):
# Handle cases when path is just an executable
# - it allows to use executable from PATH
# - basename must match 'tool' value (without extension)
extless_path, ext = os.path.splitext(path)
extless_path, _ext = os.path.splitext(path)
if extless_path == tool:
executable_path = find_executable(tool)
if executable_path and (
@ -181,7 +181,7 @@ def find_tool_in_custom_paths(paths, tool, validation_func=None):
# If path is a file validate it
if os.path.isfile(normalized):
basename, ext = os.path.splitext(os.path.basename(path))
basename, _ext = os.path.splitext(os.path.basename(path))
# Check if the filename has actually the sane bane as 'tool'
if basename == tool:
executable_path = find_executable(normalized)

View file

@ -1,5 +1,6 @@
from .exceptions import (
ProjectNotSet,
RootMissingEnv,
RootCombinationError,
TemplateMissingKey,
AnatomyTemplateUnsolved,
@ -9,6 +10,7 @@ from .anatomy import Anatomy
__all__ = (
"ProjectNotSet",
"RootMissingEnv",
"RootCombinationError",
"TemplateMissingKey",
"AnatomyTemplateUnsolved",

View file

@ -5,6 +5,11 @@ class ProjectNotSet(Exception):
"""Exception raised when is created Anatomy without project name."""
class RootMissingEnv(KeyError):
"""Raised when root requires environment variables which is not filled."""
pass
class RootCombinationError(Exception):
"""This exception is raised when templates has combined root types."""

View file

@ -2,9 +2,11 @@ import os
import platform
import numbers
from ayon_core.lib import Logger
from ayon_core.lib import Logger, StringTemplate
from ayon_core.lib.path_templates import FormatObject
from .exceptions import RootMissingEnv
class RootItem(FormatObject):
"""Represents one item or roots.
@ -21,18 +23,36 @@ class RootItem(FormatObject):
multi root setup otherwise None value is expected.
"""
def __init__(self, parent, root_raw_data, name):
super(RootItem, self).__init__()
super().__init__()
self._log = None
lowered_platform_keys = {}
for key, value in root_raw_data.items():
lowered_platform_keys[key.lower()] = value
lowered_platform_keys = {
key.lower(): value
for key, value in root_raw_data.items()
}
self.raw_data = lowered_platform_keys
self.cleaned_data = self._clean_roots(lowered_platform_keys)
self.name = name
self.parent = parent
self.available_platforms = set(lowered_platform_keys.keys())
self.value = lowered_platform_keys.get(platform.system().lower())
current_platform = platform.system().lower()
# WARNING: Using environment variables in roots is not considered
# as production safe. Some features may not work as expected, for
# example USD resolver or site sync.
try:
self.value = lowered_platform_keys[current_platform].format_map(
os.environ
)
except KeyError:
result = StringTemplate(self.value).format(os.environ.copy())
is_are = "is" if len(result.missing_keys) == 1 else "are"
missing_keys = ", ".join(result.missing_keys)
raise RootMissingEnv(
f"Root \"{name}\" requires environment variable/s"
f" {missing_keys} which {is_are} not available."
)
self.clean_value = self._clean_root(self.value)
def __format__(self, *args, **kwargs):
@ -105,10 +125,10 @@ class RootItem(FormatObject):
def _clean_roots(self, raw_data):
"""Clean all values of raw root item values."""
cleaned = {}
for key, value in raw_data.items():
cleaned[key] = self._clean_root(value)
return cleaned
return {
key: self._clean_root(value)
for key, value in raw_data.items()
}
def path_remapper(self, path, dst_platform=None, src_platform=None):
"""Remap path for specific platform.

View file

@ -27,7 +27,8 @@ from .workfile import (
get_workdir,
get_custom_workfile_template_by_string_context,
get_workfile_template_key_from_context,
get_last_workfile
get_last_workfile,
MissingWorkdirError,
)
from . import (
register_loader_plugin_path,
@ -251,7 +252,7 @@ def uninstall_host():
pyblish.api.deregister_discovery_filter(filter_pyblish_plugins)
deregister_loader_plugin_path(LOAD_PATH)
deregister_inventory_action_path(INVENTORY_PATH)
log.info("Global plug-ins unregistred")
log.info("Global plug-ins unregistered")
deregister_host()
@ -617,7 +618,18 @@ def version_up_current_workfile():
last_workfile_path = get_last_workfile(
work_root, file_template, data, extensions, True
)
new_workfile_path = version_up(last_workfile_path)
# `get_last_workfile` will return the first expected file version
# if no files exist yet. In that case, if they do not exist we will
# want to save v001
new_workfile_path = last_workfile_path
if os.path.exists(new_workfile_path):
new_workfile_path = version_up(new_workfile_path)
# Raise an error if the parent folder doesn't exist as `host.save_workfile`
# is not supposed/able to create missing folders.
parent_folder = os.path.dirname(new_workfile_path)
if not os.path.exists(parent_folder):
raise MissingWorkdirError(
f"Work area directory '{parent_folder}' does not exist.")
host.save_workfile(new_workfile_path)

View file

@ -29,6 +29,7 @@ from ayon_core.lib.events import QueuedEventSystem
from ayon_core.lib.attribute_definitions import get_default_values
from ayon_core.host import IPublishHost, IWorkfileHost
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.template_data import get_template_data
from ayon_core.pipeline.plugin_discover import DiscoverResult
from .exceptions import (
@ -480,6 +481,36 @@ class CreateContext:
self.get_current_project_name())
return self._current_project_settings
def get_template_data(
self, folder_path: Optional[str], task_name: Optional[str]
) -> Dict[str, Any]:
"""Prepare template data for given context.
Method is using cached entities and settings to prepare template data.
Args:
folder_path (Optional[str]): Folder path.
task_name (Optional[str]): Task name.
Returns:
dict[str, Any]: Template data.
"""
project_entity = self.get_current_project_entity()
folder_entity = task_entity = None
if folder_path:
folder_entity = self.get_folder_entity(folder_path)
if task_name and folder_entity:
task_entity = self.get_task_entity(folder_path, task_name)
return get_template_data(
project_entity,
folder_entity,
task_entity,
host_name=self.host_name,
settings=self.get_current_project_settings(),
)
@property
def context_has_changed(self):
"""Host context has changed.
@ -724,11 +755,19 @@ class CreateContext:
).format(creator_class.host_name, self.host_name))
continue
creator = creator_class(
project_settings,
self,
self.headless
)
# TODO report initialization error
try:
creator = creator_class(
project_settings,
self,
self.headless
)
except Exception:
self.log.error(
f"Failed to initialize plugin: {creator_class}",
exc_info=True
)
continue
if not creator.enabled:
disabled_creators[creator_identifier] = creator
@ -800,7 +839,7 @@ class CreateContext:
publish_attributes.update(output)
for plugin in self.plugins_with_defs:
attr_defs = plugin.get_attr_defs_for_context (self)
attr_defs = plugin.get_attr_defs_for_context(self)
if not attr_defs:
continue
self._publish_attributes.set_publish_plugin_attr_defs(
@ -833,7 +872,7 @@ class CreateContext:
"""
return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback)
def add_instances_removed_callback (self, callback):
def add_instances_removed_callback(self, callback):
"""Register callback for removed instances.
Event is triggered when instances are already removed from context.
@ -894,7 +933,7 @@ class CreateContext:
"""
self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback)
def add_pre_create_attr_defs_change_callback (self, callback):
def add_pre_create_attr_defs_change_callback(self, callback):
"""Register callback to listen pre-create attribute changes.
Create plugin can trigger refresh of pre-create attributes. Usage of
@ -922,7 +961,7 @@ class CreateContext:
PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback
)
def add_create_attr_defs_change_callback (self, callback):
def add_create_attr_defs_change_callback(self, callback):
"""Register callback to listen create attribute changes.
Create plugin changed attribute definitions of instance.
@ -947,7 +986,7 @@ class CreateContext:
"""
self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback)
def add_publish_attr_defs_change_callback (self, callback):
def add_publish_attr_defs_change_callback(self, callback):
"""Register callback to listen publish attribute changes.
Publish plugin changed attribute definitions of instance of context.
@ -1220,50 +1259,6 @@ class CreateContext:
with self._bulk_context("add", sender) as bulk_info:
yield bulk_info
# Set publish attributes before bulk context is exited
for instance in bulk_info.get_data():
publish_attributes = instance.publish_attributes
# Prepare publish plugin attributes and set it on instance
for plugin in self.plugins_with_defs:
try:
if is_func_signature_supported(
plugin.convert_attribute_values, self, instance
):
plugin.convert_attribute_values(self, instance)
elif plugin.__instanceEnabled__:
output = plugin.convert_attribute_values(
publish_attributes
)
if output:
publish_attributes.update(output)
except Exception:
self.log.error(
"Failed to convert attribute values of"
f" plugin '{plugin.__name__}'",
exc_info=True
)
for plugin in self.plugins_with_defs:
attr_defs = None
try:
attr_defs = plugin.get_attr_defs_for_instance(
self, instance
)
except Exception:
self.log.error(
"Failed to get attribute definitions"
f" from plugin '{plugin.__name__}'.",
exc_info=True
)
if not attr_defs:
continue
instance.set_publish_plugin_attr_defs(
plugin.__name__, attr_defs
)
@contextmanager
def bulk_instances_collection(self, sender=None):
"""DEPRECATED use 'bulk_add_instances' instead."""
@ -2212,6 +2207,50 @@ class CreateContext:
if not instances_to_validate:
return
# Set publish attributes before bulk callbacks are triggered
for instance in instances_to_validate:
publish_attributes = instance.publish_attributes
# Prepare publish plugin attributes and set it on instance
for plugin in self.plugins_with_defs:
try:
if is_func_signature_supported(
plugin.convert_attribute_values, self, instance
):
plugin.convert_attribute_values(self, instance)
elif plugin.__instanceEnabled__:
output = plugin.convert_attribute_values(
publish_attributes
)
if output:
publish_attributes.update(output)
except Exception:
self.log.error(
"Failed to convert attribute values of"
f" plugin '{plugin.__name__}'",
exc_info=True
)
for plugin in self.plugins_with_defs:
attr_defs = None
try:
attr_defs = plugin.get_attr_defs_for_instance(
self, instance
)
except Exception:
self.log.error(
"Failed to get attribute definitions"
f" from plugin '{plugin.__name__}'.",
exc_info=True
)
if not attr_defs:
continue
instance.set_publish_plugin_attr_defs(
plugin.__name__, attr_defs
)
# Cache folder and task entities for all instances at once
self.get_instances_context_info(instances_to_validate)
@ -2264,10 +2303,16 @@ class CreateContext:
for plugin_name, plugin_value in item_changes.pop(
"publish_attributes"
).items():
if plugin_value is None:
current_publish[plugin_name] = None
continue
plugin_changes = current_publish.setdefault(
plugin_name, {}
)
plugin_changes.update(plugin_value)
if plugin_changes is None:
current_publish[plugin_name] = plugin_value
else:
plugin_changes.update(plugin_value)
item_values.update(item_changes)

View file

@ -562,6 +562,10 @@ class BaseCreator(ABC):
instance
)
cur_project_name = self.create_context.get_current_project_name()
if not project_entity and project_name == cur_project_name:
project_entity = self.create_context.get_current_project_entity()
return get_product_name(
project_name,
task_name,
@ -858,18 +862,30 @@ class Creator(BaseCreator):
["CollectAnatomyInstanceData"]
["follow_workfile_version"]
)
follow_version_hosts = (
publish_settings
["CollectSceneVersion"]
["hosts"]
)
current_host = create_ctx.host.name
follow_workfile_version = (
follow_workfile_version and
current_host in follow_version_hosts
)
# Gather version number provided from the instance.
current_workfile = create_ctx.get_current_workfile_path()
version = instance.get("version")
# If follow workfile, gather version from workfile path.
if version is None and follow_workfile_version:
current_workfile = self.create_context.get_current_workfile_path()
if version is None and follow_workfile_version and current_workfile:
workfile_version = get_version_from_path(current_workfile)
version = int(workfile_version)
if workfile_version is not None:
version = int(workfile_version)
# Fill-up version with next version available.
elif version is None:
if version is None:
versions = self.get_next_versions_for_instances(
[instance]
)

View file

@ -9,7 +9,7 @@ import os
import logging
import collections
from ayon_core.pipeline.constants import AVALON_INSTANCE_ID
from ayon_core.pipeline.constants import AYON_INSTANCE_ID
from .product_name import get_product_name
@ -34,7 +34,7 @@ class LegacyCreator:
# Default data
self.data = collections.OrderedDict()
# TODO use 'AYON_INSTANCE_ID' when all hosts support it
self.data["id"] = AVALON_INSTANCE_ID
self.data["id"] = AYON_INSTANCE_ID
self.data["productType"] = self.product_type
self.data["folderPath"] = folder_path
self.data["productName"] = name

View file

@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
import typing
from typing import Optional, Dict, List, Any
from ayon_core.lib.attribute_definitions import (
@ -17,6 +18,9 @@ from ayon_core.pipeline import (
from .exceptions import ImmutableKeyError
from .changes import TrackChangesItem
if typing.TYPE_CHECKING:
from .creator_plugins import BaseCreator
class ConvertorItem:
"""Item representing convertor plugin.
@ -156,29 +160,26 @@ class AttributeValues:
return self._attr_defs_by_key.get(key, default)
def update(self, value):
changes = {}
for _key, _value in dict(value).items():
if _key in self._data and self._data.get(_key) == _value:
continue
self._data[_key] = _value
changes[_key] = _value
changes = self._update(value)
if changes:
self._parent.attribute_value_changed(self._key, changes)
def pop(self, key, default=None):
has_key = key in self._data
value = self._data.pop(key, default)
# Remove attribute definition if is 'UnknownDef'
# - gives option to get rid of unknown values
attr_def = self._attr_defs_by_key.get(key)
if isinstance(attr_def, UnknownDef):
self._attr_defs_by_key.pop(key)
self._attr_defs.remove(attr_def)
elif has_key:
self._parent.attribute_value_changed(self._key, {key: None})
value, changes = self._pop(key, default)
if changes:
self._parent.attribute_value_changed(self._key, changes)
return value
def set_value(self, value):
pop_keys = set(value.keys()) - set(self._data.keys())
changes = self._update(value)
for key in pop_keys:
_, key_changes = self._pop(key, None)
changes.update(key_changes)
if changes:
self._parent.attribute_value_changed(self._key, changes)
def reset_values(self):
self._data = {}
@ -224,6 +225,29 @@ class AttributeValues:
return serialize_attr_defs(self._attr_defs)
def _update(self, value):
changes = {}
for key, value in dict(value).items():
if key in self._data and self._data.get(key) == value:
continue
self._data[key] = value
changes[key] = value
return changes
def _pop(self, key, default):
has_key = key in self._data
value = self._data.pop(key, default)
# Remove attribute definition if is 'UnknownDef'
# - gives option to get rid of unknown values
attr_def = self._attr_defs_by_key.get(key)
changes = {}
if isinstance(attr_def, UnknownDef):
self._attr_defs_by_key.pop(key)
self._attr_defs.remove(attr_def)
elif has_key:
changes[key] = None
return value, changes
class CreatorAttributeValues(AttributeValues):
"""Creator specific attribute values of an instance."""
@ -266,6 +290,23 @@ class PublishAttributes:
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
"""Set value for plugin.
Args:
key (str): Plugin name.
value (dict[str, Any]): Value to set.
"""
current_value = self._data.get(key)
if isinstance(current_value, PublishAttributeValues):
current_value.set_value(value)
else:
self._data[key] = value
def __delitem__(self, key):
self.pop(key)
def __contains__(self, key):
return key in self._data
@ -328,7 +369,7 @@ class PublishAttributes:
return copy.deepcopy(self._origin_data)
def attribute_value_changed(self, key, changes):
self._parent.publish_attribute_value_changed(key, changes)
self._parent.publish_attribute_value_changed(key, changes)
def set_publish_plugin_attr_defs(
self,
@ -444,10 +485,11 @@ class CreatedInstance:
def __init__(
self,
product_type,
product_name,
data,
creator,
product_type: str,
product_name: str,
data: Dict[str, Any],
creator: "BaseCreator",
transient_data: Optional[Dict[str, Any]] = None,
):
self._creator = creator
creator_identifier = creator.identifier
@ -462,7 +504,9 @@ class CreatedInstance:
self._members = []
# Data that can be used for lifetime of object
self._transient_data = {}
if transient_data is None:
transient_data = {}
self._transient_data = transient_data
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
@ -492,7 +536,7 @@ class CreatedInstance:
item_id = data.get("id")
# TODO use only 'AYON_INSTANCE_ID' when all hosts support it
if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}:
item_id = AVALON_INSTANCE_ID
item_id = AYON_INSTANCE_ID
self._data["id"] = item_id
self._data["productType"] = product_type
self._data["productName"] = product_name
@ -787,16 +831,26 @@ class CreatedInstance:
self._create_context.instance_create_attr_defs_changed(self.id)
@classmethod
def from_existing(cls, instance_data, creator):
def from_existing(
cls,
instance_data: Dict[str, Any],
creator: "BaseCreator",
transient_data: Optional[Dict[str, Any]] = None,
) -> "CreatedInstance":
"""Convert instance data from workfile to CreatedInstance.
Args:
instance_data (Dict[str, Any]): Data in a structure ready for
'CreatedInstance' object.
creator (BaseCreator): Creator plugin which is creating the
instance of for which the instance belong.
"""
instance of for which the instance belongs.
transient_data (Optional[dict[str, Any]]): Instance transient
data.
Returns:
CreatedInstance: Instance object.
"""
instance_data = copy.deepcopy(instance_data)
product_type = instance_data.get("productType")
@ -809,7 +863,11 @@ class CreatedInstance:
product_name = instance_data.get("subset")
return cls(
product_type, product_name, instance_data, creator
product_type,
product_name,
instance_data,
creator,
transient_data=transient_data,
)
def attribute_value_changed(self, key, changes):

View file

@ -255,7 +255,7 @@ def deliver_sequence(
report_items[""].append(msg)
return report_items, 0
dir_path, file_name = os.path.split(str(src_path))
dir_path, _file_name = os.path.split(str(src_path))
context = repre["context"]
ext = context.get("ext", context.get("representation"))
@ -270,7 +270,7 @@ def deliver_sequence(
# context.representation could be .psd
ext = ext.replace("..", ".")
src_collections, remainder = clique.assemble(os.listdir(dir_path))
src_collections, _remainder = clique.assemble(os.listdir(dir_path))
src_collection = None
for col in src_collections:
if col.tail != ext:

View file

@ -1,6 +1,7 @@
import os
import re
import clique
import math
import opentimelineio as otio
from opentimelineio import opentime as _ot
@ -256,8 +257,14 @@ def remap_range_on_file_sequence(otio_clip, otio_range):
rate=available_range_rate,
).to_frames()
# e.g.:
# duration = 10 frames at 24fps
# if frame_in = 1001 then
# frame_out = 1010
offset_duration = max(0, otio_range.duration.to_frames() - 1)
frame_out = otio.opentime.RationalTime.from_frames(
frame_in + otio_range.duration.to_frames() - 1,
frame_in + offset_duration,
rate=available_range_rate,
).to_frames()
@ -337,8 +344,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
# modifiers
time_scalar = 1.
offset_in = 0
offset_out = 0
time_warp_nodes = []
# Check for speed effects and adjust playback speed accordingly
@ -369,24 +374,15 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
tw_node.update(metadata)
tw_node["lookup"] = list(lookup)
# get first and last frame offsets
offset_in += lookup[0]
offset_out += lookup[-1]
# add to timewarp nodes
time_warp_nodes.append(tw_node)
# multiply by time scalar
offset_in *= time_scalar
offset_out *= time_scalar
# scale handles
handle_start *= abs(time_scalar)
handle_end *= abs(time_scalar)
# flip offset and handles if reversed speed
if time_scalar < 0:
offset_in, offset_out = offset_out, offset_in
handle_start, handle_end = handle_end, handle_start
# If media source is an image sequence, returned
@ -395,17 +391,19 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
if is_input_sequence:
src_in = conformed_source_range.start_time
src_duration = conformed_source_range.duration
offset_in = otio.opentime.RationalTime(offset_in, rate=src_in.rate)
offset_duration = otio.opentime.RationalTime(
offset_out,
rate=src_duration.rate
src_duration = math.ceil(
otio_clip.source_range.duration.value
* abs(time_scalar)
)
retimed_duration = otio.opentime.RationalTime(
src_duration,
otio_clip.source_range.duration.rate
)
retimed_duration = retimed_duration.rescaled_to(src_in.rate)
trim_range = otio.opentime.TimeRange(
start_time=src_in + offset_in,
duration=src_duration + offset_duration
start_time=src_in,
duration=retimed_duration,
)
# preserve discrete frame numbers
@ -418,18 +416,92 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
else:
# compute retimed range
media_in_trimmed = conformed_source_range.start_time.value + offset_in
media_out_trimmed = media_in_trimmed + (
(
conformed_source_range.duration.value
* abs(time_scalar)
+ offset_out
) - 1
media_in_trimmed = conformed_source_range.start_time.value
offset_duration = (
conformed_source_range.duration.value
* abs(time_scalar)
)
# Offset duration by 1 for media out frame
# - only if duration is not single frame (start frame != end frame)
if offset_duration > 0:
offset_duration -= 1
media_out_trimmed = media_in_trimmed + offset_duration
media_in = available_range.start_time.value
media_out = available_range.end_time_inclusive().value
if time_warp_nodes:
# Naive approach: Resolve consecutive timewarp(s) on range,
# then check if plate range has to be extended beyond source range.
in_frame = media_in_trimmed
frame_range = [in_frame]
for _ in range(otio_clip.source_range.duration.to_frames() - 1):
in_frame += time_scalar
frame_range.append(in_frame)
# Different editorial DCC might have different TimeWarp logic.
# The following logic assumes that the "lookup" list values are
# frame offsets relative to the current source frame number.
#
# media_source_range |______1_____|______2______|______3______|
#
# media_retimed_range |______2_____|______2______|______3______|
#
# TimeWarp lookup +1 0 0
for tw_idx, tw in enumerate(time_warp_nodes):
for idx, frame_number in enumerate(frame_range):
# First timewarp, apply on media range
if tw_idx == 0:
frame_range[idx] = round(
frame_number +
(tw["lookup"][idx] * time_scalar)
)
# Consecutive timewarp, apply on the previous result
else:
new_idx = round(idx + tw["lookup"][idx])
if 0 <= new_idx < len(frame_range):
frame_range[idx] = frame_range[new_idx]
continue
# TODO: implementing this would need to actually have
# retiming engine resolve process within AYON,
# resolving wraps as curves, then projecting
# those into the previous media_range.
raise NotImplementedError(
"Unsupported consecutive timewarps "
"(out of computed range)"
)
# adjust range if needed
media_in_trimmed_before_tw = media_in_trimmed
media_in_trimmed = max(min(frame_range), media_in)
media_out_trimmed = min(max(frame_range), media_out)
# If TimeWarp changes the first frame of the soure range,
# we need to offset the first TimeWarp values accordingly.
#
# expected_range |______2_____|______2______|______3______|
#
# EDITORIAL
# media_source_range |______1_____|______2______|______3______|
#
# TimeWarp lookup +1 0 0
#
# EXTRACTED PLATE
# plate_range |______2_____|______3______|_ _ _ _ _ _ _|
#
# expected TimeWarp 0 -1 -1
if media_in_trimmed != media_in_trimmed_before_tw:
offset = media_in_trimmed_before_tw - media_in_trimmed
offset *= 1.0 / time_scalar
time_warp_nodes[0]["lookup"] = [
value + offset
for value in time_warp_nodes[0]["lookup"]
]
# adjust available handles if needed
if (media_in_trimmed - media_in) < handle_start:
handle_start = max(0, media_in_trimmed - media_in)
@ -448,16 +520,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
"retime": True,
"speed": time_scalar,
"timewarps": time_warp_nodes,
"handleStart": int(handle_start),
"handleEnd": int(handle_end)
"handleStart": math.ceil(handle_start),
"handleEnd": math.ceil(handle_end)
}
}
returning_dict = {
"mediaIn": media_in_trimmed,
"mediaOut": media_out_trimmed,
"handleStart": int(handle_start),
"handleEnd": int(handle_end),
"handleStart": math.ceil(handle_start),
"handleEnd": math.ceil(handle_end),
"speed": time_scalar
}

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import copy
import os
import re
@ -247,7 +248,8 @@ def create_skeleton_instance(
"useSequenceForReview": data.get("useSequenceForReview", True),
# map inputVersions `ObjectId` -> `str` so json supports it
"inputVersions": list(map(str, data.get("inputVersions", []))),
"colorspace": data.get("colorspace")
"colorspace": data.get("colorspace"),
"hasExplicitFrames": data.get("hasExplicitFrames")
}
if data.get("renderlayer"):
@ -324,8 +326,8 @@ def prepare_representations(
skip_integration_repre_list (list): exclude specific extensions,
do_not_add_review (bool): explicitly skip review
color_managed_plugin (publish.ColormanagedPyblishPluginMixin)
frames_to_render (str): implicit or explicit range of frames to render
this value is sent to Deadline in JobInfo.Frames
frames_to_render (str | None): implicit or explicit range of frames
to render this value is sent to Deadline in JobInfo.Frames
Returns:
list of representations
@ -337,7 +339,7 @@ def prepare_representations(
log = Logger.get_logger("farm_publishing")
if frames_to_render is not None:
frames_to_render = _get_real_frames_to_render(frames_to_render)
frames_to_render = convert_frames_str_to_list(frames_to_render)
else:
# Backwards compatibility for older logic
frame_start = int(skeleton_data.get("frameStartHandle"))
@ -386,17 +388,21 @@ def prepare_representations(
frame_start -= 1
frames_to_render.insert(0, frame_start)
files = _get_real_files_to_render(collection, frames_to_render)
filenames = [
os.path.basename(filepath)
for filepath in _get_real_files_to_render(
collection, frames_to_render
)
]
# explicitly disable review by user
preview = preview and not do_not_add_review
rep = {
"name": ext,
"ext": ext,
"files": files,
"files": filenames,
"stagingDir": staging,
"frameStart": frame_start,
"frameEnd": frame_end,
# If expectedFile are absolute, we need only filenames
"stagingDir": staging,
"fps": skeleton_data.get("fps"),
"tags": ["review"] if preview else [],
}
@ -475,21 +481,45 @@ def prepare_representations(
return representations
def _get_real_frames_to_render(frames):
"""Returns list of frames that should be rendered.
def convert_frames_str_to_list(frames: str) -> list[int]:
"""Convert frames definition string to frames.
Handles formats as:
>>> convert_frames_str_to_list('1001')
[1001]
>>> convert_frames_str_to_list('1002,1004')
[1002, 1004]
>>> convert_frames_str_to_list('1003-1005')
[1003, 1004, 1005]
>>> convert_frames_str_to_list('1001-1021x5')
[1001, 1006, 1011, 1016, 1021]
Args:
frames (str): String with frames definition.
Returns:
list[int]: List of frames.
Artists could want to selectively render only particular frames
"""
frames_to_render = []
step_pattern = re.compile(r"(?:step|by|every|x|:)(\d+)$")
output = []
step = 1
for frame in frames.split(","):
if "-" in frame:
splitted = frame.split("-")
frames_to_render.extend(
range(int(splitted[0]), int(splitted[1])+1))
frame_start, frame_end = frame.split("-")
match = step_pattern.findall(frame_end)
if match:
step = int(match[0])
frame_end = re.sub(step_pattern, "", frame_end)
output.extend(
range(int(frame_start), int(frame_end) + 1, step)
)
else:
frames_to_render.append(int(frame))
frames_to_render.sort()
return frames_to_render
output.append(int(frame))
output.sort()
return output
def _get_real_files_to_render(collection, frames_to_render):
@ -502,22 +532,23 @@ def _get_real_files_to_render(collection, frames_to_render):
This range would override and filter previously prepared expected files
from DCC.
Example:
>>> expected_files = clique.parse([
>>> "foo_v01.0001.exr",
>>> "foo_v01.0002.exr",
>>> ])
>>> frames_to_render = [1]
>>> _get_real_files_to_render(expected_files, frames_to_render)
["foo_v01.0001.exr"]
Args:
collection (clique.Collection): absolute paths
frames_to_render (list[int]): of int 1001
Returns:
(list[str])
list[str]: absolute paths of files to be rendered
Example:
--------
expectedFiles = [
"foo_v01.0001.exr",
"foo_v01.0002.exr",
]
frames_to_render = 1
>>
["foo_v01.0001.exr"] - only explicitly requested frame returned
"""
included_frames = set(collection.indexes).intersection(frames_to_render)
real_collection = clique.Collection(
@ -526,13 +557,17 @@ def _get_real_files_to_render(collection, frames_to_render):
collection.padding,
indexes=included_frames
)
real_full_paths = list(real_collection)
return [os.path.basename(file_url) for file_url in real_full_paths]
return list(real_collection)
def create_instances_for_aov(instance, skeleton, aov_filter,
skip_integration_repre_list,
do_not_add_review):
def create_instances_for_aov(
instance,
skeleton,
aov_filter,
skip_integration_repre_list,
do_not_add_review,
frames_to_render=None
):
"""Create instances from AOVs.
This will create new pyblish.api.Instances by going over expected
@ -544,6 +579,7 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
aov_filter (dict): AOV filter.
skip_integration_repre_list (list): skip
do_not_add_review (bool): Explicitly disable reviews
frames_to_render (str | None): Frames to render.
Returns:
list of pyblish.api.Instance: Instances created from
@ -590,7 +626,8 @@ def create_instances_for_aov(instance, skeleton, aov_filter,
aov_filter,
additional_color_data,
skip_integration_repre_list,
do_not_add_review
do_not_add_review,
frames_to_render
)
@ -623,14 +660,6 @@ def _get_legacy_product_name_and_group(
warnings.warn("Using legacy product name for renders",
DeprecationWarning)
if not source_product_name.startswith(product_type):
resulting_group_name = '{}{}{}{}{}'.format(
product_type,
task_name[0].upper(), task_name[1:],
source_product_name[0].upper(), source_product_name[1:])
else:
resulting_group_name = source_product_name
# create product name `<product type><Task><Product name>`
if not source_product_name.startswith(product_type):
resulting_group_name = '{}{}{}{}{}'.format(
@ -719,8 +748,15 @@ def get_product_name_and_group_from_template(
return resulting_product_name, resulting_group_name
def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
skip_integration_repre_list, do_not_add_review):
def _create_instances_for_aov(
instance,
skeleton,
aov_filter,
additional_data,
skip_integration_repre_list,
do_not_add_review,
frames_to_render
):
"""Create instance for each AOV found.
This will create new instance for every AOV it can detect in expected
@ -734,7 +770,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
skip_integration_repre_list (list): list of extensions that shouldn't
be published
do_not_add_review (bool): explicitly disable review
frames_to_render (str | None): implicit or explicit range of
frames to render this value is sent to Deadline in JobInfo.Frames
Returns:
list of instances
@ -754,10 +791,23 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
# go through AOVs in expected files
for aov, files in expected_files[0].items():
collected_files = _collect_expected_files_for_aov(files)
first_filepath = collected_files
if isinstance(first_filepath, (list, tuple)):
first_filepath = first_filepath[0]
staging_dir = os.path.dirname(first_filepath)
expected_filepath = collected_files
if isinstance(collected_files, (list, tuple)):
expected_filepath = collected_files[0]
if (
frames_to_render is not None
and isinstance(collected_files, (list, tuple)) # not single file
):
aov_frames_to_render = convert_frames_str_to_list(frames_to_render)
collections, _ = clique.assemble(collected_files)
collected_files = _get_real_files_to_render(
collections[0], aov_frames_to_render)
else:
frame_start = int(skeleton.get("frameStartHandle"))
frame_end = int(skeleton.get("frameEndHandle"))
aov_frames_to_render = list(range(frame_start, frame_end + 1))
dynamic_data = {
"aov": aov,
@ -768,7 +818,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
# TODO: this must be changed to be more robust. Any coincidence
# of camera name in the file path will be considered as
# camera name. This is not correct.
camera = [cam for cam in cameras if cam in expected_filepath]
camera = [cam for cam in cameras if cam in first_filepath]
# Is there just one camera matching?
# TODO: this is not true, we can have multiple cameras in the scene
@ -813,10 +863,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
dynamic_data=dynamic_data
)
staging = os.path.dirname(expected_filepath)
try:
staging = remap_source(staging, anatomy)
staging_dir = remap_source(staging_dir, anatomy)
except ValueError as e:
log.warning(e)
@ -824,7 +872,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
app = os.environ.get("AYON_HOST_NAME", "")
render_file_name = os.path.basename(expected_filepath)
render_file_name = os.path.basename(first_filepath)
aov_patterns = aov_filter
@ -881,10 +929,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
"name": ext,
"ext": ext,
"files": collected_files,
"frameStart": int(skeleton["frameStartHandle"]),
"frameEnd": int(skeleton["frameEndHandle"]),
"frameStart": aov_frames_to_render[0],
"frameEnd": aov_frames_to_render[-1],
# If expectedFile are absolute, we need only filenames
"stagingDir": staging,
"stagingDir": staging_dir,
"fps": new_instance.get("fps"),
"tags": ["review"] if preview else [],
"colorspaceData": {
@ -1112,7 +1160,7 @@ def prepare_cache_representations(skeleton_data, exp_files, anatomy):
"""
representations = []
collections, remainders = clique.assemble(exp_files)
collections, _remainders = clique.assemble(exp_files)
log = Logger.get_logger("farm_publishing")

View file

@ -13,15 +13,7 @@ from .utils import get_representation_path_from_context
class LoaderPlugin(list):
"""Load representation into host application
Arguments:
context (dict): avalon-core:context-1.0
.. versionadded:: 4.0
This class was introduced
"""
"""Load representation into host application"""
product_types = set()
representations = set()

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import os
import re
import json
from typing import Any, Union
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger
@ -9,7 +11,7 @@ from .anatomy import Anatomy
from .template_data import get_project_template_data
def concatenate_splitted_paths(split_paths, anatomy):
def concatenate_splitted_paths(split_paths, anatomy: Anatomy):
log = Logger.get_logger("concatenate_splitted_paths")
pattern_array = re.compile(r"\[.*\]")
output = []
@ -47,7 +49,7 @@ def concatenate_splitted_paths(split_paths, anatomy):
return output
def fill_paths(path_list, anatomy):
def fill_paths(path_list: list[str], anatomy: Anatomy):
format_data = get_project_template_data(project_name=anatomy.project_name)
format_data["root"] = anatomy.roots
filled_paths = []
@ -59,7 +61,7 @@ def fill_paths(path_list, anatomy):
return filled_paths
def create_project_folders(project_name, basic_paths=None):
def create_project_folders(project_name: str, basic_paths=None):
log = Logger.get_logger("create_project_folders")
anatomy = Anatomy(project_name)
if basic_paths is None:
@ -80,8 +82,19 @@ def create_project_folders(project_name, basic_paths=None):
os.makedirs(path)
def _list_path_items(folder_structure):
def _list_path_items(
folder_structure: Union[dict[str, Any], list[str]]):
output = []
# Allow leaf folders of the `project_folder_structure` to use a list of
# strings instead of a dictionary of keys with empty values.
if isinstance(folder_structure, list):
if not all(isinstance(item, str) for item in folder_structure):
raise ValueError(
f"List items must all be strings. Got: {folder_structure}")
return [[path] for path in folder_structure]
# Process key, value as key for folder names and value its subfolders
for key, value in folder_structure.items():
if not value:
output.append(key)
@ -99,7 +112,7 @@ def _list_path_items(folder_structure):
return output
def get_project_basic_paths(project_name):
def get_project_basic_paths(project_name: str):
project_settings = get_project_settings(project_name)
folder_structure = (
project_settings["core"]["project_folder_structure"]

View file

@ -1,3 +1,5 @@
"""Library functions for publishing."""
from __future__ import annotations
import os
import sys
import inspect
@ -12,8 +14,8 @@ import pyblish.plugin
import pyblish.api
from ayon_core.lib import (
Logger,
import_filepath,
Logger,
filter_profiles,
)
from ayon_core.settings import get_project_settings
@ -163,7 +165,7 @@ class HelpContent:
def load_help_content_from_filepath(filepath):
"""Load help content from xml file.
Xml file may containt errors and warnings.
Xml file may contain errors and warnings.
"""
errors = {}
warnings = {}
@ -208,8 +210,9 @@ def load_help_content_from_plugin(plugin):
return load_help_content_from_filepath(filepath)
def publish_plugins_discover(paths=None):
"""Find and return available pyblish plug-ins
def publish_plugins_discover(
paths: Optional[list[str]] = None) -> DiscoverResult:
"""Find and return available pyblish plug-ins.
Overridden function from `pyblish` module to be able to collect
crashed files and reason of their crash.
@ -252,17 +255,14 @@ def publish_plugins_discover(paths=None):
continue
try:
module = import_filepath(abspath, mod_name)
module = import_filepath(
abspath, mod_name, sys_module_name=mod_name)
# Store reference to original module, to avoid
# garbage collection from collecting it's global
# imports, such as `import os`.
sys.modules[abspath] = module
except Exception as err:
except Exception as err: # noqa: BLE001
# we need broad exception to catch all possible errors.
result.crashed_file_paths[abspath] = sys.exc_info()
log.debug("Skipped: \"%s\" (%s)", mod_name, err)
log.debug('Skipped: "%s" (%s)', mod_name, err)
continue
for plugin in pyblish.plugin.plugins_from_module(module):
@ -280,9 +280,8 @@ def publish_plugins_discover(paths=None):
continue
plugin_names.append(plugin.__name__)
plugin.__module__ = module.__file__
key = "{0}.{1}".format(plugin.__module__, plugin.__name__)
plugin.__file__ = module.__file__
key = f"{module.__file__}.{plugin.__name__}"
plugins[key] = plugin
# Include plug-ins from registration.
@ -361,7 +360,7 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
# Settings category determined from path
# - usually path is './<category>/plugins/publish/<plugin file>'
# - category can be host name of addon name ('maya', 'deadline', ...)
filepath = os.path.normpath(inspect.getsourcefile(plugin))
filepath = os.path.normpath(inspect.getfile(plugin))
split_path = filepath.rsplit(os.path.sep, 5)
if len(split_path) < 4:
@ -427,7 +426,7 @@ def filter_pyblish_plugins(plugins):
log = Logger.get_logger("filter_pyblish_plugins")
# TODO: Don't use host from 'pyblish.api' but from defined host by us.
# - kept becau on farm is probably used host 'shell' which propably
# - kept because on farm is probably used host 'shell' which probably
# affect how settings are applied there
host_name = pyblish.api.current_host()
project_name = os.environ.get("AYON_PROJECT_NAME")
@ -464,6 +463,12 @@ def filter_pyblish_plugins(plugins):
if getattr(plugin, "enabled", True) is False:
plugins.remove(plugin)
# Pyblish already operated a filter based on host.
# But applying settings might have changed "hosts"
# value in plugin so re-filter.
elif not pyblish.plugin.host_is_compatible(plugin):
plugins.remove(plugin)
def get_errored_instances_from_context(context, plugin=None):
"""Collect failed instances from pyblish context.
@ -523,7 +528,7 @@ def filter_instances_for_context_plugin(plugin, context):
Args:
plugin (pyblish.api.Plugin): Plugin with filters.
context (pyblish.api.Context): Pyblish context with insances.
context (pyblish.api.Context): Pyblish context with instances.
Returns:
Iterator[pyblish.lib.Instance]: Iteration of valid instances.
@ -708,6 +713,7 @@ def get_instance_staging_dir(instance):
project_settings=context.data["project_settings"],
template_data=template_data,
always_return_path=True,
username=context.data["user"],
)
staging_dir_path = staging_dir_info.directory

View file

@ -292,6 +292,9 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
```
"""
# Allow exposing tooltip from class with `optional_tooltip` attribute
optional_tooltip: Optional[str] = None
@classmethod
def get_attribute_defs(cls):
"""Attribute definitions based on plugin's optional attribute."""
@ -304,8 +307,14 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
active = getattr(cls, "active", True)
# Return boolean stored under 'active' key with label of the class name
label = cls.label or cls.__name__
return [
BoolDef("active", default=active, label=label)
BoolDef(
"active",
default=active,
label=label,
tooltip=cls.optional_tooltip,
)
]
def is_active(self, data):

View file

@ -41,7 +41,7 @@ def validate(data, schema=None):
if not _CACHED:
_precache()
root, schema = data["schema"].rsplit(":", 1)
_root, schema = data["schema"].rsplit(":", 1)
if isinstance(schema, str):
schema = _cache[schema + ".json"]

View file

@ -130,6 +130,7 @@ def get_staging_dir_info(
logger: Optional[logging.Logger] = None,
prefix: Optional[str] = None,
suffix: Optional[str] = None,
username: Optional[str] = None,
) -> Optional[StagingDir]:
"""Get staging dir info data.
@ -157,6 +158,7 @@ def get_staging_dir_info(
logger (Optional[logging.Logger]): Logger instance.
prefix (Optional[str]) Optional prefix for staging dir name.
suffix (Optional[str]): Optional suffix for staging dir name.
username (Optional[str]): AYON Username.
Returns:
Optional[StagingDir]: Staging dir info data
@ -183,7 +185,9 @@ def get_staging_dir_info(
# making few queries to database
ctx_data = get_template_data(
project_entity, folder_entity, task_entity, host_name
project_entity, folder_entity, task_entity, host_name,
settings=project_settings,
username=username
)
# add additional data
@ -205,7 +209,7 @@ def get_staging_dir_info(
staging_dir_config = get_staging_dir_config(
project_entity["name"],
task_type,
task_name ,
task_name,
product_type,
product_name,
host_name,

View file

@ -4,7 +4,7 @@ from ayon_core.settings import get_studio_settings
from ayon_core.lib.local_settings import get_ayon_username
def get_general_template_data(settings=None):
def get_general_template_data(settings=None, username=None):
"""General template data based on system settings or machine.
Output contains formatting keys:
@ -14,17 +14,22 @@ def get_general_template_data(settings=None):
Args:
settings (Dict[str, Any]): Studio or project settings.
username (Optional[str]): AYON Username.
"""
if not settings:
settings = get_studio_settings()
if username is None:
username = get_ayon_username()
core_settings = settings["core"]
return {
"studio": {
"name": core_settings["studio_name"],
"code": core_settings["studio_code"]
},
"user": get_ayon_username()
"user": username
}
@ -145,6 +150,7 @@ def get_template_data(
task_entity=None,
host_name=None,
settings=None,
username=None
):
"""Prepare data for templates filling from entered documents and info.
@ -167,12 +173,13 @@ 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.
Returns:
Dict[str, Any]: Data prepared for filling workdir template.
"""
template_data = get_general_template_data(settings)
template_data = get_general_template_data(settings, username=username)
template_data.update(get_project_template_data(project_entity))
if folder_entity:
template_data.update(get_folder_template_data(

View file

@ -226,11 +226,26 @@ class _CacheItems:
thumbnails_cache = ThumbnailsCache()
def get_thumbnail_path(project_name, thumbnail_id):
def get_thumbnail_path(
project_name: str,
entity_type: str,
entity_id: str,
thumbnail_id: str
):
"""Get path to thumbnail image.
Thumbnail is cached by thumbnail id but is received using entity type and
entity id.
Notes:
Function 'get_thumbnail_by_id' can't be used because does not work
for artists. The endpoint can't validate artist permissions.
Args:
project_name (str): Project where thumbnail belongs to.
entity_type (str): Entity type "folder", "task", "version"
and "workfile".
entity_id (str): Entity id.
thumbnail_id (Union[str, None]): Thumbnail id.
Returns:
@ -251,7 +266,7 @@ def get_thumbnail_path(project_name, thumbnail_id):
# 'get_thumbnail_by_id' did not return output of
# 'ServerAPI' method.
con = ayon_api.get_server_api_connection()
result = con.get_thumbnail_by_id(project_name, thumbnail_id)
result = con.get_thumbnail(project_name, entity_type, entity_id)
if result is not None and result.is_valid:
return _CacheItems.thumbnails_cache.store_thumbnail(

View file

@ -16,6 +16,7 @@ from .path_resolving import (
from .utils import (
should_use_last_workfile_on_launch,
should_open_workfiles_tool_on_launch,
MissingWorkdirError,
)
from .build_workfile import BuildWorkfile
@ -46,6 +47,7 @@ __all__ = (
"should_use_last_workfile_on_launch",
"should_open_workfiles_tool_on_launch",
"MissingWorkdirError",
"BuildWorkfile",

View file

@ -329,9 +329,9 @@ def get_last_workfile(
Returns:
str: Last or first workfile as filename of full path to filename.
"""
filename, version = get_last_workfile_with_version(
"""
filename, _version = get_last_workfile_with_version(
workdir, file_template, fill_data, extensions
)
if filename is None:

View file

@ -2,6 +2,11 @@ from ayon_core.lib import filter_profiles
from ayon_core.settings import get_project_settings
class MissingWorkdirError(Exception):
"""Raised when accessing a work directory not found on disk."""
pass
def should_use_last_workfile_on_launch(
project_name,
host_name,

View file

@ -54,6 +54,7 @@ from ayon_core.pipeline.plugin_discover import (
from ayon_core.pipeline.create import (
discover_legacy_creator_plugins,
CreateContext,
HiddenCreator,
)
_NOT_SET = object()
@ -309,7 +310,13 @@ class AbstractTemplateBuilder(ABC):
self._creators_by_name = creators_by_name
def _collect_creators(self):
self._creators_by_name = dict(self.create_context.creators)
self._creators_by_name = {
identifier: creator
for identifier, creator
in self.create_context.manual_creators.items()
# Do not list HiddenCreator even though it is a 'manual creator'
if not isinstance(creator, HiddenCreator)
}
def get_creators_by_name(self):
if self._creators_by_name is None:

View file

@ -211,7 +211,7 @@ class DeleteOldVersions(load.ProductLoaderPlugin):
f"This will keep only the last {versions_to_keep} "
f"versions for the {num_contexts} selected product{s}."
)
informative_text="Warning: This will delete files from disk"
informative_text = "Warning: This will delete files from disk"
detailed_text = (
f"Keep only {versions_to_keep} versions for:\n{contexts_list}"
)

View file

@ -22,6 +22,7 @@ from ayon_core.tools.utils import show_message_dialog
OTIO = None
FRAME_SPLITTER = "__frame_splitter__"
def _import_otio():
global OTIO
if OTIO is None:

View file

@ -116,11 +116,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
if not_found_folder_paths:
joined_folder_paths = ", ".join(
["\"{}\"".format(path) for path in not_found_folder_paths]
[f"\"{path}\"" for path in not_found_folder_paths]
)
self.log.warning(
f"Not found folder entities with paths {joined_folder_paths}."
)
self.log.warning((
"Not found folder entities with paths \"{}\"."
).format(joined_folder_paths))
def fill_missing_task_entities(self, context, project_name):
self.log.debug("Querying task entities for instances.")
@ -394,7 +394,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
if aov:
anatomy_data["aov"] = aov
def _fill_folder_data(self, instance, project_entity, anatomy_data):
# QUESTION: should we make sure that all folder data are popped if
# folder data cannot be found?

View file

@ -39,6 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
"blender",
"houdini",
"max",
"circuit",
]
audio_product_name = "audioMain"

View file

@ -32,13 +32,14 @@ class CollectCoreJobEnvVars(pyblish.api.ContextPlugin):
for key in [
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_USE_STAGING",
"AYON_IN_TESTS",
# NOTE Not sure why workdir is needed?
"AYON_WORKDIR",
# DEPRECATED remove when deadline stops using it (added in 1.1.2)
"AYON_DEFAULT_SETTINGS_VARIANT",
]:
value = os.getenv(key)
if value:
self.log.debug(f"Setting job env: {key}: {value}")
env[key] = value

View file

@ -50,7 +50,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin):
"comments": instance.data.get("comments", []),
}
shot_data["attributes"] = {}
shot_data["attributes"] = {}
SHOT_ATTRS = (
"handleStart",
"handleEnd",

View file

@ -1,78 +1,120 @@
"""Plugin for collecting OTIO frame ranges and related timing information.
This module contains a unified plugin that handles:
- Basic timeline frame ranges
- Source media frame ranges
- Retimed clip frame ranges
"""
Requires:
otioTimeline -> context data attribute
review -> instance data attribute
masterLayer -> instance data attribute
otioClipRange -> instance data attribute
"""
from pprint import pformat
import opentimelineio as otio
import pyblish.api
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles,
)
class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"""Getting otio ranges from otio_clip
def validate_otio_clip(instance, logger):
"""Validate if instance has required OTIO clip data.
Adding timeline and source ranges to instance data"""
Args:
instance: The instance to validate
logger: Logger object to use for debug messages
label = "Collect OTIO Frame Ranges"
Returns:
bool: True if valid, False otherwise
"""
if not instance.data.get("otioClip"):
logger.debug("Skipping collect OTIO range - no clip found.")
return False
return True
class CollectOtioRanges(pyblish.api.InstancePlugin):
"""Collect all OTIO-related frame ranges and timing information.
This plugin handles collection of:
- Basic timeline frame ranges with handles
- Source media frame ranges with handles
- Retimed clip frame ranges
Requires:
otioClip (otio.schema.Clip): OTIO clip object
workfileFrameStart (int): Starting frame of work file
Optional:
shotDurationFromSource (int): Duration from source if retimed
Provides:
frameStart (int): Start frame in timeline
frameEnd (int): End frame in timeline
clipIn (int): Clip in point
clipOut (int): Clip out point
clipInH (int): Clip in point with handles
clipOutH (int): Clip out point with handles
sourceStart (int): Source media start frame
sourceEnd (int): Source media end frame
sourceStartH (int): Source media start frame with handles
sourceEndH (int): Source media end frame with handles
"""
label = "Collect OTIO Ranges"
order = pyblish.api.CollectorOrder - 0.08
families = ["shot", "clip"]
hosts = ["resolve", "hiero", "flame", "traypublisher"]
def process(self, instance):
# Not all hosts can import these modules.
import opentimelineio as otio
from ayon_core.pipeline.editorial import (
get_media_range_with_retimes,
otio_range_to_frame_range,
otio_range_with_handles
)
"""Process the instance to collect all frame ranges.
if not instance.data.get("otioClip"):
self.log.debug("Skipping collect OTIO frame range.")
Args:
instance: The instance to process
"""
if not validate_otio_clip(instance, self.log):
return
# get basic variables
otio_clip = instance.data["otioClip"]
# Collect timeline ranges if workfile start frame is available
if "workfileFrameStart" in instance.data:
self._collect_timeline_ranges(instance, otio_clip)
# Traypublisher Simple or Advanced editorial publishing is
# working with otio clips which are having no available range
# because they are not having any media references.
try:
otio_clip.available_range()
has_available_range = True
except otio._otio.CannotComputeAvailableRangeError:
self.log.info("Clip has no available range")
has_available_range = False
# Collect source ranges if clip has available range
if has_available_range:
self._collect_source_ranges(instance, otio_clip)
# Handle retimed ranges if source duration is available
if "shotDurationFromSource" in instance.data:
self._collect_retimed_ranges(instance, otio_clip)
def _collect_timeline_ranges(self, instance, otio_clip):
"""Collect basic timeline frame ranges."""
workfile_start = instance.data["workfileFrameStart"]
workfile_source_duration = instance.data.get("shotDurationFromSource")
# get ranges
# Get timeline ranges
otio_tl_range = otio_clip.range_in_parent()
otio_src_range = otio_clip.source_range
otio_avalable_range = otio_clip.available_range()
otio_tl_range_handles = otio_range_with_handles(
otio_tl_range, instance)
otio_src_range_handles = otio_range_with_handles(
otio_src_range, instance)
otio_tl_range,
instance
)
# get source avalable start frame
src_starting_from = otio.opentime.to_frames(
otio_avalable_range.start_time,
otio_avalable_range.start_time.rate)
# Convert to frames
tl_start, tl_end = otio_range_to_frame_range(otio_tl_range)
tl_start_h, tl_end_h = otio_range_to_frame_range(otio_tl_range_handles)
# convert to frames
range_convert = otio_range_to_frame_range
tl_start, tl_end = range_convert(otio_tl_range)
tl_start_h, tl_end_h = range_convert(otio_tl_range_handles)
src_start, src_end = range_convert(otio_src_range)
src_start_h, src_end_h = range_convert(otio_src_range_handles)
frame_start = workfile_start
frame_end = frame_start + otio.opentime.to_frames(
otio_tl_range.duration, otio_tl_range.duration.rate) - 1
# in case of retimed clip and frame range should not be retimed
if workfile_source_duration:
# get available range trimmed with processed retimes
retimed_attributes = get_media_range_with_retimes(
otio_clip, 0, 0)
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in) + 1
self.log.debug(frame_end)
frame_end = frame_start + otio_tl_range.duration.to_frames() - 1
data = {
"frameStart": frame_start,
@ -81,13 +123,77 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
"clipOut": tl_end - 1,
"clipInH": tl_start_h,
"clipOutH": tl_end_h - 1,
"sourceStart": src_starting_from + src_start,
"sourceEnd": src_starting_from + src_end - 1,
"sourceStartH": src_starting_from + src_start_h,
"sourceEndH": src_starting_from + src_end_h - 1,
}
instance.data.update(data)
self.log.debug(
"_ data: {}".format(pformat(data)))
self.log.debug(
"_ instance.data: {}".format(pformat(instance.data)))
self.log.debug(f"Added frame ranges: {pformat(data)}")
def _collect_source_ranges(self, instance, otio_clip):
"""Collect source media frame ranges."""
# Get source ranges
otio_src_range = otio_clip.source_range
otio_available_range = otio_clip.available_range()
# Backward-compatibility for Hiero OTIO exporter.
# NTSC compatibility might introduce floating rates, when these are
# not exactly the same (23.976 vs 23.976024627685547)
# this will cause precision issue in computation.
# Currently round to 2 decimals for comparison,
# but this should always rescale after that.
rounded_av_rate = round(otio_available_range.start_time.rate, 2)
rounded_src_rate = round(otio_src_range.start_time.rate, 2)
if rounded_av_rate != rounded_src_rate:
conformed_src_in = otio_src_range.start_time.rescaled_to(
otio_available_range.start_time.rate
)
conformed_src_duration = otio_src_range.duration.rescaled_to(
otio_available_range.duration.rate
)
conformed_source_range = otio.opentime.TimeRange(
start_time=conformed_src_in,
duration=conformed_src_duration
)
else:
conformed_source_range = otio_src_range
source_start = conformed_source_range.start_time
source_end = source_start + conformed_source_range.duration
handle_start = otio.opentime.RationalTime(
instance.data.get("handleStart", 0),
source_start.rate
)
handle_end = otio.opentime.RationalTime(
instance.data.get("handleEnd", 0),
source_start.rate
)
source_start_h = source_start - handle_start
source_end_h = source_end + handle_end
data = {
"sourceStart": source_start.to_frames(),
"sourceEnd": source_end.to_frames() - 1,
"sourceStartH": source_start_h.to_frames(),
"sourceEndH": source_end_h.to_frames() - 1,
}
instance.data.update(data)
self.log.debug(f"Added source ranges: {pformat(data)}")
def _collect_retimed_ranges(self, instance, otio_clip):
"""Handle retimed clip frame ranges."""
retimed_attributes = get_media_range_with_retimes(otio_clip, 0, 0)
self.log.debug(f"Retimed attributes: {retimed_attributes}")
frame_start = instance.data["frameStart"]
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
frame_end = frame_start + (media_out - media_in)
data = {
"frameStart": frame_start,
"frameEnd": frame_end,
"sourceStart": media_in,
"sourceEnd": media_out,
"sourceStartH": media_in - int(retimed_attributes["handleStart"]),
"sourceEndH": media_out + int(retimed_attributes["handleEnd"]),
}
instance.data.update(data)
self.log.debug(f"Updated retimed values: {data}")

View file

@ -36,6 +36,16 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
# optionally get `reviewTrack`
review_track_name = instance.data.get("reviewTrack")
# [clip_media] setting:
# Extract current clip source range as reviewable.
# Flag review content from otio_clip.
if not review_track_name and "review" in instance.data["families"]:
otio_review_clips = [otio_clip]
# skip if no review track available
elif not review_track_name:
return
# generate range in parent
otio_tl_range = otio_clip.range_in_parent()
@ -43,12 +53,14 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
clip_frame_end = int(
otio_tl_range.start_time.value + otio_tl_range.duration.value)
# skip if no review track available
if not review_track_name:
return
# loop all tracks and match with name in `reviewTrack`
for track in otio_timeline.tracks:
# No review track defined, skip the loop
if review_track_name is None:
break
# Not current review track, skip it.
if review_track_name != track.name:
continue

View file

@ -6,6 +6,7 @@ Provides:
instance -> otioReviewClips
"""
import os
import math
import clique
import pyblish.api
@ -69,9 +70,17 @@ class CollectOtioSubsetResources(
self.log.debug(
">> retimed_attributes: {}".format(retimed_attributes))
# break down into variables
media_in = int(retimed_attributes["mediaIn"])
media_out = int(retimed_attributes["mediaOut"])
# break down into variables as rounded frame numbers
#
# 0 1 2 3 4
# |-------------|---------------|--------------|-------------|
# |_______________media range_______________|
# 0.6 3.2
#
# As rounded frames, media_in = 0 and media_out = 4
media_in = math.floor(retimed_attributes["mediaIn"])
media_out = math.ceil(retimed_attributes["mediaOut"])
handle_start = int(retimed_attributes["handleStart"])
handle_end = int(retimed_attributes["handleEnd"])
@ -149,7 +158,6 @@ class CollectOtioSubsetResources(
self.log.info(
"frame_start-frame_end: {}-{}".format(frame_start, frame_end))
review_repre = None
if is_sequence:
# file sequence way
@ -174,17 +182,17 @@ class CollectOtioSubsetResources(
path, trimmed_media_range_h, metadata)
self.staging_dir, collection = collection_data
self.log.debug(collection)
repre = self._create_representation(
frame_start, frame_end, collection=collection)
if len(collection.indexes) > 1:
self.log.debug(collection)
repre = self._create_representation(
frame_start, frame_end, collection=collection)
else:
filename = tuple(collection)[0]
self.log.debug(filename)
if (
not instance.data.get("otioReviewClips")
and "review" in instance.data["families"]
):
review_repre = self._create_representation(
frame_start, frame_end, collection=collection,
delete=True, review=True)
# TODO: discuss this, it erases frame number.
repre = self._create_representation(
frame_start, frame_end, file=filename)
else:
_trim = False
@ -200,14 +208,6 @@ class CollectOtioSubsetResources(
repre = self._create_representation(
frame_start, frame_end, file=filename, trim=_trim)
if (
not instance.data.get("otioReviewClips")
and "review" in instance.data["families"]
):
review_repre = self._create_representation(
frame_start, frame_end,
file=filename, delete=True, review=True)
instance.data["originalDirname"] = self.staging_dir
# add representation to instance data
@ -219,10 +219,6 @@ class CollectOtioSubsetResources(
instance.data["representations"].append(repre)
# add review representation to instance data
if review_repre:
instance.data["representations"].append(review_repre)
self.log.debug(instance.data)
def _create_representation(self, start, end, **kwargs):

View file

@ -31,6 +31,9 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
# Keep "filesequence" for backwards compatibility of older jobs
targets = ["filesequence", "farm"]
label = "Collect rendered frames"
settings_category = "core"
remove_files = False
_context = None
@ -120,7 +123,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
self._fill_staging_dir(repre_data, anatomy)
representations.append(repre_data)
if not staging_dir_persistent:
if self.remove_files and not staging_dir_persistent:
add_repre_files_for_cleanup(instance, repre_data)
instance.data["representations"] = representations
@ -170,7 +173,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
os.environ.update(session_data)
staging_dir_persistent = self._process_path(data, anatomy)
if not staging_dir_persistent:
if self.remove_files and not staging_dir_persistent:
context.data["cleanupFullPaths"].append(path)
context.data["cleanupEmptyDirs"].append(
os.path.dirname(path)

View file

@ -66,7 +66,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
"yeticacheUE",
"tycache",
"usd",
"oxrig"
"oxrig",
"sbsar",
]
def process(self, instance):

View file

@ -14,23 +14,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder
label = 'Collect Scene Version'
# configurable in Settings
hosts = [
"aftereffects",
"blender",
"celaction",
"fusion",
"harmony",
"hiero",
"houdini",
"maya",
"max",
"nuke",
"photoshop",
"resolve",
"tvpaint",
"motionbuilder",
"substancepainter"
]
hosts = ["*"]
# in some cases of headless publishing (for example webpublisher using PS)
# you want to ignore version from name and let integrate use next version

View file

@ -54,7 +54,8 @@ class ExtractBurnin(publish.Extractor):
"houdini",
"max",
"blender",
"unreal"
"unreal",
"circuit",
]
optional = True

View file

@ -280,6 +280,9 @@ class ExtractOIIOTranscode(publish.Extractor):
collection = collections[0]
frames = list(collection.indexes)
if collection.holes().indexes:
return files_to_convert
frame_str = "{}-{}#".format(frames[0], frames[-1])
file_name = "{}{}{}".format(collection.head, frame_str,
collection.tail)

View file

@ -147,7 +147,6 @@ class ExtractOTIOReview(
self.actual_fps = available_range.duration.rate
start = src_range.start_time.rescaled_to(self.actual_fps)
duration = src_range.duration.rescaled_to(self.actual_fps)
src_frame_start = src_range.start_time.to_frames()
# Temporary.
# Some AYON custom OTIO exporter were implemented with
@ -157,7 +156,7 @@ class ExtractOTIOReview(
if (
is_clip_from_media_sequence(r_otio_cl)
and available_range_start_frame == media_ref.start_frame
and src_frame_start < media_ref.start_frame
and start.to_frames() < media_ref.start_frame
):
available_range = otio.opentime.TimeRange(
otio.opentime.RationalTime(0, rate=self.actual_fps),
@ -287,7 +286,7 @@ class ExtractOTIOReview(
)
instance.data["representations"].append(representation)
self.log.info("Adding representation: {}".format(representation))
self.log.debug("Adding representation: {}".format(representation))
def _create_representation(self, start, duration):
"""
@ -321,6 +320,9 @@ class ExtractOTIOReview(
end = max(collection.indexes)
files = [f for f in collection]
# single frame sequence
if len(files) == 1:
files = files[0]
ext = collection.format("{tail}")
representation_data.update({
"name": ext[1:],

View file

@ -91,7 +91,8 @@ class ExtractReview(pyblish.api.InstancePlugin):
"webpublisher",
"aftereffects",
"flame",
"unreal"
"unreal",
"circuit",
]
# Supported extensions
@ -196,7 +197,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
).format(repre_name))
continue
input_ext = repre["ext"]
input_ext = repre["ext"].lower()
if input_ext.startswith("."):
input_ext = input_ext[1:]
@ -1332,7 +1333,7 @@ class ExtractReview(pyblish.api.InstancePlugin):
bg_red, bg_green, bg_blue = overscan_color
else:
# Backwards compatibility
bg_red, bg_green, bg_blue, _ = overscan_color
bg_red, bg_green, bg_blue, _ = overscan_color
overscan_color_value = "#{0:0>2X}{1:0>2X}{2:0>2X}".format(
bg_red, bg_green, bg_blue

View file

@ -35,10 +35,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"resolve",
"traypublisher",
"substancepainter",
"substancedesigner",
"nuke",
"aftereffects",
"unreal",
"houdini"
"houdini",
"circuit",
]
enabled = False
@ -161,9 +163,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# Store new staging to cleanup paths
instance.context.data["cleanupFullPaths"].append(dst_staging)
thumbnail_created = False
oiio_supported = is_oiio_supported()
thumbnail_created = False
for repre in filtered_repres:
# Reset for each iteration to handle cases where multiple
# reviewable thumbnails are needed
repre_thumb_created = False
repre_files = repre["files"]
src_staging = os.path.normpath(repre["stagingDir"])
if not isinstance(repre_files, (list, tuple)):
@ -212,7 +217,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
)
# If the input can read by OIIO then use OIIO method for
# conversion otherwise use ffmpeg
thumbnail_created = self._create_thumbnail_oiio(
repre_thumb_created = self._create_thumbnail_oiio(
full_input_path,
full_output_path,
colorspace_data
@ -221,21 +226,22 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# Try to use FFMPEG if OIIO is not supported or for cases when
# oiiotool isn't available or representation is not having
# colorspace data
if not thumbnail_created:
if not repre_thumb_created:
if oiio_supported:
self.log.debug(
"Converting with FFMPEG because input"
" can't be read by OIIO."
)
thumbnail_created = self._create_thumbnail_ffmpeg(
repre_thumb_created = self._create_thumbnail_ffmpeg(
full_input_path, full_output_path
)
# Skip representation and try next one if wasn't created
if not thumbnail_created:
if not repre_thumb_created:
continue
thumbnail_created = True
if len(explicit_repres) > 1:
repre_name = "thumbnail_{}".format(repre["outputName"])
else:
@ -342,8 +348,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# to be published locally
continue
valid = "review" in tags or "thumb-nuke" in tags
if not valid:
if "review" not in tags:
continue
if not repre.get("files"):
@ -449,7 +454,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
# output arguments from presets
jpeg_items.extend(ffmpeg_args.get("output") or [])
# we just want one frame from movie files
jpeg_items.extend(["-vframes", "1"])
jpeg_items.extend(["-frames:v", "1"])
if resolution_arg:
jpeg_items.extend(resolution_arg)
@ -497,7 +502,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"-i", video_file_path,
"-analyzeduration", max_int,
"-probesize", max_int,
"-vframes", "1"
"-frames:v", "1"
]
# add output file path

View file

@ -170,7 +170,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin):
"-analyzeduration", max_int,
"-probesize", max_int,
"-i", src_path,
"-vframes", "1",
"-frames:v", "1",
dst_path
)

View file

@ -1,7 +1,7 @@
from operator import attrgetter
import dataclasses
import os
from typing import Dict
from typing import Any, Dict, List
import pyblish.api
try:
@ -14,7 +14,8 @@ from ayon_core.lib import (
BoolDef,
UISeparatorDef,
UILabelDef,
EnumDef
EnumDef,
filter_profiles
)
try:
from ayon_core.pipeline.usdlib import (
@ -281,6 +282,9 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"fx": 500,
"lighting": 600,
}
# Default profiles to set certain instance attribute defaults based on
# profiles in settings
profiles: List[Dict[str, Any]] = []
@classmethod
def apply_settings(cls, project_settings):
@ -298,6 +302,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
if contribution_layers:
cls.contribution_layers = contribution_layers
cls.profiles = plugin_settings.get("profiles", [])
def process(self, instance):
attr_values = self.get_attr_values_from_data(instance.data)
@ -463,6 +469,29 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
if not cls.instance_matches_plugin_families(instance):
return []
# Set default target layer based on product type
current_context_task_type = create_context.get_current_task_type()
profile = filter_profiles(cls.profiles, {
"product_types": instance.data["productType"],
"task_types": current_context_task_type
})
if not profile:
profile = {}
# Define defaults
default_enabled = profile.get("contribution_enabled", True)
default_contribution_layer = profile.get(
"contribution_layer", None)
default_apply_as_variant = profile.get(
"contribution_apply_as_variant", False)
default_target_product = profile.get(
"contribution_target_product", "usdAsset")
default_init_as = (
"asset"
if profile.get("contribution_target_product") == "usdAsset"
else "shot")
init_as_visible = False
# Attributes logic
publish_attributes = instance["publish_attributes"].get(
cls.__name__, {})
@ -485,7 +514,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"In both cases the USD data itself is free to have "
"references and sublayers of its own."
),
default=True),
default=default_enabled),
TextDef("contribution_target_product",
label="Target product",
tooltip=(
@ -495,7 +524,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the contribution itself will be added to the "
"department layer."
),
default="usdAsset",
default=default_target_product,
visible=visible),
EnumDef("contribution_target_product_init",
label="Initialize as",
@ -507,8 +536,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"setting will do nothing."
),
items=["asset", "shot"],
default="asset",
visible=visible),
default=default_init_as,
visible=visible and init_as_visible),
# Asset layer, e.g. model.usd, look.usd, rig.usd
EnumDef("contribution_layer",
@ -520,7 +549,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"the list) will contribute as a stronger opinion."
),
items=list(cls.contribution_layers.keys()),
default="model",
default=default_contribution_layer,
visible=visible),
BoolDef("contribution_apply_as_variant",
label="Add as variant",
@ -532,7 +561,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
"appended to as a sublayer to the department layer "
"instead."
),
default=True,
default=default_apply_as_variant,
visible=visible),
TextDef("contribution_variant_set_name",
label="Variant Set Name",
@ -588,31 +617,6 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
"""
This is solely here to expose the attribute definitions for the
Houdini "look" family.
"""
# TODO: Improve how this is built for the look family
hosts = ["houdini"]
families = ["look"]
label = CollectUSDLayerContributions.label + " (Look)"
@classmethod
def get_attr_defs_for_instance(cls, create_context, instance):
# Filtering of instance, if needed, can be customized
if not cls.instance_matches_plugin_families(instance):
return []
defs = super().get_attr_defs_for_instance(create_context, instance)
# Update default for department layer to look
layer_def = next(d for d in defs if d.key == "contribution_layer")
layer_def.default = "look"
return defs
class ValidateUSDDependencies(pyblish.api.InstancePlugin):
families = ["usdLayer"]

View file

@ -619,8 +619,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# used for all represe
# from temp to final
original_directory = (
instance.data.get("originalDirname") or instance_stagingdir)
instance.data.get("originalDirname") or stagingdir)
_rootless = self.get_rootless_path(anatomy, original_directory)
if _rootless == original_directory:
raise KnownPublishError((
@ -684,7 +683,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
elif is_sequence_representation:
# Collection of files (sequence)
src_collections, remainders = clique.assemble(files)
src_collections, _remainders = clique.assemble(files)
src_collection = src_collections[0]
destination_indexes = list(src_collection.indexes)
@ -706,7 +705,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# In case source are published in place we need to
# skip renumbering
repre_frame_start = repre.get("frameStart")
if repre_frame_start is not None:
explicit_frames = instance.data.get("hasExplicitFrames", False)
if not explicit_frames and repre_frame_start is not None:
index_frame_start = int(repre_frame_start)
# Shift destination sequence to the start frame
destination_indexes = [

View file

@ -0,0 +1,138 @@
import copy
import pyblish.api
from typing import List
from ayon_core.lib import EnumDef
from ayon_core.pipeline import OptionalPyblishPluginMixin
class AttachReviewables(
pyblish.api.InstancePlugin, OptionalPyblishPluginMixin
):
"""Attach reviewable to other instances
This pre-integrator plugin allows instances to be 'attached to' other
instances by moving all its representations over to the other instance.
Even though this technically could work for any representation the current
intent is to use for reviewables only, like e.g. `review` or `render`
product type.
When the reviewable is attached to another instance, the instance itself
will not be published as a separate entity. Instead, the representations
will be copied/moved to the instances it is attached to.
"""
families = ["render", "review"]
order = pyblish.api.IntegratorOrder - 0.499
label = "Attach reviewables"
settings_category = "core"
def process(self, instance):
# TODO: Support farm.
# If instance is being submitted to the farm we should pass through
# the 'attached reviewables' metadata to the farm job
# TODO: Reviewable frame range and resolutions
# Because we are attaching the data to another instance, how do we
# correctly propagate the resolution + frame rate to the other
# instance? Do we even need to?
# TODO: If this were to attach 'renders' to another instance that would
# mean there wouldn't necessarily be a render publish separate as a
# result. Is that correct expected behavior?
attr_values = self.get_attr_values_from_data(instance.data)
attach_to = attr_values.get("attach", [])
if not attach_to:
self.log.debug(
"Reviewable is not set to attach to another instance."
)
return
attach_instances: List[pyblish.api.Instance] = []
for attach_instance_id in attach_to:
# Find the `pyblish.api.Instance` matching the `CreatedInstance.id`
# in the `attach_to` list
attach_instance = next(
(
_inst
for _inst in instance.context
if _inst.data.get("instance_id") == attach_instance_id
),
None,
)
if attach_instance is None:
continue
# Skip inactive instances
if not attach_instance.data.get("active", True):
continue
# For now do not support attaching to 'farm' instances until we
# can pass the 'attaching' on to the farm jobs.
if attach_instance.data.get("farm"):
self.log.warning(
"Attaching to farm instances is not supported yet."
)
continue
attach_instances.append(attach_instance)
instances_names = ", ".join(
instance.name for instance in attach_instances
)
self.log.info(
f"Attaching reviewable to other instances: {instances_names}"
)
# Copy the representations of this reviewable instance to the other
# instance
representations = instance.data.get("representations", [])
for attach_instance in attach_instances:
self.log.info(f"Attaching to {attach_instance.name}")
attach_instance.data.setdefault("representations", []).extend(
copy.deepcopy(representations)
)
# Delete representations on the reviewable instance itself
for repre in representations:
self.log.debug(
"Marking representation as deleted because it was "
f"attached to other instances instead: {repre}"
)
repre.setdefault("tags", []).append("delete")
# Stop integrator from trying to integrate this instance
if attach_to:
instance.data["integrate"] = False
@classmethod
def get_attr_defs_for_instance(cls, create_context, instance):
# TODO: Check if instance is actually a 'reviewable'
# Filtering of instance, if needed, can be customized
if not cls.instance_matches_plugin_families(instance):
return []
items = []
for other_instance in create_context.instances:
if other_instance == instance:
continue
# Do not allow attaching to other reviewable instances
if other_instance.data["productType"] in cls.families:
continue
items.append(
{
"label": other_instance.label,
"value": str(other_instance.id),
}
)
return [
EnumDef(
"attach",
label="Attach reviewable",
multiselection=True,
items=items,
tooltip="Attach this reviewable to another instance",
)
]

View file

@ -7,7 +7,7 @@ class IntegrateResourcesPath(pyblish.api.InstancePlugin):
label = "Integrate Resources Path"
order = pyblish.api.IntegratorOrder - 0.05
families = ["clip", "projectfile", "plate"]
families = ["clip", "projectfile", "plate"]
def process(self, instance):
resources = instance.data.get("resources") or []

View file

@ -27,8 +27,10 @@ import collections
import pyblish.api
import ayon_api
from ayon_api import RequestTypes
from ayon_api.operations import OperationsSession
InstanceFilterResult = collections.namedtuple(
"InstanceFilterResult",
["instance", "thumbnail_path", "version_id"]
@ -161,6 +163,30 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
return None
return os.path.normpath(filled_path)
def _create_thumbnail(self, project_name: str, src_filepath: str) -> str:
"""Upload thumbnail to AYON and return its id.
This is temporary fix of 'create_thumbnail' function in ayon_api to
fix jpeg mime type.
"""
mime_type = None
with open(src_filepath, "rb") as stream:
if b"\xff\xd8\xff" == stream.read(3):
mime_type = "image/jpeg"
if mime_type is None:
return ayon_api.create_thumbnail(project_name, src_filepath)
response = ayon_api.upload_file(
f"projects/{project_name}/thumbnails",
src_filepath,
request_type=RequestTypes.post,
headers={"Content-Type": mime_type},
)
response.raise_for_status()
return response.json()["id"]
def _integrate_thumbnails(
self,
filtered_instance_items,
@ -179,7 +205,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin):
).format(instance_label))
continue
thumbnail_id = ayon_api.create_thumbnail(
thumbnail_id = self._create_thumbnail(
project_name, thumbnail_path
)

View file

@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin):
label = "Validate File Saved"
order = pyblish.api.ValidatorOrder - 0.1
hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter",
"cinema4d"]
"cinema4d", "silhouette"]
actions = [SaveByVersionUpAction, ShowWorkfilesAction]
def process(self, context):

View file

@ -173,7 +173,6 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
if frame_end is not None:
options["frame_end"] = frame_end
options["label"] = align
self._add_burnin(text, align, options, DRAWTEXT)

View file

@ -175,7 +175,7 @@ class BaseObj:
self.log.warning("Invalid range '{}'".format(part))
continue
for idx in range(sub_parts[0], sub_parts[1]+1):
for idx in range(sub_parts[0], sub_parts[1] + 1):
indexes.append(idx)
return indexes
@ -353,7 +353,6 @@ class BaseObj:
self.items[item.id] = item
item.fill_data_format()
def reset(self):
for item in self.items.values():
item.reset()

View file

@ -282,7 +282,7 @@ class ItemTable(BaseItem):
value.draw(image, drawer)
def value_width(self):
row_heights, col_widths = self.size_values
_row_heights, col_widths = self.size_values
width = 0
for _width in col_widths:
width += _width
@ -292,7 +292,7 @@ class ItemTable(BaseItem):
return width
def value_height(self):
row_heights, col_widths = self.size_values
row_heights, _col_widths = self.size_values
height = 0
for _height in row_heights:
height += _height
@ -569,21 +569,21 @@ class TableField(BaseItem):
@property
def item_pos_x(self):
pos_x, pos_y, width, height = (
pos_x, _pos_y, _width, _height = (
self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx)
)
return pos_x
@property
def item_pos_y(self):
pos_x, pos_y, width, height = (
_pos_x, pos_y, _width, _height = (
self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx)
)
return pos_y
@property
def value_pos_x(self):
pos_x, pos_y, width, height = (
pos_x, _pos_y, width, _height = (
self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx)
)
alignment_hor = self.style["alignment-horizontal"].lower()
@ -605,7 +605,7 @@ class TableField(BaseItem):
@property
def value_pos_y(self):
pos_x, pos_y, width, height = (
_pos_x, pos_y, _width, height = (
self.parent.content_pos_info_by_cord(self.row_idx, self.col_idx)
)

View file

@ -1171,6 +1171,8 @@ ValidationArtistMessage QLabel {
#PublishLogMessage {
font-family: "Noto Sans Mono";
border: none;
padding: 0;
}
#PublishInstanceLogsLabel {

View file

@ -2,7 +2,7 @@ import copy
import typing
from typing import Optional
from qtpy import QtWidgets, QtCore
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
@ -22,6 +22,8 @@ from ayon_core.tools.utils import (
FocusSpinBox,
FocusDoubleSpinBox,
MultiSelectionComboBox,
PlaceholderLineEdit,
PlaceholderPlainTextEdit,
set_style_property,
)
from ayon_core.tools.utils import NiceCheckbox
@ -502,9 +504,9 @@ class TextAttrWidget(_BaseAttrDefWidget):
self.multiline = self.attr_def.multiline
if self.multiline:
input_widget = QtWidgets.QPlainTextEdit(self)
input_widget = PlaceholderPlainTextEdit(self)
else:
input_widget = QtWidgets.QLineEdit(self)
input_widget = PlaceholderLineEdit(self)
# Override context menu event to add revert to default action
input_widget.contextMenuEvent = self._input_widget_context_event
@ -641,7 +643,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
if self.multiselection:
input_widget = MultiSelectionComboBox(self)
input_widget = MultiSelectionComboBox(
self, placeholder=self.attr_def.placeholder
)
else:
input_widget = CustomTextComboBox(self)
@ -655,6 +659,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
for item in self.attr_def.items:
input_widget.addItem(item["label"], item["value"])
if not self.attr_def.items:
self._add_empty_item(input_widget)
idx = input_widget.findData(self.attr_def.default)
if idx >= 0:
input_widget.setCurrentIndex(idx)
@ -671,6 +678,20 @@ class EnumAttrWidget(_BaseAttrDefWidget):
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
input_widget.customContextMenuRequested.connect(self._on_context_menu)
def _add_empty_item(self, input_widget):
model = input_widget.model()
if not isinstance(model, QtGui.QStandardItemModel):
return
root_item = model.invisibleRootItem()
empty_item = QtGui.QStandardItem()
empty_item.setData("< No items to select >", QtCore.Qt.DisplayRole)
empty_item.setData("", QtCore.Qt.UserRole)
empty_item.setFlags(QtCore.Qt.NoItemFlags)
root_item.appendRow(empty_item)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)

View file

@ -1,12 +1,18 @@
from __future__ import annotations
import time
import collections
import contextlib
import typing
from abc import ABC, abstractmethod
import ayon_api
from ayon_core.lib import NestedCacheItem
if typing.TYPE_CHECKING:
from typing import Union
HIERARCHY_MODEL_SENDER = "hierarchy.model"
@ -82,19 +88,26 @@ class TaskItem:
Args:
task_id (str): Task id.
name (str): Name of task.
name (Union[str, None]): Task label.
task_type (str): Type of task.
parent_id (str): Parent folder id.
"""
def __init__(
self, task_id, name, task_type, parent_id
self,
task_id: str,
name: str,
label: Union[str, None],
task_type: str,
parent_id: str,
):
self.task_id = task_id
self.name = name
self.label = label
self.task_type = task_type
self.parent_id = parent_id
self._label = None
self._full_label = None
@property
def id(self):
@ -107,16 +120,17 @@ class TaskItem:
return self.task_id
@property
def label(self):
def full_label(self):
"""Label of task item for UI.
Returns:
str: Label of task item.
"""
if self._label is None:
self._label = "{} ({})".format(self.name, self.task_type)
return self._label
if self._full_label is None:
label = self.label or self.name
self._full_label = f"{label} ({self.task_type})"
return self._full_label
def to_data(self):
"""Converts task item to data.
@ -128,6 +142,7 @@ class TaskItem:
return {
"task_id": self.task_id,
"name": self.name,
"label": self.label,
"parent_id": self.parent_id,
"task_type": self.task_type,
}
@ -159,6 +174,7 @@ def _get_task_items_from_tasks(tasks):
output.append(TaskItem(
task["id"],
task["name"],
task["label"],
task["type"],
folder_id
))
@ -211,6 +227,9 @@ class HierarchyModel(object):
self._tasks_by_id = NestedCacheItem(
levels=2, default_factory=dict, lifetime=self.lifetime)
self._entity_ids_by_assignee = NestedCacheItem(
levels=2, default_factory=dict, lifetime=self.lifetime)
self._folders_refreshing = set()
self._tasks_refreshing = set()
self._controller = controller
@ -222,6 +241,8 @@ class HierarchyModel(object):
self._task_items.reset()
self._tasks_by_id.reset()
self._entity_ids_by_assignee.reset()
def refresh_project(self, project_name):
"""Force to refresh folder items for a project.
@ -368,7 +389,7 @@ class HierarchyModel(object):
sender (Union[str, None]): Who requested the task item.
Returns:
Union[TaskItem, None]: Task item found by name and folder id.
Optional[TaskItem]: Task item found by name and folder id.
"""
for task_item in self.get_task_items(project_name, folder_id, sender):
@ -445,6 +466,54 @@ class HierarchyModel(object):
output = self.get_task_entities(project_name, {task_id})
return output[task_id]
def get_entity_ids_for_assignees(
self, project_name: str, assignees: list[str]
):
folder_ids = set()
task_ids = set()
output = {
"folder_ids": folder_ids,
"task_ids": task_ids,
}
assignees = set(assignees)
for assignee in tuple(assignees):
cache = self._entity_ids_by_assignee[project_name][assignee]
if cache.is_valid:
assignees.discard(assignee)
assignee_data = cache.get_data()
folder_ids.update(assignee_data["folder_ids"])
task_ids.update(assignee_data["task_ids"])
if not assignees:
return output
tasks = ayon_api.get_tasks(
project_name,
assignees_all=assignees,
fields={"id", "folderId", "assignees"},
)
tasks_assignee = {}
for task in tasks:
folder_ids.add(task["folderId"])
task_ids.add(task["id"])
for assignee in task["assignees"]:
tasks_assignee.setdefault(assignee, []).append(task)
for assignee, tasks in tasks_assignee.items():
cache = self._entity_ids_by_assignee[project_name][assignee]
assignee_folder_ids = set()
assignee_task_ids = set()
assignee_data = {
"folder_ids": assignee_folder_ids,
"task_ids": assignee_task_ids,
}
for task in tasks:
assignee_folder_ids.add(task["folderId"])
assignee_task_ids.add(task["id"])
cache.update_data(assignee_data)
return output
@contextlib.contextmanager
def _folder_refresh_event_manager(self, project_name, sender):
self._folders_refreshing.add(project_name)

View file

@ -21,8 +21,49 @@ class ThumbnailsModel:
self._folders_cache.reset()
self._versions_cache.reset()
def get_thumbnail_path(self, project_name, thumbnail_id):
return self._get_thumbnail_path(project_name, thumbnail_id)
def get_thumbnail_paths(
self,
project_name,
entity_type,
entity_ids,
):
output = {
entity_id: None
for entity_id in entity_ids
}
if not project_name or not entity_type or not entity_ids:
return output
thumbnail_id_by_entity_id = {}
if entity_type == "folder":
thumbnail_id_by_entity_id = self.get_folder_thumbnail_ids(
project_name, entity_ids
)
elif entity_type == "version":
thumbnail_id_by_entity_id = self.get_version_thumbnail_ids(
project_name, entity_ids
)
if not thumbnail_id_by_entity_id:
return output
entity_ids_by_thumbnail_id = collections.defaultdict(set)
for entity_id, thumbnail_id in thumbnail_id_by_entity_id.items():
if not thumbnail_id:
continue
entity_ids_by_thumbnail_id[thumbnail_id].add(entity_id)
for thumbnail_id, entity_ids in entity_ids_by_thumbnail_id.items():
thumbnail_path = self._get_thumbnail_path(
project_name, entity_type, next(iter(entity_ids)), thumbnail_id
)
if not thumbnail_path:
continue
for entity_id in entity_ids:
output[entity_id] = thumbnail_path
return output
def get_folder_thumbnail_ids(self, project_name, folder_ids):
project_cache = self._folders_cache[project_name]
@ -56,7 +97,13 @@ class ThumbnailsModel:
output[version_id] = cache.get_data()
return output
def _get_thumbnail_path(self, project_name, thumbnail_id):
def _get_thumbnail_path(
self,
project_name,
entity_type,
entity_id,
thumbnail_id
):
if not thumbnail_id:
return None
@ -64,7 +111,12 @@ class ThumbnailsModel:
if thumbnail_id in project_cache:
return project_cache[thumbnail_id]
filepath = get_thumbnail_path(project_name, thumbnail_id)
filepath = get_thumbnail_path(
project_name,
entity_type,
entity_id,
thumbnail_id
)
project_cache[thumbnail_id] = filepath
return filepath

View file

@ -248,4 +248,3 @@ class EnhancedTabBar(QtWidgets.QTabBar):
else:
super().mouseReleaseEvent(event)

View file

@ -492,7 +492,7 @@ def show(parent=None):
try:
module.window.close()
del(module.window)
del module.window
except (AttributeError, RuntimeError):
pass

View file

@ -32,7 +32,7 @@ from qtpy import QtWidgets, QtCore, QtGui
import pyblish.api
from ayon_core import style
TAB = 4* "&nbsp;"
TAB = 4 * "&nbsp;"
HEADER_SIZE = "15px"
KEY_COLOR = QtGui.QColor("#ffffff")
@ -243,7 +243,7 @@ class DebugUI(QtWidgets.QDialog):
self._set_window_title(plugin=result["plugin"])
print(10*"<", result["plugin"].__name__, 10*">")
print(10 * "<", result["plugin"].__name__, 10 * ">")
plugin_order = result["plugin"].order
plugin_name = result["plugin"].__name__

View file

@ -160,8 +160,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[FolderItem]: Minimum possible information needed
for visualisation of folder hierarchy.
"""
"""
pass
@abstractmethod
@ -180,8 +180,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[TaskItem]: Minimum possible information needed
for visualisation of tasks.
"""
"""
pass
@abstractmethod
@ -190,8 +190,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected project name.
"""
"""
pass
@abstractmethod
@ -200,8 +200,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected folder id.
"""
"""
pass
@abstractmethod
@ -210,8 +210,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected task id.
"""
"""
pass
@abstractmethod
@ -220,8 +220,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
Union[str, None]: Selected task name.
"""
"""
pass
@abstractmethod
@ -238,8 +238,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
dict[str, Union[str, None]]: Selected context.
"""
"""
pass
@abstractmethod
@ -249,8 +249,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Args:
project_name (Union[str, None]): Project nameor None if no project
is selected.
"""
"""
pass
@abstractmethod
@ -260,8 +260,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Args:
folder_id (Union[str, None]): Folder id or None if no folder
is selected.
"""
"""
pass
@abstractmethod
@ -273,8 +273,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
is selected.
task_name (Union[str, None]): Task name or None if no task
is selected.
"""
"""
pass
# Actions
@ -290,8 +290,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Returns:
list[ActionItem]: List of action items that should be shown
for given context.
"""
"""
pass
@abstractmethod
@ -303,8 +303,8 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (str): Action identifier.
"""
"""
pass
@abstractmethod
@ -317,10 +317,10 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
action_id (Iterable[str]): Action identifiers.
action_ids (Iterable[str]): Action identifiers.
enabled (bool): New value of force not open workfile.
"""
"""
pass
@abstractmethod
@ -340,5 +340,17 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
Triggers 'controller.refresh.actions.started' event at the beginning
and 'controller.refresh.actions.finished' at the end.
"""
pass
@abstractmethod
def get_my_tasks_entity_ids(self, project_name: str):
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, Union[list[str]]]: Folder and task ids.
"""
pass

View file

@ -1,4 +1,4 @@
from ayon_core.lib import Logger
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.settings import get_project_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
@ -6,6 +6,8 @@ from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
from .models import LauncherSelectionModel, ActionsModel
NOT_SET = object()
class BaseLauncherController(
AbstractLauncherFrontEnd, AbstractLauncherBackend
@ -15,6 +17,8 @@ class BaseLauncherController(
self._event_system = None
self._log = None
self._username = NOT_SET
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
@ -168,5 +172,19 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished")
def get_my_tasks_entity_ids(self, project_name: str):
username = self._get_my_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
def _get_my_username(self):
if self._username is NOT_SET:
self._username = get_ayon_username()
return self._username
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")

View file

@ -5,17 +5,17 @@ from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
)
from ayon_core.tools.utils import (
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super(HierarchyPage, self).__init__(parent)
super().__init__(parent)
# Header
header_widget = QtWidgets.QWidget(self)
@ -43,23 +43,36 @@ class HierarchyPage(QtWidgets.QWidget):
)
content_body.setOrientation(QtCore.Qt.Horizontal)
# - Folders widget with filter
folders_wrapper = QtWidgets.QWidget(content_body)
# - filters
filters_widget = QtWidgets.QWidget(self)
folders_filter_text = PlaceholderLineEdit(folders_wrapper)
folders_filter_text = PlaceholderLineEdit(filters_widget)
folders_filter_text.setPlaceholderText("Filter folders...")
folders_widget = FoldersWidget(controller, folders_wrapper)
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)
folders_wrapper_layout = QtWidgets.QVBoxLayout(folders_wrapper)
folders_wrapper_layout.setContentsMargins(0, 0, 0, 0)
folders_wrapper_layout.addWidget(folders_filter_text, 0)
folders_wrapper_layout.addWidget(folders_widget, 1)
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)
# - Folders widget
folders_widget = FoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
content_body.addWidget(folders_wrapper)
content_body.addWidget(folders_widget)
content_body.addWidget(tasks_widget)
content_body.setStretchFactor(0, 100)
content_body.setStretchFactor(1, 65)
@ -67,20 +80,27 @@ class HierarchyPage(QtWidgets.QWidget):
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(header_widget, 0)
main_layout.addWidget(filters_widget, 0)
main_layout.addWidget(content_body, 1)
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(
self._on_my_tasks_checkbox_state_changed
)
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._project_name = None
# Post init
projects_combobox.set_listen_to_selection_change(self._is_visible)
@ -91,10 +111,14 @@ class HierarchyPage(QtWidgets.QWidget):
self._projects_combobox.set_listen_to_selection_change(visible)
if visible and project_name:
self._projects_combobox.set_selection(project_name)
self._project_name = project_name
def refresh(self):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -104,3 +128,16 @@ 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):
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -17,7 +17,7 @@ class LauncherWindow(QtWidgets.QWidget):
page_side_anim_interval = 250
def __init__(self, controller=None, parent=None):
super(LauncherWindow, self).__init__(parent)
super().__init__(parent)
if controller is None:
controller = BaseLauncherController()
@ -153,14 +153,14 @@ class LauncherWindow(QtWidgets.QWidget):
self.resize(520, 740)
def showEvent(self, event):
super(LauncherWindow, self).showEvent(event)
super().showEvent(event)
self._window_is_active = True
if not self._actions_refresh_timer.isActive():
self._actions_refresh_timer.start()
self._controller.refresh()
def closeEvent(self, event):
super(LauncherWindow, self).closeEvent(event)
super().closeEvent(event)
self._window_is_active = False
self._actions_refresh_timer.stop()
@ -176,7 +176,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._on_actions_refresh_timeout()
self._actions_refresh_timer.start()
super(LauncherWindow, self).changeEvent(event)
super().changeEvent(event)
def _on_actions_refresh_timeout(self):
# Stop timer if widget is not visible

View file

@ -108,6 +108,7 @@ class VersionItem:
version (int): Version. Can be negative when is hero version.
is_hero (bool): Is hero version.
product_id (str): Product id.
task_id (Union[str, None]): Task id.
thumbnail_id (Union[str, None]): Thumbnail id.
published_time (Union[str, None]): Published time in format
'%Y%m%dT%H%M%SZ'.
@ -127,6 +128,7 @@ class VersionItem:
version,
is_hero,
product_id,
task_id,
thumbnail_id,
published_time,
author,
@ -140,6 +142,7 @@ class VersionItem:
):
self.version_id = version_id
self.product_id = product_id
self.task_id = task_id
self.thumbnail_id = thumbnail_id
self.version = version
self.is_hero = is_hero
@ -161,6 +164,7 @@ class VersionItem:
and self.version == other.version
and self.version_id == other.version_id
and self.product_id == other.product_id
and self.task_id == other.task_id
)
def __ne__(self, other):
@ -198,6 +202,7 @@ class VersionItem:
return {
"version_id": self.version_id,
"product_id": self.product_id,
"task_id": self.task_id,
"thumbnail_id": self.thumbnail_id,
"version": self.version,
"is_hero": self.is_hero,
@ -536,6 +541,55 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
@abstractmethod
def get_task_items(self, project_name, folder_ids, sender=None):
"""Task items for folder ids.
Args:
project_name (str): Project name.
folder_ids (Iterable[str]): Folder ids.
sender (Optional[str]): Sender who requested the items.
Returns:
list[TaskItem]: List of task items.
"""
pass
@abstractmethod
def get_task_type_items(self, project_name, sender=None):
"""Task type items for a project.
This function may trigger events with topics
'projects.task_types.refresh.started' and
'projects.task_types.refresh.finished' which will contain 'sender'
value in data.
That may help to avoid re-refresh of items in UI elements.
Args:
project_name (str): Project name.
sender (str): Who requested task type items.
Returns:
list[TaskTypeItem]: Task type information.
"""
pass
@abstractmethod
def get_folder_labels(self, project_name, folder_ids):
"""Get folder labels for folder ids.
Args:
project_name (str): Project name.
folder_ids (Iterable[str]): Folder ids.
Returns:
dict[str, Optional[str]]: Folder labels by folder id.
"""
pass
@abstractmethod
def get_project_status_items(self, project_name, sender=None):
"""Items for all projects available on server.
@ -679,7 +733,12 @@ class FrontendLoaderController(_BaseLoaderController):
pass
@abstractmethod
def get_thumbnail_path(self, project_name, thumbnail_id):
def get_thumbnail_paths(
self,
project_name,
entity_type,
entity_ids
):
"""Get thumbnail path for thumbnail id.
This method should get a path to a thumbnail based on thumbnail id.
@ -688,10 +747,11 @@ class FrontendLoaderController(_BaseLoaderController):
Args:
project_name (str): Project name.
thumbnail_id (str): Thumbnail id.
entity_type (str): Entity type.
entity_ids (set[str]): Entity ids.
Returns:
Union[str, None]: Thumbnail path or None if not found.
dict[str, Union[str, None]]: Thumbnail path by entity id.
"""
pass
@ -717,8 +777,30 @@ class FrontendLoaderController(_BaseLoaderController):
Returns:
list[str]: Selected folder ids.
"""
"""
pass
@abstractmethod
def get_selected_task_ids(self):
"""Get selected task ids.
The information is based on last selection from UI.
Returns:
list[str]: Selected folder ids.
"""
pass
@abstractmethod
def set_selected_tasks(self, task_ids):
"""Set selected tasks.
Args:
task_ids (Iterable[str]): Selected task ids.
"""
pass
@abstractmethod
@ -729,8 +811,8 @@ class FrontendLoaderController(_BaseLoaderController):
Returns:
list[str]: Selected version ids.
"""
"""
pass
@abstractmethod

View file

@ -198,6 +198,31 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_task_items(self, project_name, folder_ids, sender=None):
output = []
for folder_id in folder_ids:
output.extend(self._hierarchy_model.get_task_items(
project_name, folder_id, sender
))
return output
def get_task_type_items(self, project_name, sender=None):
return self._projects_model.get_task_type_items(
project_name, sender
)
def get_folder_labels(self, project_name, folder_ids):
folder_items_by_id = self._hierarchy_model.get_folder_items_by_id(
project_name, folder_ids
)
output = {}
for folder_id, folder_item in folder_items_by_id.items():
label = None
if folder_item is not None:
label = folder_item.label
output[folder_id] = label
return output
def get_product_items(self, project_name, folder_ids, sender=None):
return self._products_model.get_product_items(
project_name, folder_ids, sender)
@ -234,9 +259,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
project_name, version_ids
)
def get_thumbnail_path(self, project_name, thumbnail_id):
return self._thumbnails_model.get_thumbnail_path(
project_name, thumbnail_id
def get_thumbnail_paths(
self,
project_name,
entity_type,
entity_ids,
):
return self._thumbnails_model.get_thumbnail_paths(
project_name, entity_type, entity_ids
)
def change_products_group(self, project_name, product_ids, group_name):
@ -299,6 +329,12 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def set_selected_folders(self, folder_ids):
self._selection_model.set_selected_folders(folder_ids)
def get_selected_task_ids(self):
return self._selection_model.get_selected_task_ids()
def set_selected_tasks(self, task_ids):
self._selection_model.set_selected_tasks(task_ids)
def get_selected_version_ids(self):
return self._selection_model.get_selected_version_ids()

View file

@ -55,6 +55,7 @@ def version_item_from_entity(version):
version=version_num,
is_hero=is_hero,
product_id=version["productId"],
task_id=version["taskId"],
thumbnail_id=version["thumbnailId"],
published_time=published_time,
author=author,

View file

@ -14,6 +14,7 @@ class SelectionModel(object):
self._project_name = None
self._folder_ids = set()
self._task_ids = set()
self._version_ids = set()
self._representation_ids = set()
@ -48,6 +49,23 @@ class SelectionModel(object):
self.event_source
)
def get_selected_task_ids(self):
return self._task_ids
def set_selected_tasks(self, task_ids):
if task_ids == self._task_ids:
return
self._task_ids = task_ids
self._controller.emit_event(
"selection.tasks.changed",
{
"project_name": self._project_name,
"task_ids": task_ids,
},
self.event_source
)
def get_selected_version_ids(self):
return self._version_ids

View file

@ -1,7 +1,10 @@
from __future__ import annotations
import typing
from typing import List, Tuple, Optional, Iterable, Any
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils.lib import (
checkstate_int_to_enum,
checkstate_enum_to_int,
@ -11,14 +14,269 @@ from ayon_core.tools.utils.constants import (
UNCHECKED_INT,
ITEM_IS_USER_TRISTATE,
)
if typing.TYPE_CHECKING:
from ayon_core.tools.loader.abstract import FrontendLoaderController
VALUE_ITEM_TYPE = 0
STANDARD_ITEM_TYPE = 1
SEPARATOR_ITEM_TYPE = 2
VALUE_ITEM_SUBTYPE = 0
SELECT_ALL_SUBTYPE = 1
DESELECT_ALL_SUBTYPE = 2
SWAP_STATE_SUBTYPE = 3
class BaseQtModel(QtGui.QStandardItemModel):
_empty_icon = None
def __init__(
self,
item_type_role: int,
item_subtype_role: int,
empty_values_label: str,
controller: FrontendLoaderController,
):
self._item_type_role = item_type_role
self._item_subtype_role = item_subtype_role
self._empty_values_label = empty_values_label
self._controller = controller
self._last_project = None
self._select_project_item = None
self._empty_values_item = None
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
super().__init__()
self.refresh(None)
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
raise NotImplementedError(
"'_get_standard_items' is not implemented"
f" for {self.__class__}"
)
def _clear_standard_items(self):
raise NotImplementedError(
"'_clear_standard_items' is not implemented"
f" for {self.__class__}"
)
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
raise NotImplementedError(
"'_prepare_new_value_items' is not implemented"
f" for {self.__class__}"
)
def refresh(self, project_name: Optional[str]):
# New project was selected
project_changed = False
if project_name != self._last_project:
self._last_project = project_name
project_changed = True
if project_name is None:
self._add_select_project_item()
return
value_items, items_to_remove = self._prepare_new_value_items(
project_name, project_changed
)
if not value_items:
self._add_empty_values_item()
return
self._remove_empty_items()
root_item = self.invisibleRootItem()
for row_idx, value_item in enumerate(value_items):
if value_item.row() == row_idx:
continue
if value_item.row() >= 0:
root_item.takeRow(value_item.row())
root_item.insertRow(row_idx, value_item)
for item in items_to_remove:
root_item.removeRow(item.row())
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_subtype = index.data(self._item_subtype_role)
if item_subtype == SELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_subtype == DESELECT_ALL_SUBTYPE:
for item in self._get_standard_items():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_subtype == SWAP_STATE_SUBTYPE:
for item in self._get_standard_items():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def _init_default_items(self):
if self._empty_values_item is not None:
return
empty_values_item = QtGui.QStandardItem(self._empty_values_label)
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_values_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, self._item_type_role)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_values_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_SUBTYPE),
(deselect_all_item, DESELECT_ALL_SUBTYPE),
(swap_states_item, SWAP_STATE_SUBTYPE),
):
item.setData(item_type, self._item_subtype_role)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_values_item = empty_values_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_values_item(self):
self._init_default_items()
return self._empty_values_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_values_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_values_item(self):
item = self._get_empty_values_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._clear_standard_items()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
_empty_icon = None
_checked_value = checkstate_enum_to_int(QtCore.Qt.Checked)
_checked_bg_color = QtGui.QColor("#2C3B4C")
@ -38,6 +296,14 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
self._icon_role = icon_role
self._item_type_role = item_type_role
@classmethod
def _get_empty_icon(cls):
if cls._empty_icon is None:
pix = QtGui.QPixmap(1, 1)
pix.fill(QtCore.Qt.transparent)
cls._empty_icon = QtGui.QIcon(pix)
return cls._empty_icon
def paint(self, painter, option, index):
item_type = None
if self._item_type_role is not None:
@ -70,6 +336,9 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate):
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
icon = self._get_index_icon(index)
if icon is None or icon.isNull():
icon = self._get_empty_icon()
option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration
# Disable visible check indicator
@ -241,6 +510,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.Key_Home,
QtCore.Qt.Key_End,
}
_top_bottom_margins = 1
_top_bottom_padding = 2
_left_right_padding = 3
_item_bg_color = QtGui.QColor("#31424e")
def __init__(
self,
@ -433,14 +706,14 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
idxs = self._get_checked_idx()
# draw the icon and text
draw_text = True
draw_items = False
combotext = None
if self._custom_text is not None:
combotext = self._custom_text
elif not idxs:
combotext = self._placeholder_text
else:
draw_text = False
draw_items = True
content_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
@ -448,7 +721,9 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtWidgets.QStyle.SC_ComboBoxEditField
).adjusted(1, 0, -1, 0)
if draw_text:
if draw_items:
self._paint_items(painter, idxs, content_field_rect)
else:
color = option.palette.color(QtGui.QPalette.Text)
color.setAlpha(67)
pen = painter.pen()
@ -459,15 +734,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
combotext
)
else:
self._paint_items(painter, idxs, content_field_rect)
painter.end()
def _paint_items(self, painter, indexes, content_rect):
origin_rect = QtCore.QRect(content_rect)
metrics = self.fontMetrics()
model = self.model()
available_width = content_rect.width()
total_used_width = 0
@ -482,31 +754,80 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
continue
icon = index.data(self._icon_role)
# TODO handle this case
if icon is None or icon.isNull():
continue
text = index.data(self._text_role)
valid_icon = icon is not None and not icon.isNull()
if valid_icon:
sizes = icon.availableSizes()
if sizes:
valid_icon = any(size.width() > 1 for size in sizes)
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
icon_rect.setWidth(metrics.height())
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
if total_used_width > 0:
total_used_width += spacing
total_used_width += icon_rect.width()
if total_used_width > available_width:
break
if valid_icon:
metrics = self.fontMetrics()
icon_rect = QtCore.QRect(content_rect)
diff = icon_rect.height() - metrics.height()
if diff < 0:
diff = 0
top_offset = diff // 2
bottom_offset = diff - top_offset
icon_rect.adjust(0, top_offset, 0, -bottom_offset)
used_width = metrics.height()
if total_used_width > 0:
total_used_width += spacing
total_used_width += used_width
if total_used_width > available_width:
break
icon_rect.setWidth(used_width)
icon.paint(
painter,
icon_rect,
QtCore.Qt.AlignCenter,
QtGui.QIcon.Normal,
QtGui.QIcon.On
)
content_rect.setLeft(icon_rect.right() + spacing)
elif text:
bg_height = (
content_rect.height()
- (2 * self._top_bottom_margins)
)
font_height = bg_height - (2 * self._top_bottom_padding)
bg_top_y = content_rect.y() + self._top_bottom_margins
font = self.font()
font.setPixelSize(font_height)
metrics = QtGui.QFontMetrics(font)
painter.setFont(font)
label_rect = metrics.boundingRect(text)
bg_width = label_rect.width() + (2 * self._left_right_padding)
if total_used_width > 0:
total_used_width += spacing
total_used_width += bg_width
if total_used_width > available_width:
break
bg_rect = QtCore.QRectF(label_rect)
bg_rect.moveTop(bg_top_y)
bg_rect.moveLeft(content_rect.left())
bg_rect.setWidth(bg_width)
bg_rect.setHeight(bg_height)
label_rect.moveTop(bg_top_y)
label_rect.moveLeft(
content_rect.left() + self._left_right_padding
)
path = QtGui.QPainterPath()
path.addRoundedRect(bg_rect, 5, 5)
painter.fillPath(path, self._item_bg_color)
painter.drawText(label_rect, QtCore.Qt.AlignCenter, text)
content_rect.setLeft(bg_rect.right() + spacing)
painter.restore()

View file

@ -0,0 +1,170 @@
from __future__ import annotations
from qtpy import QtGui, QtCore
from ._multicombobox import (
CustomPaintMultiselectComboBox,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
SELECT_ALL_TYPE = 1
DESELECT_ALL_TYPE = 2
SWAP_STATE_TYPE = 3
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 2
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 3
class ProductTypesQtModel(BaseQtModel):
refreshed = QtCore.Signal()
def __init__(self, controller):
self._reset_filters_on_refresh = True
self._refreshing = False
self._bulk_change = False
self._items_by_name = {}
super().__init__(
item_type_role=ITEM_TYPE_ROLE,
item_subtype_role=ITEM_SUBTYPE_ROLE,
empty_values_label="No product types...",
controller=controller,
)
def is_refreshing(self):
return self._refreshing
def refresh(self, project_name):
self._refreshing = True
super().refresh(project_name)
self._reset_filters_on_refresh = False
self._refreshing = False
self.refreshed.emit()
def reset_product_types_filter_on_refresh(self):
self._reset_filters_on_refresh = True
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
def _clear_standard_items(self):
self._items_by_name.clear()
def _prepare_new_value_items(self, project_name: str, _: bool) -> tuple[
list[QtGui.QStandardItem], list[QtGui.QStandardItem]
]:
product_type_items = self._controller.get_product_type_items(
project_name)
self._last_project = project_name
names_to_remove = set(self._items_by_name.keys())
items = []
items_filter_required = {}
for product_type_item in product_type_items:
name = product_type_item.name
names_to_remove.discard(name)
item = self._items_by_name.get(name)
# Apply filter to new items or if filters reset is requested
filter_required = self._reset_filters_on_refresh
if item is None:
filter_required = True
item = QtGui.QStandardItem(name)
item.setData(name, PRODUCT_TYPE_ROLE)
item.setEditable(False)
item.setCheckable(True)
self._items_by_name[name] = item
items.append(item)
if filter_required:
items_filter_required[name] = item
if items_filter_required:
product_types_filter = self._controller.get_product_types_filter()
for product_type, item in items_filter_required.items():
matching = (
int(product_type in product_types_filter.product_types)
+ int(product_types_filter.is_allow_list)
)
item.setCheckState(
QtCore.Qt.Checked
if matching % 2 == 0
else QtCore.Qt.Unchecked
)
items_to_remove = []
for name in names_to_remove:
items_to_remove.append(
self._items_by_name.pop(name)
)
# Uncheck all if all are checked (same result)
if all(
item.checkState() == QtCore.Qt.Checked
for item in items
):
for item in items:
item.setCheckState(QtCore.Qt.Unchecked)
return items, items_to_remove
class ProductTypesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):
self._controller = controller
model = ProductTypesQtModel(controller)
super().__init__(
PRODUCT_TYPE_ROLE,
PRODUCT_TYPE_ROLE,
QtCore.Qt.ForegroundRole,
QtCore.Qt.DecorationRole,
item_type_role=ITEM_TYPE_ROLE,
model=model,
parent=parent
)
model.refreshed.connect(self._on_model_refresh)
self.set_placeholder_text("Product types filter...")
self._model = model
self._last_project_name = None
self._fully_disabled_filter = False
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
controller.register_event_callback(
"projects.refresh.finished",
self._on_projects_refresh
)
self.setToolTip("Product types filter")
self.value_changed.connect(
self._on_product_type_filter_change
)
def reset_product_types_filter_on_refresh(self):
self._model.reset_product_types_filter_on_refresh()
def _on_model_refresh(self):
self.value_changed.emit()
def _on_product_type_filter_change(self):
lines = ["Product types filter"]
for item in self.get_value_info():
status_name, enabled = item
lines.append(f"{'' if enabled else ''} {status_name}")
self.setToolTip("\n".join(lines))
def _on_project_change(self, event):
project_name = event["project_name"]
self._last_project_name = project_name
self._model.refresh(project_name)
def _on_projects_refresh(self):
if self._last_project_name:
self._model.refresh(self._last_project_name)
self._on_product_type_filter_change()

View file

@ -1,256 +0,0 @@
from qtpy import QtWidgets, QtGui, QtCore
from ayon_core.tools.utils import get_qt_icon
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
class ProductTypesQtModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
filter_changed = QtCore.Signal()
def __init__(self, controller):
super(ProductTypesQtModel, self).__init__()
self._controller = controller
self._reset_filters_on_refresh = True
self._refreshing = False
self._bulk_change = False
self._last_project = None
self._items_by_name = {}
controller.register_event_callback(
"controller.reset.finished",
self._on_controller_reset_finish,
)
def is_refreshing(self):
return self._refreshing
def get_filter_info(self):
"""Product types filtering info.
Returns:
dict[str, bool]: Filtering value by product type name. False value
means to hide product type.
"""
return {
name: item.checkState() == QtCore.Qt.Checked
for name, item in self._items_by_name.items()
}
def refresh(self, project_name):
self._refreshing = True
product_type_items = self._controller.get_product_type_items(
project_name)
self._last_project = project_name
items_to_remove = set(self._items_by_name.keys())
new_items = []
items_filter_required = {}
for product_type_item in product_type_items:
name = product_type_item.name
items_to_remove.discard(name)
item = self._items_by_name.get(name)
# Apply filter to new items or if filters reset is requested
filter_required = self._reset_filters_on_refresh
if item is None:
filter_required = True
item = QtGui.QStandardItem(name)
item.setData(name, PRODUCT_TYPE_ROLE)
item.setEditable(False)
item.setCheckable(True)
new_items.append(item)
self._items_by_name[name] = item
if filter_required:
items_filter_required[name] = item
icon = get_qt_icon(product_type_item.icon)
item.setData(icon, QtCore.Qt.DecorationRole)
if items_filter_required:
product_types_filter = self._controller.get_product_types_filter()
for product_type, item in items_filter_required.items():
matching = (
int(product_type in product_types_filter.product_types)
+ int(product_types_filter.is_allow_list)
)
state = (
QtCore.Qt.Checked
if matching % 2 == 0
else QtCore.Qt.Unchecked
)
item.setCheckState(state)
root_item = self.invisibleRootItem()
if new_items:
root_item.appendRows(new_items)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
self._reset_filters_on_refresh = False
self._refreshing = False
self.refreshed.emit()
def reset_product_types_filter_on_refresh(self):
self._reset_filters_on_refresh = True
def setData(self, index, value, role=None):
checkstate_changed = False
if role is None:
role = QtCore.Qt.EditRole
elif role == QtCore.Qt.CheckStateRole:
checkstate_changed = True
output = super(ProductTypesQtModel, self).setData(index, value, role)
if checkstate_changed and not self._bulk_change:
self.filter_changed.emit()
return output
def change_state_for_all(self, checked):
if self._items_by_name:
self.change_states(checked, self._items_by_name.keys())
def change_states(self, checked, product_types):
product_types = set(product_types)
if not product_types:
return
if checked is None:
state = None
elif checked:
state = QtCore.Qt.Checked
else:
state = QtCore.Qt.Unchecked
self._bulk_change = True
changed = False
for product_type in product_types:
item = self._items_by_name.get(product_type)
if item is None:
continue
new_state = state
item_checkstate = item.checkState()
if new_state is None:
if item_checkstate == QtCore.Qt.Checked:
new_state = QtCore.Qt.Unchecked
else:
new_state = QtCore.Qt.Checked
elif item_checkstate == new_state:
continue
changed = True
item.setCheckState(new_state)
self._bulk_change = False
if changed:
self.filter_changed.emit()
def _on_controller_reset_finish(self):
self.refresh(self._last_project)
class ProductTypesView(QtWidgets.QListView):
filter_changed = QtCore.Signal()
def __init__(self, controller, parent):
super(ProductTypesView, self).__init__(parent)
self.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
self.setAlternatingRowColors(True)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
product_types_model = ProductTypesQtModel(controller)
product_types_proxy_model = QtCore.QSortFilterProxyModel()
product_types_proxy_model.setSourceModel(product_types_model)
self.setModel(product_types_proxy_model)
product_types_model.refreshed.connect(self._on_refresh_finished)
product_types_model.filter_changed.connect(self._on_filter_change)
self.customContextMenuRequested.connect(self._on_context_menu)
controller.register_event_callback(
"selection.project.changed",
self._on_project_change
)
self._controller = controller
self._refresh_product_types_filter = False
self._product_types_model = product_types_model
self._product_types_proxy_model = product_types_proxy_model
def get_filter_info(self):
return self._product_types_model.get_filter_info()
def reset_product_types_filter_on_refresh(self):
self._product_types_model.reset_product_types_filter_on_refresh()
def _on_project_change(self, event):
project_name = event["project_name"]
self._product_types_model.refresh(project_name)
def _on_refresh_finished(self):
# Apply product types filter on first show
self.filter_changed.emit()
def _on_filter_change(self):
if not self._product_types_model.is_refreshing():
self.filter_changed.emit()
def _change_selection_state(self, checkstate):
selection_model = self.selectionModel()
product_types = {
index.data(PRODUCT_TYPE_ROLE)
for index in selection_model.selectedIndexes()
}
product_types.discard(None)
self._product_types_model.change_states(checkstate, product_types)
def _on_enable_all(self):
self._product_types_model.change_state_for_all(True)
def _on_disable_all(self):
self._product_types_model.change_state_for_all(False)
def _on_context_menu(self, pos):
menu = QtWidgets.QMenu(self)
# Add enable all action
action_check_all = QtWidgets.QAction(menu)
action_check_all.setText("Enable All")
action_check_all.triggered.connect(self._on_enable_all)
# Add disable all action
action_uncheck_all = QtWidgets.QAction(menu)
action_uncheck_all.setText("Disable All")
action_uncheck_all.triggered.connect(self._on_disable_all)
menu.addAction(action_check_all)
menu.addAction(action_uncheck_all)
# Get mouse position
global_pos = self.viewport().mapToGlobal(pos)
menu.exec_(global_pos)
def event(self, event):
if event.type() == QtCore.QEvent.KeyPress:
if event.key() == QtCore.Qt.Key_Space:
self._change_selection_state(None)
return True
if event.key() == QtCore.Qt.Key_Backspace:
self._change_selection_state(False)
return True
if event.key() == QtCore.Qt.Key_Return:
self._change_selection_state(True)
return True
return super(ProductTypesView, self).event(event)

View file

@ -19,6 +19,7 @@ from .products_model import (
)
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1
TASK_ID_ROLE = QtCore.Qt.UserRole + 2
class VersionsModel(QtGui.QStandardItemModel):
@ -48,6 +49,7 @@ class VersionsModel(QtGui.QStandardItemModel):
item.setData(version_id, QtCore.Qt.UserRole)
self._items_by_id[version_id] = item
item.setData(version_item.status, STATUS_NAME_ROLE)
item.setData(version_item.task_id, TASK_ID_ROLE)
if item.row() != idx:
root_item.insertRow(idx, item)
@ -57,17 +59,30 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel):
def __init__(self):
super().__init__()
self._status_filter = None
self._task_ids_filter = None
def filterAcceptsRow(self, row, parent):
if self._status_filter is None:
return True
if self._status_filter is not None:
if not self._status_filter:
return False
if not self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
if status not in self._status_filter:
return False
index = self.sourceModel().index(row, 0, parent)
status = index.data(STATUS_NAME_ROLE)
return status in self._status_filter
if self._task_ids_filter:
index = self.sourceModel().index(row, 0, parent)
task_id = index.data(TASK_ID_ROLE)
if task_id not in self._task_ids_filter:
return False
return True
def set_tasks_filter(self, task_ids):
if self._task_ids_filter == task_ids:
return
self._task_ids_filter = task_ids
self.invalidateFilter()
def set_statuses_filter(self, status_names):
if self._status_filter == status_names:
@ -101,6 +116,13 @@ class VersionComboBox(QtWidgets.QComboBox):
def get_product_id(self):
return self._product_id
def set_tasks_filter(self, task_ids):
self._proxy_model.set_tasks_filter(task_ids)
if self.count() == 0:
return
if self.currentIndex() != 0:
self.setCurrentIndex(0)
def set_statuses_filter(self, status_names):
self._proxy_model.set_statuses_filter(status_names)
if self.count() == 0:
@ -149,6 +171,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
super().__init__(*args, **kwargs)
self._editor_by_id: Dict[str, VersionComboBox] = {}
self._task_ids_filter = None
self._statuses_filter = None
def displayText(self, value, locale):
@ -156,6 +179,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
return "N/A"
return format_version(value)
def set_tasks_filter(self, task_ids):
self._task_ids_filter = set(task_ids)
for widget in self._editor_by_id.values():
widget.set_tasks_filter(task_ids)
def set_statuses_filter(self, status_names):
self._statuses_filter = set(status_names)
for widget in self._editor_by_id.values():
@ -239,6 +267,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
version_id = index.data(VERSION_ID_ROLE)
editor.update_versions(versions, version_id)
editor.set_tasks_filter(self._task_ids_filter)
editor.set_statuses_filter(self._statuses_filter)
def setModelData(self, editor, model, index):

View file

@ -12,34 +12,35 @@ GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1
MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3
FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9
VERSION_ID_ROLE = QtCore.Qt.UserRole + 10
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
TASK_ID_ROLE = QtCore.Qt.UserRole + 5
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 6
PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 7
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 8
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 9
PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 10
VERSION_ID_ROLE = QtCore.Qt.UserRole + 11
VERSION_HERO_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 13
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 14
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 15
VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 16
VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 17
VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 18
VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 19
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 20
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 21
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 22
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 23
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 24
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 25
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 26
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 28
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 29
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 31
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31
STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 32
class ProductsModel(QtGui.QStandardItemModel):
@ -368,6 +369,7 @@ class ProductsModel(QtGui.QStandardItemModel):
"""
model_item.setData(version_item.version_id, VERSION_ID_ROLE)
model_item.setData(version_item.task_id, TASK_ID_ROLE)
model_item.setData(version_item.version, VERSION_NAME_ROLE)
model_item.setData(version_item.is_hero, VERSION_HERO_ROLE)
model_item.setData(

View file

@ -1,4 +1,6 @@
from __future__ import annotations
import collections
from typing import Optional
from qtpy import QtWidgets, QtCore
@ -15,6 +17,7 @@ from .products_model import (
GROUP_TYPE_ROLE,
MERGED_COLOR_ROLE,
FOLDER_ID_ROLE,
TASK_ID_ROLE,
PRODUCT_ID_ROLE,
VERSION_ID_ROLE,
VERSION_STATUS_NAME_ROLE,
@ -36,8 +39,9 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self._product_type_filters = {}
self._product_type_filters = None
self._statuses_filter = None
self._task_ids_filter = None
self._ascending_sort = True
def get_statuses_filter(self):
@ -45,7 +49,15 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
return None
return set(self._statuses_filter)
def set_tasks_filter(self, task_ids_filter):
if self._task_ids_filter == task_ids_filter:
return
self._task_ids_filter = task_ids_filter
self.invalidateFilter()
def set_product_type_filters(self, product_type_filters):
if self._product_type_filters == product_type_filters:
return
self._product_type_filters = product_type_filters
self.invalidateFilter()
@ -58,29 +70,41 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel):
def filterAcceptsRow(self, source_row, source_parent):
source_model = self.sourceModel()
index = source_model.index(source_row, 0, source_parent)
product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE)
product_types = []
if product_types_s:
product_types = product_types_s.split("|")
for product_type in product_types:
if not self._product_type_filters.get(product_type, True):
return False
if not self._accept_row_by_statuses(index):
if not self._accept_task_ids_filter(index):
return False
if not self._accept_row_by_role_value(
index, self._product_type_filters, PRODUCT_TYPE_ROLE
):
return False
if not self._accept_row_by_role_value(
index, self._statuses_filter, STATUS_NAME_FILTER_ROLE
):
return False
return super().filterAcceptsRow(source_row, source_parent)
def _accept_row_by_statuses(self, index):
if self._statuses_filter is None:
def _accept_task_ids_filter(self, index):
if not self._task_ids_filter:
return True
if not self._statuses_filter:
task_id = index.data(TASK_ID_ROLE)
return task_id in self._task_ids_filter
def _accept_row_by_role_value(
self,
index: QtCore.QModelIndex,
filter_value: Optional[set[str]],
role: int
):
if filter_value is None:
return True
if not filter_value:
return False
status_s = index.data(STATUS_NAME_FILTER_ROLE)
status_s = index.data(role)
for status in status_s.split("|"):
if status in self._statuses_filter:
if status in filter_value:
return True
return False
@ -120,7 +144,7 @@ class ProductsWidget(QtWidgets.QWidget):
90, # Product type
130, # Folder label
60, # Version
100, # Status
100, # Status
125, # Time
75, # Author
75, # Frames
@ -246,6 +270,16 @@ class ProductsWidget(QtWidgets.QWidget):
"""
self._products_proxy_model.setFilterFixedString(name)
def set_tasks_filter(self, task_ids):
"""Set filter of version tasks.
Args:
task_ids (set[str]): Task ids.
"""
self._version_delegate.set_tasks_filter(task_ids)
self._products_proxy_model.set_tasks_filter(task_ids)
def set_statuses_filter(self, status_names):
"""Set filter of version statuses.

View file

@ -1,4 +1,4 @@
from typing import List, Dict
from __future__ import annotations
from qtpy import QtCore, QtGui
@ -7,7 +7,7 @@ from ayon_core.tools.common_models import StatusItem
from ._multicombobox import (
CustomPaintMultiselectComboBox,
STANDARD_ITEM_TYPE,
BaseQtModel,
)
STATUS_ITEM_TYPE = 0
@ -24,62 +24,43 @@ ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5
ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6
class StatusesQtModel(QtGui.QStandardItemModel):
class StatusesQtModel(BaseQtModel):
def __init__(self, controller):
self._controller = controller
self._items_by_name: Dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {}
self._last_project = None
self._items_by_name: dict[str, QtGui.QStandardItem] = {}
self._icons_by_name_n_color: dict[str, QtGui.QIcon] = {}
super().__init__(
ITEM_TYPE_ROLE,
ITEM_SUBTYPE_ROLE,
"No statuses...",
controller,
)
self._select_project_item = None
self._empty_statuses_item = None
def _get_standard_items(self) -> list[QtGui.QStandardItem]:
return list(self._items_by_name.values())
self._select_all_item = None
self._deselect_all_item = None
self._swap_states_item = None
def _clear_standard_items(self):
self._items_by_name.clear()
super().__init__()
self.refresh(None)
def get_placeholder_text(self):
return self._placeholder
def refresh(self, project_name):
# New project was selected
# status filter is reset to show all statuses
uncheck_all = False
if project_name != self._last_project:
self._last_project = project_name
uncheck_all = True
if project_name is None:
self._add_select_project_item()
return
status_items: List[StatusItem] = (
def _prepare_new_value_items(
self, project_name: str, project_changed: bool
):
status_items: list[StatusItem] = (
self._controller.get_project_status_items(
project_name, sender=STATUSES_FILTER_SENDER
)
)
items = []
items_to_remove = []
if not status_items:
self._add_empty_statuses_item()
return
return items, items_to_remove
self._remove_empty_items()
items_to_remove = set(self._items_by_name)
root_item = self.invisibleRootItem()
names_to_remove = set(self._items_by_name)
for row_idx, status_item in enumerate(status_items):
name = status_item.name
if name in self._items_by_name:
is_new = False
item = self._items_by_name[name]
if uncheck_all:
item.setCheckState(QtCore.Qt.Unchecked)
items_to_remove.discard(name)
names_to_remove.discard(name)
else:
is_new = True
item = QtGui.QStandardItem()
item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE)
item.setCheckState(QtCore.Qt.Unchecked)
@ -100,36 +81,14 @@ class StatusesQtModel(QtGui.QStandardItemModel):
if item.data(role) != value:
item.setData(value, role)
if is_new:
root_item.insertRow(row_idx, item)
if project_changed:
item.setCheckState(QtCore.Qt.Unchecked)
items.append(item)
for name in items_to_remove:
item = self._items_by_name.pop(name)
root_item.removeRow(item.row())
for name in names_to_remove:
items_to_remove.append(self._items_by_name.pop(name))
self._add_selection_items()
def setData(self, index, value, role):
if role == QtCore.Qt.CheckStateRole and index.isValid():
item_type = index.data(ITEM_SUBTYPE_ROLE)
if item_type == SELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Checked)
return True
if item_type == DESELECT_ALL_TYPE:
for item in self._items_by_name.values():
item.setCheckState(QtCore.Qt.Unchecked)
return True
if item_type == SWAP_STATE_TYPE:
for item in self._items_by_name.values():
current_state = item.checkState()
item.setCheckState(
QtCore.Qt.Checked
if current_state == QtCore.Qt.Unchecked
else QtCore.Qt.Unchecked
)
return True
return super().setData(index, value, role)
return items, items_to_remove
def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon:
name = status_item.name
@ -147,139 +106,6 @@ class StatusesQtModel(QtGui.QStandardItemModel):
self._icons_by_name_n_color[unique_id] = icon
return icon
def _init_default_items(self):
if self._empty_statuses_item is not None:
return
empty_statuses_item = QtGui.QStandardItem("No statuses...")
select_project_item = QtGui.QStandardItem("Select project...")
select_all_item = QtGui.QStandardItem("Select all")
deselect_all_item = QtGui.QStandardItem("Deselect all")
swap_states_item = QtGui.QStandardItem("Swap")
for item in (
empty_statuses_item,
select_project_item,
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE)
select_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "done_all",
"color": "white"
}))
deselect_all_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "remove_done",
"color": "white"
}))
swap_states_item.setIcon(get_qt_icon({
"type": "material-symbols",
"name": "swap_horiz",
"color": "white"
}))
for item in (
empty_statuses_item,
select_project_item,
):
item.setFlags(QtCore.Qt.NoItemFlags)
for item, item_type in (
(select_all_item, SELECT_ALL_TYPE),
(deselect_all_item, DESELECT_ALL_TYPE),
(swap_states_item, SWAP_STATE_TYPE),
):
item.setData(item_type, ITEM_SUBTYPE_ROLE)
for item in (
select_all_item,
deselect_all_item,
swap_states_item,
):
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
self._empty_statuses_item = empty_statuses_item
self._select_project_item = select_project_item
self._select_all_item = select_all_item
self._deselect_all_item = deselect_all_item
self._swap_states_item = swap_states_item
def _get_empty_statuses_item(self):
self._init_default_items()
return self._empty_statuses_item
def _get_select_project_item(self):
self._init_default_items()
return self._select_project_item
def _get_empty_items(self):
self._init_default_items()
return [
self._empty_statuses_item,
self._select_project_item,
]
def _get_selection_items(self):
self._init_default_items()
return [
self._select_all_item,
self._deselect_all_item,
self._swap_states_item,
]
def _get_default_items(self):
return self._get_empty_items() + self._get_selection_items()
def _add_select_project_item(self):
item = self._get_select_project_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_empty_statuses_item(self):
item = self._get_empty_statuses_item()
if item.row() < 0:
self._remove_items()
root_item = self.invisibleRootItem()
root_item.appendRow(item)
def _add_selection_items(self):
root_item = self.invisibleRootItem()
items = self._get_selection_items()
for item in self._get_selection_items():
row = item.row()
if row >= 0:
root_item.takeRow(row)
root_item.appendRows(items)
def _remove_items(self):
root_item = self.invisibleRootItem()
for item in self._get_default_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
root_item.removeRows(0, root_item.rowCount())
self._items_by_name.clear()
def _remove_empty_items(self):
root_item = self.invisibleRootItem()
for item in self._get_empty_items():
if item.row() < 0:
continue
root_item.takeRow(item.row())
class StatusesCombobox(CustomPaintMultiselectComboBox):
def __init__(self, controller, parent):

View file

@ -0,0 +1,405 @@
import collections
import hashlib
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,
)
from ayon_core.tools.utils.tasks_widget import (
ITEM_ID_ROLE,
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
)
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--"
class LoaderTasksQtModel(TasksQtModel):
column_labels = [
"Task name",
"Task type",
"Folder"
]
def __init__(self, controller):
super().__init__(controller)
self._items_by_id = {}
self._groups_by_name = {}
self._last_folder_ids = set()
# This item is used to be able filter versions without any task
# - do not mismatch with '_empty_tasks_item' item from 'TasksQtModel'
self._no_tasks_item = None
def refresh(self):
"""Refresh tasks for selected folders."""
self._refresh(self._last_project_name, self._last_folder_ids)
def set_context(self, project_name, folder_ids):
self._refresh(project_name, folder_ids)
# Mark some functions from 'TasksQtModel' as not implemented
def get_index_by_name(self, task_name):
raise NotImplementedError(
"Method 'get_index_by_name' is not implemented."
)
def get_last_folder_id(self):
raise NotImplementedError(
"Method 'get_last_folder_id' is not implemented."
)
def flags(self, index):
if index.column() != 0:
index = self.index(index.row(), 0, index.parent())
return super().flags(index)
def _get_no_tasks_item(self):
if self._no_tasks_item is None:
item = QtGui.QStandardItem("No task")
icon = get_qt_icon({
"type": "material-symbols",
"name": "indeterminate_check_box",
"color": get_default_entity_icon_color(),
})
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(NO_TASKS_ID, ITEM_ID_ROLE)
item.setEditable(False)
self._no_tasks_item = item
return self._no_tasks_item
def _refresh(self, project_name, folder_ids):
self._is_refreshing = True
self._last_project_name = project_name
self._last_folder_ids = folder_ids
if not folder_ids:
self._add_invalid_selection_item()
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
return
thread_id = hashlib.sha256(
"|".join(sorted(folder_ids)).encode()
).hexdigest()
thread = self._refresh_threads.get(thread_id)
if thread is not None:
self._current_refresh_thread = thread
return
thread = RefreshThread(
thread_id,
self._thread_getter,
project_name,
folder_ids
)
self._current_refresh_thread = thread
self._refresh_threads[thread.id] = thread
thread.refresh_finished.connect(self._on_refresh_thread)
thread.start()
def _thread_getter(self, project_name, folder_ids):
task_items = self._controller.get_task_items(
project_name, folder_ids, sender=TASKS_MODEL_SENDER_NAME
)
task_type_items = {}
if hasattr(self._controller, "get_task_type_items"):
task_type_items = self._controller.get_task_type_items(
project_name, sender=TASKS_MODEL_SENDER_NAME
)
folder_ids = {
task_item.parent_id
for task_item in task_items
}
folder_labels_by_id = self._controller.get_folder_labels(
project_name, folder_ids
)
return task_items, task_type_items, folder_labels_by_id
def _on_refresh_thread(self, thread_id):
"""Callback when refresh thread is finished.
Technically can be running multiple refresh threads at the same time,
to avoid using values from wrong thread, we check if thread id is
current refresh thread id.
Tasks are stored by name, so if a folder has same task name as
previously selected folder it keeps the selection.
Args:
thread_id (str): Thread id.
"""
# Make sure to remove thread from '_refresh_threads' dict
thread = self._refresh_threads.pop(thread_id)
if (
self._current_refresh_thread is None
or thread_id != self._current_refresh_thread.id
):
return
self._fill_data_from_thread(thread)
root_item = self.invisibleRootItem()
self._has_content = root_item.rowCount() > 0
self._current_refresh_thread = None
self._is_refreshing = False
self.refreshed.emit()
def _clear_items(self):
self._items_by_id = {}
self._groups_by_name = {}
super()._clear_items()
def _fill_data_from_thread(self, thread):
task_items, task_type_items, folder_labels_by_id = thread.get_result()
# Task items are refreshed
if task_items is None:
return
# No tasks are available on folder
if not task_items:
self._add_empty_task_item()
return
self._remove_invalid_items()
task_type_item_by_name = {
task_type_item.name: task_type_item
for task_type_item in task_type_items
}
task_type_icon_cache = {}
current_ids = set()
items_by_name = collections.defaultdict(list)
for task_item in task_items:
task_id = task_item.task_id
current_ids.add(task_id)
item = self._items_by_id.get(task_id)
if item is None:
item = QtGui.QStandardItem()
item.setColumnCount(self.columnCount())
item.setEditable(False)
self._items_by_id[task_id] = item
icon = self._get_task_item_icon(
task_item,
task_type_item_by_name,
task_type_icon_cache
)
name = task_item.name
folder_id = task_item.parent_id
folder_label = folder_labels_by_id.get(folder_id)
item.setData(name, QtCore.Qt.DisplayRole)
item.setData(name, ITEM_NAME_ROLE)
item.setData(task_item.id, ITEM_ID_ROLE)
item.setData(task_item.task_type, TASK_TYPE_ROLE)
item.setData(folder_id, PARENT_ID_ROLE)
item.setData(folder_label, FOLDER_LABEL_ROLE)
item.setData(icon, QtCore.Qt.DecorationRole)
items_by_name[name].append(item)
root_item = self.invisibleRootItem()
# Make sure item is not parented
# - this is laziness to avoid re-parenting items which does
# complicate the code with no benefit
queue = collections.deque()
queue.append((None, root_item))
while queue:
(parent, item) = queue.popleft()
if not item.hasChildren():
if parent:
parent.takeRow(item.row())
continue
for row in range(item.rowCount()):
queue.append((item, item.child(row, 0)))
queue.append((parent, item))
used_group_names = set()
new_root_items = [
self._get_no_tasks_item()
]
for name, items in items_by_name.items():
if len(items) == 1:
new_root_items.extend(items)
continue
used_group_names.add(name)
group_item = self._groups_by_name.get(name)
if group_item is None:
group_item = QtGui.QStandardItem()
group_item.setData(name, QtCore.Qt.DisplayRole)
group_item.setEditable(False)
group_item.setColumnCount(self.columnCount())
self._groups_by_name[name] = group_item
# Use icon from first item
first_item_icon = items[0].data(QtCore.Qt.DecorationRole)
task_ids = [
item.data(ITEM_ID_ROLE)
for item in items
]
group_item.setData(first_item_icon, QtCore.Qt.DecorationRole)
group_item.setData("|".join(task_ids), ITEM_ID_ROLE)
group_item.appendRows(items)
new_root_items.append(group_item)
# Remove unused caches
for task_id in set(self._items_by_id) - current_ids:
self._items_by_id.pop(task_id)
for name in set(self._groups_by_name) - used_group_names:
self._groups_by_name.pop(name)
if new_root_items:
root_item.appendRows(new_root_items)
def data(self, index, role=None):
if not index.isValid():
return None
if role is None:
role = QtCore.Qt.DisplayRole
col = index.column()
if col != 0:
index = self.index(index.row(), 0, index.parent())
if col == 1:
if role == QtCore.Qt.DisplayRole:
role = TASK_TYPE_ROLE
else:
return None
if col == 2:
if role == QtCore.Qt.DisplayRole:
role = FOLDER_LABEL_ROLE
else:
return None
return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
if right.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return True
return super().lessThan(left, right)
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
def __init__(self, controller, parent):
super().__init__(parent)
tasks_view = DeselectableTreeView(self)
tasks_view.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
tasks_model = LoaderTasksQtModel(controller)
tasks_proxy_model = LoaderTasksProxyModel()
tasks_proxy_model.setSourceModel(tasks_model)
tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
tasks_view.setModel(tasks_proxy_model)
# Hide folder column by default
tasks_view.setColumnHidden(2, True)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(tasks_view, 1)
controller.register_event_callback(
"selection.folders.changed",
self._on_folders_selection_changed,
)
controller.register_event_callback(
"tasks.refresh.finished",
self._on_tasks_refresh_finished
)
selection_model = tasks_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
tasks_model.refreshed.connect(self._on_model_refresh)
self._controller = controller
self._tasks_view = tasks_view
self._tasks_model = tasks_model
self._tasks_proxy_model = tasks_proxy_model
self._fisrt_show = True
def showEvent(self, event):
super().showEvent(event)
if self._fisrt_show:
self._fisrt_show = False
header_widget = self._tasks_view.header()
header_widget.resizeSection(0, 200)
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
name (str): The string filter.
"""
self._tasks_proxy_model.setFilterFixedString(name)
if name:
self._tasks_view.expandAll()
def refresh(self):
self._tasks_model.refresh()
def _clear(self):
self._tasks_model.clear()
def _on_tasks_refresh_finished(self, event):
if event["sender"] != TASKS_MODEL_SENDER_NAME:
self._set_project_name(event["project_name"])
def _on_folders_selection_changed(self, event):
project_name = event["project_name"]
folder_ids = event["folder_ids"]
self._tasks_view.setColumnHidden(2, len(folder_ids) == 1)
self._tasks_model.set_context(project_name, folder_ids)
def _on_model_refresh(self):
self._tasks_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_ids(self):
selection_model = self._tasks_view.selectionModel()
item_ids = set()
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
if item_id is None:
continue
if item_id == NO_TASKS_ID:
item_ids.add(None)
else:
item_ids |= set(item_id.split("|"))
return item_ids
def _on_selection_change(self):
item_ids = self._get_selected_item_ids()
self._controller.set_selected_tasks(item_ids)

View file

@ -14,8 +14,9 @@ from ayon_core.tools.utils import ProjectsCombobox
from ayon_core.tools.loader.control import LoaderController
from .folders_widget import LoaderFoldersWidget
from .tasks_widget import LoaderTasksWidget
from .products_widget import ProductsWidget
from .product_types_widget import ProductTypesView
from .product_types_combo import ProductTypesCombobox
from .product_group_dialog import ProductGroupDialog
from .info_widget import InfoWidget
from .repres_widget import RepresentationsWidget
@ -164,16 +165,16 @@ class LoaderWindow(QtWidgets.QWidget):
folders_widget = LoaderFoldersWidget(controller, context_widget)
product_types_widget = ProductTypesView(controller, context_splitter)
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(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget)
context_splitter.addWidget(context_widget)
context_splitter.addWidget(product_types_widget)
context_splitter.addWidget(tasks_widget)
context_splitter.setStretchFactor(0, 65)
context_splitter.setStretchFactor(1, 35)
@ -185,6 +186,10 @@ class LoaderWindow(QtWidgets.QWidget):
products_filter_input = PlaceholderLineEdit(products_inputs_widget)
products_filter_input.setPlaceholderText("Product name filter...")
product_types_filter_combo = ProductTypesCombobox(
controller, products_inputs_widget
)
product_status_filter_combo = StatusesCombobox(controller, self)
product_group_checkbox = QtWidgets.QCheckBox(
@ -196,6 +201,7 @@ class LoaderWindow(QtWidgets.QWidget):
products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget)
products_inputs_layout.setContentsMargins(0, 0, 0, 0)
products_inputs_layout.addWidget(products_filter_input, 1)
products_inputs_layout.addWidget(product_types_filter_combo, 1)
products_inputs_layout.addWidget(product_status_filter_combo, 1)
products_inputs_layout.addWidget(product_group_checkbox, 0)
@ -244,12 +250,12 @@ class LoaderWindow(QtWidgets.QWidget):
folders_filter_input.textChanged.connect(
self._on_folder_filter_change
)
product_types_widget.filter_changed.connect(
self._on_product_type_filter_change
)
products_filter_input.textChanged.connect(
self._on_product_filter_change
)
product_types_filter_combo.value_changed.connect(
self._on_product_type_filter_change
)
product_status_filter_combo.value_changed.connect(
self._on_status_filter_change
)
@ -280,6 +286,10 @@ class LoaderWindow(QtWidgets.QWidget):
"selection.folders.changed",
self._on_folders_selection_changed,
)
controller.register_event_callback(
"selection.tasks.changed",
self._on_tasks_selection_change,
)
controller.register_event_callback(
"selection.versions.changed",
self._on_versions_selection_changed,
@ -304,9 +314,10 @@ class LoaderWindow(QtWidgets.QWidget):
self._folders_filter_input = folders_filter_input
self._folders_widget = folders_widget
self._product_types_widget = product_types_widget
self._tasks_widget = tasks_widget
self._products_filter_input = products_filter_input
self._product_types_filter_combo = product_types_filter_combo
self._product_status_filter_combo = product_status_filter_combo
self._product_group_checkbox = product_group_checkbox
self._products_widget = products_widget
@ -335,7 +346,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._controller.reset()
def showEvent(self, event):
super(LoaderWindow, self).showEvent(event)
super().showEvent(event)
if self._first_show:
self._on_first_show()
@ -343,9 +354,13 @@ class LoaderWindow(QtWidgets.QWidget):
self._show_timer.start()
def closeEvent(self, event):
super(LoaderWindow, self).closeEvent(event)
super().closeEvent(event)
self._product_types_widget.reset_product_types_filter_on_refresh()
(
self
._product_types_filter_combo
.reset_product_types_filter_on_refresh()
)
self._reset_on_show = True
@ -363,7 +378,7 @@ class LoaderWindow(QtWidgets.QWidget):
event.setAccepted(True)
return
super(LoaderWindow, self).keyPressEvent(event)
super().keyPressEvent(event)
def _on_first_show(self):
self._first_show = False
@ -423,14 +438,16 @@ class LoaderWindow(QtWidgets.QWidget):
def _on_product_filter_change(self, text):
self._products_widget.set_name_filter(text)
def _on_tasks_selection_change(self, event):
self._products_widget.set_tasks_filter(event["task_ids"])
def _on_status_filter_change(self):
status_names = self._product_status_filter_combo.get_value()
self._products_widget.set_statuses_filter(status_names)
def _on_product_type_filter_change(self):
self._products_widget.set_product_type_filter(
self._product_types_widget.get_filter_info()
)
product_types = self._product_types_filter_combo.get_value()
self._products_widget.set_product_type_filter(product_types)
def _on_merged_products_selection_change(self):
items = self._products_widget.get_selected_merged_products()
@ -484,38 +501,29 @@ class LoaderWindow(QtWidgets.QWidget):
self._update_thumbnails()
def _update_thumbnails(self):
# TODO make this threaded and show loading animation while running
project_name = self._selected_project_name
thumbnail_ids = set()
entity_type = None
entity_ids = set()
if self._selected_version_ids:
thumbnail_id_by_entity_id = (
self._controller.get_version_thumbnail_ids(
project_name,
self._selected_version_ids
)
)
thumbnail_ids = set(thumbnail_id_by_entity_id.values())
entity_ids = set(self._selected_version_ids)
entity_type = "version"
elif self._selected_folder_ids:
thumbnail_id_by_entity_id = (
self._controller.get_folder_thumbnail_ids(
project_name,
self._selected_folder_ids
)
)
thumbnail_ids = set(thumbnail_id_by_entity_id.values())
entity_ids = set(self._selected_folder_ids)
entity_type = "folder"
thumbnail_ids.discard(None)
if not thumbnail_ids:
self._thumbnails_widget.set_current_thumbnails(None)
return
thumbnail_paths = set()
for thumbnail_id in thumbnail_ids:
thumbnail_path = self._controller.get_thumbnail_path(
project_name, thumbnail_id)
thumbnail_paths.add(thumbnail_path)
thumbnail_path_by_entity_id = self._controller.get_thumbnail_paths(
project_name, entity_type, entity_ids
)
thumbnail_paths = set(thumbnail_path_by_entity_id.values())
thumbnail_paths.discard(None)
self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths)
if thumbnail_paths:
self._thumbnails_widget.set_current_thumbnail_paths(
thumbnail_paths
)
else:
self._thumbnails_widget.set_current_thumbnails(None)
def _on_projects_refresh(self):
self._refresh_handler.set_project_refreshed()

View file

@ -461,19 +461,19 @@ class CreateModel:
self._create_context.add_instances_added_callback(
self._cc_added_instance
)
self._create_context.add_instances_removed_callback (
self._create_context.add_instances_removed_callback(
self._cc_removed_instance
)
self._create_context.add_value_changed_callback(
self._cc_value_changed
)
self._create_context.add_pre_create_attr_defs_change_callback (
self._create_context.add_pre_create_attr_defs_change_callback(
self._cc_pre_create_attr_changed
)
self._create_context.add_create_attr_defs_change_callback (
self._create_context.add_create_attr_defs_change_callback(
self._cc_create_attr_changed
)
self._create_context.add_publish_attr_defs_change_callback (
self._create_context.add_publish_attr_defs_change_callback(
self._cc_publish_attr_changed
)

View file

@ -358,7 +358,7 @@ class PublishReportMaker:
exception = result.get("error")
if exception:
fname, line_no, func, exc = exception.traceback
fname, line_no, func, _ = exception.traceback
# Conversion of exception into string may crash
try:

View file

@ -85,6 +85,8 @@ class AttributesWidget(QtWidgets.QWidget):
layout.setContentsMargins(0, 0, 0, 0)
layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING)
layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING)
layout.setColumnStretch(0, 0)
layout.setColumnStretch(1, 1)
self._layout = layout

View file

@ -1117,6 +1117,57 @@ class LogIconFrame(QtWidgets.QFrame):
painter.end()
class LogItemMessage(QtWidgets.QTextEdit):
def __init__(self, msg, parent):
super().__init__(parent)
# Set as plain text to propagate new line characters
self.setPlainText(msg)
self.setObjectName("PublishLogMessage")
self.setReadOnly(True)
self.setFrameStyle(QtWidgets.QFrame.NoFrame)
self.setLineWidth(0)
self.setMidLineWidth(0)
pal = self.palette()
pal.setColor(QtGui.QPalette.Base, QtCore.Qt.transparent)
self.setPalette(pal)
self.setContentsMargins(0, 0, 0, 0)
viewport = self.viewport()
viewport.setContentsMargins(0, 0, 0, 0)
self.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction)
self.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
self.setLineWrapMode(QtWidgets.QTextEdit.WidgetWidth)
self.setWordWrapMode(
QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere
)
self.setSizePolicy(
QtWidgets.QSizePolicy.Preferred,
QtWidgets.QSizePolicy.Maximum
)
document = self.document()
document.documentLayout().documentSizeChanged.connect(
self._adjust_minimum_size
)
document.setDocumentMargin(0.0)
self._height = None
def _adjust_minimum_size(self, size):
self._height = size.height() + (2 * self.frameWidth())
self.updateGeometry()
def sizeHint(self):
size = super().sizeHint()
if self._height is not None:
size.setHeight(self._height)
return size
def minimumSizeHint(self):
return self.sizeHint()
class LogItemWidget(QtWidgets.QWidget):
log_level_to_flag = {
10: LOG_DEBUG_VISIBLE,
@ -1132,12 +1183,7 @@ class LogItemWidget(QtWidgets.QWidget):
type_flag, level_n = self._get_log_info(log)
icon_label = LogIconFrame(
self, log["type"], level_n, log.get("is_validation_error"))
message_label = QtWidgets.QLabel(log["msg"].rstrip(), self)
message_label.setObjectName("PublishLogMessage")
message_label.setTextInteractionFlags(
QtCore.Qt.TextBrowserInteraction)
message_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
message_label.setWordWrap(True)
message_label = LogItemMessage(log["msg"].rstrip(), self)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
@ -1290,6 +1336,7 @@ class InstanceLogsWidget(QtWidgets.QWidget):
label_widget = QtWidgets.QLabel(instance.label, self)
label_widget.setObjectName("PublishInstanceLogsLabel")
label_widget.setWordWrap(True)
logs_grid = LogsWithIconsView(instance.logs, self)
layout = QtWidgets.QVBoxLayout(self)
@ -1329,9 +1376,11 @@ class InstancesLogsView(QtWidgets.QFrame):
content_wrap_widget = QtWidgets.QWidget(scroll_area)
content_wrap_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_wrap_widget.setMinimumWidth(80)
content_widget = QtWidgets.QWidget(content_wrap_widget)
content_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
content_layout = QtWidgets.QVBoxLayout(content_widget)
content_layout.setContentsMargins(8, 8, 8, 8)
content_layout.setSpacing(15)

View file

@ -1,13 +0,0 @@
from .version import version, version_info, __version__
# This must be run prior to importing the application, due to the
# application requiring a discovered copy of Qt bindings.
from .app import show
__all__ = [
'show',
'version',
'version_info',
'__version__'
]

View file

@ -1,19 +0,0 @@
from .app import show
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--debug", action="store_true")
args = parser.parse_args()
if args.debug:
from . import mock
import pyblish.api
for Plugin in mock.plugins:
pyblish.api.register_plugin(Plugin)
show()

View file

@ -1,539 +0,0 @@
/* Global CSS */
* {
outline: none;
color: #ddd;
font-family: "Open Sans";
font-style: normal;
}
/* General CSS */
QWidget {
background: #555;
background-position: center center;
background-repeat: no-repeat;
font-size: 12px;
}
QMenu {
background-color: #555; /* sets background of the menu */
border: 1px solid #222;
}
QMenu::item {
/* sets background of menu item. set this to something non-transparent
if you want menu color and menu item color to be different */
background-color: transparent;
padding: 5px;
padding-left: 30px;
}
QMenu::item:selected { /* when user selects item using mouse or keyboard */
background-color: #666;
}
QDialog {
min-width: 300;
background: "#555";
}
QListView {
border: 0px;
background: "transparent"
}
QTreeView {
border: 0px;
background: "transparent"
}
QPushButton {
width: 27px;
height: 27px;
background: #555;
border: 1px solid #aaa;
border-radius: 4px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
QPushButton:pressed {
background: "#777";
}
QPushButton:hover {
color: white;
background: "#666";
}
QPushButton:disabled {
color: rgba(255, 255, 255, 50);
}
QTextEdit, QLineEdit {
background: #555;
border: 1px solid #333;
font-size: 9pt;
color: #fff;
}
QCheckBox {
min-width: 17px;
max-width: 17px;
border: 1px solid #222;
background: transparent;
}
QCheckBox::indicator {
width: 15px;
height: 15px;
/*background: #444;*/
background: transparent;
border: 1px solid #555;
}
QCheckBox::indicator:checked {
background: #222;
}
QComboBox {
background: #444;
color: #EEE;
font-size: 8pt;
border: 1px solid #333;
padding: 0px;
}
QComboBox[combolist="true"]::drop-down {
background: transparent;
}
QComboBox[combolist="true"]::down-arrow {
max-width: 0px;
width: 1px;
}
QComboBox[combolist="true"] QAbstractItemView {
background: #555;
}
QScrollBar:vertical {
border: none;
background: transparent;
width: 6px;
margin: 0;
}
QScrollBar::handle:vertical {
background: #333;
border-radius: 3px;
min-height: 20px;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
border: 1px solid #444;
width: 3px;
height: 3px;
background: white;
}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
background: none;
}
QToolTip {
color: #eee;
background-color: #555;
border: none;
padding: 5px;
}
QLabel {
border-radius: 0px;
}
QToolButton {
background-color: transparent;
margin: 0px;
padding: 0px;
border-radius: 0px;
border: none;
}
/* Specific CSS */
#PerspectiveToggleBtn {
border-bottom: 3px solid lightblue;
border-top: 0px;
border-radius: 0px;
border-right: 1px solid #232323;
border-left: 0px;
font-size: 26pt;
font-family: "FontAwesome";
}
#Terminal QComboBox::drop-down {
width: 60px;
}
#Header {
background: #555;
border: 1px solid #444;
padding: 0px;
margin: 0px;
}
#Header QRadioButton {
border: 3px solid "transparent";
border-right: 1px solid #333;
left: 2px;
}
#Header QRadioButton::indicator {
width: 65px;
height: 40px;
background-repeat: no-repeat;
background-position: center center;
image: none;
}
#Header QRadioButton:hover {
background-color: rgba(255, 255, 255, 10);
}
#Header QRadioButton:checked {
background-color: rgba(255, 255, 255, 20);
border-bottom: 3px solid "lightblue";
}
#Body {
padding: 0px;
border: 1px solid #333;
background: #444;
}
#Body QWidget {
background: #444;
}
#Header #TerminalTab {
background-image: url("img/tab-terminal.png");
}
#Header #OverviewTab {
background-image: url("img/tab-overview.png");
}
#ButtonWithMenu {
background: #555;
border: 1px solid #fff;
border-radius: 4px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
}
#ButtonWithMenu:pressed {
background: #777;
}
#ButtonWithMenu:hover {
color: white;
background: #666;
}
#ButtonWithMenu:disabled {
background: #666;
color: #999;
border: 1px solid #999;
}
#FooterSpacer, #FooterInfo, #HeaderSpacer {
background: transparent;
}
#Footer {
background: #555;
min-height: 43px;
}
#Footer[success="1"] {
background: #458056
}
#Footer[success="0"] {
background-color: #AA5050
}
#Footer QPushButton {
background: #555;
border: 1px solid #aaa;
border-radius: 4px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
#Footer QPushButton:pressed:hover {
color: #3784c5;
background: #444;
}
#Footer QPushButton:hover {
background: #505050;
border: 2px solid #3784c5;
}
#Footer QPushButton:disabled {
border: 1px solid #888;
background: #666;
color: #999;
}
#ClosingPlaceholder {
background: rgba(0, 0, 0, 50);
}
#CommentIntentWidget {
background: transparent;
}
#CommentBox, #CommentPlaceholder {
font-family: "Open Sans";
font-size: 8pt;
padding: 5px;
background: #444;
}
#CommentBox {
selection-background-color: #222;
}
#CommentBox:disabled, #CommentPlaceholder:disabled, #IntentBox:disabled {
background: #555;
}
#CommentPlaceholder {
color: #888
}
#IntentBox {
background: #444;
font-size: 8pt;
padding: 5px;
min-width: 75px;
color: #EEE;
}
#IntentBox::drop-down:button {
border: 0px;
background: transparent;
}
#IntentBox::down-arrow {
image: url("/img/down_arrow.png");
}
#IntentBox::down-arrow:disabled {
image: url();
}
#TerminalView {
background-color: transparent;
}
#TerminalView:item {
background-color: transparent;
}
#TerminalView:hover {
background-color: transparent;
}
#TerminalView:selected {
background-color: transparent;
}
#TerminalView:item:hover {
color: #ffffff;
}
#TerminalView:item:selected {
color: #eeeeee;
}
#TerminalView QTextEdit {
padding:3px;
color: #aaa;
border-radius: 7px;
border-color: #222;
border-style: solid;
border-width: 2px;
background-color: #333;
}
#TerminalView QTextEdit:hover {
background-color: #353535;
}
#TerminalView QTextEdit:selected {
background-color: #303030;
}
#ExpandableWidgetContent {
border: none;
background-color: #232323;
color:#eeeeee;
}
#EllidableLabel {
font-size: 16pt;
font-weight: normal;
}
#PerspectiveScrollContent {
border: 1px solid #333;
border-radius: 0px;
}
#PerspectiveWidgetContent{
padding: 0px;
}
#PerspectiveLabel {
background-color: transparent;
border: none;
}
#PerspectiveIndicator {
font-size: 16pt;
font-weight: normal;
padding: 5px;
background-color: #ffffff;
color: #333333;
}
#PerspectiveIndicator[state="warning"] {
background-color: #ff9900;
color: #ffffff;
}
#PerspectiveIndicator[state="active"] {
background-color: #99CEEE;
color: #ffffff;
}
#PerspectiveIndicator[state="error"] {
background-color: #cc4a4a;
color: #ffffff;
}
#PerspectiveIndicator[state="ok"] {
background-color: #69a567;
color: #ffffff;
}
#ExpandableHeader {
background-color: transparent;
margin: 0px;
padding: 0px;
border-radius: 0px;
border: none;
}
#ExpandableHeader QWidget {
color: #ddd;
}
#ExpandableHeader QWidget:hover {
color: #fff;
}
#TerminalFilterWidget QPushButton {
/* font: %(font_size_pt)spt; */
font-family: "FontAwesome";
text-align: center;
background-color: transparent;
border-width: 1px;
border-color: #777777;
border-style: none;
padding: 0px;
border-radius: 8px;
}
#TerminalFilterWidget QPushButton:hover {
background: #5f5f5f;
border-style: none;
}
#TerminalFilterWidget QPushButton:pressed {
background: #606060;
border-style: none;
}
#TerminalFilterWidget QPushButton:pressed:hover {
background: #626262;
border-style: none;
}
#TerminalFilerBtn[type="info"]:checked {color: rgb(255, 255, 255);}
#TerminalFilerBtn[type="info"]:hover:pressed {color: rgba(255, 255, 255, 163);}
#TerminalFilerBtn[type="info"] {color: rgba(255, 255, 255, 63);}
#TerminalFilerBtn[type="error"]:checked {color: rgb(255, 74, 74);}
#TerminalFilerBtn[type="error"]:hover:pressed {color: rgba(255, 74, 74, 163);}
#TerminalFilerBtn[type="error"] {color: rgba(255, 74, 74, 63);}
#TerminalFilerBtn[type="log_debug"]:checked {color: rgb(255, 102, 232);}
#TerminalFilerBtn[type="log_debug"] {color: rgba(255, 102, 232, 63);}
#TerminalFilerBtn[type="log_debug"]:hover:pressed {
color: rgba(255, 102, 232, 163);
}
#TerminalFilerBtn[type="log_info"]:checked {color: rgb(102, 171, 255);}
#TerminalFilerBtn[type="log_info"] {color: rgba(102, 171, 255, 63);}
#TerminalFilerBtn[type="log_info"]:hover:pressed {
color: rgba(102, 171, 255, 163);
}
#TerminalFilerBtn[type="log_warning"]:checked {color: rgb(255, 186, 102);}
#TerminalFilerBtn[type="log_warning"] {color: rgba(255, 186, 102, 63);}
#TerminalFilerBtn[type="log_warning"]:hover:pressed {
color: rgba(255, 186, 102, 163);
}
#TerminalFilerBtn[type="log_error"]:checked {color: rgb(255, 77, 88);}
#TerminalFilerBtn[type="log_error"] {color: rgba(255, 77, 88, 63);}
#TerminalFilerBtn[type="log_error"]:hover:pressed {
color: rgba(255, 77, 88, 163);
}
#TerminalFilerBtn[type="log_critical"]:checked {color: rgb(255, 79, 117);}
#TerminalFilerBtn[type="log_critical"] {color: rgba(255, 79, 117, 63);}
#TerminalFilerBtn[type="log_critical"]:hover:pressed {
color: rgba(255, 79, 117, 163);
}
#SuspendLogsBtn {
background: #444;
border: none;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-family: "FontAwesome";
font-size: 11pt;
color: white;
padding: 0px;
}
#SuspendLogsBtn:hover {
background: #333;
}
#SuspendLogsBtn:disabled {
background: #4c4c4c;
}

View file

@ -1,110 +0,0 @@
from __future__ import print_function
import os
import sys
import ctypes
import platform
import contextlib
from qtpy import QtCore, QtGui, QtWidgets
from . import control, settings, util, window
self = sys.modules[__name__]
# Maintain reference to currently opened window
self._window = None
@contextlib.contextmanager
def application():
app = QtWidgets.QApplication.instance()
if not app:
print("Starting new QApplication..")
app = QtWidgets.QApplication(sys.argv)
yield app
app.exec_()
else:
print("Using existing QApplication..")
yield app
if os.environ.get("PYBLISH_GUI_ALWAYS_EXEC"):
app.exec_()
def install_translator(app):
translator = QtCore.QTranslator(app)
translator.load(QtCore.QLocale.system(), "i18n/",
directory=util.root)
app.installTranslator(translator)
print("Installed translator")
def install_fonts():
database = QtGui.QFontDatabase()
fonts = [
"opensans/OpenSans-Bold.ttf",
"opensans/OpenSans-BoldItalic.ttf",
"opensans/OpenSans-ExtraBold.ttf",
"opensans/OpenSans-ExtraBoldItalic.ttf",
"opensans/OpenSans-Italic.ttf",
"opensans/OpenSans-Light.ttf",
"opensans/OpenSans-LightItalic.ttf",
"opensans/OpenSans-Regular.ttf",
"opensans/OpenSans-Semibold.ttf",
"opensans/OpenSans-SemiboldItalic.ttf",
"fontawesome/fontawesome-webfont.ttf"
]
for font in fonts:
path = util.get_asset("font", font)
# TODO(marcus): Check if they are already installed first.
# In hosts, this will be called each time the GUI is shown,
# potentially installing a font each time.
if database.addApplicationFont(path) < 0:
print("Could not install %s" % path)
else:
print("Installed %s" % font)
def on_destroyed():
"""Remove internal reference to window on window destroyed"""
self._window = None
def show(parent=None):
with open(util.get_asset("app.css")) as f:
css = f.read()
# Make relative paths absolute
root = util.get_asset("").replace("\\", "/")
css = css.replace("url(\"", "url(\"%s" % root)
with application() as app:
if platform.system().lower() == "windows":
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
u"pyblish_pype"
)
install_fonts()
install_translator(app)
if self._window is None:
ctrl = control.Controller()
self._window = window.Window(ctrl, parent)
self._window.destroyed.connect(on_destroyed)
self._window.show()
self._window.activateWindow()
self._window.setWindowTitle(settings.WindowTitle)
font = QtGui.QFont("Open Sans", 8, QtGui.QFont.Normal)
self._window.setFont(font)
self._window.setStyleSheet(css)
self._window.reset()
self._window.resize(*settings.WindowSize)
return self._window

View file

@ -1,733 +0,0 @@
tags = {
"500px": u"\uf26e",
"adjust": u"\uf042",
"adn": u"\uf170",
"align-center": u"\uf037",
"align-justify": u"\uf039",
"align-left": u"\uf036",
"align-right": u"\uf038",
"amazon": u"\uf270",
"ambulance": u"\uf0f9",
"american-sign-language-interpreting": u"\uf2a3",
"anchor": u"\uf13d",
"android": u"\uf17b",
"angellist": u"\uf209",
"angle-double-down": u"\uf103",
"angle-double-left": u"\uf100",
"angle-double-right": u"\uf101",
"angle-double-up": u"\uf102",
"angle-down": u"\uf107",
"angle-left": u"\uf104",
"angle-right": u"\uf105",
"angle-up": u"\uf106",
"apple": u"\uf179",
"archive": u"\uf187",
"area-chart": u"\uf1fe",
"arrow-circle-down": u"\uf0ab",
"arrow-circle-left": u"\uf0a8",
"arrow-circle-o-down": u"\uf01a",
"arrow-circle-o-left": u"\uf190",
"arrow-circle-o-right": u"\uf18e",
"arrow-circle-o-up": u"\uf01b",
"arrow-circle-right": u"\uf0a9",
"arrow-circle-up": u"\uf0aa",
"arrow-down": u"\uf063",
"arrow-left": u"\uf060",
"arrow-right": u"\uf061",
"arrow-up": u"\uf062",
"arrows": u"\uf047",
"arrows-alt": u"\uf0b2",
"arrows-h": u"\uf07e",
"arrows-v": u"\uf07d",
"asl-interpreting (alias)": u"\uf2a3",
"assistive-listening-systems": u"\uf2a2",
"asterisk": u"\uf069",
"at": u"\uf1fa",
"audio-description": u"\uf29e",
"automobile (alias)": u"\uf1b9",
"backward": u"\uf04a",
"balance-scale": u"\uf24e",
"ban": u"\uf05e",
"bank (alias)": u"\uf19c",
"bar-chart": u"\uf080",
"bar-chart-o (alias)": u"\uf080",
"barcode": u"\uf02a",
"bars": u"\uf0c9",
"battery-0 (alias)": u"\uf244",
"battery-1 (alias)": u"\uf243",
"battery-2 (alias)": u"\uf242",
"battery-3 (alias)": u"\uf241",
"battery-4 (alias)": u"\uf240",
"battery-empty": u"\uf244",
"battery-full": u"\uf240",
"battery-half": u"\uf242",
"battery-quarter": u"\uf243",
"battery-three-quarters": u"\uf241",
"bed": u"\uf236",
"beer": u"\uf0fc",
"behance": u"\uf1b4",
"behance-square": u"\uf1b5",
"bell": u"\uf0f3",
"bell-o": u"\uf0a2",
"bell-slash": u"\uf1f6",
"bell-slash-o": u"\uf1f7",
"bicycle": u"\uf206",
"binoculars": u"\uf1e5",
"birthday-cake": u"\uf1fd",
"bitbucket": u"\uf171",
"bitbucket-square": u"\uf172",
"bitcoin (alias)": u"\uf15a",
"black-tie": u"\uf27e",
"blind": u"\uf29d",
"bluetooth": u"\uf293",
"bluetooth-b": u"\uf294",
"bold": u"\uf032",
"bolt": u"\uf0e7",
"bomb": u"\uf1e2",
"book": u"\uf02d",
"bookmark": u"\uf02e",
"bookmark-o": u"\uf097",
"braille": u"\uf2a1",
"briefcase": u"\uf0b1",
"btc": u"\uf15a",
"bug": u"\uf188",
"building": u"\uf1ad",
"building-o": u"\uf0f7",
"bullhorn": u"\uf0a1",
"bullseye": u"\uf140",
"bus": u"\uf207",
"buysellads": u"\uf20d",
"cab (alias)": u"\uf1ba",
"calculator": u"\uf1ec",
"calendar": u"\uf073",
"calendar-check-o": u"\uf274",
"calendar-minus-o": u"\uf272",
"calendar-o": u"\uf133",
"calendar-plus-o": u"\uf271",
"calendar-times-o": u"\uf273",
"camera": u"\uf030",
"camera-retro": u"\uf083",
"car": u"\uf1b9",
"caret-down": u"\uf0d7",
"caret-left": u"\uf0d9",
"caret-right": u"\uf0da",
"caret-square-o-down": u"\uf150",
"caret-square-o-left": u"\uf191",
"caret-square-o-right": u"\uf152",
"caret-square-o-up": u"\uf151",
"caret-up": u"\uf0d8",
"cart-arrow-down": u"\uf218",
"cart-plus": u"\uf217",
"cc": u"\uf20a",
"cc-amex": u"\uf1f3",
"cc-diners-club": u"\uf24c",
"cc-discover": u"\uf1f2",
"cc-jcb": u"\uf24b",
"cc-mastercard": u"\uf1f1",
"cc-paypal": u"\uf1f4",
"cc-stripe": u"\uf1f5",
"cc-visa": u"\uf1f0",
"certificate": u"\uf0a3",
"chain (alias)": u"\uf0c1",
"chain-broken": u"\uf127",
"check": u"\uf00c",
"check-circle": u"\uf058",
"check-circle-o": u"\uf05d",
"check-square": u"\uf14a",
"check-square-o": u"\uf046",
"chevron-circle-down": u"\uf13a",
"chevron-circle-left": u"\uf137",
"chevron-circle-right": u"\uf138",
"chevron-circle-up": u"\uf139",
"chevron-down": u"\uf078",
"chevron-left": u"\uf053",
"chevron-right": u"\uf054",
"chevron-up": u"\uf077",
"child": u"\uf1ae",
"chrome": u"\uf268",
"circle": u"\uf111",
"circle-o": u"\uf10c",
"circle-o-notch": u"\uf1ce",
"circle-thin": u"\uf1db",
"clipboard": u"\uf0ea",
"clock-o": u"\uf017",
"clone": u"\uf24d",
"close (alias)": u"\uf00d",
"cloud": u"\uf0c2",
"cloud-download": u"\uf0ed",
"cloud-upload": u"\uf0ee",
"cny (alias)": u"\uf157",
"code": u"\uf121",
"code-fork": u"\uf126",
"codepen": u"\uf1cb",
"codiepie": u"\uf284",
"coffee": u"\uf0f4",
"cog": u"\uf013",
"cogs": u"\uf085",
"columns": u"\uf0db",
"comment": u"\uf075",
"comment-o": u"\uf0e5",
"commenting": u"\uf27a",
"commenting-o": u"\uf27b",
"comments": u"\uf086",
"comments-o": u"\uf0e6",
"compass": u"\uf14e",
"compress": u"\uf066",
"connectdevelop": u"\uf20e",
"contao": u"\uf26d",
"copy (alias)": u"\uf0c5",
"copyright": u"\uf1f9",
"creative-commons": u"\uf25e",
"credit-card": u"\uf09d",
"credit-card-alt": u"\uf283",
"crop": u"\uf125",
"crosshairs": u"\uf05b",
"css3": u"\uf13c",
"cube": u"\uf1b2",
"cubes": u"\uf1b3",
"cut (alias)": u"\uf0c4",
"cutlery": u"\uf0f5",
"dashboard (alias)": u"\uf0e4",
"dashcube": u"\uf210",
"database": u"\uf1c0",
"deaf": u"\uf2a4",
"deafness (alias)": u"\uf2a4",
"dedent (alias)": u"\uf03b",
"delicious": u"\uf1a5",
"desktop": u"\uf108",
"deviantart": u"\uf1bd",
"diamond": u"\uf219",
"digg": u"\uf1a6",
"dollar (alias)": u"\uf155",
"dot-circle-o": u"\uf192",
"download": u"\uf019",
"dribbble": u"\uf17d",
"dropbox": u"\uf16b",
"drupal": u"\uf1a9",
"edge": u"\uf282",
"edit (alias)": u"\uf044",
"eject": u"\uf052",
"ellipsis-h": u"\uf141",
"ellipsis-v": u"\uf142",
"empire": u"\uf1d1",
"envelope": u"\uf0e0",
"envelope-o": u"\uf003",
"envelope-square": u"\uf199",
"envira": u"\uf299",
"eraser": u"\uf12d",
"eur": u"\uf153",
"euro (alias)": u"\uf153",
"exchange": u"\uf0ec",
"exclamation": u"\uf12a",
"exclamation-circle": u"\uf06a",
"exclamation-triangle": u"\uf071",
"expand": u"\uf065",
"expeditedssl": u"\uf23e",
"external-link": u"\uf08e",
"external-link-square": u"\uf14c",
"eye": u"\uf06e",
"eye-slash": u"\uf070",
"eyedropper": u"\uf1fb",
"fa (alias)": u"\uf2b4",
"facebook": u"\uf09a",
"facebook-f (alias)": u"\uf09a",
"facebook-official": u"\uf230",
"facebook-square": u"\uf082",
"fast-backward": u"\uf049",
"fast-forward": u"\uf050",
"fax": u"\uf1ac",
"feed (alias)": u"\uf09e",
"female": u"\uf182",
"fighter-jet": u"\uf0fb",
"file": u"\uf15b",
"file-archive-o": u"\uf1c6",
"file-audio-o": u"\uf1c7",
"file-code-o": u"\uf1c9",
"file-excel-o": u"\uf1c3",
"file-image-o": u"\uf1c5",
"file-movie-o (alias)": u"\uf1c8",
"file-o": u"\uf016",
"file-pdf-o": u"\uf1c1",
"file-photo-o (alias)": u"\uf1c5",
"file-picture-o (alias)": u"\uf1c5",
"file-powerpoint-o": u"\uf1c4",
"file-sound-o (alias)": u"\uf1c7",
"file-text": u"\uf15c",
"file-text-o": u"\uf0f6",
"file-video-o": u"\uf1c8",
"file-word-o": u"\uf1c2",
"file-zip-o (alias)": u"\uf1c6",
"files-o": u"\uf0c5",
"film": u"\uf008",
"filter": u"\uf0b0",
"fire": u"\uf06d",
"fire-extinguisher": u"\uf134",
"firefox": u"\uf269",
"first-order": u"\uf2b0",
"flag": u"\uf024",
"flag-checkered": u"\uf11e",
"flag-o": u"\uf11d",
"flash (alias)": u"\uf0e7",
"flask": u"\uf0c3",
"flickr": u"\uf16e",
"floppy-o": u"\uf0c7",
"folder": u"\uf07b",
"folder-o": u"\uf114",
"folder-open": u"\uf07c",
"folder-open-o": u"\uf115",
"font": u"\uf031",
"font-awesome": u"\uf2b4",
"fonticons": u"\uf280",
"fort-awesome": u"\uf286",
"forumbee": u"\uf211",
"forward": u"\uf04e",
"foursquare": u"\uf180",
"frown-o": u"\uf119",
"futbol-o": u"\uf1e3",
"gamepad": u"\uf11b",
"gavel": u"\uf0e3",
"gbp": u"\uf154",
"ge (alias)": u"\uf1d1",
"gear (alias)": u"\uf013",
"gears (alias)": u"\uf085",
"genderless": u"\uf22d",
"get-pocket": u"\uf265",
"gg": u"\uf260",
"gg-circle": u"\uf261",
"gift": u"\uf06b",
"git": u"\uf1d3",
"git-square": u"\uf1d2",
"github": u"\uf09b",
"github-alt": u"\uf113",
"github-square": u"\uf092",
"gitlab": u"\uf296",
"gittip (alias)": u"\uf184",
"glass": u"\uf000",
"glide": u"\uf2a5",
"glide-g": u"\uf2a6",
"globe": u"\uf0ac",
"google": u"\uf1a0",
"google-plus": u"\uf0d5",
"google-plus-circle (alias)": u"\uf2b3",
"google-plus-official": u"\uf2b3",
"google-plus-square": u"\uf0d4",
"google-wallet": u"\uf1ee",
"graduation-cap": u"\uf19d",
"gratipay": u"\uf184",
"group (alias)": u"\uf0c0",
"h-square": u"\uf0fd",
"hacker-news": u"\uf1d4",
"hand-grab-o (alias)": u"\uf255",
"hand-lizard-o": u"\uf258",
"hand-o-down": u"\uf0a7",
"hand-o-left": u"\uf0a5",
"hand-o-right": u"\uf0a4",
"hand-o-up": u"\uf0a6",
"hand-paper-o": u"\uf256",
"hand-peace-o": u"\uf25b",
"hand-pointer-o": u"\uf25a",
"hand-rock-o": u"\uf255",
"hand-scissors-o": u"\uf257",
"hand-spock-o": u"\uf259",
"hand-stop-o (alias)": u"\uf256",
"hard-of-hearing (alias)": u"\uf2a4",
"hashtag": u"\uf292",
"hdd-o": u"\uf0a0",
"header": u"\uf1dc",
"headphones": u"\uf025",
"heart": u"\uf004",
"heart-o": u"\uf08a",
"heartbeat": u"\uf21e",
"history": u"\uf1da",
"home": u"\uf015",
"hospital-o": u"\uf0f8",
"hotel (alias)": u"\uf236",
"hourglass": u"\uf254",
"hourglass-1 (alias)": u"\uf251",
"hourglass-2 (alias)": u"\uf252",
"hourglass-3 (alias)": u"\uf253",
"hourglass-end": u"\uf253",
"hourglass-half": u"\uf252",
"hourglass-o": u"\uf250",
"hourglass-start": u"\uf251",
"houzz": u"\uf27c",
"html5": u"\uf13b",
"i-cursor": u"\uf246",
"ils": u"\uf20b",
"image (alias)": u"\uf03e",
"inbox": u"\uf01c",
"indent": u"\uf03c",
"industry": u"\uf275",
"info": u"\uf129",
"info-circle": u"\uf05a",
"inr": u"\uf156",
"instagram": u"\uf16d",
"institution (alias)": u"\uf19c",
"internet-explorer": u"\uf26b",
"intersex (alias)": u"\uf224",
"ioxhost": u"\uf208",
"italic": u"\uf033",
"joomla": u"\uf1aa",
"jpy": u"\uf157",
"jsfiddle": u"\uf1cc",
"key": u"\uf084",
"keyboard-o": u"\uf11c",
"krw": u"\uf159",
"language": u"\uf1ab",
"laptop": u"\uf109",
"lastfm": u"\uf202",
"lastfm-square": u"\uf203",
"leaf": u"\uf06c",
"leanpub": u"\uf212",
"legal (alias)": u"\uf0e3",
"lemon-o": u"\uf094",
"level-down": u"\uf149",
"level-up": u"\uf148",
"life-bouy (alias)": u"\uf1cd",
"life-buoy (alias)": u"\uf1cd",
"life-ring": u"\uf1cd",
"life-saver (alias)": u"\uf1cd",
"lightbulb-o": u"\uf0eb",
"line-chart": u"\uf201",
"link": u"\uf0c1",
"linkedin": u"\uf0e1",
"linkedin-square": u"\uf08c",
"linux": u"\uf17c",
"list": u"\uf03a",
"list-alt": u"\uf022",
"list-ol": u"\uf0cb",
"list-ul": u"\uf0ca",
"location-arrow": u"\uf124",
"lock": u"\uf023",
"long-arrow-down": u"\uf175",
"long-arrow-left": u"\uf177",
"long-arrow-right": u"\uf178",
"long-arrow-up": u"\uf176",
"low-vision": u"\uf2a8",
"magic": u"\uf0d0",
"magnet": u"\uf076",
"mail-forward (alias)": u"\uf064",
"mail-reply (alias)": u"\uf112",
"mail-reply-all (alias)": u"\uf122",
"male": u"\uf183",
"map": u"\uf279",
"map-marker": u"\uf041",
"map-o": u"\uf278",
"map-pin": u"\uf276",
"map-signs": u"\uf277",
"mars": u"\uf222",
"mars-double": u"\uf227",
"mars-stroke": u"\uf229",
"mars-stroke-h": u"\uf22b",
"mars-stroke-v": u"\uf22a",
"maxcdn": u"\uf136",
"meanpath": u"\uf20c",
"medium": u"\uf23a",
"medkit": u"\uf0fa",
"meh-o": u"\uf11a",
"mercury": u"\uf223",
"microphone": u"\uf130",
"microphone-slash": u"\uf131",
"minus": u"\uf068",
"minus-circle": u"\uf056",
"minus-square": u"\uf146",
"minus-square-o": u"\uf147",
"mixcloud": u"\uf289",
"mobile": u"\uf10b",
"mobile-phone (alias)": u"\uf10b",
"modx": u"\uf285",
"money": u"\uf0d6",
"moon-o": u"\uf186",
"mortar-board (alias)": u"\uf19d",
"motorcycle": u"\uf21c",
"mouse-pointer": u"\uf245",
"music": u"\uf001",
"navicon (alias)": u"\uf0c9",
"neuter": u"\uf22c",
"newspaper-o": u"\uf1ea",
"object-group": u"\uf247",
"object-ungroup": u"\uf248",
"odnoklassniki": u"\uf263",
"odnoklassniki-square": u"\uf264",
"opencart": u"\uf23d",
"openid": u"\uf19b",
"opera": u"\uf26a",
"optin-monster": u"\uf23c",
"outdent": u"\uf03b",
"pagelines": u"\uf18c",
"paint-brush": u"\uf1fc",
"paper-plane": u"\uf1d8",
"paper-plane-o": u"\uf1d9",
"paperclip": u"\uf0c6",
"paragraph": u"\uf1dd",
"paste (alias)": u"\uf0ea",
"pause": u"\uf04c",
"pause-circle": u"\uf28b",
"pause-circle-o": u"\uf28c",
"paw": u"\uf1b0",
"paypal": u"\uf1ed",
"pencil": u"\uf040",
"pencil-square": u"\uf14b",
"pencil-square-o": u"\uf044",
"percent": u"\uf295",
"phone": u"\uf095",
"phone-square": u"\uf098",
"photo (alias)": u"\uf03e",
"picture-o": u"\uf03e",
"pie-chart": u"\uf200",
"pied-piper": u"\uf2ae",
"pied-piper-alt": u"\uf1a8",
"pied-piper-pp": u"\uf1a7",
"pinterest": u"\uf0d2",
"pinterest-p": u"\uf231",
"pinterest-square": u"\uf0d3",
"plane": u"\uf072",
"play": u"\uf04b",
"play-circle": u"\uf144",
"play-circle-o": u"\uf01d",
"plug": u"\uf1e6",
"plus": u"\uf067",
"plus-circle": u"\uf055",
"plus-square": u"\uf0fe",
"plus-square-o": u"\uf196",
"power-off": u"\uf011",
"print": u"\uf02f",
"product-hunt": u"\uf288",
"puzzle-piece": u"\uf12e",
"qq": u"\uf1d6",
"qrcode": u"\uf029",
"question": u"\uf128",
"question-circle": u"\uf059",
"question-circle-o": u"\uf29c",
"quote-left": u"\uf10d",
"quote-right": u"\uf10e",
"ra (alias)": u"\uf1d0",
"random": u"\uf074",
"rebel": u"\uf1d0",
"recycle": u"\uf1b8",
"reddit": u"\uf1a1",
"reddit-alien": u"\uf281",
"reddit-square": u"\uf1a2",
"refresh": u"\uf021",
"registered": u"\uf25d",
"remove (alias)": u"\uf00d",
"renren": u"\uf18b",
"reorder (alias)": u"\uf0c9",
"repeat": u"\uf01e",
"reply": u"\uf112",
"reply-all": u"\uf122",
"resistance (alias)": u"\uf1d0",
"retweet": u"\uf079",
"rmb (alias)": u"\uf157",
"road": u"\uf018",
"rocket": u"\uf135",
"rotate-left (alias)": u"\uf0e2",
"rotate-right (alias)": u"\uf01e",
"rouble (alias)": u"\uf158",
"rss": u"\uf09e",
"rss-square": u"\uf143",
"rub": u"\uf158",
"ruble (alias)": u"\uf158",
"rupee (alias)": u"\uf156",
"safari": u"\uf267",
"save (alias)": u"\uf0c7",
"scissors": u"\uf0c4",
"scribd": u"\uf28a",
"search": u"\uf002",
"search-minus": u"\uf010",
"search-plus": u"\uf00e",
"sellsy": u"\uf213",
"send (alias)": u"\uf1d8",
"send-o (alias)": u"\uf1d9",
"server": u"\uf233",
"share": u"\uf064",
"share-alt": u"\uf1e0",
"share-alt-square": u"\uf1e1",
"share-square": u"\uf14d",
"share-square-o": u"\uf045",
"shekel (alias)": u"\uf20b",
"sheqel (alias)": u"\uf20b",
"shield": u"\uf132",
"ship": u"\uf21a",
"shirtsinbulk": u"\uf214",
"shopping-bag": u"\uf290",
"shopping-basket": u"\uf291",
"shopping-cart": u"\uf07a",
"sign-in": u"\uf090",
"sign-language": u"\uf2a7",
"sign-out": u"\uf08b",
"signal": u"\uf012",
"signing (alias)": u"\uf2a7",
"simplybuilt": u"\uf215",
"sitemap": u"\uf0e8",
"skyatlas": u"\uf216",
"skype": u"\uf17e",
"slack": u"\uf198",
"sliders": u"\uf1de",
"slideshare": u"\uf1e7",
"smile-o": u"\uf118",
"snapchat": u"\uf2ab",
"snapchat-ghost": u"\uf2ac",
"snapchat-square": u"\uf2ad",
"soccer-ball-o (alias)": u"\uf1e3",
"sort": u"\uf0dc",
"sort-alpha-asc": u"\uf15d",
"sort-alpha-desc": u"\uf15e",
"sort-amount-asc": u"\uf160",
"sort-amount-desc": u"\uf161",
"sort-asc": u"\uf0de",
"sort-desc": u"\uf0dd",
"sort-down (alias)": u"\uf0dd",
"sort-numeric-asc": u"\uf162",
"sort-numeric-desc": u"\uf163",
"sort-up (alias)": u"\uf0de",
"soundcloud": u"\uf1be",
"space-shuttle": u"\uf197",
"spinner": u"\uf110",
"spoon": u"\uf1b1",
"spotify": u"\uf1bc",
"square": u"\uf0c8",
"square-o": u"\uf096",
"stack-exchange": u"\uf18d",
"stack-overflow": u"\uf16c",
"star": u"\uf005",
"star-half": u"\uf089",
"star-half-empty (alias)": u"\uf123",
"star-half-full (alias)": u"\uf123",
"star-half-o": u"\uf123",
"star-o": u"\uf006",
"steam": u"\uf1b6",
"steam-square": u"\uf1b7",
"step-backward": u"\uf048",
"step-forward": u"\uf051",
"stethoscope": u"\uf0f1",
"sticky-note": u"\uf249",
"sticky-note-o": u"\uf24a",
"stop": u"\uf04d",
"stop-circle": u"\uf28d",
"stop-circle-o": u"\uf28e",
"street-view": u"\uf21d",
"strikethrough": u"\uf0cc",
"stumbleupon": u"\uf1a4",
"stumbleupon-circle": u"\uf1a3",
"subscript": u"\uf12c",
"subway": u"\uf239",
"suitcase": u"\uf0f2",
"sun-o": u"\uf185",
"superscript": u"\uf12b",
"support (alias)": u"\uf1cd",
"table": u"\uf0ce",
"tablet": u"\uf10a",
"tachometer": u"\uf0e4",
"tag": u"\uf02b",
"tags": u"\uf02c",
"tasks": u"\uf0ae",
"taxi": u"\uf1ba",
"television": u"\uf26c",
"tencent-weibo": u"\uf1d5",
"terminal": u"\uf120",
"text-height": u"\uf034",
"text-width": u"\uf035",
"th": u"\uf00a",
"th-large": u"\uf009",
"th-list": u"\uf00b",
"themeisle": u"\uf2b2",
"thumb-tack": u"\uf08d",
"thumbs-down": u"\uf165",
"thumbs-o-down": u"\uf088",
"thumbs-o-up": u"\uf087",
"thumbs-up": u"\uf164",
"ticket": u"\uf145",
"times": u"\uf00d",
"times-circle": u"\uf057",
"times-circle-o": u"\uf05c",
"tint": u"\uf043",
"toggle-down (alias)": u"\uf150",
"toggle-left (alias)": u"\uf191",
"toggle-off": u"\uf204",
"toggle-on": u"\uf205",
"toggle-right (alias)": u"\uf152",
"toggle-up (alias)": u"\uf151",
"trademark": u"\uf25c",
"train": u"\uf238",
"transgender": u"\uf224",
"transgender-alt": u"\uf225",
"trash": u"\uf1f8",
"trash-o": u"\uf014",
"tree": u"\uf1bb",
"trello": u"\uf181",
"tripadvisor": u"\uf262",
"trophy": u"\uf091",
"truck": u"\uf0d1",
"try": u"\uf195",
"tty": u"\uf1e4",
"tumblr": u"\uf173",
"tumblr-square": u"\uf174",
"turkish-lira (alias)": u"\uf195",
"tv (alias)": u"\uf26c",
"twitch": u"\uf1e8",
"twitter": u"\uf099",
"twitter-square": u"\uf081",
"umbrella": u"\uf0e9",
"underline": u"\uf0cd",
"undo": u"\uf0e2",
"universal-access": u"\uf29a",
"university": u"\uf19c",
"unlink (alias)": u"\uf127",
"unlock": u"\uf09c",
"unlock-alt": u"\uf13e",
"unsorted (alias)": u"\uf0dc",
"upload": u"\uf093",
"usb": u"\uf287",
"usd": u"\uf155",
"user": u"\uf007",
"user-md": u"\uf0f0",
"user-plus": u"\uf234",
"user-secret": u"\uf21b",
"user-times": u"\uf235",
"users": u"\uf0c0",
"venus": u"\uf221",
"venus-double": u"\uf226",
"venus-mars": u"\uf228",
"viacoin": u"\uf237",
"viadeo": u"\uf2a9",
"viadeo-square": u"\uf2aa",
"video-camera": u"\uf03d",
"vimeo": u"\uf27d",
"vimeo-square": u"\uf194",
"vine": u"\uf1ca",
"vk": u"\uf189",
"volume-control-phone": u"\uf2a0",
"volume-down": u"\uf027",
"volume-off": u"\uf026",
"volume-up": u"\uf028",
"warning (alias)": u"\uf071",
"wechat (alias)": u"\uf1d7",
"weibo": u"\uf18a",
"weixin": u"\uf1d7",
"whatsapp": u"\uf232",
"wheelchair": u"\uf193",
"wheelchair-alt": u"\uf29b",
"wifi": u"\uf1eb",
"wikipedia-w": u"\uf266",
"windows": u"\uf17a",
"won (alias)": u"\uf159",
"wordpress": u"\uf19a",
"wpbeginner": u"\uf297",
"wpforms": u"\uf298",
"wrench": u"\uf0ad",
"xing": u"\uf168",
"xing-square": u"\uf169",
"y-combinator": u"\uf23b",
"y-combinator-square (alias)": u"\uf1d4",
"yahoo": u"\uf19e",
"yc (alias)": u"\uf23b",
"yc-square (alias)": u"\uf1d4",
"yelp": u"\uf1e9",
"yen (alias)": u"\uf157",
"yoast": u"\uf2b1",
"youtube": u"\uf167",
"youtube-play": u"\uf16a",
"youtube-square": u"\uf166"
}

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