mirror of
https://github.com/ynput/ayon-core.git
synced 2026-01-01 08:24:53 +01:00
Merge branch 'develop' into chore/cleanup_plugin_code_cosmetics
This commit is contained in:
commit
59543ae492
194 changed files with 9256 additions and 10505 deletions
|
|
@ -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__))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
|
|||
"photoshop",
|
||||
"tvpaint",
|
||||
"substancepainter",
|
||||
"substancedesigner",
|
||||
"aftereffects",
|
||||
"wrap",
|
||||
"openrv",
|
||||
"cinema4d"
|
||||
"cinema4d",
|
||||
"silhouette",
|
||||
}
|
||||
launch_types = {LaunchTypes.local}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
|
|||
"blender",
|
||||
"houdini",
|
||||
"max",
|
||||
"circuit",
|
||||
]
|
||||
|
||||
audio_product_name = "audioMain"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin):
|
|||
"comments": instance.data.get("comments", []),
|
||||
}
|
||||
|
||||
shot_data["attributes"] = {}
|
||||
shot_data["attributes"] = {}
|
||||
SHOT_ATTRS = (
|
||||
"handleStart",
|
||||
"handleEnd",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
|
|||
"yeticacheUE",
|
||||
"tycache",
|
||||
"usd",
|
||||
"oxrig"
|
||||
"oxrig",
|
||||
"sbsar",
|
||||
]
|
||||
|
||||
def process(self, instance):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class ExtractBurnin(publish.Extractor):
|
|||
"houdini",
|
||||
"max",
|
||||
"blender",
|
||||
"unreal"
|
||||
"unreal",
|
||||
"circuit",
|
||||
]
|
||||
|
||||
optional = True
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
138
client/ayon_core/plugins/publish/integrate_attach_reviewable.py
Normal file
138
client/ayon_core/plugins/publish/integrate_attach_reviewable.py
Normal 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",
|
||||
)
|
||||
]
|
||||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,8 @@ ValidationArtistMessage QLabel {
|
|||
|
||||
#PublishLogMessage {
|
||||
font-family: "Noto Sans Mono";
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#PublishInstanceLogsLabel {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -248,4 +248,3 @@ class EnhancedTabBar(QtWidgets.QTabBar):
|
|||
|
||||
else:
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
|
|
|||
|
|
@ -492,7 +492,7 @@ def show(parent=None):
|
|||
|
||||
try:
|
||||
module.window.close()
|
||||
del(module.window)
|
||||
del module.window
|
||||
except (AttributeError, RuntimeError):
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ from qtpy import QtWidgets, QtCore, QtGui
|
|||
import pyblish.api
|
||||
from ayon_core import style
|
||||
|
||||
TAB = 4* " "
|
||||
TAB = 4 * " "
|
||||
HEADER_SIZE = "15px"
|
||||
|
||||
KEY_COLOR = QtGui.QColor("#ffffff")
|
||||
|
|
@ -243,7 +243,7 @@ class DebugUI(QtWidgets.QDialog):
|
|||
|
||||
self._set_window_title(plugin=result["plugin"])
|
||||
|
||||
print(10*"<", result["plugin"].__name__, 10*">")
|
||||
print(10 * "<", result["plugin"].__name__, 10 * ">")
|
||||
|
||||
plugin_order = result["plugin"].order
|
||||
plugin_name = result["plugin"].__name__
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
170
client/ayon_core/tools/loader/ui/product_types_combo.py
Normal file
170
client/ayon_core/tools/loader/ui/product_types_combo.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
405
client/ayon_core/tools/loader/ui/tasks_widget.py
Normal file
405
client/ayon_core/tools/loader/ui/tasks_widget.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__'
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue