diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 54f5d68b98..c0ab04abef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,20 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.0.14 + - 1.0.13 + - 1.0.12 + - 1.0.11 + - 1.0.10 + - 1.0.9 + - 1.0.8 + - 1.0.7 + - 1.0.6 + - 1.0.5 + - 1.0.4 + - 1.0.3 + - 1.0.2 + - 1.0.1 - 1.0.0 - 0.4.4 - 0.4.3 diff --git a/.github/workflows/assign_pr_to_project.yml b/.github/workflows/assign_pr_to_project.yml new file mode 100644 index 0000000000..e61d281c2a --- /dev/null +++ b/.github/workflows/assign_pr_to_project.yml @@ -0,0 +1,48 @@ +name: 🔸Auto assign pr +on: + workflow_dispatch: + inputs: + pr_number: + type: string + description: "Run workflow for this PR number" + required: true + project_id: + type: string + description: "Github Project Number" + required: true + default: "16" + pull_request: + types: + - opened + +env: + GH_TOKEN: ${{ github.token }} + +jobs: + get-pr-repo: + runs-on: ubuntu-latest + outputs: + pr_repo_name: ${{ steps.get-repo-name.outputs.repo_name || github.event.pull_request.head.repo.full_name }} + + # INFO `github.event.pull_request.head.repo.full_name` is not available on manual triggered (dispatched) runs + steps: + - name: Get PR repo name + if: ${{ github.event_name == 'workflow_dispatch' }} + id: get-repo-name + run: | + repo_name=$(gh pr view ${{ inputs.pr_number }} --json headRepository,headRepositoryOwner --repo ${{ github.repository }} | jq -r '.headRepositoryOwner.login + "/" + .headRepository.name') + echo "repo_name=$repo_name" >> $GITHUB_OUTPUT + + auto-assign-pr: + needs: + - get-pr-repo + if: ${{ needs.get-pr-repo.outputs.pr_repo_name == github.repository }} + uses: ynput/ops-repo-automation/.github/workflows/pr_to_project.yml@main + with: + repo: "${{ github.repository }}" + project_id: ${{ inputs.project_id != '' && fromJSON(inputs.project_id) || 16 }} + pull_request_number: ${{ github.event.pull_request.number || fromJSON(inputs.pr_number) }} + secrets: + # INFO fallback to default `github.token` is required for PRs from forks + # INFO organization secrets won't be available to forks + token: ${{ secrets.YNPUT_BOT_TOKEN || github.token}} diff --git a/.github/workflows/pr_linting.yml b/.github/workflows/pr_linting.yml index 3d2431b69a..896d5b7f4d 100644 --- a/.github/workflows/pr_linting.yml +++ b/.github/workflows/pr_linting.yml @@ -21,4 +21,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 + - uses: astral-sh/ruff-action@v1 + with: + changed-files: "true" diff --git a/.github/workflows/pr_unittests.yaml b/.github/workflows/pr_unittests.yaml new file mode 100644 index 0000000000..811843d5e3 --- /dev/null +++ b/.github/workflows/pr_unittests.yaml @@ -0,0 +1,31 @@ +name: 🧐 Run Unit Tests + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number}} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + - name: Install requirements + run: ./tools/manage.sh create-env + - name: Run tests + run: ./tools/manage.sh run-tests diff --git a/.github/workflows/validate_pr_labels.yml b/.github/workflows/validate_pr_labels.yml new file mode 100644 index 0000000000..f25e263c98 --- /dev/null +++ b/.github/workflows/validate_pr_labels.yml @@ -0,0 +1,18 @@ +name: 🔎 Validate PR Labels +on: + pull_request: + types: + - opened + - edited + - labeled + - unlabeled + +jobs: + validate-type-label: + uses: ynput/ops-repo-automation/.github/workflows/validate_pr_labels.yml@main + with: + repo: "${{ github.repository }}" + pull_request_number: ${{ github.event.pull_request.number }} + query_prefix: "type: " + secrets: + token: ${{ secrets.YNPUT_BOT_TOKEN }} diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 982626ad9d..72270fa585 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -370,67 +370,11 @@ def _load_ayon_addons(log): return all_addon_modules -def _load_addons_in_core(log): - # Add current directory at first place - # - has small differences in import logic - addon_modules = [] - modules_dir = os.path.join(AYON_CORE_ROOT, "modules") - if not os.path.exists(modules_dir): - log.warning( - f"Could not find path when loading AYON addons \"{modules_dir}\"" - ) - return addon_modules - - ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES - for filename in os.listdir(modules_dir): - # Ignore filenames - if filename in ignored_filenames: - continue - - fullpath = os.path.join(modules_dir, filename) - basename, ext = os.path.splitext(filename) - - # Validations - if os.path.isdir(fullpath): - # Check existence of init file - init_path = os.path.join(fullpath, "__init__.py") - if not os.path.exists(init_path): - log.debug(( - "Addon directory does not contain __init__.py" - f" file {fullpath}" - )) - continue - - elif ext != ".py": - continue - - # TODO add more logic how to define if folder is addon or not - # - check manifest and content of manifest - try: - # Don't import dynamically current directory modules - import_str = f"ayon_core.modules.{basename}" - default_module = __import__(import_str, fromlist=("", )) - addon_modules.append(default_module) - - except Exception: - log.error( - f"Failed to import in-core addon '{basename}'.", - exc_info=True - ) - return addon_modules - - def _load_addons(): log = Logger.get_logger("AddonsLoader") - addon_modules = _load_ayon_addons(log) - # All addon in 'modules' folder are tray actions and should be moved - # to tray tool. - # TODO remove - addon_modules.extend(_load_addons_in_core(log)) - # Store modules to local cache - _LoadCache.addon_modules = addon_modules + _LoadCache.addon_modules = _load_ayon_addons(log) class AYONAddon(ABC): @@ -535,8 +479,8 @@ class AYONAddon(ABC): Implementation of this method is optional. Note: - The logic can be similar to logic in tray, but tray does not require - to be logged in. + The logic can be similar to logic in tray, but tray does not + require to be logged in. Args: process_context (ProcessContext): Context of child @@ -950,6 +894,21 @@ class AddonsManager: output.extend(paths) return output + def collect_launcher_action_paths(self): + """Helper to collect launcher action paths from addons. + + Returns: + list: List of paths to launcher actions. + + """ + output = self._collect_plugin_paths( + "get_launcher_action_paths" + ) + # Add default core actions + actions_dir = os.path.join(AYON_CORE_ROOT, "plugins", "actions") + output.insert(0, actions_dir) + return output + def collect_create_plugin_paths(self, host_name): """Helper to collect creator plugin paths from addons. diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index b273e7839b..72191e3453 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -54,6 +54,13 @@ class IPluginPaths(AYONInterface): paths = [paths] return paths + def get_launcher_action_paths(self): + """Receive launcher actions paths. + + Give addons ability to add launcher actions paths. + """ + return self._get_plugin_paths_by_type("actions") + def get_create_plugin_paths(self, host_name): """Receive create plugin paths. @@ -125,6 +132,7 @@ class ITrayAddon(AYONInterface): tray_initialized = False _tray_manager = None + _admin_submenu = None @abstractmethod def tray_init(self): @@ -198,6 +206,27 @@ class ITrayAddon(AYONInterface): if hasattr(self.manager, "add_doubleclick_callback"): self.manager.add_doubleclick_callback(self, callback) + @staticmethod + def admin_submenu(tray_menu): + if ITrayAddon._admin_submenu is None: + from qtpy import QtWidgets + + admin_submenu = QtWidgets.QMenu("Admin", tray_menu) + admin_submenu.menuAction().setVisible(False) + ITrayAddon._admin_submenu = admin_submenu + return ITrayAddon._admin_submenu + + @staticmethod + def add_action_to_admin_submenu(label, tray_menu): + from qtpy import QtWidgets + + menu = ITrayAddon.admin_submenu(tray_menu) + action = QtWidgets.QAction(label, menu) + menu.addAction(action) + if not menu.menuAction().isVisible(): + menu.menuAction().setVisible(True) + return action + class ITrayAction(ITrayAddon): """Implementation of Tray action. @@ -211,7 +240,6 @@ class ITrayAction(ITrayAddon): """ admin_action = False - _admin_submenu = None _action_item = None @property @@ -229,12 +257,7 @@ class ITrayAction(ITrayAddon): from qtpy import QtWidgets if self.admin_action: - menu = self.admin_submenu(tray_menu) - action = QtWidgets.QAction(self.label, menu) - menu.addAction(action) - if not menu.menuAction().isVisible(): - menu.menuAction().setVisible(True) - + action = self.add_action_to_admin_submenu(self.label, tray_menu) else: action = QtWidgets.QAction(self.label, tray_menu) tray_menu.addAction(action) @@ -248,16 +271,6 @@ class ITrayAction(ITrayAddon): def tray_exit(self): return - @staticmethod - def admin_submenu(tray_menu): - if ITrayAction._admin_submenu is None: - from qtpy import QtWidgets - - admin_submenu = QtWidgets.QMenu("Admin", tray_menu) - admin_submenu.menuAction().setVisible(False) - ITrayAction._admin_submenu = admin_submenu - return ITrayAction._admin_submenu - class ITrayService(ITrayAddon): # Module's property diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index b80b243db2..6f89a6d17d 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -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,6 +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, +) @@ -146,7 +150,8 @@ def publish_report_viewer(): @main_cli.command() @click.argument("output_path") @click.option("--project", help="Define project context") -@click.option("--folder", help="Define folder in project (project must be set)") +@click.option( + "--folder", help="Define folder in project (project must be set)") @click.option( "--strict", is_flag=True, @@ -234,24 +239,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" @@ -262,8 +262,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) @@ -289,8 +289,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") diff --git a/client/ayon_core/hooks/pre_add_last_workfile_arg.py b/client/ayon_core/hooks/pre_add_last_workfile_arg.py index d5914c2352..bafc075888 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -26,10 +26,12 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "photoshop", "tvpaint", "substancepainter", + "substancedesigner", "aftereffects", "wrap", "openrv", - "cinema4d" + "cinema4d", + "silhouette", } launch_types = {LaunchTypes.local} diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 7406aa42cf..9f5c8c7339 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -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() diff --git a/client/ayon_core/host/dirmap.py b/client/ayon_core/host/dirmap.py index 19841845e7..3f02be6614 100644 --- a/client/ayon_core/host/dirmap.py +++ b/client/ayon_core/host/dirmap.py @@ -117,10 +117,7 @@ class HostDirmap(ABC): It checks if Site Sync is enabled and user chose to use local site, in that case configuration in Local Settings takes precedence """ - - dirmap_label = "{}-dirmap".format(self.host_name) - mapping_sett = self.project_settings[self.host_name].get(dirmap_label, - {}) + mapping_sett = self.project_settings[self.host_name].get("dirmap", {}) local_mapping = self._get_local_sync_dirmap() mapping_enabled = mapping_sett.get("enabled") or bool(local_mapping) if not mapping_enabled: diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 34956fd33f..6b334aa16a 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -6,82 +6,58 @@ import json import copy import warnings from abc import ABCMeta, abstractmethod -from typing import Any, Optional +import typing +from typing import ( + Any, + Optional, + List, + Set, + Dict, + Iterable, + TypeVar, +) 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]], + List[Any], + List[EnumItemDict] + ] + + + class FileDefItemDict(TypedDict): + directory: str + filenames: List[str] + frames: Optional[List[int]] + template: Optional[str] + is_sequence: Optional[bool] + + # Global variable which store attribute definitions by type # - default types are registered on import _attr_defs_by_type = {} - -def register_attr_def_class(cls): - """Register attribute definition. - - Currently registered definitions are used to deserialize data to objects. - - Attrs: - cls (AbstractAttrDef): Non-abstract class to be registered with unique - 'type' attribute. - - Raises: - KeyError: When type was already registered. - """ - - if cls.type in _attr_defs_by_type: - raise KeyError("Type \"{}\" was already registered".format(cls.type)) - _attr_defs_by_type[cls.type] = cls - - -def get_attributes_keys(attribute_definitions): - """Collect keys from list of attribute definitions. - - Args: - attribute_definitions (List[AbstractAttrDef]): Objects of attribute - definitions. - - Returns: - Set[str]: Keys that will be created using passed attribute definitions. - """ - - keys = set() - if not attribute_definitions: - return keys - - for attribute_def in attribute_definitions: - if not isinstance(attribute_def, UIDef): - keys.add(attribute_def.key) - return keys - - -def get_default_values(attribute_definitions): - """Receive default values for attribute definitions. - - Args: - attribute_definitions (List[AbstractAttrDef]): Attribute definitions - for which default values should be collected. - - Returns: - Dict[str, Any]: Default values for passed attribute definitions. - """ - - output = {} - if not attribute_definitions: - return output - - for attr_def in attribute_definitions: - # Skip UI definitions - if not isinstance(attr_def, UIDef): - output[attr_def.key] = attr_def.default - return output +# Type hint helpers +IntFloatType = "Union[int, float]" class AbstractAttrDefMeta(ABCMeta): """Metaclass to validate the existence of 'key' attribute. Each object of `AbstractAttrDef` must have defined 'key' attribute. - """ + """ def __call__(cls, *args, **kwargs): obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs) init_class = getattr(obj, "__init__class__", None) @@ -93,8 +69,12 @@ class AbstractAttrDefMeta(ABCMeta): def _convert_reversed_attr( - main_value, depr_value, main_label, depr_label, default -): + main_value: Any, + depr_value: Any, + main_label: str, + depr_label: str, + default: Any, +) -> Any: if main_value is not None and depr_value is not None: if main_value == depr_value: print( @@ -140,8 +120,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): enabled (Optional[bool]): Item is enabled (for UI purposes). hidden (Optional[bool]): DEPRECATED: Use 'visible' instead. disabled (Optional[bool]): DEPRECATED: Use 'enabled' instead. - """ + """ type_attributes = [] is_value_def = True @@ -183,7 +163,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): def id(self) -> str: return self._id - def clone(self): + def clone(self) -> "Self": data = self.serialize() data.pop("type") return self.deserialize(data) @@ -251,28 +231,28 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): Returns: str: Type of attribute definition. - """ + """ pass @abstractmethod - def convert_value(self, value): + def convert_value(self, value: Any) -> Any: """Convert value to a valid one. Convert passed value to a valid type. Use default if value can't be converted. - """ + """ pass - def serialize(self): + def serialize(self) -> Dict[str, Any]: """Serialize object to data so it's possible to recreate it. Returns: Dict[str, Any]: Serialized object that can be passed to 'deserialize' method. - """ + """ data = { "type": self.type, "key": self.key, @@ -288,7 +268,7 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): return data @classmethod - def deserialize(cls, data): + def deserialize(cls, data: Dict[str, Any]) -> "Self": """Recreate object from data. Data can be received using 'serialize' method. @@ -299,10 +279,12 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): return cls(**data) - def _def_type_compare(self, other: "AbstractAttrDef") -> bool: + def _def_type_compare(self, other: "Self") -> bool: return True +AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef) + # ----------------------------------------- # UI attribute definitions won't hold value # ----------------------------------------- @@ -310,13 +292,19 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): class UIDef(AbstractAttrDef): is_value_def = False - def __init__(self, key=None, default=None, *args, **kwargs): + def __init__( + self, + key: Optional[str] = None, + default: Optional[Any] = None, + *args, + **kwargs + ): super().__init__(key, default, *args, **kwargs) def is_value_valid(self, value: Any) -> bool: return True - def convert_value(self, value): + def convert_value(self, value: Any) -> Any: return value @@ -343,18 +331,18 @@ class UnknownDef(AbstractAttrDef): This attribute can be used to keep existing data unchanged but does not have known definition of type. - """ + """ type = "unknown" - def __init__(self, key, default=None, **kwargs): + def __init__(self, key: str, default: Optional[Any] = None, **kwargs): kwargs["default"] = default super().__init__(key, **kwargs) def is_value_valid(self, value: Any) -> bool: return True - def convert_value(self, value): + def convert_value(self, value: Any) -> Any: return value @@ -365,11 +353,11 @@ class HiddenDef(AbstractAttrDef): to other attributes (e.g. in multi-page UIs). Keep in mind the value should be possible to parse by json parser. - """ + """ type = "hidden" - def __init__(self, key, default=None, **kwargs): + def __init__(self, key: str, default: Optional[Any] = None, **kwargs): kwargs["default"] = default kwargs["visible"] = False super().__init__(key, **kwargs) @@ -377,7 +365,7 @@ class HiddenDef(AbstractAttrDef): def is_value_valid(self, value: Any) -> bool: return True - def convert_value(self, value): + def convert_value(self, value: Any) -> Any: return value @@ -392,8 +380,8 @@ class NumberDef(AbstractAttrDef): maximum(int, float): Maximum possible value. decimals(int): Maximum decimal points of value. default(int, float): Default value for conversion. - """ + """ type = "number" type_attributes = [ "minimum", @@ -402,7 +390,12 @@ class NumberDef(AbstractAttrDef): ] def __init__( - self, key, minimum=None, maximum=None, decimals=None, default=None, + self, + key: str, + minimum: Optional[IntFloatType] = None, + maximum: Optional[IntFloatType] = None, + decimals: Optional[int] = None, + default: Optional[IntFloatType] = None, **kwargs ): minimum = 0 if minimum is None else minimum @@ -428,9 +421,9 @@ class NumberDef(AbstractAttrDef): super().__init__(key, default=default, **kwargs) - self.minimum = minimum - self.maximum = maximum - self.decimals = 0 if decimals is None else decimals + self.minimum: IntFloatType = minimum + self.maximum: IntFloatType = maximum + self.decimals: int = 0 if decimals is None else decimals def is_value_valid(self, value: Any) -> bool: if self.decimals == 0: @@ -442,7 +435,7 @@ class NumberDef(AbstractAttrDef): return False return True - def convert_value(self, value): + def convert_value(self, value: Any) -> IntFloatType: if isinstance(value, str): try: value = float(value) @@ -477,8 +470,8 @@ class TextDef(AbstractAttrDef): regex(str, re.Pattern): Regex validation. placeholder(str): UI placeholder for attribute. default(str, None): Default value. Empty string used when not defined. - """ + """ type = "text" type_attributes = [ "multiline", @@ -486,7 +479,12 @@ class TextDef(AbstractAttrDef): ] def __init__( - self, key, multiline=None, regex=None, placeholder=None, default=None, + self, + key: str, + multiline: Optional[bool] = None, + regex: Optional[str] = None, + placeholder: Optional[str] = None, + default: Optional[str] = None, **kwargs ): if default is None: @@ -505,9 +503,9 @@ class TextDef(AbstractAttrDef): if isinstance(regex, str): regex = re.compile(regex) - self.multiline = multiline - self.placeholder = placeholder - self.regex = regex + self.multiline: bool = multiline + self.placeholder: Optional[str] = placeholder + self.regex: Optional["Pattern"] = regex def is_value_valid(self, value: Any) -> bool: if not isinstance(value, str): @@ -516,12 +514,12 @@ class TextDef(AbstractAttrDef): return False return True - def convert_value(self, value): + def convert_value(self, value: Any) -> str: if isinstance(value, str): return value return self.default - def serialize(self): + def serialize(self) -> Dict[str, Any]: data = super().serialize() regex = None if self.regex is not None: @@ -545,28 +543,46 @@ class EnumDef(AbstractAttrDef): is enabled. Args: - items (Union[list[str], list[dict[str, Any]]): Items definition that - can be converted using 'prepare_enum_items'. + key (str): Key under which value is stored. + items (EnumItemsInputType): Items definition that can be converted + using 'prepare_enum_items'. default (Optional[Any]): Default value. Must be one key(value) from 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, items, default=None, multiselection=False, **kwargs + 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: if default is None: default = [] @@ -577,9 +593,10 @@ class EnumDef(AbstractAttrDef): super().__init__(key, default=default, **kwargs) - self.items = items - self._item_values = item_values_set - self.multiselection = multiselection + 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: @@ -605,11 +622,12 @@ class EnumDef(AbstractAttrDef): def serialize(self): data = super().serialize() data["items"] = copy.deepcopy(self.items) - data["multiselection"] = self.multiselection return data @staticmethod - def prepare_enum_items(items): + def prepare_enum_items( + items: "EnumItemsInputType" + ) -> List["EnumItemDict"]: """Convert items to unified structure. Output is a list where each item is dictionary with 'value' @@ -625,13 +643,12 @@ class EnumDef(AbstractAttrDef): ``` Args: - items (Union[Dict[str, Any], List[Any], List[Dict[str, Any]]): The - items to convert. + items (EnumItemsInputType): The items to convert. Returns: - List[Dict[str, Any]]: Unified structure of items. - """ + List[EnumItemDict]: Unified structure of items. + """ output = [] if isinstance(items, dict): for value, label in items.items(): @@ -682,11 +699,11 @@ class BoolDef(AbstractAttrDef): Args: default(bool): Default value. Set to `False` if not defined. - """ + """ type = "bool" - def __init__(self, key, default=None, **kwargs): + def __init__(self, key: str, default: Optional[bool] = None, **kwargs): if default is None: default = False super().__init__(key, default=default, **kwargs) @@ -694,7 +711,7 @@ class BoolDef(AbstractAttrDef): def is_value_valid(self, value: Any) -> bool: return isinstance(value, bool) - def convert_value(self, value): + def convert_value(self, value: Any) -> bool: if isinstance(value, bool): return value return self.default @@ -702,7 +719,11 @@ class BoolDef(AbstractAttrDef): class FileDefItem: def __init__( - self, directory, filenames, frames=None, template=None + self, + directory: str, + filenames: List[str], + frames: Optional[List[int]] = None, + template: Optional[str] = None, ): self.directory = directory @@ -731,7 +752,7 @@ class FileDefItem: ) @property - def label(self): + def label(self) -> Optional[str]: if self.is_empty: return None @@ -774,7 +795,7 @@ class FileDefItem: filename_template, ",".join(ranges) ) - def split_sequence(self): + def split_sequence(self) -> List["Self"]: if not self.is_sequence: raise ValueError("Cannot split single file item") @@ -785,7 +806,7 @@ class FileDefItem: return self.from_paths(paths, False) @property - def ext(self): + def ext(self) -> Optional[str]: if self.is_empty: return None _, ext = os.path.splitext(self.filenames[0]) @@ -794,14 +815,14 @@ class FileDefItem: return None @property - def lower_ext(self): + def lower_ext(self) -> Optional[str]: ext = self.ext if ext is not None: return ext.lower() return ext @property - def is_dir(self): + def is_dir(self) -> bool: if self.is_empty: return False @@ -810,10 +831,15 @@ class FileDefItem: return False return True - def set_directory(self, directory): + def set_directory(self, directory: str): self.directory = directory - def set_filenames(self, filenames, frames=None, template=None): + def set_filenames( + self, + filenames: List[str], + frames: Optional[List[int]] = None, + template: Optional[str] = None, + ): if frames is None: frames = [] is_sequence = False @@ -830,17 +856,21 @@ class FileDefItem: self.is_sequence = is_sequence @classmethod - def create_empty_item(cls): + def create_empty_item(cls) -> "Self": return cls("", "") @classmethod - def from_value(cls, value, allow_sequences): + def from_value( + cls, + value: "Union[List[FileDefItemDict], FileDefItemDict]", + allow_sequences: bool, + ) -> List["Self"]: """Convert passed value to FileDefItem objects. Returns: list: Created FileDefItem objects. - """ + """ # Convert single item to iterable if not isinstance(value, (list, tuple, set)): value = [value] @@ -872,7 +902,7 @@ class FileDefItem: return output @classmethod - def from_dict(cls, data): + def from_dict(cls, data: "FileDefItemDict") -> "Self": return cls( data["directory"], data["filenames"], @@ -881,7 +911,11 @@ class FileDefItem: ) @classmethod - def from_paths(cls, paths, allow_sequences): + def from_paths( + cls, + paths: List[str], + allow_sequences: bool, + ) -> List["Self"]: filenames_by_dir = collections.defaultdict(list) for path in paths: normalized = os.path.normpath(path) @@ -910,7 +944,7 @@ class FileDefItem: return output - def to_dict(self): + def to_dict(self) -> "FileDefItemDict": output = { "is_sequence": self.is_sequence, "directory": self.directory, @@ -948,8 +982,15 @@ class FileDef(AbstractAttrDef): ] def __init__( - self, key, single_item=True, folders=None, extensions=None, - allow_sequences=True, extensions_label=None, default=None, **kwargs + self, + key: str, + single_item: Optional[bool] = True, + folders: Optional[bool] = None, + extensions: Optional[Iterable[str]] = None, + allow_sequences: Optional[bool] = True, + extensions_label: Optional[str] = None, + default: Optional["Union[FileDefItemDict, List[str]]"] = None, + **kwargs ): if folders is None and extensions is None: folders = True @@ -966,7 +1007,9 @@ class FileDef(AbstractAttrDef): FileDefItem.from_dict(default) elif isinstance(default, str): - default = FileDefItem.from_paths([default.strip()])[0] + default = FileDefItem.from_paths( + [default.strip()], allow_sequences + )[0] else: raise TypeError(( @@ -985,14 +1028,14 @@ class FileDef(AbstractAttrDef): if is_label_horizontal is None: kwargs["is_label_horizontal"] = False - self.single_item = single_item - self.folders = folders - self.extensions = set(extensions) - self.allow_sequences = allow_sequences - self.extensions_label = extensions_label + self.single_item: bool = single_item + self.folders: bool = folders + self.extensions: Set[str] = set(extensions) + self.allow_sequences: bool = allow_sequences + self.extensions_label: Optional[str] = extensions_label super().__init__(key, default=default, **kwargs) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: if not super().__eq__(other): return False @@ -1026,7 +1069,9 @@ class FileDef(AbstractAttrDef): return False return True - def convert_value(self, value): + def convert_value( + self, value: Any + ) -> "Union[FileDefItemDict, List[FileDefItemDict]]": if isinstance(value, (str, dict)): value = [value] @@ -1044,7 +1089,9 @@ class FileDef(AbstractAttrDef): pass if string_paths: - file_items = FileDefItem.from_paths(string_paths) + file_items = FileDefItem.from_paths( + string_paths, self.allow_sequences + ) dict_items.extend([ file_item.to_dict() for file_item in file_items @@ -1062,55 +1109,124 @@ class FileDef(AbstractAttrDef): return [] -def serialize_attr_def(attr_def): +def register_attr_def_class(cls: AttrDefType): + """Register attribute definition. + + Currently registered definitions are used to deserialize data to objects. + + Attrs: + cls (AttrDefType): Non-abstract class to be registered with unique + 'type' attribute. + + Raises: + KeyError: When type was already registered. + + """ + if cls.type in _attr_defs_by_type: + raise KeyError("Type \"{}\" was already registered".format(cls.type)) + _attr_defs_by_type[cls.type] = cls + + +def get_attributes_keys( + attribute_definitions: List[AttrDefType] +) -> Set[str]: + """Collect keys from list of attribute definitions. + + Args: + attribute_definitions (List[AttrDefType]): Objects of attribute + definitions. + + Returns: + Set[str]: Keys that will be created using passed attribute definitions. + + """ + keys = set() + if not attribute_definitions: + return keys + + for attribute_def in attribute_definitions: + if not isinstance(attribute_def, UIDef): + keys.add(attribute_def.key) + return keys + + +def get_default_values( + attribute_definitions: List[AttrDefType] +) -> Dict[str, Any]: + """Receive default values for attribute definitions. + + Args: + attribute_definitions (List[AttrDefType]): Attribute definitions + for which default values should be collected. + + Returns: + Dict[str, Any]: Default values for passed attribute definitions. + + """ + output = {} + if not attribute_definitions: + return output + + for attr_def in attribute_definitions: + # Skip UI definitions + if not isinstance(attr_def, UIDef): + output[attr_def.key] = attr_def.default + return output + + +def serialize_attr_def(attr_def: AttrDefType) -> Dict[str, Any]: """Serialize attribute definition to data. Args: - attr_def (AbstractAttrDef): Attribute definition to serialize. + attr_def (AttrDefType): Attribute definition to serialize. Returns: Dict[str, Any]: Serialized data. - """ + """ return attr_def.serialize() -def serialize_attr_defs(attr_defs): +def serialize_attr_defs( + attr_defs: List[AttrDefType] +) -> List[Dict[str, Any]]: """Serialize attribute definitions to data. Args: - attr_defs (List[AbstractAttrDef]): Attribute definitions to serialize. + attr_defs (List[AttrDefType]): Attribute definitions to serialize. Returns: List[Dict[str, Any]]: Serialized data. - """ + """ return [ serialize_attr_def(attr_def) for attr_def in attr_defs ] -def deserialize_attr_def(attr_def_data): +def deserialize_attr_def(attr_def_data: Dict[str, Any]) -> AttrDefType: """Deserialize attribute definition from data. Args: attr_def_data (Dict[str, Any]): Attribute definition data to deserialize. - """ + """ attr_type = attr_def_data.pop("type") cls = _attr_defs_by_type[attr_type] return cls.deserialize(attr_def_data) -def deserialize_attr_defs(attr_defs_data): +def deserialize_attr_defs( + attr_defs_data: List[Dict[str, Any]] +) -> List[AttrDefType]: """Deserialize attribute definitions. Args: List[Dict[str, Any]]: List of attribute definitions. - """ + """ return [ deserialize_attr_def(attr_def_data) for attr_def_data in attr_defs_data diff --git a/client/ayon_core/lib/env_tools.py b/client/ayon_core/lib/env_tools.py index 25bcbf7c1b..bc788a082d 100644 --- a/client/ayon_core/lib/env_tools.py +++ b/client/ayon_core/lib/env_tools.py @@ -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 diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index 95696fd272..516ea958f5 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -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 diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 690781151c..eff0068f00 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -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 ) @@ -276,12 +276,7 @@ class ASettingRegistry(ABC): @abstractmethod def _delete_item(self, name): # type: (str) -> None - """Delete item from settings. - - Note: - see :meth:`ayon_core.lib.user_settings.ARegistrySettings.delete_item` - - """ + """Delete item from settings.""" pass def __delitem__(self, name): @@ -433,12 +428,7 @@ class IniSettingRegistry(ASettingRegistry): config.write(cfg) def _delete_item(self, name): - """Delete item from default section. - - Note: - See :meth:`~ayon_core.lib.IniSettingsRegistry.delete_item_from_section` - - """ + """Delete item from default section.""" self.delete_item_from_section("MAIN", name) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index dc88ec956b..9e3e455a6c 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -1,9 +1,15 @@ import os import re +import copy import numbers +import warnings +from string import Formatter +import typing +from typing import List, Dict, Any, Set + +if typing.TYPE_CHECKING: + from typing import Union -KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})") -KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+") SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") @@ -18,9 +24,7 @@ class TemplateUnsolved(Exception): def __init__(self, template, missing_keys, invalid_types): invalid_type_items = [] for _key, _type in invalid_types.items(): - invalid_type_items.append( - "\"{0}\" {1}".format(_key, str(_type)) - ) + invalid_type_items.append(f"\"{_key}\" {str(_type)}") invalid_types_msg = "" if invalid_type_items: @@ -33,31 +37,32 @@ class TemplateUnsolved(Exception): missing_keys_msg = self.missing_keys_msg.format( ", ".join(missing_keys) ) - super(TemplateUnsolved, self).__init__( + super().__init__( self.msg.format(template, missing_keys_msg, invalid_types_msg) ) class StringTemplate: """String that can be formatted.""" - def __init__(self, template): + def __init__(self, template: str): if not isinstance(template, str): - raise TypeError("<{}> argument must be a string, not {}.".format( - self.__class__.__name__, str(type(template)) - )) + raise TypeError( + f"<{self.__class__.__name__}> argument must be a string," + f" not {str(type(template))}." + ) - self._template = template + self._template: str = template parts = [] - last_end_idx = 0 - for item in KEY_PATTERN.finditer(template): - start, end = item.span() - if start > last_end_idx: - parts.append(template[last_end_idx:start]) - parts.append(FormattingPart(template[start:end])) - last_end_idx = end + formatter = Formatter() - if last_end_idx < len(template): - parts.append(template[last_end_idx:len(template)]) + for item in formatter.parse(template): + literal_text, field_name, format_spec, conversion = item + if literal_text: + parts.append(literal_text) + if field_name: + parts.append( + FormattingPart(field_name, format_spec, conversion) + ) new_parts = [] for part in parts: @@ -77,15 +82,17 @@ class StringTemplate: if substr: new_parts.append(substr) - self._parts = self.find_optional_parts(new_parts) + self._parts: List["Union[str, OptionalPart, FormattingPart]"] = ( + self.find_optional_parts(new_parts) + ) - def __str__(self): + def __str__(self) -> str: return self.template - def __repr__(self): - return "<{}> {}".format(self.__class__.__name__, self.template) + def __repr__(self) -> str: + return f"<{self.__class__.__name__}> {self.template}" - def __contains__(self, other): + def __contains__(self, other: str) -> bool: return other in self.template def replace(self, *args, **kwargs): @@ -93,10 +100,10 @@ class StringTemplate: return self @property - def template(self): + def template(self) -> str: return self._template - def format(self, data): + def format(self, data: Dict[str, Any]) -> "TemplateResult": """ Figure out with whole formatting. Separate advanced keys (*Like '{project[name]}') from string which must @@ -108,6 +115,7 @@ class StringTemplate: Returns: TemplateResult: Filled or partially filled template containing all data needed or missing for filling template. + """ result = TemplatePartResult() for part in self._parts: @@ -135,23 +143,29 @@ class StringTemplate: invalid_types ) - def format_strict(self, *args, **kwargs): - result = self.format(*args, **kwargs) + def format_strict(self, data: Dict[str, Any]) -> "TemplateResult": + result = self.format(data) result.validate() return result @classmethod - def format_template(cls, template, data): + def format_template( + cls, template: str, data: Dict[str, Any] + ) -> "TemplateResult": objected_template = cls(template) return objected_template.format(data) @classmethod - def format_strict_template(cls, template, data): + def format_strict_template( + cls, template: str, data: Dict[str, Any] + ) -> "TemplateResult": objected_template = cls(template) return objected_template.format_strict(data) @staticmethod - def find_optional_parts(parts): + def find_optional_parts( + parts: List["Union[str, FormattingPart]"] + ) -> List["Union[str, OptionalPart, FormattingPart]"]: new_parts = [] tmp_parts = {} counted_symb = -1 @@ -216,11 +230,11 @@ class TemplateResult(str): of number. """ - used_values = None - solved = None - template = None - missing_keys = None - invalid_types = None + used_values: Dict[str, Any] = None + solved: bool = None + template: str = None + missing_keys: List[str] = None + invalid_types: Dict[str, Any] = None def __new__( cls, filled_template, template, solved, @@ -248,7 +262,7 @@ class TemplateResult(str): self.invalid_types ) - def copy(self): + def copy(self) -> "TemplateResult": cls = self.__class__ return cls( str(self), @@ -259,7 +273,7 @@ class TemplateResult(str): self.invalid_types ) - def normalized(self): + def normalized(self) -> "TemplateResult": """Convert to normalized path.""" cls = self.__class__ @@ -275,27 +289,28 @@ class TemplateResult(str): class TemplatePartResult: """Result to store result of template parts.""" - def __init__(self, optional=False): + def __init__(self, optional: bool = False): # Missing keys or invalid value types of required keys - self._missing_keys = set() - self._invalid_types = {} + self._missing_keys: Set[str] = set() + self._invalid_types: Dict[str, Any] = {} # Missing keys or invalid value types of optional keys - self._missing_optional_keys = set() - self._invalid_optional_types = {} + self._missing_optional_keys: Set[str] = set() + self._invalid_optional_types: Dict[str, Any] = {} # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} - self._used_values = {} + self._used_values: Dict[str, Any] = {} # Used values stored by key with all modifirs # - value is already formatted string # Example: {"version:0>3": "001"} - self._realy_used_values = {} + self._really_used_values: Dict[str, Any] = {} # Concatenated string output after formatting - self._output = "" + self._output: str = "" # Is this result from optional part - self._optional = True + # TODO find out why we don't use 'optional' from args + self._optional: bool = True def add_output(self, other): if isinstance(other, str): @@ -313,7 +328,7 @@ class TemplatePartResult: if other.optional and not other.solved: return self._used_values.update(other.used_values) - self._realy_used_values.update(other.realy_used_values) + self._really_used_values.update(other.really_used_values) else: raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( @@ -321,7 +336,7 @@ class TemplatePartResult: ) @property - def solved(self): + def solved(self) -> bool: if self.optional: if ( len(self.missing_optional_keys) > 0 @@ -334,45 +349,53 @@ class TemplatePartResult: ) @property - def optional(self): + def optional(self) -> bool: return self._optional @property - def output(self): + def output(self) -> str: return self._output @property - def missing_keys(self): + def missing_keys(self) -> Set[str]: return self._missing_keys @property - def missing_optional_keys(self): + def missing_optional_keys(self) -> Set[str]: return self._missing_optional_keys @property - def invalid_types(self): + def invalid_types(self) -> Dict[str, Any]: return self._invalid_types @property - def invalid_optional_types(self): + def invalid_optional_types(self) -> Dict[str, Any]: return self._invalid_optional_types @property - def realy_used_values(self): - return self._realy_used_values + def really_used_values(self) -> Dict[str, Any]: + return self._really_used_values @property - def used_values(self): + def realy_used_values(self) -> Dict[str, Any]: + warnings.warn( + "Property 'realy_used_values' is deprecated." + " Use 'really_used_values' instead.", + DeprecationWarning + ) + return self._really_used_values + + @property + def used_values(self) -> Dict[str, Any]: return self._used_values @staticmethod - def split_keys_to_subdicts(values): + def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]: output = {} + formatter = Formatter() for key, value in values.items(): - key_padding = list(KEY_PADDING_PATTERN.findall(key)) - if key_padding: - key = key_padding[0] - key_subdict = list(SUB_DICT_PATTERN.findall(key)) + _, field_name, _, _ = next(formatter.parse(f"{{{key}}}")) + key_subdict = list(SUB_DICT_PATTERN.findall(field_name)) data = output last_key = key_subdict.pop(-1) for subkey in key_subdict: @@ -382,7 +405,7 @@ class TemplatePartResult: data[last_key] = value return output - def get_clean_used_values(self): + def get_clean_used_values(self) -> Dict[str, Any]: new_used_values = {} for key, value in self.used_values.items(): if isinstance(value, FormatObject): @@ -391,19 +414,27 @@ class TemplatePartResult: return self.split_keys_to_subdicts(new_used_values) - def add_realy_used_value(self, key, value): - self._realy_used_values[key] = value + def add_really_used_value(self, key: str, value: Any): + self._really_used_values[key] = value - def add_used_value(self, key, value): + def add_realy_used_value(self, key: str, value: Any): + warnings.warn( + "Method 'add_realy_used_value' is deprecated." + " Use 'add_really_used_value' instead.", + DeprecationWarning + ) + self.add_really_used_value(key, value) + + def add_used_value(self, key: str, value: Any): self._used_values[key] = value - def add_missing_key(self, key): + def add_missing_key(self, key: str): if self._optional: self._missing_optional_keys.add(key) else: self._missing_keys.add(key) - def add_invalid_type(self, key, value): + def add_invalid_type(self, key: str, value: Any): if self._optional: self._invalid_optional_types[key] = type(value) else: @@ -421,10 +452,10 @@ class FormatObject: def __format__(self, *args, **kwargs): return self.value.__format__(*args, **kwargs) - def __str__(self): + def __str__(self) -> str: return str(self.value) - def __repr__(self): + def __repr__(self) -> str: return self.__str__() @@ -434,23 +465,44 @@ class FormattingPart: Containt only single key to format e.g. "{project[name]}". Args: - template(str): String containing the formatting key. + field_name (str): Name of key. + format_spec (str): Format specification. + conversion (Union[str, None]): Conversion type. + """ - def __init__(self, template): - self._template = template + def __init__( + self, + field_name: str, + format_spec: str, + conversion: "Union[str, None]", + ): + format_spec_v = "" + if format_spec: + format_spec_v = f":{format_spec}" + conversion_v = "" + if conversion: + conversion_v = f"!{conversion}" + + self._field_name: str = field_name + self._format_spec: str = format_spec_v + self._conversion: str = conversion_v + + template_base = f"{field_name}{format_spec_v}{conversion_v}" + self._template_base: str = template_base + self._template: str = f"{{{template_base}}}" @property - def template(self): + def template(self) -> str: return self._template - def __repr__(self): + def __repr__(self) -> str: return "".format(self._template) - def __str__(self): + def __str__(self) -> str: return self._template @staticmethod - def validate_value_type(value): + def validate_value_type(value: Any) -> bool: """Check if value can be used for formatting of single key.""" if isinstance(value, (numbers.Number, FormatObject)): return True @@ -461,7 +513,7 @@ class FormattingPart: return False @staticmethod - def validate_key_is_matched(key): + def validate_key_is_matched(key: str) -> bool: """Validate that opening has closing at correct place. Future-proof, only square brackets are currently used in keys. @@ -488,17 +540,27 @@ class FormattingPart: return False return not queue - def format(self, data, result): + @staticmethod + def keys_to_template_base(keys: List[str]): + if not keys: + return None + # Create copy of keys + keys = list(keys) + template_base = keys.pop(0) + joined_keys = "".join([f"[{key}]" for key in keys]) + return f"{template_base}{joined_keys}" + + def format( + self, data: Dict[str, Any], result: TemplatePartResult + ) -> TemplatePartResult: """Format the formattings string. Args: data(dict): Data that should be used for formatting. result(TemplatePartResult): Object where result is stored. + """ - key = self.template[1:-1] - if key in result.realy_used_values: - result.add_output(result.realy_used_values[key]) - return result + key = self._template_base # ensure key is properly formed [({})] properly closed. if not self.validate_key_is_matched(key): @@ -507,17 +569,38 @@ class FormattingPart: return result # check if key expects subdictionary keys (e.g. project[name]) - existence_check = key - key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) - if key_padding: - existence_check = key_padding[0] - key_subdict = list(SUB_DICT_PATTERN.findall(existence_check)) + key_subdict = list(SUB_DICT_PATTERN.findall(self._field_name)) value = data missing_key = False invalid_type = False used_keys = [] + keys_to_value = None + used_value = None + for sub_key in key_subdict: + if isinstance(value, list): + if not sub_key.lstrip("-").isdigit(): + invalid_type = True + break + sub_key = int(sub_key) + if sub_key < 0: + sub_key = len(value) + sub_key + + valid = 0 <= sub_key < len(value) + if not valid: + used_keys.append(sub_key) + missing_key = True + break + + used_keys.append(sub_key) + if keys_to_value is None: + keys_to_value = list(used_keys) + keys_to_value.pop(-1) + used_value = copy.deepcopy(value) + value = value[sub_key] + continue + if ( value is None or (hasattr(value, "items") and sub_key not in value) @@ -533,45 +616,57 @@ class FormattingPart: used_keys.append(sub_key) value = value.get(sub_key) - if missing_key or invalid_type: - if len(used_keys) == 0: - invalid_key = key_subdict[0] - else: - invalid_key = used_keys[0] - for idx, sub_key in enumerate(used_keys): - if idx == 0: - continue - invalid_key += "[{0}]".format(sub_key) + field_name = key_subdict[0] + if used_keys: + field_name = self.keys_to_template_base(used_keys) + if missing_key or invalid_type: if missing_key: - result.add_missing_key(invalid_key) + result.add_missing_key(field_name) elif invalid_type: - result.add_invalid_type(invalid_key, value) + result.add_invalid_type(field_name, value) result.add_output(self.template) return result - if self.validate_value_type(value): - fill_data = {} - first_value = True - for used_key in reversed(used_keys): - if first_value: - first_value = False - fill_data[used_key] = value - else: - _fill_data = {used_key: fill_data} - fill_data = _fill_data - - formatted_value = self.template.format(**fill_data) - result.add_realy_used_value(key, formatted_value) - result.add_used_value(existence_check, formatted_value) - result.add_output(formatted_value) + if not self.validate_value_type(value): + result.add_invalid_type(key, value) + result.add_output(self.template) return result - result.add_invalid_type(key, value) - result.add_output(self.template) + fill_data = root_fill_data = {} + parent_fill_data = None + parent_key = None + fill_value = data + value_filled = False + for used_key in used_keys: + if isinstance(fill_value, list): + parent_fill_data[parent_key] = fill_value + value_filled = True + break + fill_value = fill_value[used_key] + parent_fill_data = fill_data + fill_data = parent_fill_data.setdefault(used_key, {}) + parent_key = used_key + if not value_filled: + parent_fill_data[used_keys[-1]] = value + + template = f"{{{field_name}{self._format_spec}{self._conversion}}}" + formatted_value = template.format(**root_fill_data) + used_key = key + if keys_to_value is not None: + used_key = self.keys_to_template_base(keys_to_value) + + if used_value is None: + if isinstance(value, numbers.Number): + used_value = value + else: + used_value = formatted_value + result.add_really_used_value(self._field_name, used_value) + result.add_used_value(used_key, used_value) + result.add_output(formatted_value) return result @@ -585,20 +680,27 @@ class OptionalPart: 'FormattingPart'. """ - def __init__(self, parts): - self._parts = parts + def __init__( + self, + parts: List["Union[str, OptionalPart, FormattingPart]"] + ): + self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts @property - def parts(self): + def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]: return self._parts - def __str__(self): + def __str__(self) -> str: return "<{}>".format("".join([str(p) for p in self._parts])) - def __repr__(self): + def __repr__(self) -> str: return "".format("".join([str(p) for p in self._parts])) - def format(self, data, result): + def format( + self, + data: Dict[str, Any], + result: TemplatePartResult, + ) -> TemplatePartResult: new_result = TemplatePartResult(True) for part in self._parts: if isinstance(part, str): diff --git a/client/ayon_core/lib/python_2_comp.py b/client/ayon_core/lib/python_2_comp.py deleted file mode 100644 index 900db59062..0000000000 --- a/client/ayon_core/lib/python_2_comp.py +++ /dev/null @@ -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 -) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 7fe2c84789..64fd62c17a 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -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" } diff --git a/client/ayon_core/modules/__init__.py b/client/ayon_core/modules/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/client/ayon_core/modules/launcher_action.py b/client/ayon_core/modules/launcher_action.py deleted file mode 100644 index 344b0bc389..0000000000 --- a/client/ayon_core/modules/launcher_action.py +++ /dev/null @@ -1,60 +0,0 @@ -import os - -from ayon_core import AYON_CORE_ROOT -from ayon_core.addon import AYONAddon, ITrayAction - - -class LauncherAction(AYONAddon, ITrayAction): - label = "Launcher" - name = "launcher_tool" - version = "1.0.0" - - def initialize(self, settings): - - # Tray attributes - self._window = None - - def tray_init(self): - self._create_window() - - self.add_doubleclick_callback(self._show_launcher) - - def tray_start(self): - return - - def connect_with_addons(self, enabled_modules): - # Register actions - if not self.tray_initialized: - return - - from ayon_core.pipeline.actions import register_launcher_action_path - - actions_dir = os.path.join(AYON_CORE_ROOT, "plugins", "actions") - if os.path.exists(actions_dir): - register_launcher_action_path(actions_dir) - - actions_paths = self.manager.collect_plugin_paths()["actions"] - for path in actions_paths: - if path and os.path.exists(path): - register_launcher_action_path(path) - - def on_action_trigger(self): - """Implementation for ITrayAction interface. - - Show launcher tool on action trigger. - """ - - self._show_launcher() - - def _create_window(self): - if self._window: - return - from ayon_core.tools.launcher.ui import LauncherWindow - self._window = LauncherWindow() - - def _show_launcher(self): - if self._window is None: - return - self._window.show() - self._window.raise_() - self._window.activateWindow() diff --git a/client/ayon_core/modules/loader_action.py b/client/ayon_core/modules/loader_action.py deleted file mode 100644 index a58d7fd456..0000000000 --- a/client/ayon_core/modules/loader_action.py +++ /dev/null @@ -1,68 +0,0 @@ -from ayon_core.addon import AYONAddon, ITrayAddon - - -class LoaderAddon(AYONAddon, ITrayAddon): - name = "loader_tool" - version = "1.0.0" - - def initialize(self, settings): - # Tray attributes - self._loader_imported = None - self._loader_window = None - - def tray_init(self): - # Add library tool - self._loader_imported = False - try: - from ayon_core.tools.loader.ui import LoaderWindow # noqa F401 - - self._loader_imported = True - except Exception: - self.log.warning( - "Couldn't load Loader tool for tray.", - exc_info=True - ) - - # Definition of Tray menu - def tray_menu(self, tray_menu): - if not self._loader_imported: - return - - from qtpy import QtWidgets - # Actions - action_loader = QtWidgets.QAction( - "Loader", tray_menu - ) - - action_loader.triggered.connect(self.show_loader) - - tray_menu.addAction(action_loader) - - def tray_start(self, *_a, **_kw): - return - - def tray_exit(self, *_a, **_kw): - return - - def show_loader(self): - if self._loader_window is None: - from ayon_core.pipeline import install_ayon_plugins - - self._init_loader() - - install_ayon_plugins() - - self._loader_window.show() - - # Raise and activate the window - # for MacOS - self._loader_window.raise_() - # for Windows - self._loader_window.activateWindow() - - def _init_loader(self): - from ayon_core.tools.loader.ui import LoaderWindow - - libraryloader = LoaderWindow() - - self._loader_window = libraryloader diff --git a/client/ayon_core/modules/python_console_interpreter/__init__.py b/client/ayon_core/modules/python_console_interpreter/__init__.py deleted file mode 100644 index 8d5c23bdba..0000000000 --- a/client/ayon_core/modules/python_console_interpreter/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .addon import ( - PythonInterpreterAction -) - - -__all__ = ( - "PythonInterpreterAction", -) diff --git a/client/ayon_core/modules/python_console_interpreter/addon.py b/client/ayon_core/modules/python_console_interpreter/addon.py deleted file mode 100644 index b0dce2585e..0000000000 --- a/client/ayon_core/modules/python_console_interpreter/addon.py +++ /dev/null @@ -1,42 +0,0 @@ -from ayon_core.addon import AYONAddon, ITrayAction - - -class PythonInterpreterAction(AYONAddon, ITrayAction): - label = "Console" - name = "python_interpreter" - version = "1.0.0" - admin_action = True - - def initialize(self, settings): - self._interpreter_window = None - - def tray_init(self): - self.create_interpreter_window() - - def tray_exit(self): - if self._interpreter_window is not None: - self._interpreter_window.save_registry() - - def create_interpreter_window(self): - """Initializa Settings Qt window.""" - if self._interpreter_window: - return - - from ayon_core.modules.python_console_interpreter.window import ( - PythonInterpreterWidget - ) - - self._interpreter_window = PythonInterpreterWidget() - - def on_action_trigger(self): - self.show_interpreter_window() - - def show_interpreter_window(self): - self.create_interpreter_window() - - if self._interpreter_window.isVisible(): - self._interpreter_window.activateWindow() - self._interpreter_window.raise_() - return - - self._interpreter_window.show() diff --git a/client/ayon_core/modules/python_console_interpreter/window/__init__.py b/client/ayon_core/modules/python_console_interpreter/window/__init__.py deleted file mode 100644 index 92fd6f1df2..0000000000 --- a/client/ayon_core/modules/python_console_interpreter/window/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .widgets import ( - PythonInterpreterWidget -) - - -__all__ = ( - "PythonInterpreterWidget", -) diff --git a/client/ayon_core/modules/python_console_interpreter/window/widgets.py b/client/ayon_core/modules/python_console_interpreter/window/widgets.py deleted file mode 100644 index 628a2e72ff..0000000000 --- a/client/ayon_core/modules/python_console_interpreter/window/widgets.py +++ /dev/null @@ -1,660 +0,0 @@ -import os -import re -import sys -import collections -from code import InteractiveInterpreter - -import appdirs -from qtpy import QtCore, QtWidgets, QtGui - -from ayon_core import resources -from ayon_core.style import load_stylesheet -from ayon_core.lib import JSONSettingRegistry - - -ayon_art = r""" - - ▄██▄ - ▄███▄ ▀██▄ ▀██▀ ▄██▀ ▄██▀▀▀██▄ ▀███▄ █▄ - ▄▄ ▀██▄ ▀██▄ ▄██▀ ██▀ ▀██▄ ▄ ▀██▄ ███ - ▄██▀ ██▄ ▀ ▄▄ ▀ ██ ▄██ ███ ▀██▄ ███ - ▄██▀ ▀██▄ ██ ▀██▄ ▄██▀ ███ ▀██ ▀█▀ - ▄██▀ ▀██▄ ▀█ ▀██▄▄▄▄██▀ █▀ ▀██▄ - - · · - =[ by YNPUT ]:[ http://ayon.ynput.io ]= - · · - -""" - - -class PythonInterpreterRegistry(JSONSettingRegistry): - """Class handling OpenPype general settings registry. - - Attributes: - vendor (str): Name used for path construction. - product (str): Additional name used for path construction. - - """ - - def __init__(self): - self.vendor = "Ynput" - self.product = "AYON" - name = "python_interpreter_tool" - path = appdirs.user_data_dir(self.product, self.vendor) - super(PythonInterpreterRegistry, self).__init__(name, path) - - -class StdOEWrap: - def __init__(self): - self._origin_stdout_write = None - self._origin_stderr_write = None - self._listening = False - self.lines = collections.deque() - - if not sys.stdout: - sys.stdout = open(os.devnull, "w") - - if not sys.stderr: - sys.stderr = open(os.devnull, "w") - - if self._origin_stdout_write is None: - self._origin_stdout_write = sys.stdout.write - - if self._origin_stderr_write is None: - self._origin_stderr_write = sys.stderr.write - - self._listening = True - sys.stdout.write = self._stdout_listener - sys.stderr.write = self._stderr_listener - - def stop_listen(self): - self._listening = False - - def _stdout_listener(self, text): - if self._listening: - self.lines.append(text) - if self._origin_stdout_write is not None: - self._origin_stdout_write(text) - - def _stderr_listener(self, text): - if self._listening: - self.lines.append(text) - if self._origin_stderr_write is not None: - self._origin_stderr_write(text) - - -class PythonCodeEditor(QtWidgets.QPlainTextEdit): - execute_requested = QtCore.Signal() - - def __init__(self, parent): - super(PythonCodeEditor, self).__init__(parent) - - self.setObjectName("PythonCodeEditor") - - self._indent = 4 - - def _tab_shift_right(self): - cursor = self.textCursor() - selected_text = cursor.selectedText() - if not selected_text: - cursor.insertText(" " * self._indent) - return - - sel_start = cursor.selectionStart() - sel_end = cursor.selectionEnd() - cursor.setPosition(sel_end) - end_line = cursor.blockNumber() - cursor.setPosition(sel_start) - while True: - cursor.movePosition(QtGui.QTextCursor.StartOfLine) - text = cursor.block().text() - spaces = len(text) - len(text.lstrip(" ")) - new_spaces = spaces % self._indent - if not new_spaces: - new_spaces = self._indent - - cursor.insertText(" " * new_spaces) - if cursor.blockNumber() == end_line: - break - - cursor.movePosition(QtGui.QTextCursor.NextBlock) - - def _tab_shift_left(self): - tmp_cursor = self.textCursor() - sel_start = tmp_cursor.selectionStart() - sel_end = tmp_cursor.selectionEnd() - - cursor = QtGui.QTextCursor(self.document()) - cursor.setPosition(sel_end) - end_line = cursor.blockNumber() - cursor.setPosition(sel_start) - while True: - cursor.movePosition(QtGui.QTextCursor.StartOfLine) - text = cursor.block().text() - spaces = len(text) - len(text.lstrip(" ")) - if spaces: - spaces_to_remove = (spaces % self._indent) or self._indent - if spaces_to_remove > spaces: - spaces_to_remove = spaces - - cursor.setPosition( - cursor.position() + spaces_to_remove, - QtGui.QTextCursor.KeepAnchor - ) - cursor.removeSelectedText() - - if cursor.blockNumber() == end_line: - break - - cursor.movePosition(QtGui.QTextCursor.NextBlock) - - def keyPressEvent(self, event): - if event.key() == QtCore.Qt.Key_Backtab: - self._tab_shift_left() - event.accept() - return - - if event.key() == QtCore.Qt.Key_Tab: - if event.modifiers() == QtCore.Qt.NoModifier: - self._tab_shift_right() - event.accept() - return - - if ( - event.key() == QtCore.Qt.Key_Return - and event.modifiers() == QtCore.Qt.ControlModifier - ): - self.execute_requested.emit() - event.accept() - return - - super(PythonCodeEditor, self).keyPressEvent(event) - - -class PythonTabWidget(QtWidgets.QWidget): - add_tab_requested = QtCore.Signal() - before_execute = QtCore.Signal(str) - - def __init__(self, parent): - super(PythonTabWidget, self).__init__(parent) - - code_input = PythonCodeEditor(self) - - self.setFocusProxy(code_input) - - add_tab_btn = QtWidgets.QPushButton("Add tab...", self) - add_tab_btn.setToolTip("Add new tab") - - execute_btn = QtWidgets.QPushButton("Execute", self) - execute_btn.setToolTip("Execute command (Ctrl + Enter)") - - btns_layout = QtWidgets.QHBoxLayout() - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addWidget(add_tab_btn) - btns_layout.addStretch(1) - btns_layout.addWidget(execute_btn) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(code_input, 1) - layout.addLayout(btns_layout, 0) - - add_tab_btn.clicked.connect(self._on_add_tab_clicked) - execute_btn.clicked.connect(self._on_execute_clicked) - code_input.execute_requested.connect(self.execute) - - self._code_input = code_input - self._interpreter = InteractiveInterpreter() - - def _on_add_tab_clicked(self): - self.add_tab_requested.emit() - - def _on_execute_clicked(self): - self.execute() - - def get_code(self): - return self._code_input.toPlainText() - - def set_code(self, code_text): - self._code_input.setPlainText(code_text) - - def execute(self): - code_text = self._code_input.toPlainText() - self.before_execute.emit(code_text) - self._interpreter.runcode(code_text) - - -class TabNameDialog(QtWidgets.QDialog): - default_width = 330 - default_height = 85 - - def __init__(self, parent): - super(TabNameDialog, self).__init__(parent) - - self.setWindowTitle("Enter tab name") - - name_label = QtWidgets.QLabel("Tab name:", self) - name_input = QtWidgets.QLineEdit(self) - - inputs_layout = QtWidgets.QHBoxLayout() - inputs_layout.addWidget(name_label) - inputs_layout.addWidget(name_input) - - ok_btn = QtWidgets.QPushButton("Ok", self) - cancel_btn = QtWidgets.QPushButton("Cancel", self) - btns_layout = QtWidgets.QHBoxLayout() - btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn) - btns_layout.addWidget(cancel_btn) - - layout = QtWidgets.QVBoxLayout(self) - layout.addLayout(inputs_layout) - layout.addStretch(1) - layout.addLayout(btns_layout) - - ok_btn.clicked.connect(self._on_ok_clicked) - cancel_btn.clicked.connect(self._on_cancel_clicked) - - self._name_input = name_input - self._ok_btn = ok_btn - self._cancel_btn = cancel_btn - - self._result = None - - self.resize(self.default_width, self.default_height) - - def set_tab_name(self, name): - self._name_input.setText(name) - - def result(self): - return self._result - - def showEvent(self, event): - super(TabNameDialog, self).showEvent(event) - btns_width = max( - self._ok_btn.width(), - self._cancel_btn.width() - ) - - self._ok_btn.setMinimumWidth(btns_width) - self._cancel_btn.setMinimumWidth(btns_width) - - def _on_ok_clicked(self): - self._result = self._name_input.text() - self.accept() - - def _on_cancel_clicked(self): - self._result = None - self.reject() - - -class OutputTextWidget(QtWidgets.QTextEdit): - v_max_offset = 4 - - def vertical_scroll_at_max(self): - v_scroll = self.verticalScrollBar() - return v_scroll.value() > v_scroll.maximum() - self.v_max_offset - - def scroll_to_bottom(self): - v_scroll = self.verticalScrollBar() - return v_scroll.setValue(v_scroll.maximum()) - - -class EnhancedTabBar(QtWidgets.QTabBar): - double_clicked = QtCore.Signal(QtCore.QPoint) - right_clicked = QtCore.Signal(QtCore.QPoint) - mid_clicked = QtCore.Signal(QtCore.QPoint) - - def __init__(self, parent): - super(EnhancedTabBar, self).__init__(parent) - - self.setDrawBase(False) - - def mouseDoubleClickEvent(self, event): - self.double_clicked.emit(event.globalPos()) - event.accept() - - def mouseReleaseEvent(self, event): - if event.button() == QtCore.Qt.RightButton: - self.right_clicked.emit(event.globalPos()) - event.accept() - return - - elif event.button() == QtCore.Qt.MidButton: - self.mid_clicked.emit(event.globalPos()) - event.accept() - - else: - super(EnhancedTabBar, self).mouseReleaseEvent(event) - - -class PythonInterpreterWidget(QtWidgets.QWidget): - default_width = 1000 - default_height = 600 - - def __init__(self, allow_save_registry=True, parent=None): - super(PythonInterpreterWidget, self).__init__(parent) - - self.setWindowTitle("AYON Console") - self.setWindowIcon(QtGui.QIcon(resources.get_ayon_icon_filepath())) - - self.ansi_escape = re.compile( - r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]" - ) - - self._tabs = [] - - self._stdout_err_wrapper = StdOEWrap() - - output_widget = OutputTextWidget(self) - output_widget.setObjectName("PythonInterpreterOutput") - output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) - output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - - tab_widget = QtWidgets.QTabWidget(self) - tab_bar = EnhancedTabBar(tab_widget) - tab_widget.setTabBar(tab_bar) - tab_widget.setTabsClosable(False) - tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - - widgets_splitter = QtWidgets.QSplitter(self) - widgets_splitter.setOrientation(QtCore.Qt.Vertical) - widgets_splitter.addWidget(output_widget) - widgets_splitter.addWidget(tab_widget) - widgets_splitter.setStretchFactor(0, 1) - widgets_splitter.setStretchFactor(1, 1) - height = int(self.default_height / 2) - widgets_splitter.setSizes([height, self.default_height - height]) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(widgets_splitter) - - line_check_timer = QtCore.QTimer() - line_check_timer.setInterval(200) - - line_check_timer.timeout.connect(self._on_timer_timeout) - tab_bar.right_clicked.connect(self._on_tab_right_click) - tab_bar.double_clicked.connect(self._on_tab_double_click) - tab_bar.mid_clicked.connect(self._on_tab_mid_click) - tab_widget.tabCloseRequested.connect(self._on_tab_close_req) - - self._widgets_splitter = widgets_splitter - self._output_widget = output_widget - self._tab_widget = tab_widget - self._line_check_timer = line_check_timer - - self._append_lines([ayon_art]) - - self._first_show = True - self._splitter_size_ratio = None - self._allow_save_registry = allow_save_registry - self._registry_saved = True - - self._init_from_registry() - - if self._tab_widget.count() < 1: - self.add_tab("Python") - - def _init_from_registry(self): - setting_registry = PythonInterpreterRegistry() - width = None - height = None - try: - width = setting_registry.get_item("width") - height = setting_registry.get_item("height") - - except ValueError: - pass - - if width is None or width < 200: - width = self.default_width - - if height is None or height < 200: - height = self.default_height - - self.resize(width, height) - - try: - self._splitter_size_ratio = ( - setting_registry.get_item("splitter_sizes") - ) - - except ValueError: - pass - - try: - tab_defs = setting_registry.get_item("tabs") or [] - for tab_def in tab_defs: - widget = self.add_tab(tab_def["name"]) - widget.set_code(tab_def["code"]) - - except ValueError: - pass - - def save_registry(self): - # Window was not showed - if not self._allow_save_registry or self._registry_saved: - return - - self._registry_saved = True - setting_registry = PythonInterpreterRegistry() - - setting_registry.set_item("width", self.width()) - setting_registry.set_item("height", self.height()) - - setting_registry.set_item( - "splitter_sizes", self._widgets_splitter.sizes() - ) - - tabs = [] - for tab_idx in range(self._tab_widget.count()): - widget = self._tab_widget.widget(tab_idx) - tab_code = widget.get_code() - tab_name = self._tab_widget.tabText(tab_idx) - tabs.append({ - "name": tab_name, - "code": tab_code - }) - - setting_registry.set_item("tabs", tabs) - - def _on_tab_right_click(self, global_point): - point = self._tab_widget.mapFromGlobal(global_point) - tab_bar = self._tab_widget.tabBar() - tab_idx = tab_bar.tabAt(point) - last_index = tab_bar.count() - 1 - if tab_idx < 0 or tab_idx > last_index: - return - - menu = QtWidgets.QMenu(self._tab_widget) - - add_tab_action = QtWidgets.QAction("Add tab...", menu) - add_tab_action.setToolTip("Add new tab") - - rename_tab_action = QtWidgets.QAction("Rename...", menu) - rename_tab_action.setToolTip("Rename tab") - - duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu) - duplicate_tab_action.setToolTip("Duplicate code to new tab") - - close_tab_action = QtWidgets.QAction("Close", menu) - close_tab_action.setToolTip("Close tab and lose content") - close_tab_action.setEnabled(self._tab_widget.tabsClosable()) - - menu.addAction(add_tab_action) - menu.addAction(rename_tab_action) - menu.addAction(duplicate_tab_action) - menu.addAction(close_tab_action) - - result = menu.exec_(global_point) - if result is None: - return - - if result is rename_tab_action: - self._rename_tab_req(tab_idx) - - elif result is add_tab_action: - self._on_add_requested() - - elif result is duplicate_tab_action: - self._duplicate_requested(tab_idx) - - elif result is close_tab_action: - self._on_tab_close_req(tab_idx) - - def _rename_tab_req(self, tab_idx): - dialog = TabNameDialog(self) - dialog.set_tab_name(self._tab_widget.tabText(tab_idx)) - dialog.exec_() - tab_name = dialog.result() - if tab_name: - self._tab_widget.setTabText(tab_idx, tab_name) - - def _duplicate_requested(self, tab_idx=None): - if tab_idx is None: - tab_idx = self._tab_widget.currentIndex() - - src_widget = self._tab_widget.widget(tab_idx) - dst_widget = self._add_tab() - if dst_widget is None: - return - dst_widget.set_code(src_widget.get_code()) - - def _on_tab_mid_click(self, global_point): - point = self._tab_widget.mapFromGlobal(global_point) - tab_bar = self._tab_widget.tabBar() - tab_idx = tab_bar.tabAt(point) - last_index = tab_bar.count() - 1 - if tab_idx < 0 or tab_idx > last_index: - return - - self._on_tab_close_req(tab_idx) - - def _on_tab_double_click(self, global_point): - point = self._tab_widget.mapFromGlobal(global_point) - tab_bar = self._tab_widget.tabBar() - tab_idx = tab_bar.tabAt(point) - last_index = tab_bar.count() - 1 - if tab_idx < 0 or tab_idx > last_index: - return - - self._rename_tab_req(tab_idx) - - def _on_tab_close_req(self, tab_index): - if self._tab_widget.count() == 1: - return - - widget = self._tab_widget.widget(tab_index) - if widget in self._tabs: - self._tabs.remove(widget) - self._tab_widget.removeTab(tab_index) - - if self._tab_widget.count() == 1: - self._tab_widget.setTabsClosable(False) - - def _append_lines(self, lines): - at_max = self._output_widget.vertical_scroll_at_max() - tmp_cursor = QtGui.QTextCursor(self._output_widget.document()) - tmp_cursor.movePosition(QtGui.QTextCursor.End) - for line in lines: - tmp_cursor.insertText(line) - - if at_max: - self._output_widget.scroll_to_bottom() - - def _on_timer_timeout(self): - if self._stdout_err_wrapper.lines: - lines = [] - while self._stdout_err_wrapper.lines: - line = self._stdout_err_wrapper.lines.popleft() - lines.append(self.ansi_escape.sub("", line)) - self._append_lines(lines) - - def _on_add_requested(self): - self._add_tab() - - def _add_tab(self): - dialog = TabNameDialog(self) - dialog.exec_() - tab_name = dialog.result() - if tab_name: - return self.add_tab(tab_name) - - return None - - def _on_before_execute(self, code_text): - at_max = self._output_widget.vertical_scroll_at_max() - document = self._output_widget.document() - tmp_cursor = QtGui.QTextCursor(document) - tmp_cursor.movePosition(QtGui.QTextCursor.End) - tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-")) - - code_block_format = QtGui.QTextFrameFormat() - code_block_format.setBackground(QtGui.QColor(27, 27, 27)) - code_block_format.setPadding(4) - - tmp_cursor.insertFrame(code_block_format) - char_format = tmp_cursor.charFormat() - char_format.setForeground( - QtGui.QBrush(QtGui.QColor(114, 224, 198)) - ) - tmp_cursor.setCharFormat(char_format) - tmp_cursor.insertText(code_text) - - # Create new cursor - tmp_cursor = QtGui.QTextCursor(document) - tmp_cursor.movePosition(QtGui.QTextCursor.End) - tmp_cursor.insertText("{}\n".format(20 * "-")) - - if at_max: - self._output_widget.scroll_to_bottom() - - def add_tab(self, tab_name, index=None): - widget = PythonTabWidget(self) - widget.before_execute.connect(self._on_before_execute) - widget.add_tab_requested.connect(self._on_add_requested) - if index is None: - if self._tab_widget.count() > 0: - index = self._tab_widget.currentIndex() + 1 - else: - index = 0 - - self._tabs.append(widget) - self._tab_widget.insertTab(index, widget, tab_name) - self._tab_widget.setCurrentIndex(index) - - if self._tab_widget.count() > 1: - self._tab_widget.setTabsClosable(True) - widget.setFocus() - return widget - - def showEvent(self, event): - self._line_check_timer.start() - self._registry_saved = False - super(PythonInterpreterWidget, self).showEvent(event) - # First show setup - if self._first_show: - self._first_show = False - self._on_first_show() - - self._output_widget.scroll_to_bottom() - - def _on_first_show(self): - # Change stylesheet - self.setStyleSheet(load_stylesheet()) - # Check if splitter size ratio is set - # - first store value to local variable and then unset it - splitter_size_ratio = self._splitter_size_ratio - self._splitter_size_ratio = None - # Skip if is not set - if not splitter_size_ratio: - return - - # Skip if number of size items does not match to splitter - splitters_count = len(self._widgets_splitter.sizes()) - if len(splitter_size_ratio) == splitters_count: - self._widgets_splitter.setSizes(splitter_size_ratio) - - def closeEvent(self, event): - self.save_registry() - super(PythonInterpreterWidget, self).closeEvent(event) - self._line_check_timer.stop() diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 8e89029e7b..41bcd0dbd1 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -7,6 +7,10 @@ from .constants import ( from .anatomy import Anatomy +from .tempdir import get_temp_dir + +from .staging_dir import get_staging_dir_info + from .create import ( BaseCreator, Creator, @@ -117,6 +121,12 @@ __all__ = ( # --- Anatomy --- "Anatomy", + # --- Temp dir --- + "get_temp_dir", + + # --- Staging dir --- + "get_staging_dir_info", + # --- Create --- "BaseCreator", "Creator", diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 44c9e5d673..b9ae906ab4 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -585,9 +585,6 @@ def version_up_current_workfile(): """Function to increment and save workfile """ host = registered_host() - if not host.has_unsaved_changes(): - print("No unsaved changes, skipping file save..") - return project_name = get_current_project_name() folder_path = get_current_folder_path() diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6bfd64b822..24557234f4 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -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 @@ -1283,12 +1322,16 @@ class CreateContext: @contextmanager def bulk_pre_create_attr_defs_change(self, sender=None): - with self._bulk_context("pre_create_attrs_change", sender) as bulk_info: + with self._bulk_context( + "pre_create_attrs_change", sender + ) as bulk_info: yield bulk_info @contextmanager def bulk_create_attr_defs_change(self, sender=None): - with self._bulk_context("create_attrs_change", sender) as bulk_info: + with self._bulk_context( + "create_attrs_change", sender + ) as bulk_info: yield bulk_info @contextmanager @@ -1946,9 +1989,9 @@ class CreateContext: creator are just removed from context. Args: - instances (List[CreatedInstance]): Instances that should be removed. - Remove logic is done using creator, which may require to - do other cleanup than just remove instance from context. + instances (List[CreatedInstance]): Instances that should be + removed. Remove logic is done using creator, which may require + to do other cleanup than just remove instance from context. sender (Optional[str]): Sender of the event. """ diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index fe41d2fe65..cbc06145fb 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import os import copy import collections from typing import TYPE_CHECKING, Optional, Dict, Any @@ -6,7 +7,7 @@ from typing import TYPE_CHECKING, Optional, Dict, Any from abc import ABC, abstractmethod from ayon_core.settings import get_project_settings -from ayon_core.lib import Logger +from ayon_core.lib import Logger, get_version_from_path from ayon_core.pipeline.plugin_discover import ( discover, register_plugin, @@ -14,6 +15,7 @@ from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path ) +from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name @@ -560,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, @@ -831,6 +837,108 @@ class Creator(BaseCreator): """ return self.pre_create_attr_defs + def get_staging_dir(self, instance) -> Optional[StagingDir]: + """Return the staging dir and persistence from instance. + + Args: + instance (CreatedInstance): Instance for which should be staging + dir gathered. + + Returns: + Optional[namedtuple]: Staging dir path and persistence or None + """ + create_ctx = self.create_context + product_name = instance.get("productName") + product_type = instance.get("productType") + folder_path = instance.get("folderPath") + + # this can only work if product name and folder path are available + if not product_name or not folder_path: + return None + + publish_settings = self.project_settings["core"]["publish"] + follow_workfile_version = ( + publish_settings + ["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 and current_workfile: + workfile_version = get_version_from_path(current_workfile) + if workfile_version is not None: + version = int(workfile_version) + + # Fill-up version with next version available. + if version is None: + versions = self.get_next_versions_for_instances( + [instance] + ) + version, = tuple(versions.values()) + + template_data = {"version": version} + + staging_dir_info = get_staging_dir_info( + create_ctx.get_current_project_entity(), + create_ctx.get_folder_entity(folder_path), + create_ctx.get_task_entity(folder_path, instance.get("task")), + product_type, + product_name, + create_ctx.host_name, + anatomy=create_ctx.get_current_project_anatomy(), + project_settings=create_ctx.get_current_project_settings(), + always_return_path=False, + logger=self.log, + template_data=template_data, + ) + + return staging_dir_info or None + + def apply_staging_dir(self, instance): + """Apply staging dir with persistence to instance's transient data. + + Method is called on instance creation and on instance update. + + Args: + instance (CreatedInstance): Instance for which should be staging + dir applied. + + Returns: + Optional[str]: Staging dir path or None if not applied. + """ + staging_dir_info = self.get_staging_dir(instance) + if staging_dir_info is None: + return None + + # path might be already created by get_staging_dir_info + staging_dir_path = staging_dir_info.directory + os.makedirs(staging_dir_path, exist_ok=True) + + instance.transient_data.update({ + "stagingDir": staging_dir_path, + "stagingDir_persistent": staging_dir_info.is_persistent, + "stagingDir_is_custom": staging_dir_info.is_custom, + }) + + self.log.info(f"Applied staging dir to instance: {staging_dir_path}") + + return staging_dir_path + def _pre_create_attr_defs_changed(self): """Called when pre-create attribute definitions change. diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py index ec9b23ac62..f6427d9bd1 100644 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ b/client/ayon_core/pipeline/create/legacy_create.py @@ -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 diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index eaeef6500e..0daec8a7ad 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,5 +1,9 @@ import ayon_api -from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data +from ayon_core.lib import ( + StringTemplate, + filter_profiles, + prepare_template_data, +) from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index ba4a373597..17bb85b720 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -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. @@ -429,18 +433,26 @@ class CreatedInstance: __immutable_keys = ( "id", "instance_id", - "product_type", + "productType", "creator_identifier", "creator_attributes", "publish_attributes" ) + # Keys that can be changed, but should not be removed from instance + __required_keys = { + "folderPath": None, + "task": None, + "productName": None, + "active": True, + } 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 @@ -455,7 +467,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 {}) @@ -485,7 +499,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 @@ -515,6 +529,9 @@ class CreatedInstance: if data: self._data.update(data) + for key, default in self.__required_keys.items(): + self._data.setdefault(key, default) + if not self._data.get("instance_id"): self._data["instance_id"] = str(uuid4()) @@ -567,6 +584,8 @@ class CreatedInstance: has_key = key in self._data output = self._data.pop(key, *args, **kwargs) if has_key: + if key in self.__required_keys: + self._data[key] = self.__required_keys[key] self._create_context.instance_values_changed( self.id, {key: None} ) @@ -775,16 +794,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") @@ -797,7 +826,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): diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 366c261e08..55c840f3a5 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -387,7 +387,7 @@ def get_representations_delivery_template_data( # convert representation entity. Fixed in 'ayon_api' 1.0.10. if isinstance(template_data, str): con = ayon_api.get_server_api_connection() - repre_entity = con._representation_conversion(repre_entity) + con._representation_conversion(repre_entity) template_data = repre_entity["context"] template_data.update(copy.deepcopy(general_template_data)) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index a49a981d2a..8b6cfc52f1 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -1,6 +1,7 @@ import os import re import clique +import math import opentimelineio as otio from opentimelineio import opentime as _ot @@ -196,11 +197,11 @@ def is_clip_from_media_sequence(otio_clip): return is_input_sequence or is_input_sequence_legacy -def remap_range_on_file_sequence(otio_clip, in_out_range): +def remap_range_on_file_sequence(otio_clip, otio_range): """ Args: otio_clip (otio.schema.Clip): The OTIO clip to check. - in_out_range (tuple[float, float]): The in-out range to remap. + otio_range (otio.schema.TimeRange): The trim range to apply. Returns: tuple(int, int): The remapped range as discrete frame number. @@ -211,36 +212,59 @@ def remap_range_on_file_sequence(otio_clip, in_out_range): if not is_clip_from_media_sequence(otio_clip): raise ValueError(f"Cannot map on non-file sequence clip {otio_clip}.") - try: - media_in_trimmed, media_out_trimmed = in_out_range - - except ValueError as error: - raise ValueError("Invalid in_out_range provided.") from error - media_ref = otio_clip.media_reference available_range = otio_clip.available_range() - source_range = otio_clip.source_range available_range_rate = available_range.start_time.rate - media_in = available_range.start_time.value + + # 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(available_range_rate, 2) + rounded_range_rate = round(otio_range.start_time.rate, 2) + + if rounded_av_rate != rounded_range_rate: + raise ValueError("Inconsistent range between clip and provided clip") + + source_range = otio_clip.source_range + media_in = available_range.start_time + available_range_start_frame = ( + available_range.start_time.to_frames() + ) # Temporary. # Some AYON custom OTIO exporter were implemented with relative # source range for image sequence. Following code maintain # backward-compatibility by adjusting media_in # while we are updating those. + conformed_src_in = source_range.start_time.rescaled_to( + available_range_rate + ) if ( is_clip_from_media_sequence(otio_clip) - and otio_clip.available_range().start_time.to_frames() == media_ref.start_frame - and source_range.start_time.to_frames() < media_ref.start_frame + and available_range_start_frame == media_ref.start_frame + and conformed_src_in.to_frames() < media_ref.start_frame ): - media_in = 0 + media_in = otio.opentime.RationalTime( + 0, rate=available_range_rate + ) + src_offset_in = otio_range.start_time - media_in frame_in = otio.opentime.RationalTime.from_frames( - media_in_trimmed - media_in + media_ref.start_frame, + media_ref.start_frame + src_offset_in.to_frames(), 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( - media_out_trimmed - media_in + media_ref.start_frame, + frame_in + offset_duration, rate=available_range_rate, ).to_frames() @@ -258,21 +282,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): media_ref = otio_clip.media_reference is_input_sequence = is_clip_from_media_sequence(otio_clip) - # Temporary. - # Some AYON custom OTIO exporter were implemented with relative - # source range for image sequence. Following code maintain - # backward-compatibility by adjusting available range - # while we are updating those. - if ( - is_input_sequence - and available_range.start_time.to_frames() == media_ref.start_frame - and source_range.start_time.to_frames() < media_ref.start_frame - ): - available_range = _ot.TimeRange( - _ot.RationalTime(0, rate=available_range_rate), - available_range.duration, - ) - # Conform source range bounds to available range rate # .e.g. embedded TC of (3600 sec/ 1h), duration 100 frames # @@ -303,8 +312,12 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): rounded_av_rate = round(available_range_rate, 2) rounded_src_rate = round(source_range.start_time.rate, 2) if rounded_av_rate != rounded_src_rate: - conformed_src_in = source_range.start_time.rescaled_to(available_range_rate) - conformed_src_duration = source_range.duration.rescaled_to(available_range_rate) + conformed_src_in = source_range.start_time.rescaled_to( + available_range_rate + ) + conformed_src_duration = source_range.duration.rescaled_to( + available_range_rate + ) conformed_source_range = otio.opentime.TimeRange( start_time=conformed_src_in, duration=conformed_src_duration @@ -313,10 +326,24 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): else: conformed_source_range = source_range + # Temporary. + # Some AYON custom OTIO exporter were implemented with relative + # source range for image sequence. Following code maintain + # backward-compatibility by adjusting available range + # while we are updating those. + if ( + is_input_sequence + and available_range.start_time.to_frames() == media_ref.start_frame + and conformed_source_range.start_time.to_frames() < + media_ref.start_frame + ): + available_range = _ot.TimeRange( + _ot.RationalTime(0, rate=available_range_rate), + available_range.duration, + ) + # modifiers time_scalar = 1. - offset_in = 0 - offset_out = 0 time_warp_nodes = [] # Check for speed effects and adjust playback speed accordingly @@ -347,51 +374,134 @@ 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 - # 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 = available_range.start_time.value - media_out = available_range.end_time_inclusive().value - # If media source is an image sequence, returned # mediaIn/mediaOut have to correspond # to frame numbers from source sequence. if is_input_sequence: + + src_in = conformed_source_range.start_time + 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, + duration=retimed_duration, + ) + # preserve discrete frame numbers media_in_trimmed, media_out_trimmed = remap_range_on_file_sequence( otio_clip, - (media_in_trimmed, media_out_trimmed) + trim_range, ) media_in = media_ref.start_frame media_out = media_in + available_range.duration.to_frames() - 1 + else: + # compute retimed range + 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) @@ -410,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 } diff --git a/client/ayon_core/pipeline/entity_uri.py b/client/ayon_core/pipeline/entity_uri.py index 1dee9a1423..1362389ee9 100644 --- a/client/ayon_core/pipeline/entity_uri.py +++ b/client/ayon_core/pipeline/entity_uri.py @@ -18,13 +18,13 @@ def parse_ayon_entity_uri(uri: str) -> Optional[dict]: Example: >>> parse_ayon_entity_uri( - >>> "ayon://test/char/villain?product=modelMain&version=2&representation=usd" # noqa: E501 + >>> "ayon://test/char/villain?product=modelMain&version=2&representation=usd" >>> ) {'project': 'test', 'folderPath': '/char/villain', 'product': 'modelMain', 'version': 1, 'representation': 'usd'} >>> parse_ayon_entity_uri( - >>> "ayon+entity://project/folder?product=renderMain&version=3&representation=exr" # noqa: E501 + >>> "ayon+entity://project/folder?product=renderMain&version=3&representation=exr" >>> ) {'project': 'project', 'folderPath': '/folder', 'product': 'renderMain', 'version': 3, @@ -34,7 +34,7 @@ def parse_ayon_entity_uri(uri: str) -> Optional[dict]: dict[str, Union[str, int]]: The individual key with their values as found in the ayon entity URI. - """ + """ # noqa: E501 if not (uri.startswith("ayon+entity://") or uri.startswith("ayon://")): return {} diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 98951b2766..c6f3ae7115 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -1,3 +1,4 @@ +from __future__ import annotations import copy import os import re @@ -8,7 +9,10 @@ import attr import ayon_api import clique from ayon_core.lib import Logger -from ayon_core.pipeline import get_current_project_name, get_representation_path +from ayon_core.pipeline import ( + get_current_project_name, + get_representation_path, +) from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline.farm.patterning import match_aov_pattern from ayon_core.pipeline.publish import KnownPublishError @@ -244,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"): @@ -295,11 +300,17 @@ def _add_review_families(families): return families -def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, - skip_integration_repre_list, - do_not_add_review, - context, - color_managed_plugin): +def prepare_representations( + skeleton_data, + exp_files, + anatomy, + aov_filter, + skip_integration_repre_list, + do_not_add_review, + context, + color_managed_plugin, + frames_to_render=None +): """Create representations for file sequences. This will return representations of expected files if they are not @@ -315,6 +326,8 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, 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 | None): implicit or explicit range of frames + to render this value is sent to Deadline in JobInfo.Frames Returns: list of representations @@ -325,6 +338,14 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, log = Logger.get_logger("farm_publishing") + if frames_to_render is not None: + frames_to_render = convert_frames_str_to_list(frames_to_render) + else: + # Backwards compatibility for older logic + frame_start = int(skeleton_data.get("frameStartHandle")) + frame_end = int(skeleton_data.get("frameEndHandle")) + frames_to_render = list(range(frame_start, frame_end + 1)) + # create representation for every collected sequence for collection in collections: ext = collection.tail.lstrip(".") @@ -361,20 +382,27 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, " This may cause issues on farm." ).format(staging)) - frame_start = int(skeleton_data.get("frameStartHandle")) + frame_start = frames_to_render[0] + frame_end = frames_to_render[-1] if skeleton_data.get("slate"): frame_start -= 1 + frames_to_render.insert(0, frame_start) + 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": [os.path.basename(f) for f in list(collection)], - "frameStart": frame_start, - "frameEnd": int(skeleton_data.get("frameEndHandle")), - # If expectedFile are absolute, we need only filenames + "files": filenames, "stagingDir": staging, + "frameStart": frame_start, + "frameEnd": frame_end, "fps": skeleton_data.get("fps"), "tags": ["review"] if preview else [], } @@ -453,9 +481,93 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, return representations -def create_instances_for_aov(instance, skeleton, aov_filter, - skip_integration_repre_list, - do_not_add_review): +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. + + """ + step_pattern = re.compile(r"(?:step|by|every|x|:)(\d+)$") + + output = [] + step = 1 + for frame in frames.split(","): + if "-" in frame: + 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: + output.append(int(frame)) + output.sort() + return output + + +def _get_real_files_to_render(collection, frames_to_render): + """Filter files with frames that should be really rendered. + + 'expected_files' are collected from DCC based on timeline setting. This is + being calculated differently in each DCC. Filtering here is on single place + + But artists might explicitly set frames they want to render in Publisher UI + 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]: absolute paths of files to be rendered + + + """ + included_frames = set(collection.indexes).intersection(frames_to_render) + real_collection = clique.Collection( + collection.head, + collection.tail, + collection.padding, + indexes=included_frames + ) + return list(real_collection) + + +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 @@ -467,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 @@ -513,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 ) @@ -642,8 +756,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 @@ -657,7 +778,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 @@ -677,10 +799,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, @@ -691,7 +826,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 @@ -702,9 +837,14 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, project_settings = instance.context.data.get("project_settings") - use_legacy_product_name = True try: - use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501 + use_legacy_product_name = ( + project_settings + ["core"] + ["tools"] + ["creator"] + ["use_legacy_product_names_for_renders"] + ) except KeyError: warnings.warn( ("use_legacy_for_renders not found in project settings. " @@ -720,7 +860,9 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, dynamic_data=dynamic_data) else: - product_name, group_name = get_product_name_and_group_from_template( + ( + product_name, group_name + ) = get_product_name_and_group_from_template( task_entity=instance.data["taskEntity"], project_name=instance.context.data["projectName"], host_name=instance.context.data["hostName"], @@ -729,10 +871,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) @@ -740,7 +880,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 @@ -797,10 +937,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": { @@ -863,7 +1003,7 @@ def _collect_expected_files_for_aov(files): # but we really expect only one collection. # Nothing else make sense. if len(cols) != 1: - raise ValueError("Only one image sequence type is expected.") # noqa: E501 + raise ValueError("Only one image sequence type is expected.") return list(cols[0]) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 1fb906fd65..b601914acd 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -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() diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index ee2c1af07f..de8e1676e7 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -465,7 +465,9 @@ def update_container(container, version=-1): from ayon_core.pipeline import get_current_project_name # Compute the different version from 'representation' - project_name = get_current_project_name() + project_name = container.get("project_name") + if project_name is None: + project_name = get_current_project_name() repre_id = container["representation"] if not _is_valid_representation_id(repre_id): raise ValueError( @@ -542,9 +544,6 @@ def update_container(container, version=-1): ) ) - path = get_representation_path(new_representation) - if not path or not os.path.exists(path): - raise ValueError("Path {} doesn't exist".format(path)) project_entity = ayon_api.get_project(project_name) context = { "project": project_entity, @@ -553,6 +552,9 @@ def update_container(container, version=-1): "version": new_version, "representation": new_representation, } + path = get_representation_path_from_context(context) + if not path or not os.path.exists(path): + raise ValueError("Path {} doesn't exist".format(path)) return Loader().update(container, context) @@ -588,7 +590,9 @@ def switch_container(container, representation, loader_plugin=None): ) # Get the new representation to switch to - project_name = get_current_project_name() + project_name = container.get("project_name") + if project_name is None: + project_name = get_current_project_name() context = get_representation_context( project_name, representation["id"] diff --git a/client/ayon_core/pipeline/project_folders.py b/client/ayon_core/pipeline/project_folders.py index 902b969457..def2af9ba1 100644 --- a/client/ayon_core/pipeline/project_folders.py +++ b/client/ayon_core/pipeline/project_folders.py @@ -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"] diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index ac71239acf..5363e0b378 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -3,6 +3,7 @@ from .constants import ( ValidateContentsOrder, ValidateSceneOrder, ValidateMeshOrder, + FARM_JOB_ENV_DATA_KEY, ) from .publish_plugins import ( @@ -59,6 +60,7 @@ __all__ = ( "ValidateContentsOrder", "ValidateSceneOrder", "ValidateMeshOrder", + "FARM_JOB_ENV_DATA_KEY", "AbstractMetaInstancePlugin", "AbstractMetaContextPlugin", diff --git a/client/ayon_core/pipeline/publish/constants.py b/client/ayon_core/pipeline/publish/constants.py index 38f5ffef3f..a33e8f9eed 100644 --- a/client/ayon_core/pipeline/publish/constants.py +++ b/client/ayon_core/pipeline/publish/constants.py @@ -8,4 +8,5 @@ ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3 DEFAULT_PUBLISH_TEMPLATE = "default" DEFAULT_HERO_PUBLISH_TEMPLATE = "default" -TRANSIENT_DIR_TEMPLATE = "default" + +FARM_JOB_ENV_DATA_KEY: str = "farmJobEnv" diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index dc2eef3bb9..cc5f67c74b 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -2,7 +2,7 @@ import os import sys import inspect import copy -import tempfile +import warnings import xml.etree.ElementTree from typing import Optional, Union, List @@ -18,15 +18,11 @@ from ayon_core.lib import ( ) from ayon_core.settings import get_project_settings from ayon_core.addon import AddonsManager -from ayon_core.pipeline import ( - tempdir, - Anatomy -) +from ayon_core.pipeline import get_staging_dir_info from ayon_core.pipeline.plugin_discover import DiscoverResult from .constants import ( DEFAULT_PUBLISH_TEMPLATE, DEFAULT_HERO_PUBLISH_TEMPLATE, - TRANSIENT_DIR_TEMPLATE ) @@ -468,6 +464,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. @@ -581,58 +583,6 @@ def context_plugin_should_run(plugin, context): return False -def get_instance_staging_dir(instance): - """Unified way how staging dir is stored and created on instances. - - First check if 'stagingDir' is already set in instance data. - In case there already is new tempdir will not be created. - - It also supports `AYON_TMPDIR`, so studio can define own temp - shared repository per project or even per more granular context. - Template formatting is supported also with optional keys. Folder is - created in case it doesn't exists. - - Available anatomy formatting keys: - - root[work | ] - - project[name | code] - - Note: - Staging dir does not have to be necessarily in tempdir so be careful - about its usage. - - Args: - instance (pyblish.lib.Instance): Instance for which we want to get - staging dir. - - Returns: - str: Path to staging dir of instance. - """ - staging_dir = instance.data.get('stagingDir') - if staging_dir: - return staging_dir - - anatomy = instance.context.data.get("anatomy") - - # get customized tempdir path from `AYON_TMPDIR` env var - custom_temp_dir = tempdir.create_custom_tempdir( - anatomy.project_name, anatomy) - - if custom_temp_dir: - staging_dir = os.path.normpath( - tempfile.mkdtemp( - prefix="pyblish_tmp_", - dir=custom_temp_dir - ) - ) - else: - staging_dir = os.path.normpath( - tempfile.mkdtemp(prefix="pyblish_tmp_") - ) - instance.data['stagingDir'] = staging_dir - - return staging_dir - - def get_publish_repre_path(instance, repre, only_published=False): """Get representation path that can be used for integration. @@ -685,6 +635,8 @@ def get_publish_repre_path(instance, repre, only_published=False): return None +# deprecated: backward compatibility only (2024-09-12) +# TODO: remove in the future def get_custom_staging_dir_info( project_name, host_name, @@ -694,67 +646,88 @@ def get_custom_staging_dir_info( product_name, project_settings=None, anatomy=None, - log=None + log=None, ): - """Checks profiles if context should use special custom dir as staging. + from ayon_core.pipeline.staging_dir import get_staging_dir_config + warnings.warn( + ( + "Function 'get_custom_staging_dir_info' in" + " 'ayon_core.pipeline.publish' is deprecated. Please use" + " 'get_custom_staging_dir_info'" + " in 'ayon_core.pipeline.stagingdir'." + ), + DeprecationWarning, + ) + tr_data = get_staging_dir_config( + project_name, + task_type, + task_name, + product_type, + product_name, + host_name, + project_settings=project_settings, + anatomy=anatomy, + log=log, + ) - Args: - project_name (str) - host_name (str) - product_type (str) - task_name (str) - task_type (str) - product_name (str) - project_settings(Dict[str, Any]): Prepared project settings. - anatomy (Dict[str, Any]) - log (Logger) (optional) + if not tr_data: + return None, None + + return tr_data["template"], tr_data["persistence"] + + +def get_instance_staging_dir(instance): + """Unified way how staging dir is stored and created on instances. + + First check if 'stagingDir' is already set in instance data. + In case there already is new tempdir will not be created. Returns: - (tuple) - Raises: - ValueError - if misconfigured template should be used + str: Path to staging dir """ - settings = project_settings or get_project_settings(project_name) - custom_staging_dir_profiles = (settings["core"] - ["tools"] - ["publish"] - ["custom_staging_dir_profiles"]) - if not custom_staging_dir_profiles: - return None, None + staging_dir = instance.data.get("stagingDir") - if not log: - log = Logger.get_logger("get_custom_staging_dir_info") + if staging_dir: + return staging_dir - filtering_criteria = { - "hosts": host_name, - "families": product_type, - "task_names": task_name, - "task_types": task_type, - "subsets": product_name - } - profile = filter_profiles(custom_staging_dir_profiles, - filtering_criteria, - logger=log) + anatomy_data = instance.data["anatomyData"] + template_data = copy.deepcopy(anatomy_data) - if not profile or not profile["active"]: - return None, None + # context data based variables + context = instance.context - if not anatomy: - anatomy = Anatomy(project_name) + # add current file as workfile name into formatting data + current_file = context.data.get("currentFile") + if current_file: + workfile = os.path.basename(current_file) + workfile_name, _ = os.path.splitext(workfile) + template_data["workfile_name"] = workfile_name - template_name = profile["template_name"] or TRANSIENT_DIR_TEMPLATE - - custom_staging_dir = anatomy.get_template_item( - "staging", template_name, "directory", default=None + staging_dir_info = get_staging_dir_info( + context.data["projectEntity"], + instance.data.get("folderEntity"), + instance.data.get("taskEntity"), + instance.data["productType"], + instance.data["productName"], + context.data["hostName"], + anatomy=context.data["anatomy"], + project_settings=context.data["project_settings"], + template_data=template_data, + always_return_path=True, + username=context.data["user"], ) - if custom_staging_dir is None: - raise ValueError(( - "Anatomy of project \"{}\" does not have set" - " \"{}\" template key!" - ).format(project_name, template_name)) - is_persistent = profile["custom_staging_dir_persistent"] - return custom_staging_dir.template, is_persistent + staging_dir_path = staging_dir_info.directory + + # path might be already created by get_staging_dir_info + os.makedirs(staging_dir_path, exist_ok=True) + instance.data.update({ + "stagingDir": staging_dir_path, + "stagingDir_persistent": staging_dir_info.is_persistent, + "stagingDir_is_custom": staging_dir_info.is_custom + }) + + return staging_dir_path def get_published_workfile_instance(context): @@ -799,7 +772,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True): return # determine published path from Anatomy. - template_data = workfile_instance.data.get("anatomyData") + template_data = copy.deepcopy(workfile_instance.data["anatomyData"]) rep = workfile_instance.data["representations"][0] template_data["representation"] = rep.get("name") template_data["ext"] = rep.get("ext") diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index d2c70894cc..cc6887e762 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -205,9 +205,9 @@ class AYONPyblishPluginMixin: if not cls.__instanceEnabled__: return False - for _ in pyblish.logic.plugins_by_families( - [cls], [instance.product_type] - ): + families = [instance.product_type] + families.extend(instance.get("families", [])) + for _ in pyblish.logic.plugins_by_families([cls], families): return True return False @@ -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): diff --git a/client/ayon_core/pipeline/staging_dir.py b/client/ayon_core/pipeline/staging_dir.py new file mode 100644 index 0000000000..1cb2979415 --- /dev/null +++ b/client/ayon_core/pipeline/staging_dir.py @@ -0,0 +1,242 @@ +import logging +import warnings +from typing import Optional, Dict, Any +from dataclasses import dataclass + +from ayon_core.lib import Logger, filter_profiles +from ayon_core.settings import get_project_settings + +from .template_data import get_template_data +from .anatomy import Anatomy +from .tempdir import get_temp_dir + + +@dataclass +class StagingDir: + directory: str + is_persistent: bool + # Whether the staging dir is a custom staging dir + is_custom: bool + + def __setattr__(self, key, value): + if key == "persistent": + warnings.warn( + "'StagingDir.persistent' is deprecated." + " Use 'StagingDir.is_persistent' instead.", + DeprecationWarning + ) + key = "is_persistent" + super().__setattr__(key, value) + + @property + def persistent(self): + warnings.warn( + "'StagingDir.persistent' is deprecated." + " Use 'StagingDir.is_persistent' instead.", + DeprecationWarning + ) + return self.is_persistent + + +def get_staging_dir_config( + project_name: str, + task_type: Optional[str], + task_name: Optional[str], + product_type: str, + product_name: str, + host_name: str, + project_settings: Optional[Dict[str, Any]] = None, + anatomy: Optional[Anatomy] = None, + log: Optional[logging.Logger] = None, +) -> Optional[Dict[str, Any]]: + """Get matching staging dir profile. + + Args: + host_name (str): Name of host. + project_name (str): Name of project. + task_type (Optional[str]): Type of task. + task_name (Optional[str]): Name of task. + product_type (str): Type of product. + product_name (str): Name of product. + project_settings(Dict[str, Any]): Prepared project settings. + anatomy (Dict[str, Any]) + log (Optional[logging.Logger]) + + Returns: + Dict or None: Data with directory template and is_persistent or None + + Raises: + KeyError - if misconfigured template should be used + + """ + settings = project_settings or get_project_settings(project_name) + + staging_dir_profiles = settings["core"]["tools"]["publish"][ + "custom_staging_dir_profiles" + ] + + if not staging_dir_profiles: + return None + + if not log: + log = Logger.get_logger("get_staging_dir_config") + + filtering_criteria = { + "hosts": host_name, + "task_types": task_type, + "task_names": task_name, + "product_types": product_type, + "product_names": product_name, + } + profile = filter_profiles( + staging_dir_profiles, filtering_criteria, logger=log) + + if not profile or not profile["active"]: + return None + + if not anatomy: + anatomy = Anatomy(project_name) + + # get template from template name + template_name = profile["template_name"] + + template = anatomy.get_template_item("staging", template_name) + + if not template: + # template should always be found either from anatomy or from profile + raise KeyError( + f"Staging template '{template_name}' was not found." + "Check project anatomy or settings at: " + "'ayon+settings://core/tools/publish/custom_staging_dir_profiles'" + ) + + data_persistence = profile["custom_staging_dir_persistent"] + + return {"template": template, "persistence": data_persistence} + + +def get_staging_dir_info( + project_entity: Dict[str, Any], + folder_entity: Optional[Dict[str, Any]], + task_entity: Optional[Dict[str, Any]], + product_type: str, + product_name: str, + host_name: str, + anatomy: Optional[Anatomy] = None, + project_settings: Optional[Dict[str, Any]] = None, + template_data: Optional[Dict[str, Any]] = None, + always_return_path: bool = True, + force_tmp_dir: bool = False, + logger: Optional[logging.Logger] = None, + prefix: Optional[str] = None, + suffix: Optional[str] = None, + username: Optional[str] = None, +) -> Optional[StagingDir]: + """Get staging dir info data. + + If `force_temp` is set, staging dir will be created as tempdir. + If `always_get_some_dir` is set, staging dir will be created as tempdir if + no staging dir profile is found. + If `prefix` or `suffix` is not set, default values will be used. + + Arguments: + project_entity (Dict[str, Any]): Project entity. + folder_entity (Optional[Dict[str, Any]]): Folder entity. + task_entity (Optional[Dict[str, Any]]): Task entity. + product_type (str): Type of product. + product_name (str): Name of product. + host_name (str): Name of host. + anatomy (Optional[Anatomy]): Anatomy object. + project_settings (Optional[Dict[str, Any]]): Prepared project settings. + template_data (Optional[Dict[str, Any]]): Additional data for + formatting staging dir template. + always_return_path (Optional[bool]): If True, staging dir will be + created as tempdir if no staging dir profile is found. Input value + False will return None if no staging dir profile is found. + force_tmp_dir (Optional[bool]): If True, staging dir will be created as + tempdir. + 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 + + """ + log = logger or Logger.get_logger("get_staging_dir_info") + + if anatomy is None: + anatomy = Anatomy( + project_entity["name"], project_entity=project_entity + ) + + if force_tmp_dir: + return StagingDir( + get_temp_dir( + project_name=project_entity["name"], + anatomy=anatomy, + prefix=prefix, + suffix=suffix, + ), + is_persistent=False, + is_custom=False + ) + + # making few queries to database + ctx_data = get_template_data( + project_entity, folder_entity, task_entity, host_name, + settings=project_settings, + username=username + ) + + # add additional data + ctx_data["product"] = { + "type": product_type, + "name": product_name + } + + # add additional template formatting data + if template_data: + ctx_data.update(template_data) + + task_name = task_type = None + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + # get staging dir config + staging_dir_config = get_staging_dir_config( + project_entity["name"], + task_type, + task_name , + product_type, + product_name, + host_name, + project_settings=project_settings, + anatomy=anatomy, + log=log, + ) + + if staging_dir_config: + dir_template = staging_dir_config["template"]["directory"] + return StagingDir( + dir_template.format_strict(ctx_data), + is_persistent=staging_dir_config["persistence"], + is_custom=True + ) + + # no config found but force an output + if always_return_path: + return StagingDir( + get_temp_dir( + project_name=project_entity["name"], + anatomy=anatomy, + prefix=prefix, + suffix=suffix, + ), + is_persistent=False, + is_custom=False + ) + + return None diff --git a/client/ayon_core/pipeline/tempdir.py b/client/ayon_core/pipeline/tempdir.py index d8f42ea60a..38b03f5c85 100644 --- a/client/ayon_core/pipeline/tempdir.py +++ b/client/ayon_core/pipeline/tempdir.py @@ -3,11 +3,90 @@ Temporary folder operations """ import os +import tempfile +from pathlib import Path +import warnings + from ayon_core.lib import StringTemplate from ayon_core.pipeline import Anatomy +def get_temp_dir( + project_name, anatomy=None, prefix=None, suffix=None, use_local_temp=False +): + """Get temporary dir path. + + If `use_local_temp` is set, tempdir will be created in local tempdir. + If `anatomy` is not set, default anatomy will be used. + If `prefix` or `suffix` is not set, default values will be used. + + It also supports `AYON_TMPDIR`, so studio can define own temp + shared repository per project or even per more granular context. + Template formatting is supported also with optional keys. Folder is + created in case it doesn't exists. + + Args: + project_name (str): Name of project. + anatomy (Optional[Anatomy]): Project Anatomy object. + suffix (Optional[str]): Suffix for tempdir. + prefix (Optional[str]): Prefix for tempdir. + use_local_temp (Optional[bool]): If True, temp dir will be created in + local tempdir. + + Returns: + str: Path to staging dir of instance. + + """ + if prefix is None: + prefix = "ay_tmp_" + suffix = suffix or "" + + if use_local_temp: + return _create_local_staging_dir(prefix, suffix) + + # make sure anatomy is set + if not anatomy: + anatomy = Anatomy(project_name) + + # get customized tempdir path from `OPENPYPE_TMPDIR` env var + custom_temp_dir = _create_custom_tempdir(anatomy.project_name, anatomy) + + return _create_local_staging_dir(prefix, suffix, dirpath=custom_temp_dir) + + +def _create_local_staging_dir(prefix, suffix, dirpath=None): + """Create local staging dir + + Args: + prefix (str): prefix for tempdir + suffix (str): suffix for tempdir + dirpath (Optional[str]): path to tempdir + + Returns: + str: path to tempdir + """ + # use pathlib for creating tempdir + return tempfile.mkdtemp( + prefix=prefix, suffix=suffix, dir=dirpath + ) + + def create_custom_tempdir(project_name, anatomy=None): + """Backward compatibility deprecated since 2024/12/09. + """ + warnings.warn( + "Used deprecated 'create_custom_tempdir' " + "use 'ayon_core.pipeline.tempdir.get_temp_dir' instead.", + DeprecationWarning, + ) + + if anatomy is None: + anatomy = Anatomy(project_name) + + return _create_custom_tempdir(project_name, anatomy) + + +def _create_custom_tempdir(project_name, anatomy): """ Create custom tempdir Template path formatting is supporting: @@ -18,42 +97,35 @@ def create_custom_tempdir(project_name, anatomy=None): Args: project_name (str): project name - anatomy (ayon_core.pipeline.Anatomy)[optional]: Anatomy object + anatomy (ayon_core.pipeline.Anatomy): Anatomy object Returns: str | None: formatted path or None """ env_tmpdir = os.getenv("AYON_TMPDIR") if not env_tmpdir: - return + return None custom_tempdir = None if "{" in env_tmpdir: - if anatomy is None: - anatomy = Anatomy(project_name) # create base formate data - data = { + template_data = { "root": anatomy.roots, "project": { "name": anatomy.project_name, "code": anatomy.project_code, - } + }, } # path is anatomy template custom_tempdir = StringTemplate.format_template( - env_tmpdir, data).normalized() + env_tmpdir, template_data) + + custom_tempdir_path = Path(custom_tempdir) else: # path is absolute - custom_tempdir = env_tmpdir + custom_tempdir_path = Path(env_tmpdir) - # create the dir path if it doesn't exists - if not os.path.exists(custom_tempdir): - try: - # create it if it doesn't exists - os.makedirs(custom_tempdir) - except IOError as error: - raise IOError( - "Path couldn't be created: {}".format(error)) + custom_tempdir_path.mkdir(parents=True, exist_ok=True) - return custom_tempdir + return custom_tempdir_path.as_posix() diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py index d5f06d6a59..0a95a98be8 100644 --- a/client/ayon_core/pipeline/template_data.py +++ b/client/ayon_core/pipeline/template_data.py @@ -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 } @@ -87,14 +92,13 @@ def get_folder_template_data(folder_entity, project_name): """ path = folder_entity["path"] - hierarchy_parts = path.split("/") - # Remove empty string from the beginning - hierarchy_parts.pop(0) + # Remove empty string from the beginning and split by '/' + parents = path.lstrip("/").split("/") # Remove last part which is folder name - folder_name = hierarchy_parts.pop(-1) - hierarchy = "/".join(hierarchy_parts) - if hierarchy_parts: - parent_name = hierarchy_parts[-1] + folder_name = parents.pop(-1) + hierarchy = "/".join(parents) + if parents: + parent_name = parents[-1] else: parent_name = project_name @@ -103,6 +107,7 @@ def get_folder_template_data(folder_entity, project_name): "name": folder_name, "type": folder_entity["folderType"], "path": path, + "parents": parents, }, "asset": folder_name, "hierarchy": hierarchy, @@ -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( diff --git a/client/ayon_core/pipeline/workfile/path_resolving.py b/client/ayon_core/pipeline/workfile/path_resolving.py index 47d6f4ddfa..61c6e5b876 100644 --- a/client/ayon_core/pipeline/workfile/path_resolving.py +++ b/client/ayon_core/pipeline/workfile/path_resolving.py @@ -2,6 +2,7 @@ import os import re import copy import platform +from typing import Optional, Dict, Any import ayon_api @@ -16,12 +17,12 @@ from ayon_core.pipeline.template_data import get_template_data def get_workfile_template_key_from_context( - project_name, - folder_path, - task_name, - host_name, - project_settings=None -): + project_name: str, + folder_path: str, + task_name: str, + host_name: str, + project_settings: Optional[Dict[str, Any]] = None, +) -> str: """Helper function to get template key for workfile template. Do the same as `get_workfile_template_key` but returns value for "session @@ -34,15 +35,23 @@ def get_workfile_template_key_from_context( host_name (str): Host name. project_settings (Dict[str, Any]): Project settings for passed 'project_name'. Not required at all but makes function faster. - """ + Returns: + str: Workfile template name. + + """ folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path, fields={"id"} + project_name, + folder_path, + fields={"id"}, ) task_entity = ayon_api.get_task_by_name( - project_name, folder_entity["id"], task_name + project_name, + folder_entity["id"], + task_name, + fields={"taskType"}, ) - task_type = task_entity.get("type") + task_type = task_entity.get("taskType") return get_workfile_template_key( project_name, task_type, host_name, project_settings diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 4412e4489b..27da278c5e 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -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: diff --git a/client/ayon_core/plugins/actions/open_file_explorer.py b/client/ayon_core/plugins/actions/open_file_explorer.py index 50a3107444..e96392ec00 100644 --- a/client/ayon_core/plugins/actions/open_file_explorer.py +++ b/client/ayon_core/plugins/actions/open_file_explorer.py @@ -99,7 +99,7 @@ class OpenTaskPath(LauncherAction): if platform_name == "windows": args = ["start", path] elif platform_name == "darwin": - args = ["open", "-na", path] + args = ["open", "-R", path] elif platform_name == "linux": args = ["xdg-open", path] else: diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index a0bd57d7dc..677ebb04a2 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -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.") @@ -413,14 +413,16 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Backwards compatible (Deprecated since 24/06/06) or instance.data.get("newAssetPublishing") ): - hierarchy = instance.data["hierarchy"] - anatomy_data["hierarchy"] = hierarchy + folder_path = instance.data["folderPath"] + parents = folder_path.lstrip("/").split("/") + folder_name = parents.pop(-1) parent_name = project_entity["name"] - if hierarchy: - parent_name = hierarchy.split("/")[-1] + hierarchy = "" + if parents: + parent_name = parents[-1] + hierarchy = "/".join(parents) - folder_name = instance.data["folderPath"].split("/")[-1] anatomy_data.update({ "asset": folder_name, "hierarchy": hierarchy, @@ -432,6 +434,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Using 'Shot' is current default behavior of editorial # (or 'newHierarchyIntegration') publishing. "type": "Shot", + "parents": parents, }, }) diff --git a/client/ayon_core/plugins/publish/collect_custom_staging_dir.py b/client/ayon_core/plugins/publish/collect_custom_staging_dir.py deleted file mode 100644 index 49c3a98dd2..0000000000 --- a/client/ayon_core/plugins/publish/collect_custom_staging_dir.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Requires: - anatomy - - -Provides: - instance.data -> stagingDir (folder path) - -> stagingDir_persistent (bool) -""" -import copy -import os.path - -import pyblish.api - -from ayon_core.pipeline.publish.lib import get_custom_staging_dir_info - - -class CollectCustomStagingDir(pyblish.api.InstancePlugin): - """Looks through profiles if stagingDir should be persistent and in special - location. - - Transient staging dir could be useful in specific use cases where is - desirable to have temporary renders in specific, persistent folders, could - be on disks optimized for speed for example. - - It is studio responsibility to clean up obsolete folders with data. - - Location of the folder is configured in `project_anatomy/templates/others`. - ('transient' key is expected, with 'folder' key) - - Which family/task type/product is applicable is configured in: - `project_settings/global/tools/publish/custom_staging_dir_profiles` - - """ - label = "Collect Custom Staging Directory" - order = pyblish.api.CollectorOrder + 0.4990 - - template_key = "transient" - - def process(self, instance): - product_type = instance.data["productType"] - product_name = instance.data["productName"] - host_name = instance.context.data["hostName"] - project_name = instance.context.data["projectName"] - project_settings = instance.context.data["project_settings"] - anatomy = instance.context.data["anatomy"] - task = instance.data["anatomyData"].get("task", {}) - - transient_tml, is_persistent = get_custom_staging_dir_info( - project_name, - host_name, - product_type, - product_name, - task.get("name"), - task.get("type"), - project_settings=project_settings, - anatomy=anatomy, - log=self.log) - - if transient_tml: - anatomy_data = copy.deepcopy(instance.data["anatomyData"]) - anatomy_data["root"] = anatomy.roots - scene_name = instance.context.data.get("currentFile") - if scene_name: - anatomy_data["scene_name"] = os.path.basename(scene_name) - transient_dir = transient_tml.format(**anatomy_data) - instance.data["stagingDir"] = transient_dir - - instance.data["stagingDir_persistent"] = is_persistent - result_str = "Adding '{}' as".format(transient_dir) - else: - result_str = "Not adding" - - self.log.debug("{} custom staging dir for instance with '{}'".format( - result_str, product_type - )) diff --git a/client/ayon_core/plugins/publish/collect_farm_env_variables.py b/client/ayon_core/plugins/publish/collect_farm_env_variables.py new file mode 100644 index 0000000000..2782ea86ac --- /dev/null +++ b/client/ayon_core/plugins/publish/collect_farm_env_variables.py @@ -0,0 +1,46 @@ +import os + +import pyblish.api + +from ayon_core.lib import get_ayon_username +from ayon_core.pipeline.publish import FARM_JOB_ENV_DATA_KEY + + +class CollectCoreJobEnvVars(pyblish.api.ContextPlugin): + """Collect set of environment variables to submit with deadline jobs""" + order = pyblish.api.CollectorOrder - 0.45 + label = "AYON core Farm Environment Variables" + targets = ["local"] + + def process(self, context): + env = context.data.setdefault(FARM_JOB_ENV_DATA_KEY, {}) + + # Disable colored logs on farm + for key, value in ( + ("AYON_LOG_NO_COLORS", "1"), + ("AYON_PROJECT_NAME", context.data["projectName"]), + ("AYON_FOLDER_PATH", context.data.get("folderPath")), + ("AYON_TASK_NAME", context.data.get("task")), + # NOTE we should use 'context.data["user"]' but that has higher + # order. + ("AYON_USERNAME", get_ayon_username()), + ("AYON_HOST_NAME", context.data["hostName"]), + ): + if value: + self.log.debug(f"Setting job env: {key}: {value}") + env[key] = value + + for key in [ + "AYON_BUNDLE_NAME", + "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 + diff --git a/client/ayon_core/plugins/publish/collect_hierarchy.py b/client/ayon_core/plugins/publish/collect_hierarchy.py index 2ae3cc67f3..266c2e1458 100644 --- a/client/ayon_core/plugins/publish/collect_hierarchy.py +++ b/client/ayon_core/plugins/publish/collect_hierarchy.py @@ -13,8 +13,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): label = "Collect Hierarchy" order = pyblish.api.CollectorOrder - 0.076 - families = ["shot"] - hosts = ["resolve", "hiero", "flame"] + hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, context): project_name = context.data["projectName"] @@ -32,36 +31,50 @@ class CollectHierarchy(pyblish.api.ContextPlugin): product_type = instance.data["productType"] families = instance.data["families"] - # exclude other families then self.families with intersection - if not set(self.families).intersection( - set(families + [product_type]) - ): + # exclude other families then "shot" with intersection + if "shot" not in (families + [product_type]): + self.log.debug("Skipping not a shot: {}".format(families)) continue - # exclude if not masterLayer True + # Skip if is not a hero track if not instance.data.get("heroTrack"): + self.log.debug("Skipping not a shot from hero track") continue shot_data = { "entity_type": "folder", - # WARNING Default folder type is hardcoded - # suppose that all instances are Shots - "folder_type": "Shot", + # WARNING unless overwritten, default folder type is hardcoded + # to shot + "folder_type": instance.data.get("folder_type") or "Shot", "tasks": instance.data.get("tasks") or {}, "comments": instance.data.get("comments", []), - "attributes": { - "handleStart": instance.data["handleStart"], - "handleEnd": instance.data["handleEnd"], - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], - "clipIn": instance.data["clipIn"], - "clipOut": instance.data["clipOut"], - "fps": instance.data["fps"], - "resolutionWidth": instance.data["resolutionWidth"], - "resolutionHeight": instance.data["resolutionHeight"], - "pixelAspect": instance.data["pixelAspect"], - }, } + + shot_data["attributes"] = {} + SHOT_ATTRS = ( + "handleStart", + "handleEnd", + "frameStart", + "frameEnd", + "clipIn", + "clipOut", + "fps", + "resolutionWidth", + "resolutionHeight", + "pixelAspect", + ) + for shot_attr in SHOT_ATTRS: + attr_value = instance.data.get(shot_attr) + if attr_value is None: + # Shot attribute might not be defined (e.g. CSV ingest) + self.log.debug( + "%s shot attribute is not defined for instance.", + shot_attr + ) + continue + + shot_data["attributes"][shot_attr] = attr_value + # Split by '/' for AYON where asset is a path name = instance.data["folderPath"].split("/")[-1] actual = {name: shot_data} diff --git a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py new file mode 100644 index 0000000000..1034b9a716 --- /dev/null +++ b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py @@ -0,0 +1,47 @@ +""" +Requires: + anatomy + + +Provides: + instance.data -> stagingDir (folder path) + -> stagingDir_persistent (bool) +""" + +import pyblish.api + +from ayon_core.pipeline.publish import get_instance_staging_dir + + +class CollectManagedStagingDir(pyblish.api.InstancePlugin): + """Apply matching Staging Dir profile to a instance. + + Apply Staging dir via profiles could be useful in specific use cases + where is desirable to have temporary renders in specific, + persistent folders, could be on disks optimized for speed for example. + + It is studio's responsibility to clean up obsolete folders with data. + + Location of the folder is configured in: + `ayon+anatomy://_/templates/staging`. + + Which family/task type/subset is applicable is configured in: + `ayon+settings://core/tools/publish/custom_staging_dir_profiles` + """ + + label = "Collect Managed Staging Directory" + order = pyblish.api.CollectorOrder + 0.4990 + + def process(self, instance): + """ Collect the staging data and stores it to the instance. + + Args: + instance (object): The instance to inspect. + """ + staging_dir_path = get_instance_staging_dir(instance) + persistance = instance.data.get("stagingDir_persistent", False) + + self.log.info(( + f"Instance staging dir was set to `{staging_dir_path}` " + f"and persistence is set to `{persistance}`" + )) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index d1c8d03212..0a4efc2172 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -1,74 +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. + + Args: + instance: The instance to process + """ + if not validate_otio_clip(instance, self.log): + return + + 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"] + + # Get timeline ranges + otio_tl_range = otio_clip.range_in_parent() + otio_tl_range_handles = otio_range_with_handles( + otio_tl_range, + instance ) - # get basic variables - otio_clip = instance.data["otioClip"] - workfile_start = instance.data["workfileFrameStart"] - workfile_source_duration = instance.data.get("shotDurationFromSource") + # 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) - # get 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) - - # 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 - 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, @@ -77,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}") diff --git a/client/ayon_core/plugins/publish/collect_otio_review.py b/client/ayon_core/plugins/publish/collect_otio_review.py index 69cf9199e7..064d4e3f3b 100644 --- a/client/ayon_core/plugins/publish/collect_otio_review.py +++ b/client/ayon_core/plugins/publish/collect_otio_review.py @@ -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 @@ -95,9 +107,42 @@ class CollectOtioReview(pyblish.api.InstancePlugin): instance.data["label"] = label + " (review)" instance.data["families"] += ["review", "ftrack"] instance.data["otioReviewClips"] = otio_review_clips + self.log.info( "Creating review track: {}".format(otio_review_clips)) + # get colorspace from metadata if available + # get metadata from first clip with media reference + r_otio_cl = next( + ( + clip + for clip in otio_review_clips + if ( + isinstance(clip, otio.schema.Clip) + and clip.media_reference + ) + ), + None + ) + if r_otio_cl is not None: + media_ref = r_otio_cl.media_reference + media_metadata = media_ref.metadata + + # TODO: we might need some alternative method since + # native OTIO exports do not support ayon metadata + review_colorspace = media_metadata.get( + "ayon.source.colorspace" + ) + if review_colorspace is None: + # Backwards compatibility for older scenes + review_colorspace = media_metadata.get( + "openpype.source.colourtransform" + ) + if review_colorspace: + instance.data["reviewColorspace"] = review_colorspace + self.log.info( + "Review colorspace: {}".format(review_colorspace)) + self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) self.log.debug( diff --git a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py index 37a5e87a7a..f1fa6a817d 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -6,16 +6,21 @@ Provides: instance -> otioReviewClips """ import os +import math import clique import pyblish.api +from ayon_core.pipeline import publish from ayon_core.pipeline.publish import ( get_publish_template_name ) -class CollectOtioSubsetResources(pyblish.api.InstancePlugin): +class CollectOtioSubsetResources( + pyblish.api.InstancePlugin, + publish.ColormanagedPyblishPluginMixin +): """Get Resources for a product version""" label = "Collect OTIO Subset Resources" @@ -65,9 +70,17 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): 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"]) @@ -169,9 +182,18 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): 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) + + # TODO: discuss this, it erases frame number. + repre = self._create_representation( + frame_start, frame_end, file=filename) + else: _trim = False @@ -187,12 +209,18 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): repre = self._create_representation( frame_start, frame_end, file=filename, trim=_trim) + instance.data["originalDirname"] = self.staging_dir + # add representation to instance data if repre: - # add representation to instance data + colorspace = instance.data.get("colorspace") + # add colorspace data to representation + self.set_representation_colorspace( + repre, instance.context, colorspace) + instance.data["representations"].append(repre) - self.log.debug(">>>>>>>> {}".format(repre)) + self.log.debug(instance.data) @@ -213,7 +241,8 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): representation_data = { "frameStart": start, "frameEnd": end, - "stagingDir": self.staging_dir + "stagingDir": self.staging_dir, + "tags": [], } if kwargs.get("collection"): @@ -239,8 +268,10 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): "frameEnd": end, }) - if kwargs.get("trim") is True: - representation_data["tags"] = ["trim"] + for tag_name in ("trim", "delete", "review"): + if kwargs.get(tag_name) is True: + representation_data["tags"].append(tag_name) + return representation_data def get_template_name(self, instance): diff --git a/client/ayon_core/plugins/publish/collect_rendered_files.py b/client/ayon_core/plugins/publish/collect_rendered_files.py index 42ba096d14..deecf7ba24 100644 --- a/client/ayon_core/plugins/publish/collect_rendered_files.py +++ b/client/ayon_core/plugins/publish/collect_rendered_files.py @@ -93,8 +93,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # now we can just add instances from json file and we are done any_staging_dir_persistent = False - for instance_data in data.get("instances"): - + for instance_data in data["instances"]: self.log.debug(" - processing instance for {}".format( instance_data.get("productName"))) instance = self._context.create_instance( @@ -105,7 +104,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): instance.data.update(instance_data) # stash render job id for later validation - instance.data["render_job_id"] = data.get("job").get("_id") + instance.data["publishJobMetadata"] = data + # TODO remove 'render_job_id' here and rather use + # 'publishJobMetadata' where is needed. + # - this is deadline specific + instance.data["render_job_id"] = data.get("job", {}).get("_id") staging_dir_persistent = instance.data.get( "stagingDir_persistent", False ) diff --git a/client/ayon_core/plugins/publish/collect_resources_path.py b/client/ayon_core/plugins/publish/collect_resources_path.py index 7a80d0054c..2e5b296228 100644 --- a/client/ayon_core/plugins/publish/collect_resources_path.py +++ b/client/ayon_core/plugins/publish/collect_resources_path.py @@ -66,7 +66,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "yeticacheUE", "tycache", "usd", - "oxrig" + "oxrig", + "sbsar", ] def process(self, instance): diff --git a/client/ayon_core/plugins/publish/collect_scene_version.py b/client/ayon_core/plugins/publish/collect_scene_version.py index 8d643062bc..7979b66abe 100644 --- a/client/ayon_core/plugins/publish/collect_scene_version.py +++ b/client/ayon_core/plugins/publish/collect_scene_version.py @@ -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 diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 2007240d3d..8e8764fc33 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -9,11 +9,13 @@ import clique import pyblish.api from ayon_core import resources, AYON_CORE_ROOT -from ayon_core.pipeline import publish +from ayon_core.pipeline import ( + publish, + get_temp_dir +) from ayon_core.lib import ( run_ayon_launcher_process, - get_transcode_temp_directory, convert_input_paths_for_ffmpeg, should_convert_for_ffmpeg ) @@ -250,7 +252,10 @@ class ExtractBurnin(publish.Extractor): # - change staging dir of source representation # - must be set back after output definitions processing if do_convert: - new_staging_dir = get_transcode_temp_directory() + new_staging_dir = get_temp_dir( + project_name=instance.context.data["projectName"], + use_local_temp=True, + ) repre["stagingDir"] = new_staging_dir convert_input_paths_for_ffmpeg( diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 0d88f3073f..2e92b1808a 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -3,16 +3,16 @@ import copy import clique import pyblish.api -from ayon_core.pipeline import publish +from ayon_core.pipeline import ( + publish, + get_temp_dir +) from ayon_core.lib import ( - is_oiio_supported, ) - from ayon_core.lib.transcoding import ( UnknownRGBAChannelsError, convert_colorspace, - get_transcode_temp_directory, ) from ayon_core.lib.profiles_filtering import filter_profiles @@ -117,7 +117,10 @@ class ExtractOIIOTranscode(publish.Extractor): new_repre = copy.deepcopy(repre) original_staging_dir = new_repre["stagingDir"] - new_staging_dir = get_transcode_temp_directory() + new_staging_dir = get_temp_dir( + project_name=instance.context.data["projectName"], + use_local_temp=True, + ) new_repre["stagingDir"] = new_staging_dir output_extension = output_def["extension"] @@ -128,6 +131,7 @@ class ExtractOIIOTranscode(publish.Extractor): output_extension) transcoding_type = output_def["transcoding_type"] + target_colorspace = view = display = None # NOTE: we use colorspace_data as the fallback values for # the target colorspace. @@ -159,8 +163,12 @@ class ExtractOIIOTranscode(publish.Extractor): additional_command_args = (output_def["oiiotool_args"] ["additional_command_args"]) + files_to_convert = self._translate_to_sequence( + files_to_convert) + self.log.debug("Files to convert: {}".format(files_to_convert)) unknown_rgba_channels = False for file_name in files_to_convert: + self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) output_path = self._get_output_file_path(input_path, @@ -281,7 +289,7 @@ class ExtractOIIOTranscode(publish.Extractor): (list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr] """ pattern = [clique.PATTERNS["frames"]] - collections, remainder = clique.assemble( + collections, _ = clique.assemble( files_to_convert, patterns=pattern, assume_padded_when_ambiguous=True) @@ -292,6 +300,9 @@ class ExtractOIIOTranscode(publish.Extractor): collection = collections[0] frames = list(collection.indexes) + if collection.holes(): + return files_to_convert + frame_str = "{}-{}#".format(frames[0], frames[-1]) file_name = "{}{}{}".format(collection.head, frame_str, collection.tail) diff --git a/client/ayon_core/plugins/publish/extract_colorspace_data.py b/client/ayon_core/plugins/publish/extract_colorspace_data.py index 7da4890748..0ffa0f3035 100644 --- a/client/ayon_core/plugins/publish/extract_colorspace_data.py +++ b/client/ayon_core/plugins/publish/extract_colorspace_data.py @@ -37,6 +37,9 @@ class ExtractColorspaceData(publish.Extractor, # get colorspace settings context = instance.context + # colorspace name could be kept in instance.data + colorspace = instance.data.get("colorspace") + # loop representations for representation in representations: # skip if colorspaceData is already at representation @@ -44,5 +47,4 @@ class ExtractColorspaceData(publish.Extractor, continue self.set_representation_colorspace( - representation, context - ) + representation, context, colorspace) diff --git a/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py b/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py index a169affc66..25467fd94f 100644 --- a/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py +++ b/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py @@ -22,7 +22,6 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.01 label = "Extract Hierarchy To AYON" - families = ["clip", "shot"] def process(self, context): if not context.data.get("hierarchyContext"): diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 98723beffa..472694d334 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -71,20 +71,18 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): name = inst.data["folderPath"] recycling_file = [f for f in created_files if name in f] - - # frameranges - timeline_in_h = inst.data["clipInH"] - timeline_out_h = inst.data["clipOutH"] - fps = inst.data["fps"] - - # create duration - duration = (timeline_out_h - timeline_in_h) + 1 + audio_clip = inst.data["otioClip"] + audio_range = audio_clip.range_in_parent() + duration = audio_range.duration.to_frames() # ffmpeg generate new file only if doesn't exists already if not recycling_file: - # convert to seconds - start_sec = float(timeline_in_h / fps) - duration_sec = float(duration / fps) + parent_track = audio_clip.parent() + parent_track_start = parent_track.range_in_parent().start_time + relative_start_time = ( + audio_range.start_time - parent_track_start) + start_sec = relative_start_time.to_seconds() + duration_sec = audio_range.duration.to_seconds() # temp audio file audio_fpath = self.create_temp_file(name) @@ -163,34 +161,36 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): output = [] # go trough all audio tracks - for otio_track in otio_timeline.tracks: - if "Audio" not in otio_track.kind: - continue + for otio_track in otio_timeline.audio_tracks(): self.log.debug("_" * 50) playhead = 0 for otio_clip in otio_track: self.log.debug(otio_clip) - if isinstance(otio_clip, otio.schema.Gap): - playhead += otio_clip.source_range.duration.value - elif isinstance(otio_clip, otio.schema.Clip): - start = otio_clip.source_range.start_time.value - duration = otio_clip.source_range.duration.value - fps = otio_clip.source_range.start_time.rate + if (isinstance(otio_clip, otio.schema.Clip) and + not otio_clip.media_reference.is_missing_reference): + media_av_start = otio_clip.available_range().start_time + clip_start = otio_clip.source_range.start_time + fps = clip_start.rate + conformed_av_start = media_av_start.rescaled_to(fps) + # ffmpeg ignores embedded tc + start = clip_start - conformed_av_start + duration = otio_clip.source_range.duration media_path = otio_clip.media_reference.target_url input = { "mediaPath": media_path, "delayFrame": playhead, - "startFrame": start, - "durationFrame": duration, + "startFrame": start.to_frames(), + "durationFrame": duration.to_frames(), "delayMilSec": int(float(playhead / fps) * 1000), - "startSec": float(start / fps), - "durationSec": float(duration / fps), - "fps": fps + "startSec": start.to_seconds(), + "durationSec": duration.to_seconds(), + "fps": float(fps) } if input not in output: output.append(input) self.log.debug("__ input: {}".format(input)) - playhead += otio_clip.source_range.duration.value + + playhead += otio_clip.source_range.duration.value return output diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index faba9fd36d..7a9a020ff0 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -26,7 +26,10 @@ from ayon_core.lib import ( from ayon_core.pipeline import publish -class ExtractOTIOReview(publish.Extractor): +class ExtractOTIOReview( + publish.Extractor, + publish.ColormanagedPyblishPluginMixin +): """ Extract OTIO timeline into one concuted image sequence file. @@ -68,17 +71,24 @@ class ExtractOTIOReview(publish.Extractor): # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip + otio_review_clips = instance.data.get("otioReviewClips") + + if otio_review_clips is None: + self.log.info(f"Instance `{instance}` has no otioReviewClips") + return + # TODO: what if handles are different in `versionData`? handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] - otio_review_clips = instance.data["otioReviewClips"] # add plugin wide attributes self.representation_files = [] self.used_frames = [] self.workfile_start = int(instance.data.get( "workfileFrameStart", 1001)) - handle_start - self.padding = len(str(self.workfile_start)) + # NOTE: padding has to be converted from + # end frame since start could be lower then 1000 + self.padding = len(str(instance.data.get("frameEnd", 1001))) self.used_frames.append(self.workfile_start) self.to_width = instance.data.get( "resolutionWidth") or self.to_width @@ -86,8 +96,10 @@ class ExtractOTIOReview(publish.Extractor): "resolutionHeight") or self.to_height # skip instance if no reviewable data available - if (not isinstance(otio_review_clips[0], otio.schema.Clip)) \ - and (len(otio_review_clips) == 1): + if ( + not isinstance(otio_review_clips[0], otio.schema.Clip) + and len(otio_review_clips) == 1 + ): self.log.warning( "Instance `{}` has nothing to process".format(instance)) return @@ -119,26 +131,32 @@ class ExtractOTIOReview(publish.Extractor): res_data[key] = value break - self.to_width, self.to_height = res_data["width"], res_data["height"] - self.log.debug("> self.to_width x self.to_height: {} x {}".format( - self.to_width, self.to_height - )) + self.to_width, self.to_height = ( + res_data["width"], res_data["height"] + ) + self.log.debug( + "> self.to_width x self.to_height:" + f" {self.to_width} x {self.to_height}" + ) available_range = r_otio_cl.available_range() + available_range_start_frame = ( + available_range.start_time.to_frames() + ) processing_range = None 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) # Temporary. - # Some AYON custom OTIO exporter were implemented with relative - # source range for image sequence. Following code maintain - # backward-compatibility by adjusting available range + # Some AYON custom OTIO exporter were implemented with + # relative source range for image sequence. Following code + # maintain backward-compatibility by adjusting available range # while we are updating those. if ( is_clip_from_media_sequence(r_otio_cl) - and available_range.start_time.to_frames() == media_ref.start_frame - and src_range.start_time.to_frames() < media_ref.start_frame + and available_range_start_frame == 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), @@ -168,7 +186,7 @@ class ExtractOTIOReview(publish.Extractor): start -= clip_handle_start duration += clip_handle_start elif len(otio_review_clips) > 1 \ - and (index == len(otio_review_clips) - 1): + and (index == len(otio_review_clips) - 1): # more clips | last clip reframing with handle duration += clip_handle_end elif len(otio_review_clips) == 1: @@ -190,13 +208,9 @@ class ExtractOTIOReview(publish.Extractor): # File sequence way if is_sequence: # Remap processing range to input file sequence. - processing_range_as_frames = ( - processing_range.start_time.to_frames(), - processing_range.end_time_inclusive().to_frames() - ) first, last = remap_range_on_file_sequence( r_otio_cl, - processing_range_as_frames, + processing_range, ) input_fps = processing_range.start_time.rate @@ -236,7 +250,8 @@ class ExtractOTIOReview(publish.Extractor): # Extraction via FFmpeg. else: path = media_ref.target_url - # Set extract range from 0 (FFmpeg ignores embedded timecode). + # Set extract range from 0 (FFmpeg ignores + # embedded timecode). extract_range = otio.opentime.TimeRange( otio.opentime.RationalTime( ( @@ -263,8 +278,15 @@ class ExtractOTIOReview(publish.Extractor): # creating and registering representation representation = self._create_representation(start, duration) + + # add colorspace data to representation + if colorspace := instance.data.get("reviewColorspace"): + self.set_representation_colorspace( + representation, instance.context, colorspace + ) + 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): """ @@ -298,6 +320,9 @@ class ExtractOTIOReview(publish.Extractor): 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:], @@ -397,7 +422,8 @@ class ExtractOTIOReview(publish.Extractor): to defined image sequence format. Args: - sequence (list): input dir path string, collection object, fps in list + sequence (list): input dir path string, collection object, + fps in list. video (list)[optional]: video_path string, otio_range in list gap (int)[optional]: gap duration end_offset (int)[optional]: offset gap frame start in frames diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 06b451bfbe..7c38b0453b 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -22,8 +22,8 @@ from ayon_core.lib.transcoding import ( should_convert_for_ffmpeg, get_review_layer_name, convert_input_paths_for_ffmpeg, - get_transcode_temp_directory, ) +from ayon_core.pipeline import get_temp_dir from ayon_core.pipeline.publish import ( KnownPublishError, get_publish_instance_label, @@ -310,7 +310,10 @@ class ExtractReview(pyblish.api.InstancePlugin): # - change staging dir of source representation # - must be set back after output definitions processing if do_convert: - new_staging_dir = get_transcode_temp_directory() + new_staging_dir = get_temp_dir( + project_name=instance.context.data["projectName"], + use_local_temp=True, + ) repre["stagingDir"] = new_staging_dir convert_input_paths_for_ffmpeg( diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 37bbac8898..da429c1cd2 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -35,9 +35,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "resolve", "traypublisher", "substancepainter", + "substancedesigner", "nuke", "aftereffects", - "unreal" + "unreal", + "houdini" ] enabled = False @@ -341,8 +343,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"): diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 180cb8bbf1..ec1fddc6b1 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -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"] diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index e8fe09bab7..ae043a10a9 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -706,7 +706,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 = [ diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index f52998cef3..4f9e84aee0 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -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): diff --git a/client/ayon_core/plugins/publish/validate_unique_subsets.py b/client/ayon_core/plugins/publish/validate_unique_subsets.py index 4badeb8112..4067dd75a5 100644 --- a/client/ayon_core/plugins/publish/validate_unique_subsets.py +++ b/client/ayon_core/plugins/publish/validate_unique_subsets.py @@ -11,8 +11,8 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin): """Validate all product names are unique. This only validates whether the instances currently set to publish from - the workfile overlap one another for the folder + product they are publishing - to. + the workfile overlap one another for the folder + product they are + publishing to. This does not perform any check against existing publishes in the database since it is allowed to publish into existing products resulting in @@ -72,8 +72,10 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin): # All is ok return - msg = ("Instance product names {} are not unique. ".format(non_unique) + - "Please remove or rename duplicates.") + msg = ( + f"Instance product names {non_unique} are not unique." + " Please remove or rename duplicates." + ) formatting_data = { "non_unique": ",".join(non_unique) } diff --git a/client/ayon_core/scripts/otio_burnin.py b/client/ayon_core/scripts/otio_burnin.py index 6b132b9a6a..cb72606222 100644 --- a/client/ayon_core/scripts/otio_burnin.py +++ b/client/ayon_core/scripts/otio_burnin.py @@ -79,7 +79,8 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): - Datatypes explanation: string format must be supported by FFmpeg. Examples: "#000000", "0x000000", "black" - must be accesible by ffmpeg = name of registered Font in system or path to font file. + must be accesible by ffmpeg = name of registered Font in system + or path to font file. Examples: "Arial", "C:/Windows/Fonts/arial.ttf" - Possible keys: @@ -87,17 +88,21 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): "bg_opacity" - Opacity of background (box around text) - "bg_color" - Background color - "bg_padding" - Background padding in pixels - - "x_offset" - offsets burnin vertically by entered pixels from border - - "y_offset" - offsets burnin horizontally by entered pixels from border - + "x_offset" - offsets burnin vertically by entered pixels + from border - + "y_offset" - offsets burnin horizontally by entered pixels + from border - - x_offset & y_offset should be set at least to same value as bg_padding!! "font" - Font Family for text - "font_size" - Font size in pixels - "font_color" - Color of text - "frame_offset" - Default start frame - - - required IF start frame is not set when using frames or timecode burnins + - required IF start frame is not set when using frames + or timecode burnins - On initializing class can be set General options through "options_init" arg. - General can be overridden when adding burnin + On initializing class can be set General options through + "options_init" arg. + General options can be overridden when adding burnin. ''' TOP_CENTERED = ffmpeg_burnins.TOP_CENTERED diff --git a/client/ayon_core/settings/lib.py b/client/ayon_core/settings/lib.py index 3126bafd57..aa56fa8326 100644 --- a/client/ayon_core/settings/lib.py +++ b/client/ayon_core/settings/lib.py @@ -190,6 +190,7 @@ def get_current_project_settings(): project_name = os.environ.get("AYON_PROJECT_NAME") if not project_name: raise ValueError( - "Missing context project in environemt variable `AYON_PROJECT_NAME`." + "Missing context project in environment" + " variable `AYON_PROJECT_NAME`." ) return get_project_settings(project_name) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0d1d4f710e..0e19702d53 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -23,6 +23,9 @@ Enabled vs Disabled logic in most of stylesheets font-family: "Noto Sans"; font-weight: 450; outline: none; + + /* Define icon size to fix size issues for most of DCCs */ + icon-size: 16px; } QWidget { @@ -44,10 +47,6 @@ QLabel { background: transparent; } -QLabel[overriden="1"] { - color: {color:font-overridden}; -} - /* Inputs */ QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit { border: 1px solid {color:border}; @@ -1172,6 +1171,8 @@ ValidationArtistMessage QLabel { #PublishLogMessage { font-family: "Noto Sans Mono"; + border: none; + padding: 0; } #PublishInstanceLogsLabel { @@ -1589,6 +1590,10 @@ CreateNextPageOverlay { } /* Attribute Definition widgets */ +AttributeDefinitionsLabel[overridden="1"] { + color: {color:font-overridden}; +} + AttributeDefinitionsWidget QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit { padding: 1px; } diff --git a/client/ayon_core/tools/attribute_defs/__init__.py b/client/ayon_core/tools/attribute_defs/__init__.py index f991fdec3d..7f6cbb41be 100644 --- a/client/ayon_core/tools/attribute_defs/__init__.py +++ b/client/ayon_core/tools/attribute_defs/__init__.py @@ -1,6 +1,7 @@ from .widgets import ( create_widget_for_attr_def, AttributeDefinitionsWidget, + AttributeDefinitionsLabel, ) from .dialog import ( @@ -11,6 +12,7 @@ from .dialog import ( __all__ = ( "create_widget_for_attr_def", "AttributeDefinitionsWidget", + "AttributeDefinitionsLabel", "AttributeDefinitionsDialog", ) diff --git a/client/ayon_core/tools/attribute_defs/_constants.py b/client/ayon_core/tools/attribute_defs/_constants.py new file mode 100644 index 0000000000..b58a05bac6 --- /dev/null +++ b/client/ayon_core/tools/attribute_defs/_constants.py @@ -0,0 +1 @@ +REVERT_TO_DEFAULT_LABEL = "Revert to default" diff --git a/client/ayon_core/tools/attribute_defs/files_widget.py b/client/ayon_core/tools/attribute_defs/files_widget.py index 95091bed5a..8a40b3ff38 100644 --- a/client/ayon_core/tools/attribute_defs/files_widget.py +++ b/client/ayon_core/tools/attribute_defs/files_widget.py @@ -17,6 +17,8 @@ from ayon_core.tools.utils import ( PixmapLabel ) +from ._constants import REVERT_TO_DEFAULT_LABEL + ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2 ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3 @@ -252,7 +254,7 @@ class FilesModel(QtGui.QStandardItemModel): """Make sure that removed items are removed from items mapping. Connected with '_on_insert'. When user drag item and drop it to same - view the item is actually removed and creted again but it happens in + view the item is actually removed and created again but it happens in inner calls of Qt. """ @@ -598,7 +600,7 @@ class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" remove_requested = QtCore.Signal() - context_menu_requested = QtCore.Signal(QtCore.QPoint) + context_menu_requested = QtCore.Signal(QtCore.QPoint, bool) def __init__(self, *args, **kwargs): super(FilesView, self).__init__(*args, **kwargs) @@ -690,9 +692,8 @@ class FilesView(QtWidgets.QListView): def _on_context_menu_request(self, pos): index = self.indexAt(pos) - if index.isValid(): - point = self.viewport().mapToGlobal(pos) - self.context_menu_requested.emit(point) + point = self.viewport().mapToGlobal(pos) + self.context_menu_requested.emit(point, index.isValid()) def _on_selection_change(self): self._remove_btn.setEnabled(self.has_selected_item_ids()) @@ -721,27 +722,34 @@ class FilesView(QtWidgets.QListView): class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() + revert_requested = QtCore.Signal() def __init__(self, single_item, allow_sequences, extensions_label, parent): - super(FilesWidget, self).__init__(parent) + super().__init__(parent) self.setAcceptDrops(True) + wrapper_widget = QtWidgets.QWidget(self) + empty_widget = DropEmpty( - single_item, allow_sequences, extensions_label, self + single_item, allow_sequences, extensions_label, wrapper_widget ) files_model = FilesModel(single_item, allow_sequences) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) - files_view = FilesView(self) + files_view = FilesView(wrapper_widget) files_view.setModel(files_proxy_model) - layout = QtWidgets.QStackedLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) - layout.addWidget(empty_widget) - layout.addWidget(files_view) - layout.setCurrentWidget(empty_widget) + wrapper_layout = QtWidgets.QStackedLayout(wrapper_widget) + wrapper_layout.setContentsMargins(0, 0, 0, 0) + wrapper_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + wrapper_layout.addWidget(empty_widget) + wrapper_layout.addWidget(files_view) + wrapper_layout.setCurrentWidget(empty_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(wrapper_widget, 1) files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) @@ -761,7 +769,11 @@ class FilesWidget(QtWidgets.QFrame): self._widgets_by_id = {} - self._layout = layout + self._wrapper_widget = wrapper_widget + self._wrapper_layout = wrapper_layout + + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) def _set_multivalue(self, multivalue): if self._multivalue is multivalue: @@ -770,7 +782,7 @@ class FilesWidget(QtWidgets.QFrame): self._files_view.set_multivalue(multivalue) self._files_model.set_multivalue(multivalue) self._files_proxy_model.set_multivalue(multivalue) - self.setEnabled(not multivalue) + self._wrapper_widget.setEnabled(not multivalue) def set_value(self, value, multivalue): self._in_set_value = True @@ -829,7 +841,7 @@ class FilesWidget(QtWidgets.QFrame): self._multivalue ) widget.context_menu_requested.connect( - self._on_context_menu_requested + self._on_item_context_menu_request ) self._files_view.setIndexWidget(index, widget) self._files_proxy_model.setData( @@ -847,7 +859,7 @@ class FilesWidget(QtWidgets.QFrame): for row in range(self._files_proxy_model.rowCount()): index = self._files_proxy_model.index(row, 0) item_id = index.data(ITEM_ID_ROLE) - available_item_ids.add(index.data(ITEM_ID_ROLE)) + available_item_ids.add(item_id) widget_ids = set(self._widgets_by_id.keys()) for item_id in available_item_ids: @@ -888,22 +900,31 @@ class FilesWidget(QtWidgets.QFrame): if items_to_delete: self._remove_item_by_ids(items_to_delete) - def _on_context_menu_requested(self, pos): - if self._multivalue: - return + def _on_context_menu(self, pos): + self._on_context_menu_requested(pos, False) + def _on_context_menu_requested(self, pos, valid_index): menu = QtWidgets.QMenu(self._files_view) + if valid_index and not self._multivalue: + if self._files_view.has_selected_sequence(): + split_action = QtWidgets.QAction("Split sequence", menu) + split_action.triggered.connect(self._on_split_request) + menu.addAction(split_action) - if self._files_view.has_selected_sequence(): - split_action = QtWidgets.QAction("Split sequence", menu) - split_action.triggered.connect(self._on_split_request) - menu.addAction(split_action) + remove_action = QtWidgets.QAction("Remove", menu) + remove_action.triggered.connect(self._on_remove_requested) + menu.addAction(remove_action) - remove_action = QtWidgets.QAction("Remove", menu) - remove_action.triggered.connect(self._on_remove_requested) - menu.addAction(remove_action) + if not valid_index: + revert_action = QtWidgets.QAction(REVERT_TO_DEFAULT_LABEL, menu) + revert_action.triggered.connect(self.revert_requested) + menu.addAction(revert_action) - menu.popup(pos) + if menu.actions(): + menu.popup(pos) + + def _on_item_context_menu_request(self, pos): + self._on_context_menu_requested(pos, True) def dragEnterEvent(self, event): if self._multivalue: @@ -1011,5 +1032,5 @@ class FilesWidget(QtWidgets.QFrame): current_widget = self._files_view else: current_widget = self._empty_widget - self._layout.setCurrentWidget(current_widget) + self._wrapper_layout.setCurrentWidget(current_widget) self._files_view.update_remove_btn_visibility() diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 026aea00ad..dbd65fd215 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -1,6 +1,8 @@ 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, @@ -20,14 +22,27 @@ from ayon_core.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + PlaceholderLineEdit, + PlaceholderPlainTextEdit, + set_style_property, ) from ayon_core.tools.utils import NiceCheckbox +from ._constants import REVERT_TO_DEFAULT_LABEL from .files_widget import FilesWidget +if typing.TYPE_CHECKING: + from typing import Union -def create_widget_for_attr_def(attr_def, parent=None): - widget = _create_widget_for_attr_def(attr_def, parent) + +def create_widget_for_attr_def( + attr_def: AbstractAttrDef, + parent: Optional[QtWidgets.QWidget] = None, + handle_revert_to_default: Optional[bool] = True, +): + widget = _create_widget_for_attr_def( + attr_def, parent, handle_revert_to_default + ) if not attr_def.visible: widget.setVisible(False) @@ -36,42 +51,96 @@ def create_widget_for_attr_def(attr_def, parent=None): return widget -def _create_widget_for_attr_def(attr_def, parent=None): +def _create_widget_for_attr_def( + attr_def: AbstractAttrDef, + parent: "Union[QtWidgets.QWidget, None]", + handle_revert_to_default: bool, +): if not isinstance(attr_def, AbstractAttrDef): raise TypeError("Unexpected type \"{}\" expected \"{}\"".format( str(type(attr_def)), AbstractAttrDef )) + cls = None if isinstance(attr_def, NumberDef): - return NumberAttrWidget(attr_def, parent) + cls = NumberAttrWidget - if isinstance(attr_def, TextDef): - return TextAttrWidget(attr_def, parent) + elif isinstance(attr_def, TextDef): + cls = TextAttrWidget - if isinstance(attr_def, EnumDef): - return EnumAttrWidget(attr_def, parent) + elif isinstance(attr_def, EnumDef): + cls = EnumAttrWidget - if isinstance(attr_def, BoolDef): - return BoolAttrWidget(attr_def, parent) + elif isinstance(attr_def, BoolDef): + cls = BoolAttrWidget - if isinstance(attr_def, UnknownDef): - return UnknownAttrWidget(attr_def, parent) + elif isinstance(attr_def, UnknownDef): + cls = UnknownAttrWidget - if isinstance(attr_def, HiddenDef): - return HiddenAttrWidget(attr_def, parent) + elif isinstance(attr_def, HiddenDef): + cls = HiddenAttrWidget - if isinstance(attr_def, FileDef): - return FileAttrWidget(attr_def, parent) + elif isinstance(attr_def, FileDef): + cls = FileAttrWidget - if isinstance(attr_def, UISeparatorDef): - return SeparatorAttrWidget(attr_def, parent) + elif isinstance(attr_def, UISeparatorDef): + cls = SeparatorAttrWidget - if isinstance(attr_def, UILabelDef): - return LabelAttrWidget(attr_def, parent) + elif isinstance(attr_def, UILabelDef): + cls = LabelAttrWidget - raise ValueError("Unknown attribute definition \"{}\"".format( - str(type(attr_def)) - )) + if cls is None: + raise ValueError("Unknown attribute definition \"{}\"".format( + str(type(attr_def)) + )) + + return cls(attr_def, parent, handle_revert_to_default) + + +class AttributeDefinitionsLabel(QtWidgets.QLabel): + """Label related to value attribute definition. + + Label is used to show attribute definition label and to show if value + is overridden. + + Label can be right-clicked to revert value to default. + """ + revert_to_default_requested = QtCore.Signal(str) + + def __init__( + self, + attr_id: str, + label: str, + parent: QtWidgets.QWidget, + ): + super().__init__(label, parent) + + self._attr_id = attr_id + self._overridden = False + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + self.customContextMenuRequested.connect(self._on_context_menu) + + def set_overridden(self, overridden: bool): + if self._overridden == overridden: + return + self._overridden = overridden + set_style_property( + self, + "overridden", + "1" if overridden else "" + ) + + def _on_context_menu(self, point: QtCore.QPoint): + menu = QtWidgets.QMenu(self) + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self._request_revert_to_default) + menu.addAction(action) + menu.exec_(self.mapToGlobal(point)) + + def _request_revert_to_default(self): + self.revert_to_default_requested.emit(self._attr_id) class AttributeDefinitionsWidget(QtWidgets.QWidget): @@ -83,16 +152,18 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): """ def __init__(self, attr_defs=None, parent=None): - super(AttributeDefinitionsWidget, self).__init__(parent) + super().__init__(parent) - self._widgets = [] + self._widgets_by_id = {} + self._labels_by_id = {} self._current_keys = set() self.set_attr_defs(attr_defs) def clear_attr_defs(self): """Remove all existing widgets and reset layout if needed.""" - self._widgets = [] + self._widgets_by_id = {} + self._labels_by_id = {} self._current_keys = set() layout = self.layout() @@ -133,7 +204,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): self._current_keys.add(attr_def.key) widget = create_widget_for_attr_def(attr_def, self) - self._widgets.append(widget) + self._widgets_by_id[attr_def.id] = widget if not attr_def.visible: continue @@ -145,7 +216,13 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): col_num = 2 - expand_cols if attr_def.is_value_def and attr_def.label: - label_widget = QtWidgets.QLabel(attr_def.label, self) + label_widget = AttributeDefinitionsLabel( + attr_def.id, attr_def.label, self + ) + label_widget.revert_to_default_requested.connect( + self._on_revert_request + ) + self._labels_by_id[attr_def.id] = label_widget tooltip = attr_def.tooltip if tooltip: label_widget.setToolTip(tooltip) @@ -160,6 +237,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): if not attr_def.is_label_horizontal: row += 1 + if attr_def.is_value_def: + widget.value_changed.connect(self._on_value_change) + layout.addWidget( widget, row, col_num, 1, expand_cols ) @@ -168,7 +248,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def set_value(self, value): new_value = copy.deepcopy(value) unused_keys = set(new_value.keys()) - for widget in self._widgets: + for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if attr_def.key not in new_value: continue @@ -181,22 +261,42 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def current_value(self): output = {} - for widget in self._widgets: + for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if not isinstance(attr_def, UIDef): output[attr_def.key] = widget.current_value() return output + def _on_revert_request(self, attr_id): + widget = self._widgets_by_id.get(attr_id) + if widget is not None: + widget.set_value(widget.attr_def.default) + + def _on_value_change(self, value, attr_id): + widget = self._widgets_by_id.get(attr_id) + if widget is None: + return + label = self._labels_by_id.get(attr_id) + if label is not None: + label.set_overridden(value != widget.attr_def.default) + class _BaseAttrDefWidget(QtWidgets.QWidget): # Type 'object' may not work with older PySide versions value_changed = QtCore.Signal(object, str) + revert_to_default_requested = QtCore.Signal(str) - def __init__(self, attr_def, parent): - super(_BaseAttrDefWidget, self).__init__(parent) + def __init__( + self, + attr_def: AbstractAttrDef, + parent: "Union[QtWidgets.QWidget, None]", + handle_revert_to_default: Optional[bool] = True, + ): + super().__init__(parent) - self.attr_def = attr_def + self.attr_def: AbstractAttrDef = attr_def + self._handle_revert_to_default: bool = handle_revert_to_default main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -205,6 +305,15 @@ class _BaseAttrDefWidget(QtWidgets.QWidget): self._ui_init() + def revert_to_default_value(self): + if not self.attr_def.is_value_def: + return + + if self._handle_revert_to_default: + self.set_value(self.attr_def.default) + else: + self.revert_to_default_requested.emit(self.attr_def.id) + def _ui_init(self): raise NotImplementedError( "Method '_ui_init' is not implemented. {}".format( @@ -255,7 +364,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): clicked = QtCore.Signal() def __init__(self, text, parent): - super(ClickableLineEdit, self).__init__(parent) + super().__init__(parent) self.setText(text) self.setReadOnly(True) @@ -264,7 +373,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(ClickableLineEdit, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._mouse_pressed: @@ -272,7 +381,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLineEdit, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class NumberAttrWidget(_BaseAttrDefWidget): @@ -284,6 +393,9 @@ class NumberAttrWidget(_BaseAttrDefWidget): else: input_widget = FocusSpinBox(self) + # Override context menu event to add revert to default action + input_widget.contextMenuEvent = self._input_widget_context_event + if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) @@ -321,6 +433,16 @@ class NumberAttrWidget(_BaseAttrDefWidget): self._set_multiselection_visible(True) return False + def _input_widget_context_event(self, event): + line_edit = self._input_widget.lineEdit() + menu = line_edit.createStandardContextMenu() + menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + menu.popup(event.globalPos()) + def current_value(self): return self._input_widget.value() @@ -382,9 +504,12 @@ 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 if ( self.attr_def.placeholder @@ -407,6 +532,15 @@ class TextAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) + def _input_widget_context_event(self, event): + menu = self._input_widget.createStandardContextMenu() + menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + menu.popup(event.globalPos()) + def _on_value_change(self): if self.multiline: new_value = self._input_widget.toPlainText() @@ -459,6 +593,20 @@ class BoolAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) self.main_layout.addStretch(1) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) + + def _on_context_menu(self, pos): + self._menu = QtWidgets.QMenu(self) + + action = QtWidgets.QAction(self._menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + self._menu.addAction(action) + + global_pos = self.mapToGlobal(pos) + self._menu.exec_(global_pos) + def _on_value_change(self): new_value = self._input_widget.isChecked() self.value_changed.emit(new_value, self.attr_def.id) @@ -487,7 +635,7 @@ class BoolAttrWidget(_BaseAttrDefWidget): class EnumAttrWidget(_BaseAttrDefWidget): def __init__(self, *args, **kwargs): self._multivalue = False - super(EnumAttrWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def multiselection(self): @@ -495,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) @@ -509,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) @@ -522,6 +675,34 @@ class EnumAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) + 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) + + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + + global_pos = self.mapToGlobal(pos) + menu.exec_(global_pos) + def _on_value_change(self): new_value = self.current_value() if self._multivalue: @@ -614,7 +795,7 @@ class HiddenAttrWidget(_BaseAttrDefWidget): def setVisible(self, visible): if visible: visible = False - super(HiddenAttrWidget, self).setVisible(visible) + super().setVisible(visible) def current_value(self): if self._multivalue: @@ -650,10 +831,25 @@ class FileAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) + input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + input_widget.customContextMenuRequested.connect(self._on_context_menu) + input_widget.revert_requested.connect(self.revert_to_default_value) + def _on_value_change(self): new_value = self.current_value() self.value_changed.emit(new_value, self.attr_def.id) + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + + global_pos = self.mapToGlobal(pos) + menu.exec_(global_pos) + def current_value(self): return self._input_widget.current_value() diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index 6bccb0f468..edff8471b0 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -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 )) @@ -368,7 +384,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): diff --git a/client/ayon_core/tools/console_interpreter/__init__.py b/client/ayon_core/tools/console_interpreter/__init__.py new file mode 100644 index 0000000000..0333fe80a0 --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/__init__.py @@ -0,0 +1,8 @@ +from .abstract import AbstractInterpreterController +from .control import InterpreterController + + +__all__ = ( + "AbstractInterpreterController", + "InterpreterController", +) diff --git a/client/ayon_core/tools/console_interpreter/abstract.py b/client/ayon_core/tools/console_interpreter/abstract.py new file mode 100644 index 0000000000..a945e6e498 --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/abstract.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import List, Dict, Optional + + +@dataclass +class TabItem: + name: str + code: str + + +@dataclass +class InterpreterConfig: + width: Optional[int] + height: Optional[int] + splitter_sizes: List[int] = field(default_factory=list) + tabs: List[TabItem] = field(default_factory=list) + + +class AbstractInterpreterController(ABC): + @abstractmethod + def get_config(self) -> InterpreterConfig: + pass + + @abstractmethod + def save_config( + self, + width: int, + height: int, + splitter_sizes: List[int], + tabs: List[Dict[str, str]], + ): + pass diff --git a/client/ayon_core/tools/console_interpreter/control.py b/client/ayon_core/tools/console_interpreter/control.py new file mode 100644 index 0000000000..b931b6252c --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/control.py @@ -0,0 +1,63 @@ +from typing import List, Dict + +from ayon_core.lib import JSONSettingRegistry +from ayon_core.lib.local_settings import get_launcher_local_dir + +from .abstract import ( + AbstractInterpreterController, + TabItem, + InterpreterConfig, +) + + +class InterpreterController(AbstractInterpreterController): + def __init__(self): + self._registry = JSONSettingRegistry( + "python_interpreter_tool", + get_launcher_local_dir(), + ) + + def get_config(self): + width = None + height = None + splitter_sizes = [] + tabs = [] + try: + width = self._registry.get_item("width") + height = self._registry.get_item("height") + + except (ValueError, KeyError): + pass + + try: + splitter_sizes = self._registry.get_item("splitter_sizes") + except (ValueError, KeyError): + pass + + try: + tab_defs = self._registry.get_item("tabs") or [] + for tab_def in tab_defs: + tab_name = tab_def.get("name") + if not tab_name: + continue + code = tab_def.get("code") or "" + tabs.append(TabItem(tab_name, code)) + + except (ValueError, KeyError): + pass + + return InterpreterConfig( + width, height, splitter_sizes, tabs + ) + + def save_config( + self, + width: int, + height: int, + splitter_sizes: List[int], + tabs: List[Dict[str, str]], + ): + self._registry.set_item("width", width) + self._registry.set_item("height", height) + self._registry.set_item("splitter_sizes", splitter_sizes) + self._registry.set_item("tabs", tabs) diff --git a/client/ayon_core/tools/console_interpreter/ui/__init__.py b/client/ayon_core/tools/console_interpreter/ui/__init__.py new file mode 100644 index 0000000000..05b166892c --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/ui/__init__.py @@ -0,0 +1,8 @@ +from .window import ( + ConsoleInterpreterWindow +) + + +__all__ = ( + "ConsoleInterpreterWindow", +) diff --git a/client/ayon_core/tools/console_interpreter/ui/utils.py b/client/ayon_core/tools/console_interpreter/ui/utils.py new file mode 100644 index 0000000000..427483215d --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/ui/utils.py @@ -0,0 +1,42 @@ +import os +import sys +import collections + + +class StdOEWrap: + def __init__(self): + self._origin_stdout_write = None + self._origin_stderr_write = None + self._listening = False + self.lines = collections.deque() + + if not sys.stdout: + sys.stdout = open(os.devnull, "w") + + if not sys.stderr: + sys.stderr = open(os.devnull, "w") + + if self._origin_stdout_write is None: + self._origin_stdout_write = sys.stdout.write + + if self._origin_stderr_write is None: + self._origin_stderr_write = sys.stderr.write + + self._listening = True + sys.stdout.write = self._stdout_listener + sys.stderr.write = self._stderr_listener + + def stop_listen(self): + self._listening = False + + def _stdout_listener(self, text): + if self._listening: + self.lines.append(text) + if self._origin_stdout_write is not None: + self._origin_stdout_write(text) + + def _stderr_listener(self, text): + if self._listening: + self.lines.append(text) + if self._origin_stderr_write is not None: + self._origin_stderr_write(text) diff --git a/client/ayon_core/tools/console_interpreter/ui/widgets.py b/client/ayon_core/tools/console_interpreter/ui/widgets.py new file mode 100644 index 0000000000..2b9361666e --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/ui/widgets.py @@ -0,0 +1,251 @@ +from code import InteractiveInterpreter + +from qtpy import QtCore, QtWidgets, QtGui + + +class PythonCodeEditor(QtWidgets.QPlainTextEdit): + execute_requested = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + self.setObjectName("PythonCodeEditor") + + self._indent = 4 + + def _tab_shift_right(self): + cursor = self.textCursor() + selected_text = cursor.selectedText() + if not selected_text: + cursor.insertText(" " * self._indent) + return + + sel_start = cursor.selectionStart() + sel_end = cursor.selectionEnd() + cursor.setPosition(sel_end) + end_line = cursor.blockNumber() + cursor.setPosition(sel_start) + while True: + cursor.movePosition(QtGui.QTextCursor.StartOfLine) + text = cursor.block().text() + spaces = len(text) - len(text.lstrip(" ")) + new_spaces = spaces % self._indent + if not new_spaces: + new_spaces = self._indent + + cursor.insertText(" " * new_spaces) + if cursor.blockNumber() == end_line: + break + + cursor.movePosition(QtGui.QTextCursor.NextBlock) + + def _tab_shift_left(self): + tmp_cursor = self.textCursor() + sel_start = tmp_cursor.selectionStart() + sel_end = tmp_cursor.selectionEnd() + + cursor = QtGui.QTextCursor(self.document()) + cursor.setPosition(sel_end) + end_line = cursor.blockNumber() + cursor.setPosition(sel_start) + while True: + cursor.movePosition(QtGui.QTextCursor.StartOfLine) + text = cursor.block().text() + spaces = len(text) - len(text.lstrip(" ")) + if spaces: + spaces_to_remove = (spaces % self._indent) or self._indent + if spaces_to_remove > spaces: + spaces_to_remove = spaces + + cursor.setPosition( + cursor.position() + spaces_to_remove, + QtGui.QTextCursor.KeepAnchor + ) + cursor.removeSelectedText() + + if cursor.blockNumber() == end_line: + break + + cursor.movePosition(QtGui.QTextCursor.NextBlock) + + def keyPressEvent(self, event): + if event.key() == QtCore.Qt.Key_Backtab: + self._tab_shift_left() + event.accept() + return + + if event.key() == QtCore.Qt.Key_Tab: + if event.modifiers() == QtCore.Qt.NoModifier: + self._tab_shift_right() + event.accept() + return + + if ( + event.key() == QtCore.Qt.Key_Return + and event.modifiers() == QtCore.Qt.ControlModifier + ): + self.execute_requested.emit() + event.accept() + return + + super().keyPressEvent(event) + + +class PythonTabWidget(QtWidgets.QWidget): + add_tab_requested = QtCore.Signal() + before_execute = QtCore.Signal(str) + + def __init__(self, parent): + super().__init__(parent) + + code_input = PythonCodeEditor(self) + + self.setFocusProxy(code_input) + + add_tab_btn = QtWidgets.QPushButton("Add tab...", self) + add_tab_btn.setDefault(False) + add_tab_btn.setToolTip("Add new tab") + + execute_btn = QtWidgets.QPushButton("Execute", self) + execute_btn.setDefault(False) + execute_btn.setToolTip("Execute command (Ctrl + Enter)") + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(add_tab_btn) + btns_layout.addStretch(1) + btns_layout.addWidget(execute_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(code_input, 1) + layout.addLayout(btns_layout, 0) + + add_tab_btn.clicked.connect(self._on_add_tab_clicked) + execute_btn.clicked.connect(self._on_execute_clicked) + code_input.execute_requested.connect(self.execute) + + self._code_input = code_input + self._interpreter = InteractiveInterpreter() + + def _on_add_tab_clicked(self): + self.add_tab_requested.emit() + + def _on_execute_clicked(self): + self.execute() + + def get_code(self): + return self._code_input.toPlainText() + + def set_code(self, code_text): + self._code_input.setPlainText(code_text) + + def execute(self): + code_text = self._code_input.toPlainText() + self.before_execute.emit(code_text) + self._interpreter.runcode(code_text) + + +class TabNameDialog(QtWidgets.QDialog): + default_width = 330 + default_height = 85 + + def __init__(self, parent): + super().__init__(parent) + + self.setWindowTitle("Enter tab name") + + name_label = QtWidgets.QLabel("Tab name:", self) + name_input = QtWidgets.QLineEdit(self) + + inputs_layout = QtWidgets.QHBoxLayout() + inputs_layout.addWidget(name_label) + inputs_layout.addWidget(name_input) + + ok_btn = QtWidgets.QPushButton("Ok", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(inputs_layout) + layout.addStretch(1) + layout.addLayout(btns_layout) + + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self._name_input = name_input + self._ok_btn = ok_btn + self._cancel_btn = cancel_btn + + self._result = None + + self.resize(self.default_width, self.default_height) + + def set_tab_name(self, name): + self._name_input.setText(name) + + def result(self): + return self._result + + def showEvent(self, event): + super().showEvent(event) + btns_width = max( + self._ok_btn.width(), + self._cancel_btn.width() + ) + + self._ok_btn.setMinimumWidth(btns_width) + self._cancel_btn.setMinimumWidth(btns_width) + + def _on_ok_clicked(self): + self._result = self._name_input.text() + self.accept() + + def _on_cancel_clicked(self): + self._result = None + self.reject() + + +class OutputTextWidget(QtWidgets.QTextEdit): + v_max_offset = 4 + + def vertical_scroll_at_max(self): + v_scroll = self.verticalScrollBar() + return v_scroll.value() > v_scroll.maximum() - self.v_max_offset + + def scroll_to_bottom(self): + v_scroll = self.verticalScrollBar() + return v_scroll.setValue(v_scroll.maximum()) + + +class EnhancedTabBar(QtWidgets.QTabBar): + double_clicked = QtCore.Signal(QtCore.QPoint) + right_clicked = QtCore.Signal(QtCore.QPoint) + mid_clicked = QtCore.Signal(QtCore.QPoint) + + def __init__(self, parent): + super().__init__(parent) + + self.setDrawBase(False) + + def mouseDoubleClickEvent(self, event): + self.double_clicked.emit(event.globalPos()) + event.accept() + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.RightButton: + self.right_clicked.emit(event.globalPos()) + event.accept() + return + + elif event.button() == QtCore.Qt.MidButton: + self.mid_clicked.emit(event.globalPos()) + event.accept() + + else: + super().mouseReleaseEvent(event) + diff --git a/client/ayon_core/tools/console_interpreter/ui/window.py b/client/ayon_core/tools/console_interpreter/ui/window.py new file mode 100644 index 0000000000..a5065f96f9 --- /dev/null +++ b/client/ayon_core/tools/console_interpreter/ui/window.py @@ -0,0 +1,324 @@ +import re +from typing import Optional + +from qtpy import QtWidgets, QtGui, QtCore + +from ayon_core import resources +from ayon_core.style import load_stylesheet +from ayon_core.tools.console_interpreter import ( + AbstractInterpreterController, + InterpreterController, +) + +from .utils import StdOEWrap +from .widgets import ( + PythonTabWidget, + OutputTextWidget, + EnhancedTabBar, + TabNameDialog, +) + +ANSI_ESCAPE = re.compile( + r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]" +) +AYON_ART = r""" + + ▄██▄ + ▄███▄ ▀██▄ ▀██▀ ▄██▀ ▄██▀▀▀██▄ ▀███▄ █▄ + ▄▄ ▀██▄ ▀██▄ ▄██▀ ██▀ ▀██▄ ▄ ▀██▄ ███ + ▄██▀ ██▄ ▀ ▄▄ ▀ ██ ▄██ ███ ▀██▄ ███ + ▄██▀ ▀██▄ ██ ▀██▄ ▄██▀ ███ ▀██ ▀█▀ + ▄██▀ ▀██▄ ▀█ ▀██▄▄▄▄██▀ █▀ ▀██▄ + + · · - =[ by YNPUT ]:[ http://ayon.ynput.io ]= - · · + +""" + + +class ConsoleInterpreterWindow(QtWidgets.QWidget): + default_width = 1000 + default_height = 600 + + def __init__( + self, + controller: Optional[AbstractInterpreterController] = None, + parent: Optional[QtWidgets.QWidget] = None, + ): + super().__init__(parent) + + self.setWindowTitle("AYON Console") + self.setWindowIcon(QtGui.QIcon(resources.get_ayon_icon_filepath())) + + if controller is None: + controller = InterpreterController() + + output_widget = OutputTextWidget(self) + output_widget.setObjectName("PythonInterpreterOutput") + output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) + output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + + tab_widget = QtWidgets.QTabWidget(self) + tab_bar = EnhancedTabBar(tab_widget) + tab_widget.setTabBar(tab_bar) + tab_widget.setTabsClosable(False) + tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + widgets_splitter = QtWidgets.QSplitter(self) + widgets_splitter.setOrientation(QtCore.Qt.Vertical) + widgets_splitter.addWidget(output_widget) + widgets_splitter.addWidget(tab_widget) + widgets_splitter.setStretchFactor(0, 1) + widgets_splitter.setStretchFactor(1, 1) + height = int(self.default_height / 2) + widgets_splitter.setSizes([height, self.default_height - height]) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(widgets_splitter) + + line_check_timer = QtCore.QTimer() + line_check_timer.setInterval(200) + + line_check_timer.timeout.connect(self._on_timer_timeout) + tab_bar.right_clicked.connect(self._on_tab_right_click) + tab_bar.double_clicked.connect(self._on_tab_double_click) + tab_bar.mid_clicked.connect(self._on_tab_mid_click) + tab_widget.tabCloseRequested.connect(self._on_tab_close_req) + + self._tabs = [] + + self._stdout_err_wrapper = StdOEWrap() + + self._widgets_splitter = widgets_splitter + self._output_widget = output_widget + self._tab_widget = tab_widget + self._line_check_timer = line_check_timer + + self._append_lines([AYON_ART]) + + self._first_show = True + self._controller = controller + + def showEvent(self, event): + self._line_check_timer.start() + super().showEvent(event) + # First show setup + if self._first_show: + self._first_show = False + self._on_first_show() + + if self._tab_widget.count() < 1: + self.add_tab("Python") + + self._output_widget.scroll_to_bottom() + + def closeEvent(self, event): + self._save_registry() + super().closeEvent(event) + self._line_check_timer.stop() + + def add_tab(self, tab_name, index=None): + widget = PythonTabWidget(self) + widget.before_execute.connect(self._on_before_execute) + widget.add_tab_requested.connect(self._on_add_requested) + if index is None: + if self._tab_widget.count() > 0: + index = self._tab_widget.currentIndex() + 1 + else: + index = 0 + + self._tabs.append(widget) + self._tab_widget.insertTab(index, widget, tab_name) + self._tab_widget.setCurrentIndex(index) + + if self._tab_widget.count() > 1: + self._tab_widget.setTabsClosable(True) + widget.setFocus() + return widget + + def _on_first_show(self): + config = self._controller.get_config() + width = config.width + height = config.height + if width is None or width < 200: + width = self.default_width + if height is None or height < 200: + height = self.default_height + + for tab_item in config.tabs: + widget = self.add_tab(tab_item.name) + widget.set_code(tab_item.code) + + self.resize(width, height) + # Change stylesheet + self.setStyleSheet(load_stylesheet()) + # Check if splitter sizes are set + splitters_count = len(self._widgets_splitter.sizes()) + if len(config.splitter_sizes) == splitters_count: + self._widgets_splitter.setSizes(config.splitter_sizes) + + def _save_registry(self): + tabs = [] + for tab_idx in range(self._tab_widget.count()): + widget = self._tab_widget.widget(tab_idx) + tabs.append({ + "name": self._tab_widget.tabText(tab_idx), + "code": widget.get_code() + }) + + self._controller.save_config( + self.width(), + self.height(), + self._widgets_splitter.sizes(), + tabs + ) + + def _on_tab_right_click(self, global_point): + point = self._tab_widget.mapFromGlobal(global_point) + tab_bar = self._tab_widget.tabBar() + tab_idx = tab_bar.tabAt(point) + last_index = tab_bar.count() - 1 + if tab_idx < 0 or tab_idx > last_index: + return + + menu = QtWidgets.QMenu(self._tab_widget) + + add_tab_action = QtWidgets.QAction("Add tab...", menu) + add_tab_action.setToolTip("Add new tab") + + rename_tab_action = QtWidgets.QAction("Rename...", menu) + rename_tab_action.setToolTip("Rename tab") + + duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu) + duplicate_tab_action.setToolTip("Duplicate code to new tab") + + close_tab_action = QtWidgets.QAction("Close", menu) + close_tab_action.setToolTip("Close tab and lose content") + close_tab_action.setEnabled(self._tab_widget.tabsClosable()) + + menu.addAction(add_tab_action) + menu.addAction(rename_tab_action) + menu.addAction(duplicate_tab_action) + menu.addAction(close_tab_action) + + result = menu.exec_(global_point) + if result is None: + return + + if result is rename_tab_action: + self._rename_tab_req(tab_idx) + + elif result is add_tab_action: + self._on_add_requested() + + elif result is duplicate_tab_action: + self._duplicate_requested(tab_idx) + + elif result is close_tab_action: + self._on_tab_close_req(tab_idx) + + def _rename_tab_req(self, tab_idx): + dialog = TabNameDialog(self) + dialog.set_tab_name(self._tab_widget.tabText(tab_idx)) + dialog.exec_() + tab_name = dialog.result() + if tab_name: + self._tab_widget.setTabText(tab_idx, tab_name) + + def _duplicate_requested(self, tab_idx=None): + if tab_idx is None: + tab_idx = self._tab_widget.currentIndex() + + src_widget = self._tab_widget.widget(tab_idx) + dst_widget = self._add_tab() + if dst_widget is None: + return + dst_widget.set_code(src_widget.get_code()) + + def _on_tab_mid_click(self, global_point): + point = self._tab_widget.mapFromGlobal(global_point) + tab_bar = self._tab_widget.tabBar() + tab_idx = tab_bar.tabAt(point) + last_index = tab_bar.count() - 1 + if tab_idx < 0 or tab_idx > last_index: + return + + self._on_tab_close_req(tab_idx) + + def _on_tab_double_click(self, global_point): + point = self._tab_widget.mapFromGlobal(global_point) + tab_bar = self._tab_widget.tabBar() + tab_idx = tab_bar.tabAt(point) + last_index = tab_bar.count() - 1 + if tab_idx < 0 or tab_idx > last_index: + return + + self._rename_tab_req(tab_idx) + + def _on_tab_close_req(self, tab_index): + if self._tab_widget.count() == 1: + return + + widget = self._tab_widget.widget(tab_index) + if widget in self._tabs: + self._tabs.remove(widget) + self._tab_widget.removeTab(tab_index) + + if self._tab_widget.count() == 1: + self._tab_widget.setTabsClosable(False) + + def _append_lines(self, lines): + at_max = self._output_widget.vertical_scroll_at_max() + tmp_cursor = QtGui.QTextCursor(self._output_widget.document()) + tmp_cursor.movePosition(QtGui.QTextCursor.End) + for line in lines: + tmp_cursor.insertText(line) + + if at_max: + self._output_widget.scroll_to_bottom() + + def _on_timer_timeout(self): + if self._stdout_err_wrapper.lines: + lines = [] + while self._stdout_err_wrapper.lines: + line = self._stdout_err_wrapper.lines.popleft() + lines.append(ANSI_ESCAPE.sub("", line)) + self._append_lines(lines) + + def _on_add_requested(self): + self._add_tab() + + def _add_tab(self): + dialog = TabNameDialog(self) + dialog.exec_() + tab_name = dialog.result() + if tab_name: + return self.add_tab(tab_name) + + return None + + def _on_before_execute(self, code_text): + at_max = self._output_widget.vertical_scroll_at_max() + document = self._output_widget.document() + tmp_cursor = QtGui.QTextCursor(document) + tmp_cursor.movePosition(QtGui.QTextCursor.End) + tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-")) + + code_block_format = QtGui.QTextFrameFormat() + code_block_format.setBackground(QtGui.QColor(27, 27, 27)) + code_block_format.setPadding(4) + + tmp_cursor.insertFrame(code_block_format) + char_format = tmp_cursor.charFormat() + char_format.setForeground( + QtGui.QBrush(QtGui.QColor(114, 224, 198)) + ) + tmp_cursor.setCharFormat(char_format) + tmp_cursor.insertText(code_text) + + # Create new cursor + tmp_cursor = QtGui.QTextCursor(document) + tmp_cursor.movePosition(QtGui.QTextCursor.End) + tmp_cursor.insertText("{}\n".format(20 * "-")) + + if at_max: + self._output_widget.scroll_to_bottom() diff --git a/client/ayon_core/tools/creator/widgets.py b/client/ayon_core/tools/creator/widgets.py index 53a2ee1080..bbc6848e6c 100644 --- a/client/ayon_core/tools/creator/widgets.py +++ b/client/ayon_core/tools/creator/widgets.py @@ -104,7 +104,7 @@ class ProductNameValidator(RegularExpressionValidatorClass): def validate(self, text, pos): results = super(ProductNameValidator, self).validate(text, pos) - if results[0] == self.Invalid: + if results[0] == RegularExpressionValidatorClass.Invalid: self.invalid.emit(self.invalid_chars(text)) return results @@ -217,7 +217,9 @@ class ProductTypeDescriptionWidget(QtWidgets.QWidget): product_type_label = QtWidgets.QLabel(self) product_type_label.setObjectName("CreatorProductTypeLabel") - product_type_label.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft) + product_type_label.setAlignment( + QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft + ) help_label = QtWidgets.QLabel(self) help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) diff --git a/client/ayon_core/tools/launcher/models/actions.py b/client/ayon_core/tools/launcher/models/actions.py index 7158c05431..e1612e2b9f 100644 --- a/client/ayon_core/tools/launcher/models/actions.py +++ b/client/ayon_core/tools/launcher/models/actions.py @@ -7,6 +7,7 @@ from ayon_core.pipeline.actions import ( discover_launcher_actions, LauncherAction, LauncherActionSelection, + register_launcher_action_path, ) from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch @@ -21,9 +22,9 @@ except ImportError: Application action based on 'ApplicationManager' system. - Handling of applications in launcher is not ideal and should be completely - redone from scratch. This is just a temporary solution to keep backwards - compatibility with AYON launcher. + Handling of applications in launcher is not ideal and should be + completely redone from scratch. This is just a temporary solution + to keep backwards compatibility with AYON launcher. Todos: Move handling of errors to frontend. @@ -459,6 +460,14 @@ class ActionsModel: def _get_discovered_action_classes(self): if self._discovered_actions is None: + # NOTE We don't need to register the paths, but that would + # require to change discovery logic and deprecate all functions + # related to registering and discovering launcher actions. + addons_manager = self._get_addons_manager() + actions_paths = addons_manager.collect_launcher_action_paths() + for path in actions_paths: + if path and os.path.exists(path): + register_launcher_action_path(path) self._discovered_actions = ( discover_launcher_actions() + self._get_applications_action_classes() diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 2ffce13292..c64d718172 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -265,7 +265,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate): if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): rect = QtCore.QRectF( - option.rect.x(), option.rect.height(), 5, 5) + option.rect.x(), option.rect.y() + option.rect.height(), 5, 5) painter.setPen(QtCore.Qt.NoPen) painter.setBrush(QtGui.QColor(200, 0, 0)) painter.drawEllipse(rect) diff --git a/client/ayon_core/tools/launcher/ui/window.py b/client/ayon_core/tools/launcher/ui/window.py index 34aeab35bb..2d52a73c38 100644 --- a/client/ayon_core/tools/launcher/ui/window.py +++ b/client/ayon_core/tools/launcher/ui/window.py @@ -202,8 +202,9 @@ class LauncherWindow(QtWidgets.QWidget): self._go_to_hierarchy_page(project_name) def _on_projects_refresh(self): - # There is nothing to do, we're on projects page + # Refresh only actions on projects page if self._is_on_projects_page: + self._actions_widget.refresh() return # No projects were found -> go back to projects page diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 0b790dfbbd..26b476de1f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -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. @@ -717,8 +771,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 +805,8 @@ class FrontendLoaderController(_BaseLoaderController): Returns: list[str]: Selected version ids. - """ + """ pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 2da77337fb..7959a63edb 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -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) @@ -299,6 +324,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() @@ -372,17 +403,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): repre_ids = set() for container in containers: - repre_id = container.get("representation") - # Ignore invalid representation ids. - # - invalid representation ids may be available if e.g. is - # opened scene from OpenPype whe 'ObjectId' was used instead - # of 'uuid'. - # NOTE: Server call would crash if there is any invalid id. - # That would cause crash we won't get any information. try: + repre_id = container.get("representation") + # Ignore invalid representation ids. + # - invalid representation ids may be available if e.g. is + # opened scene from OpenPype whe 'ObjectId' was used + # instead of 'uuid'. + # NOTE: Server call would crash if there is any invalid id. + # That would cause crash we won't get any information. uuid.UUID(repre_id) repre_ids.add(repre_id) - except ValueError: + except (ValueError, TypeError, AttributeError): pass product_ids = self._products_model.get_product_ids_by_repre_ids( diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 58eab0cabe..34acc0550c 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -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, diff --git a/client/ayon_core/tools/loader/models/selection.py b/client/ayon_core/tools/loader/models/selection.py index 326ff835f6..04add26f86 100644 --- a/client/ayon_core/tools/loader/models/selection.py +++ b/client/ayon_core/tools/loader/models/selection.py @@ -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 diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index c026952418..393272fdf9 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -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() @@ -517,7 +838,11 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): def setItemCheckState(self, index, state): self.setItemData(index, state, QtCore.Qt.CheckStateRole) - def set_value(self, values: Optional[Iterable[Any]], role: Optional[int] = None): + def set_value( + self, + values: Optional[Iterable[Any]], + role: Optional[int] = None, + ): if role is None: role = self._value_role diff --git a/client/ayon_core/tools/loader/ui/product_types_combo.py b/client/ayon_core/tools/loader/ui/product_types_combo.py new file mode 100644 index 0000000000..91fa52b0e9 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/product_types_combo.py @@ -0,0 +1,169 @@ +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() diff --git a/client/ayon_core/tools/loader/ui/product_types_widget.py b/client/ayon_core/tools/loader/ui/product_types_widget.py deleted file mode 100644 index 9b1bf6326f..0000000000 --- a/client/ayon_core/tools/loader/ui/product_types_widget.py +++ /dev/null @@ -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) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 9753da37af..8cece4687f 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -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(): @@ -222,6 +250,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor = VersionComboBox(product_id, parent) editor.setProperty("itemId", item_id) + editor.setFocusPolicy(QtCore.Qt.NoFocus) editor.value_changed.connect(self._on_editor_change) editor.destroyed.connect(self._on_destroy) @@ -238,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): diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index bc24d4d7f7..cebae9bca7 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -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( @@ -499,8 +501,10 @@ class ProductsModel(QtGui.QStandardItemModel): version_item.version_id for version_item in last_version_by_product_id.values() } - repre_count_by_version_id = self._controller.get_versions_representation_count( - project_name, version_ids + repre_count_by_version_id = ( + self._controller.get_versions_representation_count( + project_name, version_ids + ) ) sync_availability_by_version_id = ( self._controller.get_version_sync_availability( diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 748a1b5fb8..94d95b9026 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -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. diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 9fe7ab62a5..2f034d00de 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -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): diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py new file mode 100644 index 0000000000..5779fc2a01 --- /dev/null +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -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) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 31c9908b23..b846484c39 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -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() diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 7fad2b8176..4ed91813d3 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -375,6 +375,14 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ): pass + @abstractmethod + def revert_instances_create_attr_values( + self, + instance_ids: List["Union[str, None]"], + key: str, + ): + pass + @abstractmethod def get_publish_attribute_definitions( self, @@ -397,6 +405,15 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ): pass + @abstractmethod + def revert_instances_publish_attr_values( + self, + instance_ids: List["Union[str, None]"], + plugin_name: str, + key: str, + ): + pass + @abstractmethod def get_product_name( self, diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 347755d557..98fdda08cf 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -412,6 +412,11 @@ class PublisherController( instance_ids, key, value ) + def revert_instances_create_attr_values(self, instance_ids, key): + self._create_model.revert_instances_create_attr_values( + instance_ids, key + ) + def get_publish_attribute_definitions(self, instance_ids, include_context): """Collect publish attribute definitions for passed instances. @@ -432,6 +437,13 @@ class PublisherController( instance_ids, plugin_name, key, value ) + def revert_instances_publish_attr_values( + self, instance_ids, plugin_name, key + ): + return self._create_model.revert_instances_publish_attr_values( + instance_ids, plugin_name, key + ) + def get_product_name( self, creator_identifier, diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 8763d79712..9644af43e0 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -40,6 +40,7 @@ from ayon_core.tools.publisher.abstract import ( ) CREATE_EVENT_SOURCE = "publisher.create.model" +_DEFAULT_VALUE = object() class CreatorType: @@ -295,7 +296,7 @@ class InstanceItem: return InstanceItem( instance.id, instance.creator_identifier, - instance.label, + instance.label or "N/A", instance.group_label, instance.product_type, instance.product_name, @@ -752,20 +753,12 @@ class CreateModel: self._remove_instances_from_context(instance_ids) def set_instances_create_attr_values(self, instance_ids, key, value): - with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): - for instance_id in instance_ids: - instance = self._get_instance_by_id(instance_id) - creator_attributes = instance["creator_attributes"] - attr_def = creator_attributes.get_attr_def(key) - if ( - attr_def is None - or not attr_def.is_value_def - or not attr_def.visible - or not attr_def.enabled - or not attr_def.is_value_valid(value) - ): - continue - creator_attributes[key] = value + self._set_instances_create_attr_values(instance_ids, key, value) + + def revert_instances_create_attr_values(self, instance_ids, key): + self._set_instances_create_attr_values( + instance_ids, key, _DEFAULT_VALUE + ) def get_creator_attribute_definitions( self, instance_ids: List[str] @@ -816,28 +809,18 @@ class CreateModel: return output def set_instances_publish_attr_values( - self, instance_ids, plugin_name, key, value + self, instance_ids, plugin_name, key, value ): - with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): - for instance_id in instance_ids: - if instance_id is None: - instance = self._create_context - else: - instance = self._get_instance_by_id(instance_id) - plugin_val = instance.publish_attributes[plugin_name] - attr_def = plugin_val.get_attr_def(key) - # Ignore if attribute is not available or enabled/visible - # on the instance, or the value is not valid for definition - if ( - attr_def is None - or not attr_def.is_value_def - or not attr_def.visible - or not attr_def.enabled - or not attr_def.is_value_valid(value) - ): - continue + self._set_instances_publish_attr_values( + instance_ids, plugin_name, key, value + ) - plugin_val[key] = value + def revert_instances_publish_attr_values( + self, instance_ids, plugin_name, key + ): + self._set_instances_publish_attr_values( + instance_ids, plugin_name, key, _DEFAULT_VALUE + ) def get_publish_attribute_definitions( self, @@ -1064,6 +1047,53 @@ class CreateModel: CreatorItem.from_creator(creator) ) + def _set_instances_create_attr_values(self, instance_ids, key, value): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id in instance_ids: + instance = self._get_instance_by_id(instance_id) + creator_attributes = instance["creator_attributes"] + attr_def = creator_attributes.get_attr_def(key) + if ( + attr_def is None + or not attr_def.is_value_def + or not attr_def.visible + or not attr_def.enabled + ): + continue + + if value is _DEFAULT_VALUE: + creator_attributes[key] = attr_def.default + + elif attr_def.is_value_valid(value): + creator_attributes[key] = value + + def _set_instances_publish_attr_values( + self, instance_ids, plugin_name, key, value + ): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id in instance_ids: + if instance_id is None: + instance = self._create_context + else: + instance = self._get_instance_by_id(instance_id) + plugin_val = instance.publish_attributes[plugin_name] + attr_def = plugin_val.get_attr_def(key) + # Ignore if attribute is not available or enabled/visible + # on the instance, or the value is not valid for definition + if ( + attr_def is None + or not attr_def.is_value_def + or not attr_def.visible + or not attr_def.enabled + ): + continue + + if value is _DEFAULT_VALUE: + plugin_val[key] = attr_def.default + + elif attr_def.is_value_valid(value): + plugin_val[key] = value + def _cc_added_instance(self, event): instance_ids = { instance.id diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/window.py b/client/ayon_core/tools/publisher/publish_report_viewer/window.py index 6921c5d162..77db65588a 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/window.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/window.py @@ -484,6 +484,34 @@ class LoadedFilesView(QtWidgets.QTreeView): self._time_delegate = time_delegate self._remove_btn = remove_btn + def showEvent(self, event): + super().showEvent(event) + self._model.refresh() + header = self.header() + header.resizeSections(QtWidgets.QHeaderView.ResizeToContents) + self._update_remove_btn() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._update_remove_btn() + + def add_filepaths(self, filepaths): + self._model.add_filepaths(filepaths) + self._fill_selection() + + def remove_item_by_id(self, item_id): + self._model.remove_item_by_id(item_id) + self._fill_selection() + + def get_current_report(self): + index = self.currentIndex() + item_id = index.data(ITEM_ID_ROLE) + return self._model.get_report_by_id(item_id) + + def refresh(self): + self._model.refresh() + self._fill_selection() + def _update_remove_btn(self): viewport = self.viewport() height = viewport.height() + self.header().height() @@ -496,28 +524,9 @@ class LoadedFilesView(QtWidgets.QTreeView): header.resizeSections(QtWidgets.QHeaderView.ResizeToContents) self._update_remove_btn() - def resizeEvent(self, event): - super().resizeEvent(event) - self._update_remove_btn() - - def showEvent(self, event): - super().showEvent(event) - self._model.refresh() - header = self.header() - header.resizeSections(QtWidgets.QHeaderView.ResizeToContents) - self._update_remove_btn() - def _on_selection_change(self): self.selection_changed.emit() - def add_filepaths(self, filepaths): - self._model.add_filepaths(filepaths) - self._fill_selection() - - def remove_item_by_id(self, item_id): - self._model.remove_item_by_id(item_id) - self._fill_selection() - def _on_remove_clicked(self): index = self.currentIndex() item_id = index.data(ITEM_ID_ROLE) @@ -533,11 +542,6 @@ class LoadedFilesView(QtWidgets.QTreeView): if index.isValid(): self.setCurrentIndex(index) - def get_current_report(self): - index = self.currentIndex() - item_id = index.data(ITEM_ID_ROLE) - return self._model.get_report_by_id(item_id) - class LoadedFilesWidget(QtWidgets.QWidget): report_changed = QtCore.Signal() @@ -577,15 +581,18 @@ class LoadedFilesWidget(QtWidgets.QWidget): self._add_filepaths(filepaths) event.accept() + def refresh(self): + self._view.refresh() + + def get_current_report(self): + return self._view.get_current_report() + def _on_report_change(self): self.report_changed.emit() def _add_filepaths(self, filepaths): self._view.add_filepaths(filepaths) - def get_current_report(self): - return self._view.get_current_report() - class PublishReportViewerWindow(QtWidgets.QWidget): default_width = 1200 @@ -624,9 +631,12 @@ class PublishReportViewerWindow(QtWidgets.QWidget): self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) - def _on_report_change(self): - report = self._loaded_files_widget.get_current_report() - self.set_report(report) + def refresh(self): + self._loaded_files_widget.refresh() def set_report(self, report_data): self._main_widget.set_report(report_data) + + def _on_report_change(self): + report = self._loaded_files_widget.get_current_report() + self.set_report(report) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 095a4eae7c..2f633b3149 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -197,7 +197,7 @@ class ConvertorItemsGroupWidget(BaseGroupWidget): else: widget = ConvertorItemCardWidget(item, self) widget.selected.connect(self._on_widget_selection) - widget.double_clicked(self.double_clicked) + widget.double_clicked.connect(self.double_clicked) self._widgets_by_id[item.id] = widget self._content_layout.insertWidget(widget_idx, widget) widget_idx += 1 diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index a09ee80ed5..c6c3b774f0 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -339,7 +339,9 @@ class OverviewWidget(QtWidgets.QFrame): self._change_visibility_for_state() self._product_content_layout.addWidget(self._create_widget, 7) self._product_content_layout.addWidget(self._product_views_widget, 3) - self._product_content_layout.addWidget(self._product_attributes_wrap, 7) + self._product_content_layout.addWidget( + self._product_attributes_wrap, 7 + ) def _change_visibility_for_state(self): self._create_widget.setVisible( diff --git a/client/ayon_core/tools/publisher/widgets/precreate_widget.py b/client/ayon_core/tools/publisher/widgets/precreate_widget.py index 5ad203d370..b786fea3b5 100644 --- a/client/ayon_core/tools/publisher/widgets/precreate_widget.py +++ b/client/ayon_core/tools/publisher/widgets/precreate_widget.py @@ -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 diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py index b0b2640640..2b9f316d41 100644 --- a/client/ayon_core/tools/publisher/widgets/product_attributes.py +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -4,8 +4,10 @@ from typing import Dict, List, Any from qtpy import QtWidgets, QtCore from ayon_core.lib.attribute_definitions import AbstractAttrDef, UnknownDef -from ayon_core.tools.utils import set_style_property -from ayon_core.tools.attribute_defs import create_widget_for_attr_def +from ayon_core.tools.attribute_defs import ( + create_widget_for_attr_def, + AttributeDefinitionsLabel, +) from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend from ayon_core.tools.publisher.constants import ( INPUTS_LAYOUT_HSPACING, @@ -16,14 +18,6 @@ if typing.TYPE_CHECKING: from typing import Union -def _set_label_overriden(label: QtWidgets.QLabel, overriden: bool): - set_style_property( - label, - "overriden", - "1" if overriden else "" - ) - - class _CreateAttrDefInfo: """Helper class to store information about create attribute definition.""" def __init__( @@ -31,12 +25,14 @@ class _CreateAttrDefInfo: attr_def: AbstractAttrDef, instance_ids: List["Union[str, None]"], defaults: List[Any], - label_widget: "Union[None, QtWidgets.QLabel]", + label_widget: "Union[AttributeDefinitionsLabel, None]", ): self.attr_def: AbstractAttrDef = attr_def self.instance_ids: List["Union[str, None]"] = instance_ids self.defaults: List[Any] = defaults - self.label_widget: "Union[None, QtWidgets.QLabel]" = label_widget + self.label_widget: "Union[AttributeDefinitionsLabel, None]" = ( + label_widget + ) class _PublishAttrDefInfo: @@ -47,13 +43,15 @@ class _PublishAttrDefInfo: plugin_name: str, instance_ids: List["Union[str, None]"], defaults: List[Any], - label_widget: "Union[None, QtWidgets.QLabel]", + label_widget: "Union[AttributeDefinitionsLabel, None]", ): self.attr_def: AbstractAttrDef = attr_def self.plugin_name: str = plugin_name self.instance_ids: List["Union[str, None]"] = instance_ids self.defaults: List[Any] = defaults - self.label_widget: "Union[None, QtWidgets.QLabel]" = label_widget + self.label_widget: "Union[AttributeDefinitionsLabel, None]" = ( + label_widget + ) class CreatorAttrsWidget(QtWidgets.QWidget): @@ -143,7 +141,9 @@ class CreatorAttrsWidget(QtWidgets.QWidget): row = 0 for attr_def, info_by_id in result: - widget = create_widget_for_attr_def(attr_def, content_widget) + widget = create_widget_for_attr_def( + attr_def, content_widget, handle_revert_to_default=False + ) default_values = [] if attr_def.is_value_def: values = [] @@ -163,6 +163,9 @@ class CreatorAttrsWidget(QtWidgets.QWidget): widget.set_value(values, True) widget.value_changed.connect(self._input_value_changed) + widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) attr_def_info = _CreateAttrDefInfo( attr_def, list(info_by_id), default_values, None ) @@ -187,7 +190,9 @@ class CreatorAttrsWidget(QtWidgets.QWidget): label = attr_def.label or attr_def.key if label: - label_widget = QtWidgets.QLabel(label, self) + label_widget = AttributeDefinitionsLabel( + attr_def.id, label, self + ) tooltip = attr_def.tooltip if tooltip: label_widget.setToolTip(tooltip) @@ -202,7 +207,10 @@ class CreatorAttrsWidget(QtWidgets.QWidget): if not attr_def.is_label_horizontal: row += 1 attr_def_info.label_widget = label_widget - _set_label_overriden(label_widget, is_overriden) + label_widget.set_overridden(is_overriden) + label_widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) content_layout.addWidget( widget, row, col_num, 1, expand_cols @@ -224,7 +232,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): for instance_id, changes in event["instance_changes"].items(): if ( instance_id in self._current_instance_ids - and "creator_attributes" not in changes + and "creator_attributes" in changes ): self._refresh_content() break @@ -237,7 +245,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): if attr_def_info.label_widget is not None: defaults = attr_def_info.defaults is_overriden = len(defaults) != 1 or value not in defaults - _set_label_overriden(attr_def_info.label_widget, is_overriden) + attr_def_info.label_widget.set_overridden(is_overriden) self._controller.set_instances_create_attr_values( attr_def_info.instance_ids, @@ -245,6 +253,16 @@ class CreatorAttrsWidget(QtWidgets.QWidget): value ) + def _on_request_revert_to_default(self, attr_id): + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: + return + self._controller.revert_instances_create_attr_values( + attr_def_info.instance_ids, + attr_def_info.attr_def.key, + ) + self._refresh_content() + class PublishPluginAttrsWidget(QtWidgets.QWidget): """Widget showing publish plugin attributes for selected instances. @@ -346,7 +364,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): for plugin_name, attr_defs, plugin_values in result: for attr_def in attr_defs: widget = create_widget_for_attr_def( - attr_def, content_widget + attr_def, content_widget, handle_revert_to_default=False ) visible_widget = attr_def.visible # Hide unknown values of publish plugins @@ -367,7 +385,12 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): if attr_def.is_value_def: label = attr_def.label or attr_def.key if label: - label_widget = QtWidgets.QLabel(label, content_widget) + label_widget = AttributeDefinitionsLabel( + attr_def.id, label, content_widget + ) + label_widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) tooltip = attr_def.tooltip if tooltip: label_widget.setToolTip(tooltip) @@ -390,6 +413,9 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): continue widget.value_changed.connect(self._input_value_changed) + widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) instance_ids = [] values = [] @@ -423,7 +449,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): widget.set_value(values[0]) if label_widget is not None: - _set_label_overriden(label_widget, is_overriden) + label_widget.set_overridden(is_overriden) self._scroll_area.setWidget(content_widget) self._content_widget = content_widget @@ -436,7 +462,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): if attr_def_info.label_widget is not None: defaults = attr_def_info.defaults is_overriden = len(defaults) != 1 or value not in defaults - _set_label_overriden(attr_def_info.label_widget, is_overriden) + attr_def_info.label_widget.set_overridden(is_overriden) self._controller.set_instances_publish_attr_values( attr_def_info.instance_ids, @@ -445,6 +471,18 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): value ) + def _on_request_revert_to_default(self, attr_id): + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: + return + + self._controller.revert_instances_publish_attr_values( + attr_def_info.instance_ids, + attr_def_info.plugin_name, + attr_def_info.attr_def.key, + ) + self._refresh_content() + def _on_instance_attr_defs_change(self, event): for instance_id in event.data: if ( @@ -460,7 +498,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): for instance_id, changes in event["instance_changes"].items(): if ( instance_id in self._current_instance_ids - and "publish_attributes" not in changes + and "publish_attributes" in changes ): self._refresh_content() break diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index 04c9ca7e56..30b318982b 100644 --- a/client/ayon_core/tools/publisher/widgets/product_context.py +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -214,8 +214,8 @@ class TasksCombobox(QtWidgets.QComboBox): Combobox gives ability to select only from intersection of task names for folder paths in selected instances. - If folder paths in selected instances does not have same tasks then combobox - will be empty. + If folder paths in selected instances does not have same tasks + then combobox will be empty. """ value_changed = QtCore.Signal() @@ -604,7 +604,7 @@ class VariantInputWidget(PlaceholderLineEdit): class GlobalAttrsWidget(QtWidgets.QWidget): - """Global attributes mainly to define context and product name of instances. + """Global attributes to define context and product name of instances. product name is or may be affected on context. Gives abiity to modify context and product name of instance. This change is not autopromoted but diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index b7afcf470a..1e46e7e52c 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -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) diff --git a/client/ayon_core/tools/publisher/widgets/tasks_model.py b/client/ayon_core/tools/publisher/widgets/tasks_model.py index 16a4111f59..8bfa81116a 100644 --- a/client/ayon_core/tools/publisher/widgets/tasks_model.py +++ b/client/ayon_core/tools/publisher/widgets/tasks_model.py @@ -22,8 +22,8 @@ class TasksModel(QtGui.QStandardItemModel): tasks with same names then model is empty too. Args: - controller (AbstractPublisherFrontend): Controller which handles creation and - publishing. + controller (AbstractPublisherFrontend): Controller which handles + creation and publishing. """ def __init__( diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index a912495d4e..ed5b909a55 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -998,7 +998,11 @@ class PublisherWindow(QtWidgets.QDialog): new_item["label"] = new_item.pop("creator_label") new_item["identifier"] = new_item.pop("creator_identifier") new_failed_info.append(new_item) - self.add_error_message_dialog(event["title"], new_failed_info, "Creator:") + self.add_error_message_dialog( + event["title"], + new_failed_info, + "Creator:" + ) def _on_convertor_error(self, event): new_failed_info = [] diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 58447a8389..fb080d158b 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -321,7 +321,7 @@ class PushToContextController: return False if ( - not self._user_values.new_folder_name + self._user_values.new_folder_name is None and not self._selection_model.get_selected_folder_id() ): return False diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ba603699bc..6bd4279219 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -26,7 +26,7 @@ from ayon_core.pipeline import Anatomy from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.publish import get_publish_template_name -from ayon_core.pipeline.create import get_product_name +from ayon_core.pipeline.create import get_product_name, TaskNotSetError UNKNOWN = object() @@ -823,15 +823,25 @@ class ProjectPushItemProcess: task_name = task_info["name"] task_type = task_info["taskType"] - product_name = get_product_name( - self._item.dst_project_name, - task_name, - task_type, - self.host_name, - product_type, - self._item.variant, - project_settings=self._project_settings - ) + try: + product_name = get_product_name( + self._item.dst_project_name, + task_name, + task_type, + self.host_name, + product_type, + self._item.variant, + project_settings=self._project_settings + ) + except TaskNotSetError: + self._status.set_failed( + "Target product name template requires task name. To continue" + " you have to select target task or change settings" + " ayon+settings://core/tools/creator/product_name_profiles" + f"?project={self._item.dst_project_name}." + ) + raise PushToProjectError(self._status.fail_reason) + self._log_info( f"Push will be integrating to product with name '{product_name}'" ) diff --git a/client/ayon_core/tools/push_to_project/models/user_values.py b/client/ayon_core/tools/push_to_project/models/user_values.py index edef2fe4fb..e52cb2917c 100644 --- a/client/ayon_core/tools/push_to_project/models/user_values.py +++ b/client/ayon_core/tools/push_to_project/models/user_values.py @@ -84,8 +84,11 @@ class UserPublishValuesModel: return self._new_folder_name = folder_name - is_valid = True - if folder_name: + if folder_name is None: + is_valid = True + elif not folder_name: + is_valid = False + else: is_valid = ( self.folder_name_regex.match(folder_name) is not None ) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 4d64509afd..a69c512fcd 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -8,12 +8,69 @@ from ayon_core.tools.utils import ( ProjectsCombobox, FoldersWidget, TasksWidget, + NiceCheckbox, ) from ayon_core.tools.push_to_project.control import ( PushToContextController, ) +class ErrorDetailDialog(QtWidgets.QDialog): + def __init__(self, parent): + super().__init__(parent) + + self.setWindowTitle("Error detail") + self.setWindowIcon(QtGui.QIcon(get_app_icon_path())) + + title_label = QtWidgets.QLabel(self) + + sep_1 = SeparatorWidget(parent=self) + + detail_widget = QtWidgets.QTextBrowser(self) + detail_widget.setReadOnly(True) + detail_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + sep_2 = SeparatorWidget(parent=self) + + btns_widget = QtWidgets.QWidget(self) + + copy_btn = QtWidgets.QPushButton("Copy", btns_widget) + close_btn = QtWidgets.QPushButton("Close", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(copy_btn, 0) + btns_layout.addWidget(close_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.addWidget(title_label, 0) + main_layout.addWidget(sep_1, 0) + main_layout.addWidget(detail_widget, 1) + main_layout.addWidget(sep_2, 0) + main_layout.addWidget(btns_widget, 0) + + copy_btn.clicked.connect(self._on_copy_click) + close_btn.clicked.connect(self._on_close_click) + + self._title_label = title_label + self._detail_widget = detail_widget + + def set_detail(self, title, detail): + self._title_label.setText(title) + self._detail_widget.setText(detail) + + def _on_copy_click(self): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._detail_widget.toPlainText()) + + def _on_close_click(self): + self.close() + + class PushToContextSelectWindow(QtWidgets.QWidget): def __init__(self, controller=None): super(PushToContextSelectWindow, self).__init__() @@ -66,9 +123,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget): # --- Inputs widget --- inputs_widget = QtWidgets.QWidget(main_splitter) + new_folder_checkbox = NiceCheckbox(True, parent=inputs_widget) + folder_name_input = PlaceholderLineEdit(inputs_widget) folder_name_input.setPlaceholderText("< Name of new folder >") folder_name_input.setObjectName("ValidatedLineEdit") + folder_name_input.setEnabled(new_folder_checkbox.isChecked()) variant_input = PlaceholderLineEdit(inputs_widget) variant_input.setPlaceholderText("< Variant >") @@ -79,6 +139,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout = QtWidgets.QFormLayout(inputs_widget) inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("Create new folder", new_folder_checkbox) inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) inputs_layout.addRow("Comment", comment_input) @@ -113,6 +174,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): overlay_label = QtWidgets.QLabel(overlay_widget) overlay_label.setAlignment(QtCore.Qt.AlignCenter) + overlay_label.setWordWrap(True) + overlay_label.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) overlay_btns_widget = QtWidgets.QWidget(overlay_widget) overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) @@ -121,13 +186,28 @@ class PushToContextSelectWindow(QtWidgets.QWidget): overlay_try_btn = QtWidgets.QPushButton( "Try again", overlay_btns_widget ) + overlay_try_btn.setToolTip( + "Hide overlay and modify submit information." + ) + + show_detail_btn = QtWidgets.QPushButton( + "Show error detail", overlay_btns_widget + ) + show_detail_btn.setToolTip( + "Show error detail dialog to copy full error." + ) + overlay_close_btn = QtWidgets.QPushButton( "Close", overlay_btns_widget ) + overlay_close_btn.setToolTip("Discard changes and close window.") overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget) + overlay_btns_layout.setContentsMargins(0, 0, 0, 0) + overlay_btns_layout.setSpacing(10) overlay_btns_layout.addStretch(1) overlay_btns_layout.addWidget(overlay_try_btn, 0) + overlay_btns_layout.addWidget(show_detail_btn, 0) overlay_btns_layout.addWidget(overlay_close_btn, 0) overlay_btns_layout.addStretch(1) @@ -156,12 +236,14 @@ class PushToContextSelectWindow(QtWidgets.QWidget): main_thread_timer.timeout.connect(self._on_main_thread_timer) show_timer.timeout.connect(self._on_show_timer) user_input_changed_timer.timeout.connect(self._on_user_input_timer) + new_folder_checkbox.stateChanged.connect(self._on_new_folder_check) folder_name_input.textChanged.connect(self._on_new_folder_change) variant_input.textChanged.connect(self._on_variant_change) comment_input.textChanged.connect(self._on_comment_change) publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) + show_detail_btn.clicked.connect(self._on_show_detail_click) overlay_close_btn.clicked.connect(self._on_close_click) overlay_try_btn.clicked.connect(self._on_try_again_click) @@ -203,23 +285,28 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._tasks_widget = tasks_widget self._variant_input = variant_input + self._new_folder_checkbox = new_folder_checkbox self._folder_name_input = folder_name_input self._comment_input = comment_input self._publish_btn = publish_btn self._overlay_widget = overlay_widget + self._show_detail_btn = show_detail_btn self._overlay_close_btn = overlay_close_btn self._overlay_try_btn = overlay_try_btn self._overlay_label = overlay_label + self._error_detail_dialog = ErrorDetailDialog(self) + self._user_input_changed_timer = user_input_changed_timer # Store current value on input text change # The value is unset when is passed to controller # The goal is to have controll over changes happened during user change # in UI and controller auto-changes - self._variant_input_text = None + self._new_folder_name_enabled = None self._new_folder_name_input_text = None + self._variant_input_text = None self._comment_input_text = None self._first_show = True @@ -235,6 +322,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._folder_is_valid = None publish_btn.setEnabled(False) + show_detail_btn.setVisible(False) overlay_close_btn.setVisible(False) overlay_try_btn.setVisible(False) @@ -289,6 +377,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self.refresh() + def _on_new_folder_check(self): + self._new_folder_name_enabled = self._new_folder_checkbox.isChecked() + self._folder_name_input.setEnabled(self._new_folder_name_enabled) + self._user_input_changed_timer.start() + def _on_new_folder_change(self, text): self._new_folder_name_input_text = text self._user_input_changed_timer.start() @@ -302,9 +395,15 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._user_input_changed_timer.start() def _on_user_input_timer(self): + folder_name_enabled = self._new_folder_name_enabled folder_name = self._new_folder_name_input_text - if folder_name is not None: + if folder_name is not None or folder_name_enabled is not None: self._new_folder_name_input_text = None + self._new_folder_name_enabled = None + if not self._new_folder_checkbox.isChecked(): + folder_name = None + elif folder_name is None: + folder_name = self._folder_name_input.text() self._controller.set_user_value_folder_name(folder_name) variant = self._variant_input_text @@ -350,16 +449,13 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label.setText(self._controller.get_source_label()) def _invalidate_new_folder_name(self, folder_name, is_valid): - self._tasks_widget.setVisible(not folder_name) + self._tasks_widget.setVisible(folder_name is None) if self._folder_is_valid is is_valid: return self._folder_is_valid = is_valid state = "" - if folder_name: - if is_valid is True: - state = "valid" - elif is_valid is False: - state = "invalid" + if folder_name is not None: + state = "valid" if is_valid else "invalid" set_style_property( self._folder_name_input, "state", state ) @@ -374,6 +470,9 @@ class PushToContextSelectWindow(QtWidgets.QWidget): def _on_submission_change(self, event): self._publish_btn.setEnabled(event["enabled"]) + def _on_show_detail_click(self): + self._error_detail_dialog.show() + def _on_close_click(self): self.close() @@ -384,8 +483,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._process_item_id = None self._last_submit_message = None + self._error_detail_dialog.close() + self._overlay_close_btn.setVisible(False) self._overlay_try_btn.setVisible(False) + self._show_detail_btn.setVisible(False) self._main_layout.setCurrentWidget(self._main_context_widget) def _on_main_thread_timer(self): @@ -401,13 +503,24 @@ class PushToContextSelectWindow(QtWidgets.QWidget): if self._main_thread_timer_can_stop: self._main_thread_timer.stop() self._overlay_close_btn.setVisible(True) - if push_failed and not fail_traceback: + if push_failed: self._overlay_try_btn.setVisible(True) + if fail_traceback: + self._show_detail_btn.setVisible(True) if push_failed: - message = "Push Failed:\n{}".format(process_status["fail_reason"]) + reason = process_status["fail_reason"] if fail_traceback: - message += "\n{}".format(fail_traceback) + message = ( + "Unhandled error happened." + " Check error detail for more information." + ) + self._error_detail_dialog.set_detail( + reason, fail_traceback + ) + else: + message = f"Push Failed:\n{reason}" + self._overlay_label.setText(message) set_style_property(self._overlay_close_btn, "state", "error") diff --git a/client/ayon_core/tools/pyblish_pype/__init__.py b/client/ayon_core/tools/pyblish_pype/__init__.py deleted file mode 100644 index ef507005a5..0000000000 --- a/client/ayon_core/tools/pyblish_pype/__init__.py +++ /dev/null @@ -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__' -] diff --git a/client/ayon_core/tools/pyblish_pype/__main__.py b/client/ayon_core/tools/pyblish_pype/__main__.py deleted file mode 100644 index 5fc1b44a35..0000000000 --- a/client/ayon_core/tools/pyblish_pype/__main__.py +++ /dev/null @@ -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() diff --git a/client/ayon_core/tools/pyblish_pype/app.css b/client/ayon_core/tools/pyblish_pype/app.css deleted file mode 100644 index 33b6acbddb..0000000000 --- a/client/ayon_core/tools/pyblish_pype/app.css +++ /dev/null @@ -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; -} diff --git a/client/ayon_core/tools/pyblish_pype/app.py b/client/ayon_core/tools/pyblish_pype/app.py deleted file mode 100644 index bdc204bfbd..0000000000 --- a/client/ayon_core/tools/pyblish_pype/app.py +++ /dev/null @@ -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 diff --git a/client/ayon_core/tools/pyblish_pype/awesome.py b/client/ayon_core/tools/pyblish_pype/awesome.py deleted file mode 100644 index c70f5b1064..0000000000 --- a/client/ayon_core/tools/pyblish_pype/awesome.py +++ /dev/null @@ -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" -} diff --git a/client/ayon_core/tools/pyblish_pype/constants.py b/client/ayon_core/tools/pyblish_pype/constants.py deleted file mode 100644 index 10f95ca4af..0000000000 --- a/client/ayon_core/tools/pyblish_pype/constants.py +++ /dev/null @@ -1,97 +0,0 @@ -from qtpy import QtCore - -EXPANDER_WIDTH = 20 - - -def flags(*args, **kwargs): - type_name = kwargs.pop("type_name", "Flags") - with_base = kwargs.pop("with_base", False) - enums = {} - for idx, attr_name in enumerate(args): - if with_base: - if idx == 0: - enums[attr_name] = 0 - continue - idx -= 1 - enums[attr_name] = 2**idx - - for attr_name, value in kwargs.items(): - enums[attr_name] = value - return type(type_name, (), enums) - - -def roles(*args, **kwargs): - type_name = kwargs.pop("type_name", "Roles") - enums = {} - for attr_name, value in kwargs.items(): - enums[attr_name] = value - - offset = 0 - for idx, attr_name in enumerate(args): - _idx = idx + QtCore.Qt.UserRole + offset - while _idx in enums.values(): - offset += 1 - _idx = idx + offset - - enums[attr_name] = _idx - - return type(type_name, (), enums) - - -Roles = roles( - "ObjectIdRole", - "ObjectUIdRole", - "TypeRole", - "PublishFlagsRole", - "LogRecordsRole", - - "IsOptionalRole", - "IsEnabledRole", - - "FamiliesRole", - - "DocstringRole", - "PathModuleRole", - "PluginActionsVisibleRole", - "PluginValidActionsRole", - "PluginActionProgressRole", - - "TerminalItemTypeRole", - - "IntentItemValue", - - type_name="ModelRoles" -) - -InstanceStates = flags( - "ContextType", - "InProgress", - "HasWarning", - "HasError", - "HasFinished", - type_name="InstanceState" -) - -PluginStates = flags( - "IsCompatible", - "InProgress", - "WasProcessed", - "WasSkipped", - "HasWarning", - "HasError", - type_name="PluginState" -) - -GroupStates = flags( - "HasWarning", - "HasError", - "HasFinished", - type_name="GroupStates" -) - -PluginActionStates = flags( - "InProgress", - "HasFailed", - "HasFinished", - type_name="PluginActionStates" -) diff --git a/client/ayon_core/tools/pyblish_pype/control.py b/client/ayon_core/tools/pyblish_pype/control.py deleted file mode 100644 index c5034e2736..0000000000 --- a/client/ayon_core/tools/pyblish_pype/control.py +++ /dev/null @@ -1,666 +0,0 @@ -"""The Controller in a Model/View/Controller-based application -The graphical components of Pyblish Lite use this object to perform -publishing. It communicates via the Qt Signals/Slots mechanism -and has no direct connection to any graphics. This is important, -because this is how unittests are able to run without requiring -an active window manager; such as via Travis-CI. -""" -import os -import sys -import inspect -import logging -import collections - -from qtpy import QtCore - -import pyblish.api -import pyblish.util -import pyblish.logic -import pyblish.lib -import pyblish.version - -from . import util -from .constants import InstanceStates - -from ayon_core.settings import get_current_project_settings - - -class IterationBreak(Exception): - pass - - -class MainThreadItem: - """Callback with args and kwargs.""" - def __init__(self, callback, *args, **kwargs): - self.callback = callback - self.args = args - self.kwargs = kwargs - - def process(self): - self.callback(*self.args, **self.kwargs) - - -class MainThreadProcess(QtCore.QObject): - """Qt based main thread process executor. - - Has timer which controls each 50ms if there is new item to process. - - This approach gives ability to update UI meanwhile plugin is in progress. - """ - # How many times let pass QtApplication to process events - # - use 2 as resize event can trigger repaint event but not process in - # same loop - count_timeout = 2 - - def __init__(self): - super(MainThreadProcess, self).__init__() - self._items_to_process = collections.deque() - - timer = QtCore.QTimer() - timer.setInterval(0) - - timer.timeout.connect(self._execute) - - self._timer = timer - self._switch_counter = self.count_timeout - - def process(self, func, *args, **kwargs): - item = MainThreadItem(func, *args, **kwargs) - self.add_item(item) - - def add_item(self, item): - self._items_to_process.append(item) - - def _execute(self): - if not self._items_to_process: - return - - if self._switch_counter > 0: - self._switch_counter -= 1 - return - - self._switch_counter = self.count_timeout - - item = self._items_to_process.popleft() - item.process() - - def start(self): - if not self._timer.isActive(): - self._timer.start() - - def stop(self): - if self._timer.isActive(): - self._timer.stop() - - def clear(self): - if self._timer.isActive(): - self._timer.stop() - self._items_to_process = collections.deque() - - def stop_if_empty(self): - if self._timer.isActive(): - item = MainThreadItem(self._stop_if_empty) - self.add_item(item) - - def _stop_if_empty(self): - if not self._items_to_process: - self.stop() - - -class Controller(QtCore.QObject): - log = logging.getLogger("PyblishController") - # Emitted when the GUI is about to start processing; - # e.g. resetting, validating or publishing. - about_to_process = QtCore.Signal(object, object) - - # ??? Emitted for each process - was_processed = QtCore.Signal(dict) - - # Emitted when reset - # - all data are reset (plugins, processing, pari yielder, etc.) - was_reset = QtCore.Signal() - - # Emitted when previous group changed - passed_group = QtCore.Signal(object) - - # Emitted when want to change state of instances - switch_toggleability = QtCore.Signal(bool) - - # On action finished - was_acted = QtCore.Signal(dict) - - # Emitted when processing has stopped - was_stopped = QtCore.Signal() - - # Emitted when processing has finished - was_finished = QtCore.Signal() - - # Emitted when plugin was skipped - was_skipped = QtCore.Signal(object) - - # store OrderGroups - now it is a singleton - order_groups = util.OrderGroups - - # When instance is toggled - instance_toggled = QtCore.Signal(object, object, object) - - def __init__(self, parent=None): - super(Controller, self).__init__(parent) - self.context = None - self.plugins = {} - self.optional_default = {} - self.instance_toggled.connect(self._on_instance_toggled) - self._main_thread_processor = MainThreadProcess() - - self._current_state = "" - - def reset_variables(self): - self.log.debug("Resetting pyblish context variables") - - # Data internal to the GUI itself - self.is_running = False - self.stopped = False - self.errored = False - self._current_state = "" - - # Active producer of pairs - self.pair_generator = None - # Active pair - self.current_pair = None - - # Orders which changes GUI - # - passing collectors order disables plugin/instance toggle - self.collect_state = 0 - - # - passing validators order disables validate button and gives ability - # to know when to stop on validate button press - self.validators_order = None - self.validated = False - - # Get collectors and validators order - plugin_groups_keys = list(self.order_groups.groups.keys()) - self.validators_order = self.order_groups.validation_order - next_group_order = None - if len(plugin_groups_keys) > 1: - next_group_order = plugin_groups_keys[1] - - # This is used to track whether or not to continue - # processing when, for example, validation has failed. - self.processing = { - "stop_on_validation": False, - # Used? - "last_plugin_order": None, - "current_group_order": plugin_groups_keys[0], - "next_group_order": next_group_order, - "nextOrder": None, - "ordersWithError": set() - } - self._set_state_by_order() - self.log.debug("Reset of pyblish context variables done") - - @property - def current_state(self): - return self._current_state - - @staticmethod - def _convert_filter_presets(filter_presets): - """Convert AYON settings presets to dictionary. - - Returns: - dict[str, dict[str, Any]]: Filter presets converted to dictionary. - """ - if not isinstance(filter_presets, list): - return filter_presets - - return { - filter_preset["name"]: { - item["name"]: item["value"] - for item in filter_preset["value"] - } - for filter_preset in filter_presets - } - - def presets_by_hosts(self): - # Get global filters as base - presets = get_current_project_settings() - if not presets: - return {} - - result = {} - hosts = pyblish.api.registered_hosts() - for host in hosts: - host_presets = presets.get(host, {}).get("filters") - if not host_presets: - continue - - host_presets = self._convert_filter_presets(host_presets) - - for key, value in host_presets.items(): - if value is None: - if key in result: - result.pop(key) - continue - - result[key] = value - - return result - - def reset_context(self): - self.log.debug("Resetting pyblish context object") - - comment = None - if ( - self.context is not None and - self.context.data.get("comment") and - # We only preserve the user typed comment if we are *not* - # resetting from a successful publish without errors - self._current_state != "Published" - ): - comment = self.context.data["comment"] - - self.context = pyblish.api.Context() - - self.context._publish_states = InstanceStates.ContextType - self.context.optional = False - - self.context.data["publish"] = True - self.context.data["name"] = "context" - - self.context.data["host"] = reversed(pyblish.api.registered_hosts()) - self.context.data["port"] = int( - os.environ.get("PYBLISH_CLIENT_PORT", -1) - ) - self.context.data["connectTime"] = pyblish.lib.time(), - self.context.data["pyblishVersion"] = pyblish.version, - self.context.data["pythonVersion"] = sys.version - - self.context.data["icon"] = "book" - - self.context.families = ("__context__",) - - if comment: - # Preserve comment on reset if user previously had a comment - self.context.data["comment"] = comment - - self.log.debug("Reset of pyblish context object done") - - def reset(self): - """Discover plug-ins and run collection.""" - self._main_thread_processor.clear() - self._main_thread_processor.process(self._reset) - self._main_thread_processor.start() - - def _reset(self): - self.reset_context() - self.reset_variables() - - self.possible_presets = self.presets_by_hosts() - - # Load plugins and set pair generator - self.load_plugins() - self.pair_generator = self._pair_yielder(self.plugins) - - self.was_reset.emit() - - # Process collectors load rest of plugins with collected instances - self.collect() - - def load_plugins(self): - self.test = pyblish.logic.registered_test() - self.optional_default = {} - - plugins = pyblish.api.discover() - - targets = set(pyblish.logic.registered_targets()) - targets.add("default") - targets = list(targets) - plugins_by_targets = pyblish.logic.plugins_by_targets(plugins, targets) - - _plugins = [] - for plugin in plugins_by_targets: - # Skip plugin if is not optional and not active - if ( - not getattr(plugin, "optional", False) - and not getattr(plugin, "active", True) - ): - continue - _plugins.append(plugin) - self.plugins = _plugins - - def on_published(self): - if self.is_running: - self.is_running = False - self._current_state = ( - "Published" if not self.errored else "Published, with errors" - ) - self.was_finished.emit() - self._main_thread_processor.stop() - - def stop(self): - self.log.debug("Stopping") - self.stopped = True - - def act(self, plugin, action): - self.is_running = True - item = MainThreadItem(self._process_action, plugin, action) - self._main_thread_processor.add_item(item) - self._main_thread_processor.start() - self._main_thread_processor.stop_if_empty() - - def _process_action(self, plugin, action): - result = pyblish.plugin.process( - plugin, self.context, None, action.id - ) - self.is_running = False - self.was_acted.emit(result) - - def emit_(self, signal, kwargs): - pyblish.api.emit(signal, **kwargs) - - def _process(self, plugin, instance=None): - """Produce `result` from `plugin` and `instance` - :func:`process` shares state with :func:`_iterator` such that - an instance/plugin pair can be fetched and processed in isolation. - Arguments: - plugin (pyblish.api.Plugin): Produce result using plug-in - instance (optional, pyblish.api.Instance): Process this instance, - if no instance is provided, context is processed. - """ - - self.processing["nextOrder"] = plugin.order - - try: - result = pyblish.plugin.process(plugin, self.context, instance) - # Make note of the order at which the - # potential error error occurred. - if result["error"] is not None: - self.processing["ordersWithError"].add(plugin.order) - - except Exception as exc: - raise Exception("Unknown error({}): {}".format( - plugin.__name__, str(exc) - )) - - return result - - def _pair_yielder(self, plugins): - for plugin in plugins: - if ( - self.processing["current_group_order"] is not None - and plugin.order > self.processing["current_group_order"] - ): - current_group_order = self.processing["current_group_order"] - - new_next_group_order = None - new_current_group_order = self.processing["next_group_order"] - if new_current_group_order is not None: - current_next_order_found = False - for order in self.order_groups.groups.keys(): - if current_next_order_found: - new_next_group_order = order - break - - if order == new_current_group_order: - current_next_order_found = True - - self.processing["next_group_order"] = new_next_group_order - self.processing["current_group_order"] = ( - new_current_group_order - ) - - # Force update to the current state - self._set_state_by_order() - - if self.collect_state == 0: - self.collect_state = 1 - self._current_state = ( - "Ready" if not self.errored else - "Collected, with errors" - ) - self.switch_toggleability.emit(True) - self.passed_group.emit(current_group_order) - yield IterationBreak("Collected") - - else: - self.passed_group.emit(current_group_order) - if self.errored: - self._current_state = ( - "Stopped, due to errors" if not - self.processing["stop_on_validation"] else - "Validated, with errors" - ) - yield IterationBreak("Last group errored") - - if self.collect_state == 1: - self.collect_state = 2 - self.switch_toggleability.emit(False) - - if not self.validated and plugin.order > self.validators_order: - self.validated = True - if self.processing["stop_on_validation"]: - self._current_state = ( - "Validated" if not self.errored else - "Validated, with errors" - ) - yield IterationBreak("Validated") - - # Stop if was stopped - if self.stopped: - self.stopped = False - self._current_state = "Paused" - yield IterationBreak("Stopped") - - # check test if will stop - self.processing["nextOrder"] = plugin.order - message = self.test(**self.processing) - if message: - self._current_state = "Paused" - yield IterationBreak("Stopped due to \"{}\"".format(message)) - - self.processing["last_plugin_order"] = plugin.order - if not plugin.active: - pyblish.logic.log.debug("%s was inactive, skipping.." % plugin) - self.was_skipped.emit(plugin) - continue - - in_collect_stage = self.collect_state == 0 - if plugin.__instanceEnabled__: - instances = pyblish.logic.instances_by_plugin( - self.context, plugin - ) - if not instances: - self.was_skipped.emit(plugin) - continue - - for instance in instances: - if ( - not in_collect_stage - and instance.data.get("publish") is False - ): - pyblish.logic.log.debug( - "%s was inactive, skipping.." % instance - ) - continue - # Stop if was stopped - if self.stopped: - self.stopped = False - self._current_state = "Paused" - yield IterationBreak("Stopped") - - yield (plugin, instance) - else: - families = util.collect_families_from_instances( - self.context, only_active=not in_collect_stage - ) - plugins = pyblish.logic.plugins_by_families( - [plugin], families - ) - if not plugins: - self.was_skipped.emit(plugin) - continue - yield (plugin, None) - - self.passed_group.emit(self.processing["next_group_order"]) - - def iterate_and_process(self, on_finished=None): - """ Iterating inserted plugins with current context. - Collectors do not contain instances, they are None when collecting! - This process don't stop on one - """ - self._main_thread_processor.start() - - def on_next(): - self.log.debug("Looking for next pair to process") - try: - self.current_pair = next(self.pair_generator) - if isinstance(self.current_pair, IterationBreak): - raise self.current_pair - - except IterationBreak: - self.log.debug("Iteration break was raised") - self.is_running = False - self.was_stopped.emit() - self._main_thread_processor.stop() - return - - except StopIteration: - self.log.debug("Iteration stop was raised") - self.is_running = False - # All pairs were processed successfully! - if on_finished is not None: - self._main_thread_processor.add_item( - MainThreadItem(on_finished) - ) - self._main_thread_processor.stop_if_empty() - return - - except Exception as exc: - self.log.warning( - "Unexpected exception during `on_next` happened", - exc_info=True - ) - exc_msg = str(exc) - self._main_thread_processor.add_item( - MainThreadItem(on_unexpected_error, error=exc_msg) - ) - return - - self.about_to_process.emit(*self.current_pair) - self._main_thread_processor.add_item( - MainThreadItem(on_process) - ) - - def on_process(): - try: - self.log.debug( - "Processing pair: {}".format(str(self.current_pair)) - ) - result = self._process(*self.current_pair) - if result["error"] is not None: - self.log.debug("Error happened") - self.errored = True - - self.log.debug("Pair processed") - self.was_processed.emit(result) - - except Exception as exc: - self.log.warning( - "Unexpected exception during `on_process` happened", - exc_info=True - ) - exc_msg = str(exc) - self._main_thread_processor.add_item( - MainThreadItem(on_unexpected_error, error=exc_msg) - ) - return - - self._main_thread_processor.add_item( - MainThreadItem(on_next) - ) - - def on_unexpected_error(error): - # TODO this should be handled much differently - # TODO emit crash signal to show message box with traceback? - self.is_running = False - self.was_stopped.emit() - util.u_print(u"An unexpected error occurred:\n %s" % error) - if on_finished is not None: - self._main_thread_processor.add_item( - MainThreadItem(on_finished) - ) - self._main_thread_processor.stop_if_empty() - - self.is_running = True - self._main_thread_processor.add_item( - MainThreadItem(on_next) - ) - - def _set_state_by_order(self): - order = self.processing["current_group_order"] - self._current_state = self.order_groups.groups[order]["state"] - - def collect(self): - """ Iterate and process Collect plugins - - load_plugins method is launched again when finished - """ - self._set_state_by_order() - self._main_thread_processor.process(self._start_collect) - self._main_thread_processor.start() - - def validate(self): - """ Process plugins to validations_order value.""" - self._set_state_by_order() - self._main_thread_processor.process(self._start_validate) - self._main_thread_processor.start() - - def publish(self): - """ Iterate and process all remaining plugins.""" - self._set_state_by_order() - self._main_thread_processor.process(self._start_publish) - self._main_thread_processor.start() - - def _start_collect(self): - self.iterate_and_process() - - def _start_validate(self): - self.processing["stop_on_validation"] = True - self.iterate_and_process() - - def _start_publish(self): - self.processing["stop_on_validation"] = False - self.iterate_and_process(self.on_published) - - def cleanup(self): - """Forcefully delete objects from memory - In an ideal world, this shouldn't be necessary. Garbage - collection guarantees that anything without reference - is automatically removed. - However, because this application is designed to be run - multiple times from the same interpreter process, extra - case must be taken to ensure there are no memory leaks. - Explicitly deleting objects shines a light on where objects - may still be referenced in the form of an error. No errors - means this was unnecessary, but that's ok. - """ - - for instance in self.context: - del(instance) - - for plugin in self.plugins: - del(plugin) - - def _on_instance_toggled(self, instance, old_value, new_value): - callbacks = pyblish.api.registered_callbacks().get("instanceToggled") - if not callbacks: - return - - for callback in callbacks: - try: - callback(instance, old_value, new_value) - except Exception: - self.log.warning( - "Callback for `instanceToggled` crashed. {}".format( - os.path.abspath(inspect.getfile(callback)) - ), - exc_info=True - ) diff --git a/client/ayon_core/tools/pyblish_pype/delegate.py b/client/ayon_core/tools/pyblish_pype/delegate.py deleted file mode 100644 index bb253dd1a3..0000000000 --- a/client/ayon_core/tools/pyblish_pype/delegate.py +++ /dev/null @@ -1,540 +0,0 @@ -import platform - -from qtpy import QtWidgets, QtGui, QtCore - -from . import model -from .awesome import tags as awesome -from .constants import ( - PluginStates, InstanceStates, PluginActionStates, Roles, EXPANDER_WIDTH -) - -colors = { - "error": QtGui.QColor("#ff4a4a"), - "warning": QtGui.QColor("#ff9900"), - "ok": QtGui.QColor("#77AE24"), - "active": QtGui.QColor("#99CEEE"), - "idle": QtCore.Qt.white, - "inactive": QtGui.QColor("#888"), - "hover": QtGui.QColor(255, 255, 255, 10), - "selected": QtGui.QColor(255, 255, 255, 20), - "outline": QtGui.QColor("#333"), - "group": QtGui.QColor("#333"), - "group-hover": QtGui.QColor("#3c3c3c"), - "group-selected-hover": QtGui.QColor("#555555"), - "expander-bg": QtGui.QColor("#222"), - "expander-hover": QtGui.QColor("#2d6c9f"), - "expander-selected-hover": QtGui.QColor("#3784c5") -} - -scale_factors = {"darwin": 1.5} -scale_factor = scale_factors.get(platform.system().lower(), 1.0) -fonts = { - "h3": QtGui.QFont("Open Sans", 10 * scale_factor, QtGui.QFont.Normal), - "h4": QtGui.QFont("Open Sans", 8 * scale_factor, QtGui.QFont.Normal), - "h5": QtGui.QFont("Open Sans", 8 * scale_factor, QtGui.QFont.DemiBold), - "awesome6": QtGui.QFont("FontAwesome", 6 * scale_factor), - "awesome10": QtGui.QFont("FontAwesome", 10 * scale_factor), - "smallAwesome": QtGui.QFont("FontAwesome", 8 * scale_factor), - "largeAwesome": QtGui.QFont("FontAwesome", 16 * scale_factor), -} -font_metrics = { - "awesome6": QtGui.QFontMetrics(fonts["awesome6"]), - "h4": QtGui.QFontMetrics(fonts["h4"]), - "h5": QtGui.QFontMetrics(fonts["h5"]) -} -icons = { - "action": awesome["adn"], - "angle-right": awesome["angle-right"], - "angle-left": awesome["angle-left"], - "plus-sign": awesome['plus'], - "minus-sign": awesome['minus'] -} - - -class PluginItemDelegate(QtWidgets.QStyledItemDelegate): - """Generic delegate for model items""" - - def paint(self, painter, option, index): - """Paint checkbox and text. - _ - |_| My label > - """ - - body_rect = QtCore.QRectF(option.rect) - - check_rect = QtCore.QRectF(body_rect) - check_rect.setWidth(check_rect.height()) - check_offset = (check_rect.height() / 4) + 1 - check_rect.adjust( - check_offset, check_offset, -check_offset, -check_offset - ) - - check_color = colors["idle"] - - perspective_icon = icons["angle-right"] - perspective_rect = QtCore.QRectF(body_rect) - perspective_rect.setWidth(perspective_rect.height()) - perspective_rect.adjust(0, 3, 0, 0) - perspective_rect.translate( - body_rect.width() - (perspective_rect.width() / 2 + 2), - 0 - ) - - publish_states = index.data(Roles.PublishFlagsRole) - if publish_states & PluginStates.InProgress: - check_color = colors["active"] - - elif publish_states & PluginStates.HasError: - check_color = colors["error"] - - elif publish_states & PluginStates.HasWarning: - check_color = colors["warning"] - - elif publish_states & PluginStates.WasProcessed: - check_color = colors["ok"] - - elif not index.data(Roles.IsEnabledRole): - check_color = colors["inactive"] - - offset = (body_rect.height() - font_metrics["h4"].height()) / 2 - label_rect = QtCore.QRectF(body_rect.adjusted( - check_rect.width() + 12, offset - 1, 0, 0 - )) - - assert label_rect.width() > 0 - - label = index.data(QtCore.Qt.DisplayRole) - label = font_metrics["h4"].elidedText( - label, - QtCore.Qt.ElideRight, - label_rect.width() - 20 - ) - - font_color = colors["idle"] - if not index.data(QtCore.Qt.CheckStateRole): - font_color = colors["inactive"] - - # Maintain reference to state, so we can restore it once we're done - painter.save() - - # Draw perspective icon - painter.setFont(fonts["awesome10"]) - painter.setPen(QtGui.QPen(font_color)) - painter.drawText(perspective_rect, perspective_icon) - - # Draw label - painter.setFont(fonts["h4"]) - painter.setPen(QtGui.QPen(font_color)) - painter.drawText(label_rect, label) - - # Draw action icon - if index.data(Roles.PluginActionsVisibleRole): - painter.save() - action_state = index.data(Roles.PluginActionProgressRole) - if action_state & PluginActionStates.HasFailed: - color = colors["error"] - elif action_state & PluginActionStates.HasFinished: - color = colors["ok"] - elif action_state & PluginActionStates.InProgress: - color = colors["active"] - else: - color = colors["idle"] - - painter.setFont(fonts["smallAwesome"]) - painter.setPen(QtGui.QPen(color)) - - icon_rect = QtCore.QRectF( - option.rect.adjusted( - label_rect.width() - perspective_rect.width() / 2, - label_rect.height() / 3, 0, 0 - ) - ) - painter.drawText(icon_rect, icons["action"]) - - painter.restore() - - # Draw checkbox - pen = QtGui.QPen(check_color, 1) - painter.setPen(pen) - - if index.data(Roles.IsOptionalRole): - painter.drawRect(check_rect) - - if index.data(QtCore.Qt.CheckStateRole): - optional_check_rect = QtCore.QRectF(check_rect) - optional_check_rect.adjust(2, 2, -1, -1) - painter.fillRect(optional_check_rect, check_color) - - else: - painter.fillRect(check_rect, check_color) - - if option.state & QtWidgets.QStyle.State_MouseOver: - painter.fillRect(body_rect, colors["hover"]) - - if option.state & QtWidgets.QStyle.State_Selected: - painter.fillRect(body_rect, colors["selected"]) - - # Ok, we're done, tidy up. - painter.restore() - - def sizeHint(self, option, index): - return QtCore.QSize(option.rect.width(), 20) - - -class InstanceItemDelegate(QtWidgets.QStyledItemDelegate): - """Generic delegate for model items""" - - def paint(self, painter, option, index): - """Paint checkbox and text. - _ - |_| My label > - """ - - body_rect = QtCore.QRectF(option.rect) - - check_rect = QtCore.QRectF(body_rect) - check_rect.setWidth(check_rect.height()) - offset = (check_rect.height() / 4) + 1 - check_rect.adjust(offset, offset, -(offset), -(offset)) - - check_color = colors["idle"] - - perspective_icon = icons["angle-right"] - perspective_rect = QtCore.QRectF(body_rect) - perspective_rect.setWidth(perspective_rect.height()) - perspective_rect.adjust(0, 3, 0, 0) - perspective_rect.translate( - body_rect.width() - (perspective_rect.width() / 2 + 2), - 0 - ) - - publish_states = index.data(Roles.PublishFlagsRole) - if publish_states & InstanceStates.InProgress: - check_color = colors["active"] - - elif publish_states & InstanceStates.HasError: - check_color = colors["error"] - - elif publish_states & InstanceStates.HasWarning: - check_color = colors["warning"] - - elif publish_states & InstanceStates.HasFinished: - check_color = colors["ok"] - - elif not index.data(Roles.IsEnabledRole): - check_color = colors["inactive"] - - offset = (body_rect.height() - font_metrics["h4"].height()) / 2 - label_rect = QtCore.QRectF(body_rect.adjusted( - check_rect.width() + 12, offset - 1, 0, 0 - )) - - assert label_rect.width() > 0 - - label = index.data(QtCore.Qt.DisplayRole) - label = font_metrics["h4"].elidedText( - label, - QtCore.Qt.ElideRight, - label_rect.width() - 20 - ) - - font_color = colors["idle"] - if not index.data(QtCore.Qt.CheckStateRole): - font_color = colors["inactive"] - - # Maintain reference to state, so we can restore it once we're done - painter.save() - - # Draw perspective icon - painter.setFont(fonts["awesome10"]) - painter.setPen(QtGui.QPen(font_color)) - painter.drawText(perspective_rect, perspective_icon) - - # Draw label - painter.setFont(fonts["h4"]) - painter.setPen(QtGui.QPen(font_color)) - painter.drawText(label_rect, label) - - # Draw checkbox - pen = QtGui.QPen(check_color, 1) - painter.setPen(pen) - - if index.data(Roles.IsOptionalRole): - painter.drawRect(check_rect) - - if index.data(QtCore.Qt.CheckStateRole): - optional_check_rect = QtCore.QRectF(check_rect) - optional_check_rect.adjust(2, 2, -1, -1) - painter.fillRect(optional_check_rect, check_color) - - else: - painter.fillRect(check_rect, check_color) - - if option.state & QtWidgets.QStyle.State_MouseOver: - painter.fillRect(body_rect, colors["hover"]) - - if option.state & QtWidgets.QStyle.State_Selected: - painter.fillRect(body_rect, colors["selected"]) - - # Ok, we're done, tidy up. - painter.restore() - - def sizeHint(self, option, index): - return QtCore.QSize(option.rect.width(), 20) - - -class InstanceDelegate(QtWidgets.QStyledItemDelegate): - """Generic delegate for instance header""" - - radius = 8.0 - - def __init__(self, parent): - super(InstanceDelegate, self).__init__(parent) - self.item_delegate = InstanceItemDelegate(parent) - - def paint(self, painter, option, index): - if index.data(Roles.TypeRole) in ( - model.InstanceType, model.PluginType - ): - self.item_delegate.paint(painter, option, index) - return - - self.group_item_paint(painter, option, index) - - def group_item_paint(self, painter, option, index): - """Paint text - _ - My label - """ - body_rect = QtCore.QRectF(option.rect) - bg_rect = QtCore.QRectF( - body_rect.left(), body_rect.top() + 1, - body_rect.width() - 5, body_rect.height() - 2 - ) - - expander_rect = QtCore.QRectF(bg_rect) - expander_rect.setWidth(EXPANDER_WIDTH) - - remainder_rect = QtCore.QRectF( - expander_rect.x() + expander_rect.width(), - expander_rect.y(), - bg_rect.width() - expander_rect.width(), - expander_rect.height() - ) - - width = float(expander_rect.width()) - height = float(expander_rect.height()) - - x_pos = expander_rect.x() - y_pos = expander_rect.y() - - x_radius = min(self.radius, width / 2) - y_radius = min(self.radius, height / 2) - x_radius2 = x_radius * 2 - y_radius2 = y_radius * 2 - - expander_path = QtGui.QPainterPath() - expander_path.moveTo(x_pos, y_pos + y_radius) - expander_path.arcTo( - x_pos, y_pos, - x_radius2, y_radius2, - 180.0, -90.0 - ) - expander_path.lineTo(x_pos + width, y_pos) - expander_path.lineTo(x_pos + width, y_pos + height) - expander_path.lineTo(x_pos + x_radius, y_pos + height) - expander_path.arcTo( - x_pos, y_pos + height - y_radius2, - x_radius2, y_radius2, - 270.0, -90.0 - ) - expander_path.closeSubpath() - - width = float(remainder_rect.width()) - height = float(remainder_rect.height()) - x_pos = remainder_rect.x() - y_pos = remainder_rect.y() - - x_radius = min(self.radius, width / 2) - y_radius = min(self.radius, height / 2) - x_radius2 = x_radius * 2 - y_radius2 = y_radius * 2 - - remainder_path = QtGui.QPainterPath() - remainder_path.moveTo(x_pos + width, y_pos + height - y_radius) - remainder_path.arcTo( - x_pos + width - x_radius2, y_pos + height - y_radius2, - x_radius2, y_radius2, - 0.0, -90.0 - ) - remainder_path.lineTo(x_pos, y_pos + height) - remainder_path.lineTo(x_pos, y_pos) - remainder_path.lineTo(x_pos + width - x_radius, y_pos) - remainder_path.arcTo( - x_pos + width - x_radius2, y_pos, - x_radius2, y_radius2, - 90.0, -90.0 - ) - remainder_path.closeSubpath() - - painter.fillPath(expander_path, colors["expander-bg"]) - painter.fillPath(remainder_path, colors["group"]) - - mouse_pos = option.widget.mapFromGlobal(QtGui.QCursor.pos()) - selected = option.state & QtWidgets.QStyle.State_Selected - hovered = option.state & QtWidgets.QStyle.State_MouseOver - - if selected and hovered: - if expander_rect.contains(mouse_pos): - painter.fillPath( - expander_path, colors["expander-selected-hover"] - ) - else: - painter.fillPath( - remainder_path, colors["group-selected-hover"] - ) - - elif hovered: - if expander_rect.contains(mouse_pos): - painter.fillPath(expander_path, colors["expander-hover"]) - else: - painter.fillPath(remainder_path, colors["group-hover"]) - - text_height = font_metrics["awesome6"].height() - adjust_value = (expander_rect.height() - text_height) / 2 - expander_rect.adjust( - adjust_value + 1.5, adjust_value - 0.5, - -adjust_value + 1.5, -adjust_value - 0.5 - ) - - offset = (remainder_rect.height() - font_metrics["h5"].height()) / 2 - label_rect = QtCore.QRectF(remainder_rect.adjusted( - 5, offset - 1, 0, 0 - )) - - expander_icon = icons["plus-sign"] - - expanded = self.parent().isExpanded(index) - if expanded: - expander_icon = icons["minus-sign"] - label = index.data(QtCore.Qt.DisplayRole) - label = font_metrics["h5"].elidedText( - label, QtCore.Qt.ElideRight, label_rect.width() - ) - - # Maintain reference to state, so we can restore it once we're done - painter.save() - - painter.setFont(fonts["awesome6"]) - painter.setPen(QtGui.QPen(colors["idle"])) - painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon) - - # Draw label - painter.setFont(fonts["h5"]) - painter.drawText(label_rect, label) - - # Ok, we're done, tidy up. - painter.restore() - - def sizeHint(self, option, index): - return QtCore.QSize(option.rect.width(), 20) - - -class PluginDelegate(QtWidgets.QStyledItemDelegate): - """Generic delegate for plugin header""" - - def __init__(self, parent): - super(PluginDelegate, self).__init__(parent) - self.item_delegate = PluginItemDelegate(parent) - - def paint(self, painter, option, index): - if index.data(Roles.TypeRole) in ( - model.InstanceType, model.PluginType - ): - self.item_delegate.paint(painter, option, index) - return - - self.group_item_paint(painter, option, index) - - def group_item_paint(self, painter, option, index): - """Paint text - _ - My label - """ - body_rect = QtCore.QRectF(option.rect) - bg_rect = QtCore.QRectF( - body_rect.left(), body_rect.top() + 1, - body_rect.width() - 5, body_rect.height() - 2 - ) - radius = 8.0 - bg_path = QtGui.QPainterPath() - bg_path.addRoundedRect(bg_rect, radius, radius) - hovered = option.state & QtWidgets.QStyle.State_MouseOver - selected = option.state & QtWidgets.QStyle.State_Selected - if hovered and selected: - painter.fillPath(bg_path, colors["group-selected-hover"]) - elif hovered: - painter.fillPath(bg_path, colors["group-hover"]) - else: - painter.fillPath(bg_path, colors["group"]) - - expander_rect = QtCore.QRectF(bg_rect) - expander_rect.setWidth(expander_rect.height()) - text_height = font_metrics["awesome6"].height() - adjust_value = (expander_rect.height() - text_height) / 2 - expander_rect.adjust( - adjust_value + 1.5, adjust_value - 0.5, - -adjust_value + 1.5, -adjust_value - 0.5 - ) - - offset = (bg_rect.height() - font_metrics["h5"].height()) / 2 - label_rect = QtCore.QRectF(bg_rect.adjusted( - expander_rect.width() + 12, offset - 1, 0, 0 - )) - - assert label_rect.width() > 0 - - expander_icon = icons["plus-sign"] - - expanded = self.parent().isExpanded(index) - if expanded: - expander_icon = icons["minus-sign"] - label = index.data(QtCore.Qt.DisplayRole) - label = font_metrics["h5"].elidedText( - label, QtCore.Qt.ElideRight, label_rect.width() - ) - - # Maintain reference to state, so we can restore it once we're done - painter.save() - - painter.setFont(fonts["awesome6"]) - painter.setPen(QtGui.QPen(colors["idle"])) - painter.drawText(expander_rect, QtCore.Qt.AlignCenter, expander_icon) - - # Draw label - painter.setFont(fonts["h5"]) - painter.drawText(label_rect, label) - - # Ok, we're done, tidy up. - painter.restore() - - def sizeHint(self, option, index): - return QtCore.QSize(option.rect.width(), 20) - - -class TerminalItem(QtWidgets.QStyledItemDelegate): - """Delegate used exclusively for the Terminal""" - - def paint(self, painter, option, index): - super(TerminalItem, self).paint(painter, option, index) - item_type = index.data(Roles.TypeRole) - if item_type == model.TerminalDetailType: - return - - hover = QtGui.QPainterPath() - hover.addRect(QtCore.QRectF(option.rect).adjusted(0, 0, -1, -1)) - if option.state & QtWidgets.QStyle.State_Selected: - painter.fillPath(hover, colors["selected"]) - - if option.state & QtWidgets.QStyle.State_MouseOver: - painter.fillPath(hover, colors["hover"]) diff --git a/client/ayon_core/tools/pyblish_pype/font/fontawesome/fontawesome-webfont.ttf b/client/ayon_core/tools/pyblish_pype/font/fontawesome/fontawesome-webfont.ttf deleted file mode 100644 index 9d02852c14..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/fontawesome/fontawesome-webfont.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/LICENSE.txt b/client/ayon_core/tools/pyblish_pype/font/opensans/LICENSE.txt deleted file mode 100644 index d645695673..0000000000 --- a/client/ayon_core/tools/pyblish_pype/font/opensans/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Bold.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Bold.ttf deleted file mode 100644 index fd79d43bea..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Bold.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-BoldItalic.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-BoldItalic.ttf deleted file mode 100644 index 9bc800958a..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-BoldItalic.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-ExtraBold.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-ExtraBold.ttf deleted file mode 100644 index 21f6f84a07..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-ExtraBold.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-ExtraBoldItalic.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-ExtraBoldItalic.ttf deleted file mode 100644 index 31cb688340..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-ExtraBoldItalic.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Italic.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Italic.ttf deleted file mode 100644 index c90da48ff3..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Italic.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Light.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Light.ttf deleted file mode 100644 index 0d381897da..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Light.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-LightItalic.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-LightItalic.ttf deleted file mode 100644 index 68299c4bc6..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-LightItalic.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Regular.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Regular.ttf deleted file mode 100644 index db433349b7..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Regular.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Semibold.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Semibold.ttf deleted file mode 100644 index 1a7679e394..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-Semibold.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-SemiboldItalic.ttf b/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-SemiboldItalic.ttf deleted file mode 100644 index 59b6d16b06..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/font/opensans/OpenSans-SemiboldItalic.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/i18n/pyblish_lite.pro b/client/ayon_core/tools/pyblish_pype/i18n/pyblish_lite.pro deleted file mode 100644 index c8e2a5b56f..0000000000 --- a/client/ayon_core/tools/pyblish_pype/i18n/pyblish_lite.pro +++ /dev/null @@ -1,2 +0,0 @@ -SOURCES = ../window.py -TRANSLATIONS = zh_CN.ts \ No newline at end of file diff --git a/client/ayon_core/tools/pyblish_pype/i18n/zh_CN.qm b/client/ayon_core/tools/pyblish_pype/i18n/zh_CN.qm deleted file mode 100644 index fed08d8a51..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/i18n/zh_CN.qm and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/i18n/zh_CN.ts b/client/ayon_core/tools/pyblish_pype/i18n/zh_CN.ts deleted file mode 100644 index 18ba81f69f..0000000000 --- a/client/ayon_core/tools/pyblish_pype/i18n/zh_CN.ts +++ /dev/null @@ -1,96 +0,0 @@ - - - - Window - - - Finishing up reset.. - 完成重置.. - - - - Comment.. - 备注.. - - - - Processing - 处理 - - - - Stopped due to error(s), see Terminal. - 因错误终止, 请查看终端。 - - - - Finished successfully! - 成功完成! - - - - About to reset.. - 即将重置.. - - - - Preparing validate.. - 准备校验.. - - - - Preparing publish.. - 准备发布.. - - - - Preparing - 准备 - - - - Action prepared. - 动作已就绪。 - - - - Cleaning up models.. - 清理数据模型.. - - - - Cleaning up terminal.. - 清理终端.. - - - - Cleaning up controller.. - 清理控制器.. - - - - All clean! - 清理完成! - - - - Good bye - 再见 - - - - ..as soon as processing is finished.. - ..处理即将完成.. - - - - Stopping.. - 正在停止.. - - - - Closing.. - 正在关闭.. - - - diff --git a/client/ayon_core/tools/pyblish_pype/img/down_arrow.png b/client/ayon_core/tools/pyblish_pype/img/down_arrow.png deleted file mode 100644 index e271f7f90b..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/img/down_arrow.png and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/img/logo-extrasmall.png b/client/ayon_core/tools/pyblish_pype/img/logo-extrasmall.png deleted file mode 100644 index ebe45c4c6e..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/img/logo-extrasmall.png and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/img/tab-overview.png b/client/ayon_core/tools/pyblish_pype/img/tab-overview.png deleted file mode 100644 index 443a750a7c..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/img/tab-overview.png and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/img/tab-terminal.png b/client/ayon_core/tools/pyblish_pype/img/tab-terminal.png deleted file mode 100644 index ea1bcff98d..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/img/tab-terminal.png and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/mock.py b/client/ayon_core/tools/pyblish_pype/mock.py deleted file mode 100644 index c85ff0f2ba..0000000000 --- a/client/ayon_core/tools/pyblish_pype/mock.py +++ /dev/null @@ -1,732 +0,0 @@ -import os -import time -import subprocess - -import pyblish.api - - -class MyAction(pyblish.api.Action): - label = "My Action" - on = "processed" - - def process(self, context, plugin): - self.log.info("Running!") - - -class MyOtherAction(pyblish.api.Action): - label = "My Other Action" - - def process(self, context, plugin): - self.log.info("Running!") - - -class CollectComment(pyblish.api.ContextPlugin): - """This collector has a very long comment. - - The idea is that this comment should either be elided, or word- - wrapped in the corresponding view. - - """ - - order = pyblish.api.CollectorOrder - - def process(self, context): - context.data["comment"] = "" - - -class MyCollector(pyblish.api.ContextPlugin): - label = "My Collector" - order = pyblish.api.CollectorOrder - - def process(self, context): - context.create_instance("MyInstance 1", families=["myFamily"]) - context.create_instance("MyInstance 2", families=["myFamily 2"]) - context.create_instance( - "MyInstance 3", - families=["myFamily 2"], - publish=False - ) - - -class MyValidator(pyblish.api.InstancePlugin): - order = pyblish.api.ValidatorOrder - active = False - label = "My Validator" - actions = [MyAction, - MyOtherAction] - - def process(self, instance): - self.log.info("Validating: %s" % instance) - - -class MyExtractor(pyblish.api.InstancePlugin): - order = pyblish.api.ExtractorOrder - families = ["myFamily"] - label = "My Extractor" - - def process(self, instance): - self.log.info("Extracting: %s" % instance) - - -class CollectRenamed(pyblish.api.Collector): - def process(self, context): - i = context.create_instance("MyInstanceXYZ", family="MyFamily") - i.set_data("name", "My instance") - - -class CollectNegatron(pyblish.api.Collector): - """Negative collector adds Negatron""" - - order = pyblish.api.Collector.order - 0.49 - - def process_context(self, context): - self.log.info("Collecting Negatron") - context.create_instance("Negatron", family="MyFamily") - - -class CollectPositron(pyblish.api.Collector): - """Positive collector adds Positron""" - - order = pyblish.api.Collector.order + 0.49 - - def process_context(self, context): - self.log.info("Collecting Positron") - context.create_instance("Positron", family="MyFamily") - - -class SelectInstances(pyblish.api.Selector): - """Select debugging instances - - These instances are part of the evil plan to destroy the world. - Be weary, be vigilant, be sexy. - - """ - - def process_context(self, context): - self.log.info("Selecting instances..") - - for instance in instances[:-1]: - name, data = instance["name"], instance["data"] - self.log.info("Selecting: %s" % name) - instance = context.create_instance(name) - - for key, value in data.items(): - instance.set_data(key, value) - - -class SelectDiInstances(pyblish.api.Selector): - """Select DI instances""" - - name = "Select Dependency Instances" - - def process(self, context): - name, data = instances[-1]["name"], instances[-1]["data"] - self.log.info("Selecting: %s" % name) - instance = context.create_instance(name) - - for key, value in data.items(): - instance.set_data(key, value) - - -class SelectInstancesFailure(pyblish.api.Selector): - """Select some instances, but fail before adding anything to the context. - - That's right. I'm programmed to fail. Try me. - - """ - - __fail__ = True - - def process_context(self, context): - self.log.warning("I'm about to fail") - raise AssertionError("I was programmed to fail") - - -class SelectInstances2(pyblish.api.Selector): - def process(self, context): - self.log.warning("I'm good") - - -class ValidateNamespace(pyblish.api.Validator): - """Namespaces must be orange - - In case a namespace is not orange, report immediately to - your officer in charge, ask for a refund, do a backflip. - - This has been an example of: - - - A long doc-string - - With a list - - And plenty of newlines and tabs. - - """ - - families = ["B"] - - def process(self, instance): - self.log.info("Validating the namespace of %s" % instance.data("name")) - self.log.info("""And here's another message, quite long, in fact it's -too long to be displayed in a single row of text. -But that's how we roll down here. It's got \nnew lines\nas well. - -- And lists -- And more lists - - """) - - -class ValidateContext(pyblish.api.Validator): - families = ["A", "B"] - - def process_context(self, context): - self.log.info("Processing context..") - - -class ValidateContextFailure(pyblish.api.Validator): - optional = True - families = ["C"] - __fail__ = True - - def process_context(self, context): - self.log.info("About to fail..") - raise AssertionError("""I was programmed to fail - -The reason I failed was because the sun was not aligned with the tides, -and the moon is gray; not yellow. Try again when the moon is yellow.""") - - -class Validator1(pyblish.api.Validator): - """Test of the order attribute""" - order = pyblish.api.Validator.order + 0.1 - families = ["A"] - - def process_instance(self, instance): - pass - - -class Validator2(pyblish.api.Validator): - order = pyblish.api.Validator.order + 0.2 - families = ["B"] - - def process_instance(self, instance): - pass - - -class Validator3(pyblish.api.Validator): - order = pyblish.api.Validator.order + 0.3 - families = ["B"] - - def process_instance(self, instance): - pass - - -class ValidateFailureMock(pyblish.api.Validator): - """Plug-in that always fails""" - optional = True - order = pyblish.api.Validator.order + 0.1 - families = ["C"] - __fail__ = True - - def process_instance(self, instance): - self.log.debug("e = mc^2") - self.log.info("About to fail..") - self.log.warning("Failing.. soooon..") - self.log.critical("Ok, you're done.") - raise AssertionError("""ValidateFailureMock was destined to fail.. - -Here's some extended information about what went wrong. - -It has quite the long string associated with it, including -a few newlines and a list. - -- Item 1 -- Item 2 - -""") - - -class ValidateIsIncompatible(pyblish.api.Validator): - """This plug-in should never appear..""" - requires = False # This is invalid - - -class ValidateWithRepair(pyblish.api.Validator): - """A validator with repair functionality""" - optional = True - families = ["C"] - __fail__ = True - - def process_instance(self, instance): - raise AssertionError( - "%s is invalid, try repairing it!" % instance.name - ) - - def repair_instance(self, instance): - self.log.info("Attempting to repair..") - self.log.info("Success!") - - -class ValidateWithRepairFailure(pyblish.api.Validator): - """A validator with repair functionality that fails""" - optional = True - families = ["C"] - __fail__ = True - - def process_instance(self, instance): - raise AssertionError( - "%s is invalid, try repairing it!" % instance.name - ) - - def repair_instance(self, instance): - self.log.info("Attempting to repair..") - raise AssertionError("Could not repair due to X") - - -class ValidateWithVeryVeryVeryLongLongNaaaaame(pyblish.api.Validator): - """A validator with repair functionality that fails""" - families = ["A"] - - -class ValidateWithRepairContext(pyblish.api.Validator): - """A validator with repair functionality that fails""" - optional = True - families = ["C"] - __fail__ = True - - def process_context(self, context): - raise AssertionError("Could not validate context, try repairing it") - - def repair_context(self, context): - self.log.info("Attempting to repair..") - raise AssertionError("Could not repair") - - -class ExtractAsMa(pyblish.api.Extractor): - """Extract contents of each instance into .ma - - Serialise scene using Maya's own facilities and then put - it on the hard-disk. Once complete, this plug-in relies - on a Conformer to put it in it's final location, as this - extractor merely positions it in the users local temp- - directory. - - """ - - optional = True - __expected__ = { - "logCount": ">=4" - } - - def process_instance(self, instance): - self.log.info("About to extract scene to .ma..") - self.log.info("Extraction went well, now verifying the data..") - - if instance.name == "Richard05": - self.log.warning("You're almost running out of disk space!") - - self.log.info("About to finish up") - self.log.info("Finished successfully") - - -class ConformAsset(pyblish.api.Conformer): - """Conform the world - - Step 1: Conform all humans and Step 2: Conform all non-humans. - Once conforming has completed, rinse and repeat. - - """ - - optional = True - - def process_instance(self, instance): - self.log.info("About to conform all humans..") - - if instance.name == "Richard05": - self.log.warning("Richard05 is a conformist!") - - self.log.info("About to conform all non-humans..") - self.log.info("Conformed Successfully") - - -class ValidateInstancesDI(pyblish.api.Validator): - """Validate using the DI interface""" - families = ["diFamily"] - - def process(self, instance): - self.log.info("Validating %s.." % instance.data("name")) - - -class ValidateDIWithRepair(pyblish.api.Validator): - families = ["diFamily"] - optional = True - __fail__ = True - - def process(self, instance): - raise AssertionError("I was programmed to fail, for repair") - - def repair(self, instance): - self.log.info("Repairing %s" % instance.data("name")) - - -class ExtractInstancesDI(pyblish.api.Extractor): - """Extract using the DI interface""" - families = ["diFamily"] - - def process(self, instance): - self.log.info("Extracting %s.." % instance.data("name")) - - -class ValidateWithLabel(pyblish.api.Validator): - """Validate using the DI interface""" - label = "Validate with Label" - - -class ValidateWithLongLabel(pyblish.api.Validator): - """Validate using the DI interface""" - label = "Validate with Loooooooooooooooooooooong Label" - - -class SimplePlugin1(pyblish.api.Plugin): - """Validate using the simple-plugin interface""" - - def process(self): - self.log.info("I'm a simple plug-in, only processed once") - - -class SimplePlugin2(pyblish.api.Plugin): - """Validate using the simple-plugin interface - - It doesn't have an order, and will likely end up *before* all - other plug-ins. (due to how sorted([1, 2, 3, None]) works) - - """ - - def process(self, context): - self.log.info("Processing the context, simply: %s" % context) - - -class SimplePlugin3(pyblish.api.Plugin): - """Simply process every instance""" - - def process(self, instance): - self.log.info("Processing the instance, simply: %s" % instance) - - -class ContextAction(pyblish.api.Action): - label = "Context action" - - def process(self, context): - self.log.info("I have access to the context") - self.log.info("Context.instances: %s" % str(list(context))) - - -class FailingAction(pyblish.api.Action): - label = "Failing action" - - def process(self): - self.log.info("About to fail..") - raise Exception("I failed") - - -class LongRunningAction(pyblish.api.Action): - label = "Long-running action" - - def process(self): - self.log.info("Sleeping for 2 seconds..") - time.sleep(2) - self.log.info("Ah, that's better") - - -class IconAction(pyblish.api.Action): - label = "Icon action" - icon = "crop" - - def process(self): - self.log.info("I have an icon") - - -class PluginAction(pyblish.api.Action): - label = "Plugin action" - - def process(self, plugin): - self.log.info("I have access to my parent plug-in") - self.log.info("Which is %s" % plugin.id) - - -class LaunchExplorerAction(pyblish.api.Action): - label = "Open in Explorer" - icon = "folder-open" - - def process(self, context): - cwd = os.getcwd() - self.log.info("Opening %s in Explorer" % cwd) - result = subprocess.call("start .", cwd=cwd, shell=True) - self.log.debug(result) - - -class ProcessedAction(pyblish.api.Action): - label = "Success action" - icon = "check" - on = "processed" - - def process(self): - self.log.info("I am only available on a successful plug-in") - - -class FailedAction(pyblish.api.Action): - label = "Failure action" - icon = "close" - on = "failed" - - -class SucceededAction(pyblish.api.Action): - label = "Success action" - icon = "check" - on = "succeeded" - - def process(self): - self.log.info("I am only available on a successful plug-in") - - -class LongLabelAction(pyblish.api.Action): - label = "An incredibly, incredicly looooon label. Very long." - icon = "close" - - -class BadEventAction(pyblish.api.Action): - label = "Bad event action" - on = "not exist" - - -class InactiveAction(pyblish.api.Action): - active = False - - -class PluginWithActions(pyblish.api.Validator): - optional = True - actions = [ - pyblish.api.Category("General"), - ContextAction, - FailingAction, - LongRunningAction, - IconAction, - PluginAction, - pyblish.api.Category("Empty"), - pyblish.api.Category("OS"), - LaunchExplorerAction, - pyblish.api.Separator, - FailedAction, - SucceededAction, - pyblish.api.Category("Debug"), - BadEventAction, - InactiveAction, - LongLabelAction, - pyblish.api.Category("Empty"), - ] - - def process(self): - self.log.info("Ran PluginWithActions") - - -class FailingPluginWithActions(pyblish.api.Validator): - optional = True - actions = [ - FailedAction, - SucceededAction, - ] - - def process(self): - raise Exception("I was programmed to fail") - - -class ValidateDefaultOff(pyblish.api.Validator): - families = ["A", "B"] - active = False - optional = True - - def process(self, instance): - self.log.info("Processing instance..") - - -class ValidateWithHyperlinks(pyblish.api.Validator): - """To learn about Pyblish - - click here (http://pyblish.com) - - """ - - families = ["A", "B"] - - def process(self, instance): - self.log.info("Processing instance..") - - msg = "To learn about Pyblish, " - msg += "click here (http://pyblish.com)" - - self.log.info(msg) - - -class LongRunningCollector(pyblish.api.Collector): - """I will take at least 2 seconds...""" - def process(self, context): - self.log.info("Sleeping for 2 seconds..") - time.sleep(2) - self.log.info("Good morning") - - -class LongRunningValidator(pyblish.api.Validator): - """I will take at least 2 seconds...""" - def process(self, context): - self.log.info("Sleeping for 2 seconds..") - time.sleep(2) - self.log.info("Good morning") - - -class RearrangingPlugin(pyblish.api.ContextPlugin): - """Sort plug-ins by family, and then reverse it""" - order = pyblish.api.CollectorOrder + 0.2 - - def process(self, context): - self.log.info("Reversing instances in the context..") - context[:] = sorted( - context, - key=lambda i: i.data["family"], - reverse=True - ) - self.log.info("Reversed!") - - -class InactiveInstanceCollectorPlugin(pyblish.api.InstancePlugin): - """Special case of an InstancePlugin running as a Collector""" - order = pyblish.api.CollectorOrder + 0.1 - active = False - - def process(self, instance): - raise TypeError("I shouldn't have run in the first place") - - -class CollectWithIcon(pyblish.api.ContextPlugin): - order = pyblish.api.CollectorOrder - - def process(self, context): - instance = context.create_instance("With Icon") - instance.data["icon"] = "play" - - -instances = [ - { - "name": "Peter01", - "data": { - "family": "A", - "publish": False - } - }, - { - "name": "Richard05", - "data": { - "family": "A", - } - }, - { - "name": "Steven11", - "data": { - "family": "B", - } - }, - { - "name": "Piraya12", - "data": { - "family": "B", - } - }, - { - "name": "Marcus", - "data": { - "family": "C", - } - }, - { - "name": "Extra1", - "data": { - "family": "C", - } - }, - { - "name": "DependencyInstance", - "data": { - "family": "diFamily" - } - }, - { - "name": "NoFamily", - "data": {} - }, - { - "name": "Failure 1", - "data": { - "family": "failure", - "fail": False - } - }, - { - "name": "Failure 2", - "data": { - "family": "failure", - "fail": True - } - } -] - -plugins = [ - MyCollector, - MyValidator, - MyExtractor, - - CollectRenamed, - CollectNegatron, - CollectPositron, - SelectInstances, - SelectInstances2, - SelectDiInstances, - SelectInstancesFailure, - ValidateFailureMock, - ValidateNamespace, - # ValidateIsIncompatible, - ValidateWithVeryVeryVeryLongLongNaaaaame, - ValidateContext, - ValidateContextFailure, - Validator1, - Validator2, - Validator3, - ValidateWithRepair, - ValidateWithRepairFailure, - ValidateWithRepairContext, - ValidateWithLabel, - ValidateWithLongLabel, - ValidateDefaultOff, - ValidateWithHyperlinks, - ExtractAsMa, - ConformAsset, - - SimplePlugin1, - SimplePlugin2, - SimplePlugin3, - - ValidateInstancesDI, - ExtractInstancesDI, - ValidateDIWithRepair, - - PluginWithActions, - FailingPluginWithActions, - - # LongRunningCollector, - # LongRunningValidator, - - RearrangingPlugin, - InactiveInstanceCollectorPlugin, - - CollectComment, - CollectWithIcon, -] - -pyblish.api.sort_plugins(plugins) diff --git a/client/ayon_core/tools/pyblish_pype/model.py b/client/ayon_core/tools/pyblish_pype/model.py deleted file mode 100644 index 3a402f386e..0000000000 --- a/client/ayon_core/tools/pyblish_pype/model.py +++ /dev/null @@ -1,1117 +0,0 @@ -"""Qt models - -Description: - The model contains the original objects from Pyblish, such as - pyblish.api.Instance and pyblish.api.Plugin. The model then - provides an interface for reading and writing to those. - -GUI data: - Aside from original data, such as pyblish.api.Plugin.optional, - the GUI also hosts data internal to itself, such as whether or - not an item has processed such that it may be colored appropriately - in the view. This data is prefixed with two underscores (__). - - E.g. - - _has_processed - - This is so that the the GUI-only data doesn't accidentally overwrite - or cause confusion with existing data in plug-ins and instances. - -Roles: - Data is accessed via standard Qt "roles". You can think of a role - as the key of a dictionary, except they can only be integers. - -""" -from __future__ import unicode_literals - -import pyblish - -from . import settings, util -from .awesome import tags as awesome -from qtpy import QtCore, QtGui -import qtawesome -from six import text_type -from .constants import PluginStates, InstanceStates, GroupStates, Roles - - -# ItemTypes -UserType = QtGui.QStandardItem.UserType -if hasattr(UserType, "value"): - UserType = UserType.value -InstanceType = UserType -PluginType = UserType + 1 -GroupType = UserType + 2 -TerminalLabelType = UserType + 3 -TerminalDetailType = UserType + 4 - - -class QAwesomeTextIconFactory: - icons = {} - - @classmethod - def icon(cls, icon_name): - if icon_name not in cls.icons: - cls.icons[icon_name] = awesome.get(icon_name) - return cls.icons[icon_name] - - -class QAwesomeIconFactory: - icons = {} - - @classmethod - def icon(cls, icon_name, icon_color): - if icon_name not in cls.icons: - cls.icons[icon_name] = {} - - if icon_color not in cls.icons[icon_name]: - cls.icons[icon_name][icon_color] = qtawesome.icon( - icon_name, - color=icon_color - ) - return cls.icons[icon_name][icon_color] - - -class IntentModel(QtGui.QStandardItemModel): - """Model for QComboBox with intents. - - It is expected that one inserted item is dictionary. - Key represents #Label and Value represent #Value. - - Example: - { - "Testing": "test", - "Publishing": "publish" - } - - First and default value is {"< Not Set >": None} - """ - - default_empty_label = "< Not set >" - - def __init__(self, parent=None): - super(IntentModel, self).__init__(parent) - self._item_count = 0 - self.default_index = 0 - - @property - def has_items(self): - return self._item_count > 0 - - def reset(self): - self.clear() - self._item_count = 0 - self.default_index = 0 - - # Intent settings are not available in core addon - intent_settings = {} - - items = intent_settings.get("items", {}) - if not items: - return - - allow_empty_intent = intent_settings.get("allow_empty_intent", True) - empty_intent_label = ( - intent_settings.get("empty_intent_label") - or self.default_empty_label - ) - listed_items = list(items.items()) - if allow_empty_intent: - listed_items.insert(0, ("", empty_intent_label)) - - default = intent_settings.get("default") - - for idx, item in enumerate(listed_items): - item_value = item[0] - if item_value == default: - self.default_index = idx - break - - self._add_items(listed_items) - - def _add_items(self, items): - for item in items: - value, label = item - new_item = QtGui.QStandardItem() - new_item.setData(label, QtCore.Qt.DisplayRole) - new_item.setData(value, Roles.IntentItemValue) - - self.setItem(self._item_count, new_item) - self._item_count += 1 - - -class PluginItem(QtGui.QStandardItem): - """Plugin item implementation.""" - - def __init__(self, plugin): - super(PluginItem, self).__init__() - - item_text = plugin.__name__ - if settings.UseLabel: - if hasattr(plugin, "label") and plugin.label: - item_text = plugin.label - - self.plugin = plugin - - self.setData(item_text, QtCore.Qt.DisplayRole) - self.setData(False, Roles.IsEnabledRole) - self.setData(0, Roles.PublishFlagsRole) - self.setData(0, Roles.PluginActionProgressRole) - icon_name = "" - if hasattr(plugin, "icon") and plugin.icon: - icon_name = plugin.icon - icon = QAwesomeTextIconFactory.icon(icon_name) - self.setData(icon, QtCore.Qt.DecorationRole) - - actions = [] - if hasattr(plugin, "actions") and plugin.actions: - actions = list(plugin.actions) - plugin.actions = actions - - is_checked = True - is_optional = getattr(plugin, "optional", False) - if is_optional: - is_checked = getattr(plugin, "active", True) - - plugin.active = is_checked - plugin.optional = is_optional - - self.setData( - "{}.{}".format(plugin.__module__, plugin.__name__), - Roles.ObjectUIdRole - ) - - self.setFlags( - QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled - ) - - def type(self): - return PluginType - - def data(self, role=QtCore.Qt.DisplayRole): - if role == Roles.IsOptionalRole: - return self.plugin.optional - - if role == Roles.ObjectIdRole: - return self.plugin.id - - if role == Roles.TypeRole: - return self.type() - - if role == QtCore.Qt.CheckStateRole: - return self.plugin.active - - if role == Roles.PathModuleRole: - return self.plugin.__module__ - - if role == Roles.FamiliesRole: - return self.plugin.families - - if role == Roles.DocstringRole: - return self.plugin.__doc__ - - if role == Roles.PluginActionsVisibleRole: - return self._data_actions_visible() - - if role == Roles.PluginValidActionsRole: - return self._data_valid_actions() - - return super(PluginItem, self).data(role) - - def _data_actions_visible(self): - # Can only run actions on active plug-ins. - if not self.plugin.active or not self.plugin.actions: - return False - - publish_states = self.data(Roles.PublishFlagsRole) - if ( - not publish_states & PluginStates.IsCompatible - or publish_states & PluginStates.WasSkipped - ): - return False - - # Context specific actions - for action in self.plugin.actions: - if action.on == "failed": - if publish_states & PluginStates.HasError: - return True - - elif action.on == "succeeded": - if ( - publish_states & PluginStates.WasProcessed - and not publish_states & PluginStates.HasError - ): - return True - - elif action.on == "processed": - if publish_states & PluginStates.WasProcessed: - return True - - elif action.on == "notProcessed": - if not publish_states & PluginStates.WasProcessed: - return True - return False - - def _data_valid_actions(self): - valid_actions = [] - - # Can only run actions on active plug-ins. - if not self.plugin.active or not self.plugin.actions: - return valid_actions - - if not self.plugin.active or not self.plugin.actions: - return False - - publish_states = self.data(Roles.PublishFlagsRole) - if ( - not publish_states & PluginStates.IsCompatible - or publish_states & PluginStates.WasSkipped - ): - return False - - # Context specific actions - for action in self.plugin.actions: - valid = False - if action.on == "failed": - if publish_states & PluginStates.HasError: - valid = True - - elif action.on == "succeeded": - if ( - publish_states & PluginStates.WasProcessed - and not publish_states & PluginStates.HasError - ): - valid = True - - elif action.on == "processed": - if publish_states & PluginStates.WasProcessed: - valid = True - - elif action.on == "notProcessed": - if not publish_states & PluginStates.WasProcessed: - valid = True - - if valid: - valid_actions.append(action) - - if not valid_actions: - return valid_actions - - actions_len = len(valid_actions) - # Discard empty groups - indexex_to_remove = [] - for idx, action in enumerate(valid_actions): - if action.__type__ != "category": - continue - - next_id = idx + 1 - if next_id >= actions_len: - indexex_to_remove.append(idx) - continue - - next = valid_actions[next_id] - if next.__type__ != "action": - indexex_to_remove.append(idx) - - for idx in reversed(indexex_to_remove): - valid_actions.pop(idx) - - return valid_actions - - def setData(self, value, role=None): - if role is None: - role = QtCore.Qt.UserRole + 1 - - if role == QtCore.Qt.CheckStateRole: - if not self.data(Roles.IsEnabledRole): - return False - self.plugin.active = value - self.emitDataChanged() - return - - elif role == Roles.PluginActionProgressRole: - if isinstance(value, list): - _value = self.data(Roles.PluginActionProgressRole) - for flag in value: - _value |= flag - value = _value - - elif isinstance(value, dict): - _value = self.data(Roles.PluginActionProgressRole) - for flag, _bool in value.items(): - if _bool is True: - _value |= flag - elif _value & flag: - _value ^= flag - value = _value - - elif role == Roles.PublishFlagsRole: - if isinstance(value, list): - _value = self.data(Roles.PublishFlagsRole) - for flag in value: - _value |= flag - value = _value - - elif isinstance(value, dict): - _value = self.data(Roles.PublishFlagsRole) - for flag, _bool in value.items(): - if _bool is True: - _value |= flag - elif _value & flag: - _value ^= flag - value = _value - - if value & PluginStates.HasWarning: - if self.parent(): - self.parent().setData( - {GroupStates.HasWarning: True}, - Roles.PublishFlagsRole - ) - if value & PluginStates.HasError: - if self.parent(): - self.parent().setData( - {GroupStates.HasError: True}, - Roles.PublishFlagsRole - ) - - return super(PluginItem, self).setData(value, role) - - -class GroupItem(QtGui.QStandardItem): - def __init__(self, *args, **kwargs): - self.order = kwargs.pop("order", None) - self.publish_states = 0 - super(GroupItem, self).__init__(*args, **kwargs) - - def flags(self): - return QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled - - def data(self, role=QtCore.Qt.DisplayRole): - if role == Roles.PublishFlagsRole: - return self.publish_states - - if role == Roles.TypeRole: - return self.type() - - return super(GroupItem, self).data(role) - - def setData(self, value, role=(QtCore.Qt.UserRole + 1)): - if role == Roles.PublishFlagsRole: - if isinstance(value, list): - _value = self.data(Roles.PublishFlagsRole) - for flag in value: - _value |= flag - value = _value - - elif isinstance(value, dict): - _value = self.data(Roles.PublishFlagsRole) - for flag, _bool in value.items(): - if _bool is True: - _value |= flag - elif _value & flag: - _value ^= flag - value = _value - self.publish_states = value - self.emitDataChanged() - return True - - return super(GroupItem, self).setData(value, role) - - def type(self): - return GroupType - - -class PluginModel(QtGui.QStandardItemModel): - def __init__(self, controller, *args, **kwargs): - super(PluginModel, self).__init__(*args, **kwargs) - - self.controller = controller - self.checkstates = {} - self.group_items = {} - self.plugin_items = {} - - def reset(self): - self.group_items = {} - self.plugin_items = {} - self.clear() - - def append(self, plugin): - plugin_groups = self.controller.order_groups.groups - label = None - order = None - for _order, item in reversed(plugin_groups.items()): - if _order is None or plugin.order < _order: - label = item["label"] - order = _order - else: - break - - if label is None: - label = "Other" - - group_item = self.group_items.get(label) - if not group_item: - group_item = GroupItem(label, order=order) - self.appendRow(group_item) - self.group_items[label] = group_item - - new_item = PluginItem(plugin) - group_item.appendRow(new_item) - - self.plugin_items[plugin._id] = new_item - - def store_checkstates(self): - self.checkstates.clear() - - for plugin_item in self.plugin_items.values(): - if not plugin_item.plugin.optional: - continue - - uid = plugin_item.data(Roles.ObjectUIdRole) - self.checkstates[uid] = plugin_item.data(QtCore.Qt.CheckStateRole) - - def restore_checkstates(self): - for plugin_item in self.plugin_items.values(): - if not plugin_item.plugin.optional: - continue - - uid = plugin_item.data(Roles.ObjectUIdRole) - state = self.checkstates.get(uid) - if state is not None: - plugin_item.setData(state, QtCore.Qt.CheckStateRole) - - def update_with_result(self, result): - plugin = result["plugin"] - item = self.plugin_items[plugin.id] - - new_flag_states = { - PluginStates.InProgress: False, - PluginStates.WasProcessed: True - } - - publish_states = item.data(Roles.PublishFlagsRole) - - has_warning = publish_states & PluginStates.HasWarning - new_records = result.get("records") or [] - if not has_warning: - for record in new_records: - level_no = record.get("levelno") - if level_no and level_no >= 30: - new_flag_states[PluginStates.HasWarning] = True - break - - if ( - not publish_states & PluginStates.HasError - and not result["success"] - ): - new_flag_states[PluginStates.HasError] = True - - if not publish_states & PluginStates.IsCompatible: - new_flag_states[PluginStates.IsCompatible] = True - - item.setData(new_flag_states, Roles.PublishFlagsRole) - - records = item.data(Roles.LogRecordsRole) or [] - records.extend(new_records) - - item.setData(records, Roles.LogRecordsRole) - - return item - - def update_compatibility(self): - context = self.controller.context - - families = util.collect_families_from_instances(context, True) - for plugin_item in self.plugin_items.values(): - publish_states = plugin_item.data(Roles.PublishFlagsRole) - if ( - publish_states & PluginStates.WasProcessed - or publish_states & PluginStates.WasSkipped - ): - continue - - is_compatible = False - # A plugin should always show if it has processed. - if plugin_item.plugin.__instanceEnabled__: - compatible_instances = pyblish.logic.instances_by_plugin( - context, plugin_item.plugin - ) - for instance in context: - if not instance.data.get("publish"): - continue - - if instance in compatible_instances: - is_compatible = True - break - else: - plugins = pyblish.logic.plugins_by_families( - [plugin_item.plugin], families - ) - if plugins: - is_compatible = True - - current_is_compatible = publish_states & PluginStates.IsCompatible - if ( - (is_compatible and not current_is_compatible) - or (not is_compatible and current_is_compatible) - ): - new_flag = { - PluginStates.IsCompatible: is_compatible - } - plugin_item.setData(new_flag, Roles.PublishFlagsRole) - - -class PluginFilterProxy(QtCore.QSortFilterProxyModel): - def filterAcceptsRow(self, source_row, source_parent): - index = self.sourceModel().index(source_row, 0, source_parent) - item_type = index.data(Roles.TypeRole) - if item_type != PluginType: - return True - - publish_states = index.data(Roles.PublishFlagsRole) - if ( - publish_states & PluginStates.WasSkipped - or not publish_states & PluginStates.IsCompatible - ): - return False - return True - - -class InstanceItem(QtGui.QStandardItem): - """Instance item implementation.""" - - def __init__(self, instance): - super(InstanceItem, self).__init__() - - self.instance = instance - self.is_context = False - publish_states = getattr(instance, "_publish_states", 0) - if publish_states & InstanceStates.ContextType: - self.is_context = True - - instance._publish_states = publish_states - instance._logs = [] - instance.optional = getattr(instance, "optional", True) - instance.data["publish"] = instance.data.get("publish", True) - - family = self.data(Roles.FamiliesRole)[0] - self.setData( - "{}.{}".format(family, self.instance.data["name"]), - Roles.ObjectUIdRole - ) - - def flags(self): - return QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled - - def type(self): - return InstanceType - - def data(self, role=QtCore.Qt.DisplayRole): - if role == QtCore.Qt.DisplayRole: - label = None - if settings.UseLabel: - label = self.instance.data.get("label") - - if not label: - if self.is_context: - label = "Context" - else: - label = self.instance.data["name"] - return label - - if role == QtCore.Qt.DecorationRole: - icon_name = self.instance.data.get("icon") or "file" - return QAwesomeTextIconFactory.icon(icon_name) - - if role == Roles.TypeRole: - return self.type() - - if role == Roles.ObjectIdRole: - return self.instance.id - - if role == Roles.FamiliesRole: - if self.is_context: - return ["Context"] - - families = [] - family = self.instance.data.get("family") - if family: - families.append(family) - - _families = self.instance.data.get("families") or [] - for _family in _families: - if _family not in families: - families.append(_family) - - return families - - if role == Roles.IsOptionalRole: - return self.instance.optional - - if role == QtCore.Qt.CheckStateRole: - return self.instance.data["publish"] - - if role == Roles.PublishFlagsRole: - return self.instance._publish_states - - if role == Roles.LogRecordsRole: - return self.instance._logs - - return super(InstanceItem, self).data(role) - - def setData(self, value, role=(QtCore.Qt.UserRole + 1)): - if role == QtCore.Qt.CheckStateRole: - if not self.data(Roles.IsEnabledRole): - return - self.instance.data["publish"] = value - self.emitDataChanged() - return - - if role == Roles.IsEnabledRole: - if not self.instance.optional: - return - - if role == Roles.PublishFlagsRole: - if isinstance(value, list): - _value = self.instance._publish_states - for flag in value: - _value |= flag - value = _value - - elif isinstance(value, dict): - _value = self.instance._publish_states - for flag, _bool in value.items(): - if _bool is True: - _value |= flag - elif _value & flag: - _value ^= flag - value = _value - - if value & InstanceStates.HasWarning: - if self.parent(): - self.parent().setData( - {GroupStates.HasWarning: True}, - Roles.PublishFlagsRole - ) - if value & InstanceStates.HasError: - if self.parent(): - self.parent().setData( - {GroupStates.HasError: True}, - Roles.PublishFlagsRole - ) - - self.instance._publish_states = value - self.emitDataChanged() - return - - if role == Roles.LogRecordsRole: - self.instance._logs = value - self.emitDataChanged() - return - - return super(InstanceItem, self).setData(value, role) - - -class InstanceModel(QtGui.QStandardItemModel): - - group_created = QtCore.Signal(QtCore.QModelIndex) - - def __init__(self, controller, *args, **kwargs): - super(InstanceModel, self).__init__(*args, **kwargs) - - self.controller = controller - self.checkstates = {} - self.group_items = {} - self.instance_items = {} - - def reset(self): - self.group_items = {} - self.instance_items = {} - self.clear() - - def append(self, instance): - new_item = InstanceItem(instance) - if new_item.is_context: - self.appendRow(new_item) - else: - families = new_item.data(Roles.FamiliesRole) - group_item = self.group_items.get(families[0]) - if not group_item: - group_item = GroupItem(families[0]) - self.appendRow(group_item) - self.group_items[families[0]] = group_item - self.group_created.emit(group_item.index()) - - group_item.appendRow(new_item) - instance_id = instance.id - self.instance_items[instance_id] = new_item - - def remove(self, instance_id): - instance_item = self.instance_items.pop(instance_id) - parent_item = instance_item.parent() - parent_item.removeRow(instance_item.row()) - if parent_item.rowCount(): - return - - self.group_items.pop(parent_item.data(QtCore.Qt.DisplayRole)) - self.removeRow(parent_item.row()) - - def store_checkstates(self): - self.checkstates.clear() - - for instance_item in self.instance_items.values(): - if not instance_item.instance.optional: - continue - - uid = instance_item.data(Roles.ObjectUIdRole) - self.checkstates[uid] = instance_item.data( - QtCore.Qt.CheckStateRole - ) - - def restore_checkstates(self): - for instance_item in self.instance_items.values(): - if not instance_item.instance.optional: - continue - - uid = instance_item.data(Roles.ObjectUIdRole) - state = self.checkstates.get(uid) - if state is not None: - instance_item.setData(state, QtCore.Qt.CheckStateRole) - - def update_with_result(self, result): - instance = result["instance"] - if instance is None: - instance_id = self.controller.context.id - else: - instance_id = instance.id - - item = self.instance_items.get(instance_id) - if not item: - return - - new_flag_states = { - InstanceStates.InProgress: False - } - - publish_states = item.data(Roles.PublishFlagsRole) - has_warning = publish_states & InstanceStates.HasWarning - new_records = result.get("records") or [] - if not has_warning: - for record in new_records: - level_no = record.get("levelno") - if level_no and level_no >= 30: - new_flag_states[InstanceStates.HasWarning] = True - break - - if ( - not publish_states & InstanceStates.HasError - and not result["success"] - ): - new_flag_states[InstanceStates.HasError] = True - - item.setData(new_flag_states, Roles.PublishFlagsRole) - - records = item.data(Roles.LogRecordsRole) or [] - records.extend(new_records) - - item.setData(records, Roles.LogRecordsRole) - - return item - - def update_compatibility(self, context, instances): - families = util.collect_families_from_instances(context, True) - for plugin_item in self.plugin_items.values(): - publish_states = plugin_item.data(Roles.PublishFlagsRole) - if ( - publish_states & PluginStates.WasProcessed - or publish_states & PluginStates.WasSkipped - ): - continue - - is_compatible = False - # A plugin should always show if it has processed. - if plugin_item.plugin.__instanceEnabled__: - compatibleInstances = pyblish.logic.instances_by_plugin( - context, plugin_item.plugin - ) - for instance in instances: - if not instance.data.get("publish"): - continue - - if instance in compatibleInstances: - is_compatible = True - break - else: - plugins = pyblish.logic.plugins_by_families( - [plugin_item.plugin], families - ) - if plugins: - is_compatible = True - - current_is_compatible = publish_states & PluginStates.IsCompatible - if ( - (is_compatible and not current_is_compatible) - or (not is_compatible and current_is_compatible) - ): - plugin_item.setData( - {PluginStates.IsCompatible: is_compatible}, - Roles.PublishFlagsRole - ) - - -class InstanceSortProxy(QtCore.QSortFilterProxyModel): - def __init__(self, *args, **kwargs): - super(InstanceSortProxy, self).__init__(*args, **kwargs) - # Do not care about lower/upper case - self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) - - def lessThan(self, x_index, y_index): - x_type = x_index.data(Roles.TypeRole) - y_type = y_index.data(Roles.TypeRole) - if x_type != y_type: - if x_type == GroupType: - return False - return True - return super(InstanceSortProxy, self).lessThan(x_index, y_index) - - -class TerminalDetailItem(QtGui.QStandardItem): - key_label_record_map = ( - ("instance", "Instance"), - ("msg", "Message"), - ("name", "Plugin"), - ("pathname", "Path"), - ("lineno", "Line"), - ("traceback", "Traceback"), - ("levelname", "Level"), - ("threadName", "Thread"), - ("msecs", "Millis") - ) - - def __init__(self, record_item): - self.record_item = record_item - self.msg = None - msg = record_item.get("msg") - if msg is None: - msg = record_item["label"].split("\n")[0] - - super(TerminalDetailItem, self).__init__(msg) - - def data(self, role=QtCore.Qt.DisplayRole): - if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): - if self.msg is None: - self.msg = self.compute_detail_text(self.record_item) - return self.msg - return super(TerminalDetailItem, self).data(role) - - def compute_detail_text(self, item_data): - if item_data["type"] == "info": - return item_data["label"] - - html_text = "" - for key, title in self.key_label_record_map: - if key not in item_data: - continue - value = item_data[key] - text = ( - str(value) - .replace("<", "<") - .replace(">", ">") - .replace('\n', '
') - .replace(' ', ' ') - ) - - title_tag = ( - '{}: ' - ' color:#fff;\" >{}: ' - ).format(title) - - html_text += ( - '{}' - '{}' - ).format(title_tag, text) - - html_text = '{}
'.format( - html_text - ) - return html_text - - -class TerminalModel(QtGui.QStandardItemModel): - item_icon_name = { - "info": "fa.info", - "record": "fa.circle", - "error": "fa.exclamation-triangle", - } - - item_icon_colors = { - "info": "#ffffff", - "error": "#ff4a4a", - "log_debug": "#ff66e8", - "log_info": "#66abff", - "log_warning": "#ffba66", - "log_error": "#ff4d58", - "log_critical": "#ff4f75", - None: "#333333" - } - - level_to_record = ( - (10, "log_debug"), - (20, "log_info"), - (30, "log_warning"), - (40, "log_error"), - (50, "log_critical") - - ) - - def __init__(self, *args, **kwargs): - super(TerminalModel, self).__init__(*args, **kwargs) - self.reset() - - def reset(self): - self.clear() - - def prepare_records(self, result, suspend_logs): - prepared_records = [] - instance_name = None - instance = result["instance"] - if instance is not None: - instance_name = instance.data["name"] - - if not suspend_logs: - for record in result.get("records") or []: - if isinstance(record, dict): - record_item = record - else: - record_item = { - "label": text_type(record.msg), - "type": "record", - "levelno": record.levelno, - "threadName": record.threadName, - "name": record.name, - "filename": record.filename, - "pathname": record.pathname, - "lineno": record.lineno, - "msg": text_type(record.msg), - "msecs": record.msecs, - "levelname": record.levelname - } - - if instance_name is not None: - record_item["instance"] = instance_name - - prepared_records.append(record_item) - - error = result.get("error") - if error: - fname, line_no, func, exc = error.traceback - error_item = { - "label": str(error), - "type": "error", - "filename": str(fname), - "lineno": str(line_no), - "func": str(func), - "traceback": error.formatted_traceback, - } - - if instance_name is not None: - error_item["instance"] = instance_name - - prepared_records.append(error_item) - - return prepared_records - - def append(self, record_items): - all_record_items = [] - for record_item in record_items: - record_type = record_item["type"] - # Add error message to detail - if record_type == "error": - record_item["msg"] = record_item["label"] - terminal_item_type = None - if record_type == "record": - for level, _type in self.level_to_record: - if level > record_item["levelno"]: - break - terminal_item_type = _type - - else: - terminal_item_type = record_type - - icon_color = self.item_icon_colors.get(terminal_item_type) - icon_name = self.item_icon_name.get(record_type) - - top_item_icon = None - if icon_color and icon_name: - top_item_icon = QAwesomeIconFactory.icon(icon_name, icon_color) - - label = record_item["label"].split("\n")[0] - - top_item = QtGui.QStandardItem() - all_record_items.append(top_item) - - detail_item = TerminalDetailItem(record_item) - top_item.appendRow(detail_item) - - top_item.setData(TerminalLabelType, Roles.TypeRole) - top_item.setData(terminal_item_type, Roles.TerminalItemTypeRole) - top_item.setData(label, QtCore.Qt.DisplayRole) - top_item.setFlags( - QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled - ) - - if top_item_icon: - top_item.setData(top_item_icon, QtCore.Qt.DecorationRole) - - detail_item.setData(TerminalDetailType, Roles.TypeRole) - - self.invisibleRootItem().appendRows(all_record_items) - - def update_with_result(self, result): - self.append(result["records"]) - - -class TerminalProxy(QtCore.QSortFilterProxyModel): - filter_buttons_checks = { - "info": settings.TerminalFilters.get("info", True), - "log_debug": settings.TerminalFilters.get("log_debug", True), - "log_info": settings.TerminalFilters.get("log_info", True), - "log_warning": settings.TerminalFilters.get("log_warning", True), - "log_error": settings.TerminalFilters.get("log_error", True), - "log_critical": settings.TerminalFilters.get("log_critical", True), - "error": settings.TerminalFilters.get("error", True) - } - - instances = [] - - def __init__(self, view, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) - self.__class__.instances.append(self) - # Store parent because by own `QSortFilterProxyModel` has `parent` - # method not returning parent QObject in PySide and PyQt4 - self.view = view - - @classmethod - def change_filter(cls, name, value): - cls.filter_buttons_checks[name] = value - - for instance in cls.instances: - try: - instance.invalidate() - if instance.view: - instance.view.updateGeometry() - - except RuntimeError: - # C++ Object was deleted - cls.instances.remove(instance) - - def filterAcceptsRow(self, source_row, source_parent): - index = self.sourceModel().index(source_row, 0, source_parent) - item_type = index.data(Roles.TypeRole) - if not item_type == TerminalLabelType: - return True - terminal_item_type = index.data(Roles.TerminalItemTypeRole) - return self.__class__.filter_buttons_checks.get( - terminal_item_type, True - ) diff --git a/client/ayon_core/tools/pyblish_pype/settings.py b/client/ayon_core/tools/pyblish_pype/settings.py deleted file mode 100644 index 5b69eb6a50..0000000000 --- a/client/ayon_core/tools/pyblish_pype/settings.py +++ /dev/null @@ -1,30 +0,0 @@ -from .util import env_variable_to_bool - -# Customize the window of the pyblish-lite window. -WindowTitle = "Pyblish" - -# Customize whether to show label names for plugins. -UseLabel = True - -# Customize which tab to start on. Possible choices are: "artist", "overview" -# and "terminal". -InitialTab = "overview" - -# Customize the window size. -WindowSize = (430, 600) - -TerminalFilters = { - "info": True, - "log_debug": True, - "log_info": True, - "log_warning": True, - "log_error": True, - "log_critical": True, - "traceback": True, -} - -# Allow animations in GUI -Animated = env_variable_to_bool("AYON_PYBLISH_ANIMATED", True) - -# Print UI info message to console -PrintInfo = env_variable_to_bool("AYON_PYBLISH_PRINT_INFO", True) diff --git a/client/ayon_core/tools/pyblish_pype/util.py b/client/ayon_core/tools/pyblish_pype/util.py deleted file mode 100644 index d24b07a409..0000000000 --- a/client/ayon_core/tools/pyblish_pype/util.py +++ /dev/null @@ -1,145 +0,0 @@ -from __future__ import ( - absolute_import, - division, - print_function, - unicode_literals -) - -import os -import sys -import collections - -from qtpy import QtCore -from six import text_type -import pyblish.api - -root = os.path.dirname(__file__) - - -def get_asset(*path): - """Return path to asset, relative the install directory - - Usage: - >>> path = get_asset("dir", "to", "asset.png") - >>> path == os.path.join(root, "dir", "to", "asset.png") - True - - Arguments: - path (str): One or more paths, to be concatenated - - """ - - return os.path.join(root, *path) - - -def defer(delay, func): - """Append artificial delay to `func` - - This aids in keeping the GUI responsive, but complicates logic - when producing tests. To combat this, the environment variable ensures - that every operation is synchronous. - - Arguments: - delay (float): Delay multiplier; default 1, 0 means no delay - func (callable): Any callable - - """ - - delay *= float(os.getenv("PYBLISH_DELAY", 1)) - if delay > 0: - return QtCore.QTimer.singleShot(delay, func) - else: - return func() - - -def u_print(msg, **kwargs): - """`print` with encoded unicode. - - `print` unicode may cause UnicodeEncodeError - or non-readable result when `PYTHONIOENCODING` is not set. - this will fix it. - - Arguments: - msg (unicode): Message to print. - **kwargs: Keyword argument for `print` function. - """ - - if isinstance(msg, text_type): - encoding = None - try: - encoding = os.getenv('PYTHONIOENCODING', sys.stdout.encoding) - except AttributeError: - # `sys.stdout.encoding` may not exists. - pass - msg = msg.encode(encoding or 'utf-8', 'replace') - print(msg, **kwargs) - - -def collect_families_from_instances(instances, only_active=False): - all_families = set() - for instance in instances: - if only_active: - if instance.data.get("publish") is False: - continue - family = instance.data.get("family") - if family: - all_families.add(family) - - families = instance.data.get("families") or tuple() - for family in families: - all_families.add(family) - - return list(all_families) - - -class OrderGroups: - validation_order = pyblish.api.ValidatorOrder + 0.5 - groups = collections.OrderedDict(( - ( - pyblish.api.CollectorOrder + 0.5, - { - "label": "Collect", - "state": "Collecting.." - } - ), - ( - pyblish.api.ValidatorOrder + 0.5, - { - "label": "Validate", - "state": "Validating.." - } - ), - ( - pyblish.api.ExtractorOrder + 0.5, - { - "label": "Extract", - "state": "Extracting.." - } - ), - ( - pyblish.api.IntegratorOrder + 0.5, - { - "label": "Integrate", - "state": "Integrating.." - } - ), - ( - None, - { - "label": "Other", - "state": "Finishing.." - } - ) - )) - - -def env_variable_to_bool(env_key, default=False): - """Boolean based on environment variable value.""" - value = os.environ.get(env_key) - if value is not None: - value = value.lower() - if value in ("true", "1", "yes", "on"): - return True - elif value in ("false", "0", "no", "off"): - return False - return default diff --git a/client/ayon_core/tools/pyblish_pype/vendor/__init__.py b/client/ayon_core/tools/pyblish_pype/vendor/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/__init__.py b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/__init__.py deleted file mode 100644 index 4a0001ebb7..0000000000 --- a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -qtawesome - use font-awesome in PyQt / PySide applications - -This is a port to Python of the C++ QtAwesome library by Rick Blommers -""" -from .iconic_font import IconicFont, set_global_defaults -from .animation import Pulse, Spin -from ._version import version_info, __version__ - -_resource = {'iconic': None, } - - -def _instance(): - if _resource['iconic'] is None: - _resource['iconic'] = IconicFont(('fa', 'fontawesome-webfont.ttf', 'fontawesome-webfont-charmap.json'), - ('ei', 'elusiveicons-webfont.ttf', 'elusiveicons-webfont-charmap.json')) - return _resource['iconic'] - - -def icon(*args, **kwargs): - return _instance().icon(*args, **kwargs) - - -def load_font(*args, **kwargs): - return _instance().load_font(*args, **kwargs) - - -def charmap(prefixed_name): - prefix, name = prefixed_name.split('.') - return _instance().charmap[prefix][name] - - -def font(*args, **kwargs): - return _instance().font(*args, **kwargs) - - -def set_defaults(**kwargs): - return set_global_defaults(**kwargs) - diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/_version.py b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/_version.py deleted file mode 100644 index 7af886d1a0..0000000000 --- a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/_version.py +++ /dev/null @@ -1,2 +0,0 @@ -version_info = (0, 3, 0, 'dev') -__version__ = '.'.join(map(str, version_info)) diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/animation.py b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/animation.py deleted file mode 100644 index ac69507444..0000000000 --- a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/animation.py +++ /dev/null @@ -1,41 +0,0 @@ -from qtpy import QtCore - - -class Spin: - - def __init__(self, parent_widget, interval=10, step=1): - self.parent_widget = parent_widget - self.interval, self.step = interval, step - self.info = {} - - def _update(self, parent_widget): - if self.parent_widget in self.info: - timer, angle, step = self.info[self.parent_widget] - - if angle >= 360: - angle = 0 - - angle += step - self.info[parent_widget] = timer, angle, step - parent_widget.update() - - def setup(self, icon_painter, painter, rect): - - if self.parent_widget not in self.info: - timer = QtCore.QTimer() - timer.timeout.connect(lambda: self._update(self.parent_widget)) - self.info[self.parent_widget] = [timer, 0, self.step] - timer.start(self.interval) - else: - timer, angle, self.step = self.info[self.parent_widget] - x_center = rect.width() * 0.5 - y_center = rect.height() * 0.5 - painter.translate(x_center, y_center) - painter.rotate(angle) - painter.translate(-x_center, -y_center) - - -class Pulse(Spin): - - def __init__(self, parent_widget): - Spin.__init__(self, parent_widget, interval=300, step=45) diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/elusiveicons-webfont-charmap.json b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/elusiveicons-webfont-charmap.json deleted file mode 100644 index 099bcb818c..0000000000 --- a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/elusiveicons-webfont-charmap.json +++ /dev/null @@ -1,306 +0,0 @@ -{ - "address-book": "0xf102", - "address-book-alt": "0xf101", - "adjust": "0xf104", - "adjust-alt": "0xf103", - "adult": "0xf105", - "align-center": "0xf106", - "align-justify": "0xf107", - "align-left": "0xf108", - "align-right": "0xf109", - "arrow-down": "0xf10a", - "arrow-left": "0xf10b", - "arrow-right": "0xf10c", - "arrow-up": "0xf10d", - "asl": "0xf10e", - "asterisk": "0xf10f", - "backward": "0xf110", - "ban-circle": "0xf111", - "barcode": "0xf112", - "behance": "0xf113", - "bell": "0xf114", - "blind": "0xf115", - "blogger": "0xf116", - "bold": "0xf117", - "book": "0xf118", - "bookmark": "0xf11a", - "bookmark-empty": "0xf119", - "braille": "0xf11b", - "briefcase": "0xf11c", - "broom": "0xf11d", - "brush": "0xf11e", - "bulb": "0xf11f", - "bullhorn": "0xf120", - "calendar": "0xf122", - "calendar-sign": "0xf121", - "camera": "0xf123", - "car": "0xf124", - "caret-down": "0xf125", - "caret-left": "0xf126", - "caret-right": "0xf127", - "caret-up": "0xf128", - "cc": "0xf129", - "certificate": "0xf12a", - "check": "0xf12c", - "check-empty": "0xf12b", - "chevron-down": "0xf12d", - "chevron-left": "0xf12e", - "chevron-right": "0xf12f", - "chevron-up": "0xf130", - "child": "0xf131", - "circle-arrow-down": "0xf132", - "circle-arrow-left": "0xf133", - "circle-arrow-right": "0xf134", - "circle-arrow-up": "0xf135", - "cloud": "0xf137", - "cloud-alt": "0xf136", - "cog": "0xf139", - "cog-alt": "0xf138", - "cogs": "0xf13a", - "comment": "0xf13c", - "comment-alt": "0xf13b", - "compass": "0xf13e", - "compass-alt": "0xf13d", - "credit-card": "0xf13f", - "css": "0xf140", - "dashboard": "0xf141", - "delicious": "0xf142", - "deviantart": "0xf143", - "digg": "0xf144", - "download": "0xf146", - "download-alt": "0xf145", - "dribbble": "0xf147", - "edit": "0xf148", - "eject": "0xf149", - "envelope": "0xf14b", - "envelope-alt": "0xf14a", - "error": "0xf14d", - "error-alt": "0xf14c", - "eur": "0xf14e", - "exclamation-sign": "0xf14f", - "eye-close": "0xf150", - "eye-open": "0xf151", - "facebook": "0xf152", - "facetime-video": "0xf153", - "fast-backward": "0xf154", - "fast-forward": "0xf155", - "female": "0xf156", - "file": "0xf15c", - "file-alt": "0xf157", - "file-edit": "0xf159", - "file-edit-alt": "0xf158", - "file-new": "0xf15b", - "file-new-alt": "0xf15a", - "film": "0xf15d", - "filter": "0xf15e", - "fire": "0xf15f", - "flag": "0xf161", - "flag-alt": "0xf160", - "flickr": "0xf162", - "folder": "0xf166", - "folder-close": "0xf163", - "folder-open": "0xf164", - "folder-sign": "0xf165", - "font": "0xf167", - "fontsize": "0xf168", - "fork": "0xf169", - "forward": "0xf16b", - "forward-alt": "0xf16a", - "foursquare": "0xf16c", - "friendfeed": "0xf16e", - "friendfeed-rect": "0xf16d", - "fullscreen": "0xf16f", - "gbp": "0xf170", - "gift": "0xf171", - "github": "0xf173", - "github-text": "0xf172", - "glass": "0xf174", - "glasses": "0xf175", - "globe": "0xf177", - "globe-alt": "0xf176", - "googleplus": "0xf178", - "graph": "0xf17a", - "graph-alt": "0xf179", - "group": "0xf17c", - "group-alt": "0xf17b", - "guidedog": "0xf17d", - "hand-down": "0xf17e", - "hand-left": "0xf17f", - "hand-right": "0xf180", - "hand-up": "0xf181", - "hdd": "0xf182", - "headphones": "0xf183", - "hearing-impaired": "0xf184", - "heart": "0xf187", - "heart-alt": "0xf185", - "heart-empty": "0xf186", - "home": "0xf189", - "home-alt": "0xf188", - "hourglass": "0xf18a", - "idea": "0xf18c", - "idea-alt": "0xf18b", - "inbox": "0xf18f", - "inbox-alt": "0xf18d", - "inbox-box": "0xf18e", - "indent-left": "0xf190", - "indent-right": "0xf191", - "info-circle": "0xf192", - "instagram": "0xf193", - "iphone-home": "0xf194", - "italic": "0xf195", - "key": "0xf196", - "laptop": "0xf198", - "laptop-alt": "0xf197", - "lastfm": "0xf199", - "leaf": "0xf19a", - "lines": "0xf19b", - "link": "0xf19c", - "linkedin": "0xf19d", - "list": "0xf19f", - "list-alt": "0xf19e", - "livejournal": "0xf1a0", - "lock": "0xf1a2", - "lock-alt": "0xf1a1", - "magic": "0xf1a3", - "magnet": "0xf1a4", - "male": "0xf1a5", - "map-marker": "0xf1a7", - "map-marker-alt": "0xf1a6", - "mic": "0xf1a9", - "mic-alt": "0xf1a8", - "minus": "0xf1ab", - "minus-sign": "0xf1aa", - "move": "0xf1ac", - "music": "0xf1ad", - "myspace": "0xf1ae", - "network": "0xf1af", - "off": "0xf1b0", - "ok": "0xf1b3", - "ok-circle": "0xf1b1", - "ok-sign": "0xf1b2", - "opensource": "0xf1b4", - "paper-clip": "0xf1b6", - "paper-clip-alt": "0xf1b5", - "path": "0xf1b7", - "pause": "0xf1b9", - "pause-alt": "0xf1b8", - "pencil": "0xf1bb", - "pencil-alt": "0xf1ba", - "person": "0xf1bc", - "phone": "0xf1be", - "phone-alt": "0xf1bd", - "photo": "0xf1c0", - "photo-alt": "0xf1bf", - "picasa": "0xf1c1", - "picture": "0xf1c2", - "pinterest": "0xf1c3", - "plane": "0xf1c4", - "play": "0xf1c7", - "play-alt": "0xf1c5", - "play-circle": "0xf1c6", - "plurk": "0xf1c9", - "plurk-alt": "0xf1c8", - "plus": "0xf1cb", - "plus-sign": "0xf1ca", - "podcast": "0xf1cc", - "print": "0xf1cd", - "puzzle": "0xf1ce", - "qrcode": "0xf1cf", - "question": "0xf1d1", - "question-sign": "0xf1d0", - "quote-alt": "0xf1d2", - "quote-right": "0xf1d4", - "quote-right-alt": "0xf1d3", - "quotes": "0xf1d5", - "random": "0xf1d6", - "record": "0xf1d7", - "reddit": "0xf1d8", - "redux": "0xf1d9", - "refresh": "0xf1da", - "remove": "0xf1dd", - "remove-circle": "0xf1db", - "remove-sign": "0xf1dc", - "repeat": "0xf1df", - "repeat-alt": "0xf1de", - "resize-full": "0xf1e0", - "resize-horizontal": "0xf1e1", - "resize-small": "0xf1e2", - "resize-vertical": "0xf1e3", - "return-key": "0xf1e4", - "retweet": "0xf1e5", - "reverse-alt": "0xf1e6", - "road": "0xf1e7", - "rss": "0xf1e8", - "scissors": "0xf1e9", - "screen": "0xf1eb", - "screen-alt": "0xf1ea", - "screenshot": "0xf1ec", - "search": "0xf1ee", - "search-alt": "0xf1ed", - "share": "0xf1f0", - "share-alt": "0xf1ef", - "shopping-cart": "0xf1f2", - "shopping-cart-sign": "0xf1f1", - "signal": "0xf1f3", - "skype": "0xf1f4", - "slideshare": "0xf1f5", - "smiley": "0xf1f7", - "smiley-alt": "0xf1f6", - "soundcloud": "0xf1f8", - "speaker": "0xf1f9", - "spotify": "0xf1fa", - "stackoverflow": "0xf1fb", - "star": "0xf1fe", - "star-alt": "0xf1fc", - "star-empty": "0xf1fd", - "step-backward": "0xf1ff", - "step-forward": "0xf200", - "stop": "0xf202", - "stop-alt": "0xf201", - "stumbleupon": "0xf203", - "tag": "0xf204", - "tags": "0xf205", - "tasks": "0xf206", - "text-height": "0xf207", - "text-width": "0xf208", - "th": "0xf20b", - "th-large": "0xf209", - "th-list": "0xf20a", - "thumbs-down": "0xf20c", - "thumbs-up": "0xf20d", - "time": "0xf20f", - "time-alt": "0xf20e", - "tint": "0xf210", - "torso": "0xf211", - "trash": "0xf213", - "trash-alt": "0xf212", - "tumblr": "0xf214", - "twitter": "0xf215", - "universal-access": "0xf216", - "unlock": "0xf218", - "unlock-alt": "0xf217", - "upload": "0xf219", - "usd": "0xf21a", - "user": "0xf21b", - "viadeo": "0xf21c", - "video": "0xf21f", - "video-alt": "0xf21d", - "video-chat": "0xf21e", - "view-mode": "0xf220", - "vimeo": "0xf221", - "vkontakte": "0xf222", - "volume-down": "0xf223", - "volume-off": "0xf224", - "volume-up": "0xf225", - "w3c": "0xf226", - "warning-sign": "0xf227", - "website": "0xf229", - "website-alt": "0xf228", - "wheelchair": "0xf22a", - "wordpress": "0xf22b", - "wrench": "0xf22d", - "wrench-alt": "0xf22c", - "youtube": "0xf22e", - "zoom-in": "0xf22f", - "zoom-out": "0xf230" -} diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/elusiveicons-webfont.ttf b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/elusiveicons-webfont.ttf deleted file mode 100644 index b6fe85d4b2..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/elusiveicons-webfont.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/fontawesome-webfont-charmap.json b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/fontawesome-webfont-charmap.json deleted file mode 100644 index 0e97d031e6..0000000000 --- a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/fontawesome-webfont-charmap.json +++ /dev/null @@ -1,696 +0,0 @@ -{ - "500px": "f26e", - "adjust": "f042", - "adn": "f170", - "align-center": "f037", - "align-justify": "f039", - "align-left": "f036", - "align-right": "f038", - "amazon": "f270", - "ambulance": "f0f9", - "anchor": "f13d", - "android": "f17b", - "angellist": "f209", - "angle-double-down": "f103", - "angle-double-left": "f100", - "angle-double-right": "f101", - "angle-double-up": "f102", - "angle-down": "f107", - "angle-left": "f104", - "angle-right": "f105", - "angle-up": "f106", - "apple": "f179", - "archive": "f187", - "area-chart": "f1fe", - "arrow-circle-down": "f0ab", - "arrow-circle-left": "f0a8", - "arrow-circle-o-down": "f01a", - "arrow-circle-o-left": "f190", - "arrow-circle-o-right": "f18e", - "arrow-circle-o-up": "f01b", - "arrow-circle-right": "f0a9", - "arrow-circle-up": "f0aa", - "arrow-down": "f063", - "arrow-left": "f060", - "arrow-right": "f061", - "arrow-up": "f062", - "arrows": "f047", - "arrows-alt": "f0b2", - "arrows-h": "f07e", - "arrows-v": "f07d", - "asterisk": "f069", - "at": "f1fa", - "automobile": "f1b9", - "backward": "f04a", - "balance-scale": "f24e", - "ban": "f05e", - "bank": "f19c", - "bar-chart": "f080", - "bar-chart-o": "f080", - "barcode": "f02a", - "bars": "f0c9", - "battery-0": "f244", - "battery-1": "f243", - "battery-2": "f242", - "battery-3": "f241", - "battery-4": "f240", - "battery-empty": "f244", - "battery-full": "f240", - "battery-half": "f242", - "battery-quarter": "f243", - "battery-three-quarters": "f241", - "bed": "f236", - "beer": "f0fc", - "behance": "f1b4", - "behance-square": "f1b5", - "bell": "f0f3", - "bell-o": "f0a2", - "bell-slash": "f1f6", - "bell-slash-o": "f1f7", - "bicycle": "f206", - "binoculars": "f1e5", - "birthday-cake": "f1fd", - "bitbucket": "f171", - "bitbucket-square": "f172", - "bitcoin": "f15a", - "black-tie": "f27e", - "bluetooth": "f293", - "bluetooth-b": "f294", - "bold": "f032", - "bolt": "f0e7", - "bomb": "f1e2", - "book": "f02d", - "bookmark": "f02e", - "bookmark-o": "f097", - "briefcase": "f0b1", - "btc": "f15a", - "bug": "f188", - "building": "f1ad", - "building-o": "f0f7", - "bullhorn": "f0a1", - "bullseye": "f140", - "bus": "f207", - "buysellads": "f20d", - "cab": "f1ba", - "calculator": "f1ec", - "calendar": "f073", - "calendar-check-o": "f274", - "calendar-minus-o": "f272", - "calendar-o": "f133", - "calendar-plus-o": "f271", - "calendar-times-o": "f273", - "camera": "f030", - "camera-retro": "f083", - "car": "f1b9", - "caret-down": "f0d7", - "caret-left": "f0d9", - "caret-right": "f0da", - "caret-square-o-down": "f150", - "caret-square-o-left": "f191", - "caret-square-o-right": "f152", - "caret-square-o-up": "f151", - "caret-up": "f0d8", - "cart-arrow-down": "f218", - "cart-plus": "f217", - "cc": "f20a", - "cc-amex": "f1f3", - "cc-diners-club": "f24c", - "cc-discover": "f1f2", - "cc-jcb": "f24b", - "cc-mastercard": "f1f1", - "cc-paypal": "f1f4", - "cc-stripe": "f1f5", - "cc-visa": "f1f0", - "certificate": "f0a3", - "chain": "f0c1", - "chain-broken": "f127", - "check": "f00c", - "check-circle": "f058", - "check-circle-o": "f05d", - "check-square": "f14a", - "check-square-o": "f046", - "chevron-circle-down": "f13a", - "chevron-circle-left": "f137", - "chevron-circle-right": "f138", - "chevron-circle-up": "f139", - "chevron-down": "f078", - "chevron-left": "f053", - "chevron-right": "f054", - "chevron-up": "f077", - "child": "f1ae", - "chrome": "f268", - "circle": "f111", - "circle-o": "f10c", - "circle-o-notch": "f1ce", - "circle-thin": "f1db", - "clipboard": "f0ea", - "clock-o": "f017", - "clone": "f24d", - "close": "f00d", - "cloud": "f0c2", - "cloud-download": "f0ed", - "cloud-upload": "f0ee", - "cny": "f157", - "code": "f121", - "code-fork": "f126", - "codepen": "f1cb", - "codiepie": "f284", - "coffee": "f0f4", - "cog": "f013", - "cogs": "f085", - "columns": "f0db", - "comment": "f075", - "comment-o": "f0e5", - "commenting": "f27a", - "commenting-o": "f27b", - "comments": "f086", - "comments-o": "f0e6", - "compass": "f14e", - "compress": "f066", - "connectdevelop": "f20e", - "contao": "f26d", - "copy": "f0c5", - "copyright": "f1f9", - "creative-commons": "f25e", - "credit-card": "f09d", - "credit-card-alt": "f283", - "crop": "f125", - "crosshairs": "f05b", - "css3": "f13c", - "cube": "f1b2", - "cubes": "f1b3", - "cut": "f0c4", - "cutlery": "f0f5", - "dashboard": "f0e4", - "dashcube": "f210", - "database": "f1c0", - "dedent": "f03b", - "delicious": "f1a5", - "desktop": "f108", - "deviantart": "f1bd", - "diamond": "f219", - "digg": "f1a6", - "dollar": "f155", - "dot-circle-o": "f192", - "download": "f019", - "dribbble": "f17d", - "dropbox": "f16b", - "drupal": "f1a9", - "edge": "f282", - "edit": "f044", - "eject": "f052", - "ellipsis-h": "f141", - "ellipsis-v": "f142", - "empire": "f1d1", - "envelope": "f0e0", - "envelope-o": "f003", - "envelope-square": "f199", - "eraser": "f12d", - "eur": "f153", - "euro": "f153", - "exchange": "f0ec", - "exclamation": "f12a", - "exclamation-circle": "f06a", - "exclamation-triangle": "f071", - "expand": "f065", - "expeditedssl": "f23e", - "external-link": "f08e", - "external-link-square": "f14c", - "eye": "f06e", - "eye-slash": "f070", - "eyedropper": "f1fb", - "facebook": "f09a", - "facebook-f": "f09a", - "facebook-official": "f230", - "facebook-square": "f082", - "fast-backward": "f049", - "fast-forward": "f050", - "fax": "f1ac", - "feed": "f09e", - "female": "f182", - "fighter-jet": "f0fb", - "file": "f15b", - "file-archive-o": "f1c6", - "file-audio-o": "f1c7", - "file-code-o": "f1c9", - "file-excel-o": "f1c3", - "file-image-o": "f1c5", - "file-movie-o": "f1c8", - "file-o": "f016", - "file-pdf-o": "f1c1", - "file-photo-o": "f1c5", - "file-picture-o": "f1c5", - "file-powerpoint-o": "f1c4", - "file-sound-o": "f1c7", - "file-text": "f15c", - "file-text-o": "f0f6", - "file-video-o": "f1c8", - "file-word-o": "f1c2", - "file-zip-o": "f1c6", - "files-o": "f0c5", - "film": "f008", - "filter": "f0b0", - "fire": "f06d", - "fire-extinguisher": "f134", - "firefox": "f269", - "flag": "f024", - "flag-checkered": "f11e", - "flag-o": "f11d", - "flash": "f0e7", - "flask": "f0c3", - "flickr": "f16e", - "floppy-o": "f0c7", - "folder": "f07b", - "folder-o": "f114", - "folder-open": "f07c", - "folder-open-o": "f115", - "font": "f031", - "fonticons": "f280", - "fort-awesome": "f286", - "forumbee": "f211", - "forward": "f04e", - "foursquare": "f180", - "frown-o": "f119", - "futbol-o": "f1e3", - "gamepad": "f11b", - "gavel": "f0e3", - "gbp": "f154", - "ge": "f1d1", - "gear": "f013", - "gears": "f085", - "genderless": "f22d", - "get-pocket": "f265", - "gg": "f260", - "gg-circle": "f261", - "gift": "f06b", - "git": "f1d3", - "git-square": "f1d2", - "github": "f09b", - "github-alt": "f113", - "github-square": "f092", - "gittip": "f184", - "glass": "f000", - "globe": "f0ac", - "google": "f1a0", - "google-plus": "f0d5", - "google-plus-square": "f0d4", - "google-wallet": "f1ee", - "graduation-cap": "f19d", - "gratipay": "f184", - "group": "f0c0", - "h-square": "f0fd", - "hacker-news": "f1d4", - "hand-grab-o": "f255", - "hand-lizard-o": "f258", - "hand-o-down": "f0a7", - "hand-o-left": "f0a5", - "hand-o-right": "f0a4", - "hand-o-up": "f0a6", - "hand-paper-o": "f256", - "hand-peace-o": "f25b", - "hand-pointer-o": "f25a", - "hand-rock-o": "f255", - "hand-scissors-o": "f257", - "hand-spock-o": "f259", - "hand-stop-o": "f256", - "hashtag": "f292", - "hdd-o": "f0a0", - "header": "f1dc", - "headphones": "f025", - "heart": "f004", - "heart-o": "f08a", - "heartbeat": "f21e", - "history": "f1da", - "home": "f015", - "hospital-o": "f0f8", - "hotel": "f236", - "hourglass": "f254", - "hourglass-1": "f251", - "hourglass-2": "f252", - "hourglass-3": "f253", - "hourglass-end": "f253", - "hourglass-half": "f252", - "hourglass-o": "f250", - "hourglass-start": "f251", - "houzz": "f27c", - "html5": "f13b", - "i-cursor": "f246", - "ils": "f20b", - "image": "f03e", - "inbox": "f01c", - "indent": "f03c", - "industry": "f275", - "info": "f129", - "info-circle": "f05a", - "inr": "f156", - "instagram": "f16d", - "institution": "f19c", - "internet-explorer": "f26b", - "intersex": "f224", - "ioxhost": "f208", - "italic": "f033", - "joomla": "f1aa", - "jpy": "f157", - "jsfiddle": "f1cc", - "key": "f084", - "keyboard-o": "f11c", - "krw": "f159", - "language": "f1ab", - "laptop": "f109", - "lastfm": "f202", - "lastfm-square": "f203", - "leaf": "f06c", - "leanpub": "f212", - "legal": "f0e3", - "lemon-o": "f094", - "level-down": "f149", - "level-up": "f148", - "life-bouy": "f1cd", - "life-buoy": "f1cd", - "life-ring": "f1cd", - "life-saver": "f1cd", - "lightbulb-o": "f0eb", - "line-chart": "f201", - "link": "f0c1", - "linkedin": "f0e1", - "linkedin-square": "f08c", - "linux": "f17c", - "list": "f03a", - "list-alt": "f022", - "list-ol": "f0cb", - "list-ul": "f0ca", - "location-arrow": "f124", - "lock": "f023", - "long-arrow-down": "f175", - "long-arrow-left": "f177", - "long-arrow-right": "f178", - "long-arrow-up": "f176", - "magic": "f0d0", - "magnet": "f076", - "mail-forward": "f064", - "mail-reply": "f112", - "mail-reply-all": "f122", - "male": "f183", - "map": "f279", - "map-marker": "f041", - "map-o": "f278", - "map-pin": "f276", - "map-signs": "f277", - "mars": "f222", - "mars-double": "f227", - "mars-stroke": "f229", - "mars-stroke-h": "f22b", - "mars-stroke-v": "f22a", - "maxcdn": "f136", - "meanpath": "f20c", - "medium": "f23a", - "medkit": "f0fa", - "meh-o": "f11a", - "mercury": "f223", - "microphone": "f130", - "microphone-slash": "f131", - "minus": "f068", - "minus-circle": "f056", - "minus-square": "f146", - "minus-square-o": "f147", - "mixcloud": "f289", - "mobile": "f10b", - "mobile-phone": "f10b", - "modx": "f285", - "money": "f0d6", - "moon-o": "f186", - "mortar-board": "f19d", - "motorcycle": "f21c", - "mouse-pointer": "f245", - "music": "f001", - "navicon": "f0c9", - "neuter": "f22c", - "newspaper-o": "f1ea", - "object-group": "f247", - "object-ungroup": "f248", - "odnoklassniki": "f263", - "odnoklassniki-square": "f264", - "opencart": "f23d", - "openid": "f19b", - "opera": "f26a", - "optin-monster": "f23c", - "outdent": "f03b", - "pagelines": "f18c", - "paint-brush": "f1fc", - "paper-plane": "f1d8", - "paper-plane-o": "f1d9", - "paperclip": "f0c6", - "paragraph": "f1dd", - "paste": "f0ea", - "pause": "f04c", - "pause-circle": "f28b", - "pause-circle-o": "f28c", - "paw": "f1b0", - "paypal": "f1ed", - "pencil": "f040", - "pencil-square": "f14b", - "pencil-square-o": "f044", - "percent": "f295", - "phone": "f095", - "phone-square": "f098", - "photo": "f03e", - "picture-o": "f03e", - "pie-chart": "f200", - "pied-piper": "f1a7", - "pied-piper-alt": "f1a8", - "pinterest": "f0d2", - "pinterest-p": "f231", - "pinterest-square": "f0d3", - "plane": "f072", - "play": "f04b", - "play-circle": "f144", - "play-circle-o": "f01d", - "plug": "f1e6", - "plus": "f067", - "plus-circle": "f055", - "plus-square": "f0fe", - "plus-square-o": "f196", - "power-off": "f011", - "print": "f02f", - "product-hunt": "f288", - "puzzle-piece": "f12e", - "qq": "f1d6", - "qrcode": "f029", - "question": "f128", - "question-circle": "f059", - "quote-left": "f10d", - "quote-right": "f10e", - "ra": "f1d0", - "random": "f074", - "rebel": "f1d0", - "recycle": "f1b8", - "reddit": "f1a1", - "reddit-alien": "f281", - "reddit-square": "f1a2", - "refresh": "f021", - "registered": "f25d", - "remove": "f00d", - "renren": "f18b", - "reorder": "f0c9", - "repeat": "f01e", - "reply": "f112", - "reply-all": "f122", - "retweet": "f079", - "rmb": "f157", - "road": "f018", - "rocket": "f135", - "rotate-left": "f0e2", - "rotate-right": "f01e", - "rouble": "f158", - "rss": "f09e", - "rss-square": "f143", - "rub": "f158", - "ruble": "f158", - "rupee": "f156", - "safari": "f267", - "save": "f0c7", - "scissors": "f0c4", - "scribd": "f28a", - "search": "f002", - "search-minus": "f010", - "search-plus": "f00e", - "sellsy": "f213", - "send": "f1d8", - "send-o": "f1d9", - "server": "f233", - "share": "f064", - "share-alt": "f1e0", - "share-alt-square": "f1e1", - "share-square": "f14d", - "share-square-o": "f045", - "shekel": "f20b", - "sheqel": "f20b", - "shield": "f132", - "ship": "f21a", - "shirtsinbulk": "f214", - "shopping-bag": "f290", - "shopping-basket": "f291", - "shopping-cart": "f07a", - "sign-in": "f090", - "sign-out": "f08b", - "signal": "f012", - "simplybuilt": "f215", - "sitemap": "f0e8", - "skyatlas": "f216", - "skype": "f17e", - "slack": "f198", - "sliders": "f1de", - "slideshare": "f1e7", - "smile-o": "f118", - "soccer-ball-o": "f1e3", - "sort": "f0dc", - "sort-alpha-asc": "f15d", - "sort-alpha-desc": "f15e", - "sort-amount-asc": "f160", - "sort-amount-desc": "f161", - "sort-asc": "f0de", - "sort-desc": "f0dd", - "sort-down": "f0dd", - "sort-numeric-asc": "f162", - "sort-numeric-desc": "f163", - "sort-up": "f0de", - "soundcloud": "f1be", - "space-shuttle": "f197", - "spinner": "f110", - "spoon": "f1b1", - "spotify": "f1bc", - "square": "f0c8", - "square-o": "f096", - "stack-exchange": "f18d", - "stack-overflow": "f16c", - "star": "f005", - "star-half": "f089", - "star-half-empty": "f123", - "star-half-full": "f123", - "star-half-o": "f123", - "star-o": "f006", - "steam": "f1b6", - "steam-square": "f1b7", - "step-backward": "f048", - "step-forward": "f051", - "stethoscope": "f0f1", - "sticky-note": "f249", - "sticky-note-o": "f24a", - "stop": "f04d", - "stop-circle": "f28d", - "stop-circle-o": "f28e", - "street-view": "f21d", - "strikethrough": "f0cc", - "stumbleupon": "f1a4", - "stumbleupon-circle": "f1a3", - "subscript": "f12c", - "subway": "f239", - "suitcase": "f0f2", - "sun-o": "f185", - "superscript": "f12b", - "support": "f1cd", - "table": "f0ce", - "tablet": "f10a", - "tachometer": "f0e4", - "tag": "f02b", - "tags": "f02c", - "tasks": "f0ae", - "taxi": "f1ba", - "television": "f26c", - "tencent-weibo": "f1d5", - "terminal": "f120", - "text-height": "f034", - "text-width": "f035", - "th": "f00a", - "th-large": "f009", - "th-list": "f00b", - "thumb-tack": "f08d", - "thumbs-down": "f165", - "thumbs-o-down": "f088", - "thumbs-o-up": "f087", - "thumbs-up": "f164", - "ticket": "f145", - "times": "f00d", - "times-circle": "f057", - "times-circle-o": "f05c", - "tint": "f043", - "toggle-down": "f150", - "toggle-left": "f191", - "toggle-off": "f204", - "toggle-on": "f205", - "toggle-right": "f152", - "toggle-up": "f151", - "trademark": "f25c", - "train": "f238", - "transgender": "f224", - "transgender-alt": "f225", - "trash": "f1f8", - "trash-o": "f014", - "tree": "f1bb", - "trello": "f181", - "tripadvisor": "f262", - "trophy": "f091", - "truck": "f0d1", - "try": "f195", - "tty": "f1e4", - "tumblr": "f173", - "tumblr-square": "f174", - "turkish-lira": "f195", - "tv": "f26c", - "twitch": "f1e8", - "twitter": "f099", - "twitter-square": "f081", - "umbrella": "f0e9", - "underline": "f0cd", - "undo": "f0e2", - "university": "f19c", - "unlink": "f127", - "unlock": "f09c", - "unlock-alt": "f13e", - "unsorted": "f0dc", - "upload": "f093", - "usb": "f287", - "usd": "f155", - "user": "f007", - "user-md": "f0f0", - "user-plus": "f234", - "user-secret": "f21b", - "user-times": "f235", - "users": "f0c0", - "venus": "f221", - "venus-double": "f226", - "venus-mars": "f228", - "viacoin": "f237", - "video-camera": "f03d", - "vimeo": "f27d", - "vimeo-square": "f194", - "vine": "f1ca", - "vk": "f189", - "volume-down": "f027", - "volume-off": "f026", - "volume-up": "f028", - "warning": "f071", - "wechat": "f1d7", - "weibo": "f18a", - "weixin": "f1d7", - "whatsapp": "f232", - "wheelchair": "f193", - "wifi": "f1eb", - "wikipedia-w": "f266", - "windows": "f17a", - "won": "f159", - "wordpress": "f19a", - "wrench": "f0ad", - "xing": "f168", - "xing-square": "f169", - "y-combinator": "f23b", - "y-combinator-square": "f1d4", - "yahoo": "f19e", - "yc": "f23b", - "yc-square": "f1d4", - "yelp": "f1e9", - "yen": "f157", - "youtube": "f167", - "youtube-play": "f16a", - "youtube-square": "f166" -} \ No newline at end of file diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/fontawesome-webfont.ttf b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/fontawesome-webfont.ttf deleted file mode 100644 index 26dea7951a..0000000000 Binary files a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/fonts/fontawesome-webfont.ttf and /dev/null differ diff --git a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/iconic_font.py b/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/iconic_font.py deleted file mode 100644 index c25739aff8..0000000000 --- a/client/ayon_core/tools/pyblish_pype/vendor/qtawesome/iconic_font.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Classes handling iconic fonts""" - -from __future__ import print_function - -import json -import os - -import six -from qtpy import QtCore, QtGui - - -_default_options = { - 'color': QtGui.QColor(50, 50, 50), - 'color_disabled': QtGui.QColor(150, 150, 150), - 'opacity': 1.0, - 'scale_factor': 1.0, -} - - -def set_global_defaults(**kwargs): - """Set global defaults for all icons""" - valid_options = ['active', 'animation', 'color', 'color_active', - 'color_disabled', 'color_selected', 'disabled', 'offset', - 'scale_factor', 'selected'] - for kw in kwargs: - if kw in valid_options: - _default_options[kw] = kwargs[kw] - else: - error = "Invalid option '{0}'".format(kw) - raise KeyError(error) - - -class CharIconPainter: - - """Char icon painter""" - - def paint(self, iconic, painter, rect, mode, state, options): - """Main paint method""" - for opt in options: - self._paint_icon(iconic, painter, rect, mode, state, opt) - - def _paint_icon(self, iconic, painter, rect, mode, state, options): - """Paint a single icon""" - painter.save() - color, char = options['color'], options['char'] - - if mode == QtGui.QIcon.Disabled: - color = options.get('color_disabled', color) - char = options.get('disabled', char) - elif mode == QtGui.QIcon.Active: - color = options.get('color_active', color) - char = options.get('active', char) - elif mode == QtGui.QIcon.Selected: - color = options.get('color_selected', color) - char = options.get('selected', char) - - painter.setPen(QtGui.QColor(color)) - # A 16 pixel-high icon yields a font size of 14, which is pixel perfect - # for font-awesome. 16 * 0.875 = 14 - # The reason for not using full-sized glyphs is the negative bearing of - # fonts. - draw_size = 0.875 * round(rect.height() * options['scale_factor']) - prefix = options['prefix'] - - # Animation setup hook - animation = options.get('animation') - if animation is not None: - animation.setup(self, painter, rect) - - painter.setFont(iconic.font(prefix, draw_size)) - if 'offset' in options: - rect = QtCore.QRect(rect) - rect.translate(options['offset'][0] * rect.width(), - options['offset'][1] * rect.height()) - - painter.setOpacity(options.get('opacity', 1.0)) - - painter.drawText(rect, - QtCore.Qt.AlignCenter | QtCore.Qt.AlignVCenter, - char) - painter.restore() - - -class CharIconEngine(QtGui.QIconEngine): - - """Specialization of QtGui.QIconEngine used to draw font-based icons""" - - def __init__(self, iconic, painter, options): - super(CharIconEngine, self).__init__() - self.iconic = iconic - self.painter = painter - self.options = options - - def paint(self, painter, rect, mode, state): - self.painter.paint( - self.iconic, painter, rect, mode, state, self.options) - - def pixmap(self, size, mode, state): - pm = QtGui.QPixmap(size) - pm.fill(QtCore.Qt.transparent) - self.paint(QtGui.QPainter(pm), - QtCore.QRect(QtCore.QPoint(0, 0), size), - mode, - state) - return pm - - -class IconicFont(QtCore.QObject): - - """Main class for managing iconic fonts""" - - def __init__(self, *args): - """Constructor - - :param *args: tuples - Each positional argument is a tuple of 3 or 4 values - - The prefix string to be used when accessing a given font set - - The ttf font filename - - The json charmap filename - - Optionally, the directory containing these files. When not - provided, the files will be looked up in ./fonts/ - """ - super(IconicFont, self).__init__() - self.painter = CharIconPainter() - self.painters = {} - self.fontname = {} - self.charmap = {} - for fargs in args: - self.load_font(*fargs) - - def load_font(self, - prefix, - ttf_filename, - charmap_filename, - directory=None): - """Loads a font file and the associated charmap - - If `directory` is None, the files will be looked up in ./fonts/ - - Arguments - --------- - prefix: str - prefix string to be used when accessing a given font set - ttf_filename: str - ttf font filename - charmap_filename: str - charmap filename - directory: str or None, optional - directory for font and charmap files - """ - - def hook(obj): - result = {} - for key in obj: - result[key] = six.unichr(int(obj[key], 16)) - return result - - if directory is None: - directory = os.path.join( - os.path.dirname(os.path.realpath(__file__)), 'fonts') - - with open(os.path.join(directory, charmap_filename), 'r') as codes: - self.charmap[prefix] = json.load(codes, object_hook=hook) - - id_ = QtGui.QFontDatabase.addApplicationFont( - os.path.join(directory, ttf_filename)) - - loadedFontFamilies = QtGui.QFontDatabase.applicationFontFamilies(id_) - - if(loadedFontFamilies): - self.fontname[prefix] = loadedFontFamilies[0] - else: - print('Font is empty') - - def icon(self, *names, **kwargs): - """Returns a QtGui.QIcon object corresponding to the provided icon name - (including prefix) - - Arguments - --------- - names: list of str - icon name, of the form PREFIX.NAME - - options: dict - options to be passed to the icon painter - """ - options_list = kwargs.pop('options', [{}] * len(names)) - general_options = kwargs - - if len(options_list) != len(names): - error = '"options" must be a list of size {0}'.format(len(names)) - raise Exception(error) - - parsed_options = [] - for i in range(len(options_list)): - specific_options = options_list[i] - parsed_options.append(self._parse_options(specific_options, - general_options, - names[i])) - - # Process high level API - api_options = parsed_options - - return self._icon_by_painter(self.painter, api_options) - - def _parse_options(self, specific_options, general_options, name): - """ """ - options = dict(_default_options, **general_options) - options.update(specific_options) - - # Handle icons for states - icon_kw = ['disabled', 'active', 'selected', 'char'] - names = [options.get(kw, name) for kw in icon_kw] - prefix, chars = self._get_prefix_chars(names) - options.update(dict(zip(*(icon_kw, chars)))) - options.update({'prefix': prefix}) - - # Handle colors for states - color_kw = ['color_active', 'color_selected'] - colors = [options.get(kw, options['color']) for kw in color_kw] - options.update(dict(zip(*(color_kw, colors)))) - - return options - - def _get_prefix_chars(self, names): - """ """ - chars = [] - for name in names: - if '.' in name: - prefix, n = name.split('.') - if prefix in self.charmap: - if n in self.charmap[prefix]: - chars.append(self.charmap[prefix][n]) - else: - error = 'Invalid icon name "{0}" in font "{1}"'.format( - n, prefix) - raise Exception(error) - else: - error = 'Invalid font prefix "{0}"'.format(prefix) - raise Exception(error) - else: - raise Exception('Invalid icon name') - - return prefix, chars - - def font(self, prefix, size): - """Returns QtGui.QFont corresponding to the given prefix and size - - Arguments - --------- - prefix: str - prefix string of the loaded font - size: int - size for the font - """ - font = QtGui.QFont(self.fontname[prefix]) - font.setPixelSize(size) - return font - - def set_custom_icon(self, name, painter): - """Associates a user-provided CharIconPainter to an icon name - The custom icon can later be addressed by calling - icon('custom.NAME') where NAME is the provided name for that icon. - - Arguments - --------- - name: str - name of the custom icon - painter: CharIconPainter - The icon painter, implementing - `paint(self, iconic, painter, rect, mode, state, options)` - """ - self.painters[name] = painter - - def _custom_icon(self, name, **kwargs): - """Returns the custom icon corresponding to the given name""" - options = dict(_default_options, **kwargs) - if name in self.painters: - painter = self.painters[name] - return self._icon_by_painter(painter, options) - else: - return QtGui.QIcon() - - def _icon_by_painter(self, painter, options): - """Returns the icon corresponding to the given painter""" - engine = CharIconEngine(self, painter, options) - return QtGui.QIcon(engine) diff --git a/client/ayon_core/tools/pyblish_pype/version.py b/client/ayon_core/tools/pyblish_pype/version.py deleted file mode 100644 index 5f1dce8011..0000000000 --- a/client/ayon_core/tools/pyblish_pype/version.py +++ /dev/null @@ -1,11 +0,0 @@ - -VERSION_MAJOR = 2 -VERSION_MINOR = 9 -VERSION_PATCH = 0 - - -version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) -version = '%i.%i.%i' % version_info -__version__ = version - -__all__ = ['version', 'version_info', '__version__'] diff --git a/client/ayon_core/tools/pyblish_pype/view.py b/client/ayon_core/tools/pyblish_pype/view.py deleted file mode 100644 index cc6604fc63..0000000000 --- a/client/ayon_core/tools/pyblish_pype/view.py +++ /dev/null @@ -1,334 +0,0 @@ -from qtpy import QtCore, QtWidgets -from . import model -from .constants import Roles, EXPANDER_WIDTH -# Imported when used -widgets = None - - -def _import_widgets(): - global widgets - if widgets is None: - from . import widgets - - -class OverviewView(QtWidgets.QTreeView): - # An item is requesting to be toggled, with optional forced-state - toggled = QtCore.Signal(QtCore.QModelIndex, object) - show_perspective = QtCore.Signal(QtCore.QModelIndex) - - def __init__(self, parent=None): - super(OverviewView, self).__init__(parent) - - self.horizontalScrollBar().hide() - self.viewport().setAttribute(QtCore.Qt.WA_Hover, True) - self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.setItemsExpandable(True) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.setHeaderHidden(True) - self.setRootIsDecorated(False) - self.setIndentation(0) - - def event(self, event): - if not event.type() == QtCore.QEvent.KeyPress: - return super(OverviewView, self).event(event) - - elif event.key() == QtCore.Qt.Key_Space: - for index in self.selectionModel().selectedIndexes(): - self.toggled.emit(index, None) - - return True - - elif event.key() == QtCore.Qt.Key_Backspace: - for index in self.selectionModel().selectedIndexes(): - self.toggled.emit(index, False) - - return True - - elif event.key() == QtCore.Qt.Key_Return: - for index in self.selectionModel().selectedIndexes(): - self.toggled.emit(index, True) - - return True - - return super(OverviewView, self).event(event) - - def focusOutEvent(self, event): - self.selectionModel().clear() - - def mouseReleaseEvent(self, event): - if event.button() in (QtCore.Qt.LeftButton, QtCore.Qt.RightButton): - # Deselect all group labels - indexes = self.selectionModel().selectedIndexes() - for index in indexes: - if index.data(Roles.TypeRole) == model.GroupType: - self.selectionModel().select( - index, QtCore.QItemSelectionModel.Deselect - ) - - return super(OverviewView, self).mouseReleaseEvent(event) - - -class PluginView(OverviewView): - def __init__(self, *args, **kwargs): - super(PluginView, self).__init__(*args, **kwargs) - self.clicked.connect(self.item_expand) - - def item_expand(self, index): - if index.data(Roles.TypeRole) == model.GroupType: - if self.isExpanded(index): - self.collapse(index) - else: - self.expand(index) - - def mouseReleaseEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - indexes = self.selectionModel().selectedIndexes() - if len(indexes) == 1: - index = indexes[0] - pos_index = self.indexAt(event.pos()) - # If instance or Plugin and is selected - if ( - index == pos_index - and index.data(Roles.TypeRole) == model.PluginType - ): - if event.pos().x() < 20: - self.toggled.emit(index, None) - elif event.pos().x() > self.width() - 20: - self.show_perspective.emit(index) - - return super(PluginView, self).mouseReleaseEvent(event) - - -class InstanceView(OverviewView): - def __init__(self, *args, **kwargs): - super(InstanceView, self).__init__(*args, **kwargs) - self.setSortingEnabled(True) - self.sortByColumn(0, QtCore.Qt.AscendingOrder) - self.viewport().setMouseTracking(True) - self._pressed_group_index = None - self._pressed_expander = None - - def mouseMoveEvent(self, event): - index = self.indexAt(event.pos()) - if index.data(Roles.TypeRole) == model.GroupType: - self.update(index) - super(InstanceView, self).mouseMoveEvent(event) - - def item_expand(self, index, expand=None): - if expand is None: - expand = not self.isExpanded(index) - - if expand: - self.expand(index) - else: - self.collapse(index) - - def group_toggle(self, index): - if not index.isValid(): - return - model = index.model() - - chilren_indexes_checked = [] - chilren_indexes_unchecked = [] - for idx in range(model.rowCount(index)): - child_index = model.index(idx, 0, index) - if not child_index.data(Roles.IsEnabledRole): - continue - - if child_index.data(QtCore.Qt.CheckStateRole): - chilren_indexes_checked.append(child_index) - else: - chilren_indexes_unchecked.append(child_index) - - if chilren_indexes_checked: - to_change_indexes = chilren_indexes_checked - new_state = False - else: - to_change_indexes = chilren_indexes_unchecked - new_state = True - - for index in to_change_indexes: - model.setData(index, new_state, QtCore.Qt.CheckStateRole) - self.toggled.emit(index, new_state) - - def _mouse_press(self, event): - if event.button() != QtCore.Qt.LeftButton: - return - - self._pressed_group_index = None - self._pressed_expander = None - - pos_index = self.indexAt(event.pos()) - if not pos_index.isValid(): - return - - if pos_index.data(Roles.TypeRole) != model.InstanceType: - self._pressed_group_index = pos_index - if event.pos().x() < 20: - self._pressed_expander = True - else: - self._pressed_expander = False - - elif event.pos().x() < 20: - indexes = self.selectionModel().selectedIndexes() - any_checked = False - if len(indexes) <= 1: - return - - if pos_index in indexes: - for index in indexes: - if index.data(QtCore.Qt.CheckStateRole): - any_checked = True - break - - for index in indexes: - self.toggled.emit(index, not any_checked) - return True - self.toggled.emit(pos_index, not any_checked) - - def mousePressEvent(self, event): - if self._mouse_press(event): - return - return super(InstanceView, self).mousePressEvent(event) - - def _mouse_release(self, event, pressed_expander, pressed_index): - if event.button() != QtCore.Qt.LeftButton: - return - - pos_index = self.indexAt(event.pos()) - if not pos_index.isValid(): - return - - if pos_index.data(Roles.TypeRole) == model.InstanceType: - indexes = self.selectionModel().selectedIndexes() - if len(indexes) == 1 and indexes[0] == pos_index: - if event.pos().x() < 20: - self.toggled.emit(indexes[0], None) - elif event.pos().x() > self.width() - 20: - self.show_perspective.emit(indexes[0]) - return True - return - - if pressed_index != pos_index: - return - - if self.state() == QtWidgets.QTreeView.State.DragSelectingState: - indexes = self.selectionModel().selectedIndexes() - if len(indexes) != 1 or indexes[0] != pos_index: - return - - if event.pos().x() < EXPANDER_WIDTH: - if pressed_expander is True: - self.item_expand(pos_index) - return True - else: - if pressed_expander is False: - self.group_toggle(pos_index) - self.item_expand(pos_index, True) - return True - - def mouseReleaseEvent(self, event): - pressed_index = self._pressed_group_index - pressed_expander = self._pressed_expander is True - self._pressed_group_index = None - self._pressed_expander = None - result = self._mouse_release(event, pressed_expander, pressed_index) - if result: - return - return super(InstanceView, self).mouseReleaseEvent(event) - - -class TerminalView(QtWidgets.QTreeView): - # An item is requesting to be toggled, with optional forced-state - def __init__(self, parent=None): - super(TerminalView, self).__init__(parent) - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.setAutoScroll(False) - self.setHeaderHidden(True) - self.setIndentation(0) - self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) - self.verticalScrollBar().setSingleStep(10) - self.setRootIsDecorated(False) - - self.clicked.connect(self.item_expand) - - _import_widgets() - - def event(self, event): - if not event.type() == QtCore.QEvent.KeyPress: - return super(TerminalView, self).event(event) - - elif event.key() == QtCore.Qt.Key_Space: - for index in self.selectionModel().selectedIndexes(): - if self.isExpanded(index): - self.collapse(index) - else: - self.expand(index) - - elif event.key() == QtCore.Qt.Key_Backspace: - for index in self.selectionModel().selectedIndexes(): - self.collapse(index) - - elif event.key() == QtCore.Qt.Key_Return: - for index in self.selectionModel().selectedIndexes(): - self.expand(index) - - return super(TerminalView, self).event(event) - - def focusOutEvent(self, event): - self.selectionModel().clear() - - def item_expand(self, index): - if index.data(Roles.TypeRole) == model.TerminalLabelType: - if self.isExpanded(index): - self.collapse(index) - else: - self.expand(index) - self.model().layoutChanged.emit() - self.updateGeometry() - - def rowsInserted(self, parent, start, end): - """Automatically scroll to bottom on each new item added.""" - super(TerminalView, self).rowsInserted(parent, start, end) - self.updateGeometry() - self.scrollToBottom() - - def expand(self, index): - """Wrapper to set widget for expanded index.""" - model = index.model() - row_count = model.rowCount(index) - is_new = False - for child_idx in range(row_count): - child_index = model.index(child_idx, index.column(), index) - widget = self.indexWidget(child_index) - if widget is None: - is_new = True - msg = child_index.data(QtCore.Qt.DisplayRole) - widget = widgets.TerminalDetail(msg) - self.setIndexWidget(child_index, widget) - super(TerminalView, self).expand(index) - if is_new: - self.updateGeometries() - - def resizeEvent(self, event): - super(self.__class__, self).resizeEvent(event) - self.model().layoutChanged.emit() - - def sizeHint(self): - size = super(TerminalView, self).sizeHint() - height = ( - self.contentsMargins().top() - + self.contentsMargins().bottom() - ) - for idx_i in range(self.model().rowCount()): - index = self.model().index(idx_i, 0) - height += self.rowHeight(index) - if self.isExpanded(index): - for idx_j in range(index.model().rowCount(index)): - child_index = index.child(idx_j, 0) - height += self.rowHeight(child_index) - - size.setHeight(height) - return size diff --git a/client/ayon_core/tools/pyblish_pype/widgets.py b/client/ayon_core/tools/pyblish_pype/widgets.py deleted file mode 100644 index 6adcc55f06..0000000000 --- a/client/ayon_core/tools/pyblish_pype/widgets.py +++ /dev/null @@ -1,555 +0,0 @@ -import sys -from qtpy import QtCore, QtWidgets, QtGui -from . import model, delegate, view, awesome -from .constants import PluginStates, InstanceStates, Roles - - -class EllidableLabel(QtWidgets.QLabel): - def __init__(self, *args, **kwargs): - super(EllidableLabel, self).__init__(*args, **kwargs) - self.setObjectName("EllidableLabel") - - def paintEvent(self, event): - painter = QtGui.QPainter(self) - - metrics = QtGui.QFontMetrics(self.font()) - elided = metrics.elidedText( - self.text(), QtCore.Qt.ElideRight, self.width() - ) - painter.drawText(self.rect(), self.alignment(), elided) - - -class PerspectiveLabel(QtWidgets.QTextEdit): - def __init__(self, parent=None): - super(PerspectiveLabel, self).__init__(parent) - self.setObjectName("PerspectiveLabel") - - size_policy = self.sizePolicy() - size_policy.setHeightForWidth(True) - size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred) - self.setSizePolicy(size_policy) - - self.textChanged.connect(self.on_text_changed) - - def on_text_changed(self, *args, **kwargs): - self.updateGeometry() - - def hasHeightForWidth(self): - return True - - def heightForWidth(self, width): - margins = self.contentsMargins() - - document_width = 0 - if width >= margins.left() + margins.right(): - document_width = width - margins.left() - margins.right() - - document = self.document().clone() - document.setTextWidth(document_width) - - return margins.top() + document.size().height() + margins.bottom() - - def sizeHint(self): - width = super(PerspectiveLabel, self).sizeHint().width() - return QtCore.QSize(width, self.heightForWidth(width)) - - -class PerspectiveWidget(QtWidgets.QWidget): - l_doc = "Documentation" - l_rec = "Records" - l_path = "Path" - - def __init__(self, parent): - super(PerspectiveWidget, self).__init__(parent) - - self.parent_widget = parent - main_layout = QtWidgets.QVBoxLayout(self) - - header_widget = QtWidgets.QWidget() - toggle_button = QtWidgets.QPushButton(parent=header_widget) - toggle_button.setObjectName("PerspectiveToggleBtn") - toggle_button.setText(delegate.icons["angle-left"]) - toggle_button.setMinimumHeight(50) - toggle_button.setFixedWidth(40) - - indicator = QtWidgets.QLabel("", parent=header_widget) - indicator.setFixedWidth(30) - indicator.setAlignment(QtCore.Qt.AlignCenter) - indicator.setObjectName("PerspectiveIndicator") - - name = EllidableLabel('*Name of inspected', parent=header_widget) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setAlignment(QtCore.Qt.AlignLeft) - header_layout.addWidget(toggle_button) - header_layout.addWidget(indicator) - header_layout.addWidget(name) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.setSpacing(10) - header_widget.setLayout(header_layout) - - main_layout.setAlignment(QtCore.Qt.AlignTop) - main_layout.addWidget(header_widget) - - scroll_widget = QtWidgets.QScrollArea(self) - scroll_widget.setObjectName("PerspectiveScrollContent") - - contents_widget = QtWidgets.QWidget(scroll_widget) - contents_widget.setObjectName("PerspectiveWidgetContent") - - layout = QtWidgets.QVBoxLayout() - layout.setAlignment(QtCore.Qt.AlignTop) - layout.setContentsMargins(0, 0, 0, 0) - - documentation = ExpandableWidget(self, self.l_doc) - doc_label = PerspectiveLabel() - documentation.set_content(doc_label) - layout.addWidget(documentation) - - path = ExpandableWidget(self, self.l_path) - path_label = PerspectiveLabel() - path.set_content(path_label) - layout.addWidget(path) - - records = ExpandableWidget(self, self.l_rec) - layout.addWidget(records) - - contents_widget.setLayout(layout) - - terminal_view = view.TerminalView() - terminal_view.setObjectName("TerminalView") - terminal_model = model.TerminalModel() - terminal_proxy = model.TerminalProxy(terminal_view) - terminal_proxy.setSourceModel(terminal_model) - - terminal_view.setModel(terminal_proxy) - terminal_delegate = delegate.TerminalItem() - terminal_view.setItemDelegate(terminal_delegate) - records.set_content(terminal_view) - - scroll_widget.setWidgetResizable(True) - scroll_widget.setWidget(contents_widget) - - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main_layout.addWidget(scroll_widget) - self.setLayout(main_layout) - - self.terminal_view = terminal_view - self.terminal_model = terminal_model - self.terminal_proxy = terminal_proxy - - self.indicator = indicator - self.scroll_widget = scroll_widget - self.contents_widget = contents_widget - self.toggle_button = toggle_button - self.name_widget = name - self.documentation = documentation - self.path = path - self.records = records - - self.toggle_button.clicked.connect(self.toggle_me) - - self.last_type = None - self.last_item_id = None - self.last_id = None - - def trim(self, docstring): - if not docstring: - return "" - # Convert tabs to spaces (following the normal Python rules) - # and split into a list of lines: - lines = docstring.expandtabs().splitlines() - # Determine minimum indentation (first line doesn't count): - try: - indent = sys.maxint - max = sys.maxint - except Exception: - indent = sys.maxsize - max = sys.maxsize - - for line in lines[1:]: - stripped = line.lstrip() - if stripped: - indent = min(indent, len(line) - len(stripped)) - # Remove indentation (first line is special): - trimmed = [lines[0].strip()] - if indent < max: - for line in lines[1:]: - trimmed.append(line[indent:].rstrip()) - # Strip off trailing and leading blank lines: - while trimmed and not trimmed[-1]: - trimmed.pop() - while trimmed and not trimmed[0]: - trimmed.pop(0) - # Return a single string: - return "\n".join(trimmed) - - def set_indicator_state(self, state): - self.indicator.setProperty("state", state) - self.indicator.style().polish(self.indicator) - - def reset(self): - self.last_id = None - self.set_records(list()) - self.set_indicator_state(None) - - def update_context(self, plugin_item, instance_item): - if not self.last_item_id or not self.last_type: - return - - if self.last_type == model.PluginType: - if not self.last_id: - _item_id = plugin_item.data(Roles.ObjectUIdRole) - if _item_id != self.last_item_id: - return - self.last_id = plugin_item.plugin.id - - elif self.last_id != plugin_item.plugin.id: - return - - self.set_context(plugin_item.index()) - return - - if self.last_type == model.InstanceType: - if not self.last_id: - _item_id = instance_item.data(Roles.ObjectUIdRole) - if _item_id != self.last_item_id: - return - self.last_id = instance_item.instance.id - - elif self.last_id != instance_item.instance.id: - return - - self.set_context(instance_item.index()) - return - - def set_context(self, index): - if not index or not index.isValid(): - index_type = None - else: - index_type = index.data(Roles.TypeRole) - - if index_type == model.InstanceType: - item_id = index.data(Roles.ObjectIdRole) - publish_states = index.data(Roles.PublishFlagsRole) - if publish_states & InstanceStates.ContextType: - type_indicator = "C" - else: - type_indicator = "I" - - if publish_states & InstanceStates.InProgress: - self.set_indicator_state("active") - - elif publish_states & InstanceStates.HasError: - self.set_indicator_state("error") - - elif publish_states & InstanceStates.HasWarning: - self.set_indicator_state("warning") - - elif publish_states & InstanceStates.HasFinished: - self.set_indicator_state("ok") - else: - self.set_indicator_state(None) - - self.documentation.setVisible(False) - self.path.setVisible(False) - - elif index_type == model.PluginType: - item_id = index.data(Roles.ObjectIdRole) - type_indicator = "P" - - doc = index.data(Roles.DocstringRole) - doc_str = "" - if doc: - doc_str = self.trim(doc) - - publish_states = index.data(Roles.PublishFlagsRole) - if publish_states & PluginStates.InProgress: - self.set_indicator_state("active") - - elif publish_states & PluginStates.HasError: - self.set_indicator_state("error") - - elif publish_states & PluginStates.HasWarning: - self.set_indicator_state("warning") - - elif publish_states & PluginStates.WasProcessed: - self.set_indicator_state("ok") - - else: - self.set_indicator_state(None) - - self.documentation.toggle_content(bool(doc_str)) - self.documentation.content.setText(doc_str) - - path = index.data(Roles.PathModuleRole) or "" - self.path.toggle_content(path.strip() != "") - self.path.content.setText(path) - - self.documentation.setVisible(True) - self.path.setVisible(True) - - else: - self.last_type = None - self.last_id = None - self.indicator.setText("?") - self.set_indicator_state(None) - self.documentation.setVisible(False) - self.path.setVisible(False) - self.records.setVisible(False) - return - - self.last_type = index_type - self.last_id = item_id - self.last_item_id = index.data(Roles.ObjectUIdRole) - - self.indicator.setText(type_indicator) - - label = index.data(QtCore.Qt.DisplayRole) - self.name_widget.setText(label) - self.records.setVisible(True) - - records = index.data(Roles.LogRecordsRole) or [] - self.set_records(records) - - def set_records(self, records): - len_records = 0 - if records: - len_records += len(records) - - data = {"records": records} - self.terminal_model.reset() - self.terminal_model.update_with_result(data) - - self.records.button_toggle_text.setText( - "{} ({})".format(self.l_rec, len_records) - ) - self.records.toggle_content(len_records > 0) - - def toggle_me(self): - self.parent_widget.parent().toggle_perspective_widget() - - -class ClickableWidget(QtWidgets.QLabel): - clicked = QtCore.Signal() - - def mouseReleaseEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - self.clicked.emit() - super(ClickableWidget, self).mouseReleaseEvent(event) - - -class ExpandableWidget(QtWidgets.QWidget): - - content = None - - def __init__(self, parent, title): - super(ExpandableWidget, self).__init__(parent) - - top_part = ClickableWidget(parent=self) - top_part.setObjectName("ExpandableHeader") - - button_size = QtCore.QSize(5, 5) - button_toggle = QtWidgets.QToolButton(parent=top_part) - button_toggle.setIconSize(button_size) - button_toggle.setArrowType(QtCore.Qt.RightArrow) - button_toggle.setCheckable(True) - button_toggle.setChecked(False) - - button_toggle_text = QtWidgets.QLabel(title, parent=top_part) - - layout = QtWidgets.QHBoxLayout(top_part) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - layout.addWidget(button_toggle) - layout.addWidget(button_toggle_text) - top_part.setLayout(layout) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(9, 9, 9, 0) - - content = QtWidgets.QFrame(self) - content.setObjectName("ExpandableWidgetContent") - content.setVisible(False) - - content_layout = QtWidgets.QVBoxLayout(content) - - main_layout.addWidget(top_part) - main_layout.addWidget(content) - self.setLayout(main_layout) - - self.setAttribute(QtCore.Qt.WA_StyledBackground) - - self.top_part = top_part - self.button_toggle = button_toggle - self.button_toggle_text = button_toggle_text - - self.content_widget = content - self.content_layout = content_layout - - self.top_part.clicked.connect(self.top_part_clicked) - self.button_toggle.clicked.connect(self.toggle_content) - - def top_part_clicked(self): - self.toggle_content(not self.button_toggle.isChecked()) - - def toggle_content(self, *args): - if len(args) > 0: - checked = args[0] - else: - checked = self.button_toggle.isChecked() - arrow_type = QtCore.Qt.RightArrow - if checked: - arrow_type = QtCore.Qt.DownArrow - self.button_toggle.setChecked(checked) - self.button_toggle.setArrowType(arrow_type) - self.content_widget.setVisible(checked) - - def resizeEvent(self, event): - super(ExpandableWidget, self).resizeEvent(event) - self.content.updateGeometry() - - def set_content(self, in_widget): - if self.content: - self.content.hide() - self.content_layout.removeWidget(self.content) - self.content_layout.addWidget(in_widget) - self.content = in_widget - - -class ButtonWithMenu(QtWidgets.QWidget): - def __init__(self, button_title, parent=None): - super(ButtonWithMenu, self).__init__(parent=parent) - self.setSizePolicy(QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Maximum - )) - - self.layout = QtWidgets.QHBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setSpacing(0) - - self.menu = QtWidgets.QMenu() - # TODO move to stylesheets - self.menu.setStyleSheet(""" - *{color: #fff; background-color: #555; border: 1px solid #222;} - ::item {background-color: transparent;padding: 5px; - padding-left: 10px;padding-right: 10px;} - ::item:selected {background-color: #666;} - """) - - self.button = QtWidgets.QPushButton(button_title) - self.button.setObjectName("ButtonWithMenu") - - self.layout.addWidget(self.button) - - self.button.clicked.connect(self.btn_clicked) - - def btn_clicked(self): - self.menu.popup(self.button.mapToGlobal( - QtCore.QPoint(0, self.button.height()) - )) - - def addItem(self, text, callback): - self.menu.addAction(text, callback) - self.button.setToolTip("Select to apply predefined presets") - - def clearMenu(self): - self.menu.clear() - self.button.setToolTip("Presets not found") - - -class CommentBox(QtWidgets.QLineEdit): - - def __init__(self, placeholder_text, parent=None): - super(CommentBox, self).__init__(parent=parent) - self.placeholder = QtWidgets.QLabel(placeholder_text, self) - self.placeholder.move(2, 2) - - def focusInEvent(self, event): - self.placeholder.setVisible(False) - return super(CommentBox, self).focusInEvent(event) - - def focusOutEvent(self, event): - current_text = self.text() - current_text = current_text.strip(" ") - self.setText(current_text) - if not self.text(): - self.placeholder.setVisible(True) - return super(CommentBox, self).focusOutEvent(event) - - -class TerminalDetail(QtWidgets.QTextEdit): - def __init__(self, text, *args, **kwargs): - super(TerminalDetail, self).__init__(*args, **kwargs) - - self.setReadOnly(True) - self.setHtml(text) - self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - self.setWordWrapMode( - QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere - ) - - def sizeHint(self): - content_margins = ( - self.contentsMargins().top() - + self.contentsMargins().bottom() - ) - size = self.document().documentLayout().documentSize().toSize() - size.setHeight(size.height() + content_margins) - return size - - -class FilterButton(QtWidgets.QPushButton): - def __init__(self, name, *args, **kwargs): - self.filter_name = name - - super(FilterButton, self).__init__(*args, **kwargs) - - self.toggled.connect(self.on_toggle) - - self.setProperty("type", name) - self.setObjectName("TerminalFilerBtn") - self.setCheckable(True) - self.setChecked( - model.TerminalProxy.filter_buttons_checks[name] - ) - - def on_toggle(self, toggle_state): - model.TerminalProxy.change_filter(self.filter_name, toggle_state) - - -class TerminalFilterWidget(QtWidgets.QWidget): - # timer.timeout.connect(lambda: self._update(self.parent_widget)) - def __init__(self, *args, **kwargs): - super(TerminalFilterWidget, self).__init__(*args, **kwargs) - self.setObjectName("TerminalFilterWidget") - self.filter_changed = QtCore.Signal() - - info_icon = awesome.tags["info"] - log_icon = awesome.tags["circle"] - error_icon = awesome.tags["exclamation-triangle"] - - filter_buttons = ( - FilterButton("info", info_icon, self), - FilterButton("log_debug", log_icon, self), - FilterButton("log_info", log_icon, self), - FilterButton("log_warning", log_icon, self), - FilterButton("log_error", log_icon, self), - FilterButton("log_critical", log_icon, self), - FilterButton("error", error_icon, self) - ) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - # Add spacers - spacer = QtWidgets.QWidget() - spacer.setAttribute(QtCore.Qt.WA_TranslucentBackground) - layout.addWidget(spacer, 1) - - for btn in filter_buttons: - layout.addWidget(btn) - - self.setLayout(layout) - - self.filter_buttons = filter_buttons diff --git a/client/ayon_core/tools/pyblish_pype/window.py b/client/ayon_core/tools/pyblish_pype/window.py deleted file mode 100644 index 01d373d841..0000000000 --- a/client/ayon_core/tools/pyblish_pype/window.py +++ /dev/null @@ -1,1315 +0,0 @@ -"""Main Window - -States: - These are all possible states and their transitions. - - - reset - ' - ' - ' - ___v__ - | | reset - | Idle |--------------------. - | |<-------------------' - | | - | | _____________ - | | validate | | reset # TODO - | |----------------->| In-progress |-----------. - | | |_____________| ' - | |<-------------------------------------------' - | | - | | _____________ - | | publish | | - | |----------------->| In-progress |---. - | | |_____________| ' - | |<-----------------------------------' - |______| - - -Todo: - There are notes spread throughout this project with the syntax: - - - TODO(username) - - The `username` is a quick and dirty indicator of who made the note - and is by no means exclusive to that person in terms of seeing it - done. Feel free to do, or make your own TODO's as you code. Just - make sure the description is sufficient for anyone reading it for - the first time to understand how to actually to it! - -""" -import sys -from functools import partial - -from . import delegate, model, settings, util, view, widgets -from .awesome import tags as awesome - -from qtpy import QtCore, QtGui, QtWidgets -from .constants import ( - PluginStates, PluginActionStates, InstanceStates, GroupStates, Roles -) -if sys.version_info[0] == 3: - from queue import Queue -else: - from Queue import Queue - - -class Window(QtWidgets.QDialog): - def __init__(self, controller, parent=None): - super(Window, self).__init__(parent=parent) - - self._suspend_logs = False - - # Use plastique style for specific ocations - # TODO set style name via environment variable - low_keys = { - key.lower(): key - for key in QtWidgets.QStyleFactory.keys() - } - if "plastique" in low_keys: - self.setStyle( - QtWidgets.QStyleFactory.create(low_keys["plastique"]) - ) - - icon = QtGui.QIcon(util.get_asset("img", "logo-extrasmall.png")) - if parent is None: - on_top_flag = QtCore.Qt.WindowStaysOnTopHint - else: - on_top_flag = QtCore.Qt.Dialog - - self.setWindowFlags( - self.windowFlags() - | QtCore.Qt.WindowTitleHint - | QtCore.Qt.WindowMaximizeButtonHint - | QtCore.Qt.WindowMinimizeButtonHint - | QtCore.Qt.WindowCloseButtonHint - | on_top_flag - ) - self.setWindowIcon(icon) - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - - self.controller = controller - - main_widget = QtWidgets.QWidget(self) - - # General layout - header_widget = QtWidgets.QWidget(parent=main_widget) - - header_tab_widget = QtWidgets.QWidget(header_widget) - header_tab_overview = QtWidgets.QRadioButton(header_tab_widget) - header_tab_terminal = QtWidgets.QRadioButton(header_tab_widget) - header_spacer = QtWidgets.QWidget(header_tab_widget) - - button_suspend_logs_widget = QtWidgets.QWidget() - button_suspend_logs_widget_layout = QtWidgets.QHBoxLayout( - button_suspend_logs_widget - ) - button_suspend_logs_widget_layout.setContentsMargins(0, 10, 0, 10) - button_suspend_logs = QtWidgets.QPushButton(header_widget) - button_suspend_logs.setFixedWidth(7) - button_suspend_logs.setSizePolicy( - QtWidgets.QSizePolicy.Preferred, - QtWidgets.QSizePolicy.Expanding - ) - button_suspend_logs_widget_layout.addWidget(button_suspend_logs) - header_aditional_btns = QtWidgets.QWidget(header_tab_widget) - - aditional_btns_layout = QtWidgets.QHBoxLayout(header_aditional_btns) - - presets_button = widgets.ButtonWithMenu(awesome["filter"]) - presets_button.setEnabled(False) - aditional_btns_layout.addWidget(presets_button) - - layout_tab = QtWidgets.QHBoxLayout(header_tab_widget) - layout_tab.setContentsMargins(0, 0, 0, 0) - layout_tab.setSpacing(0) - layout_tab.addWidget(header_tab_overview, 0) - layout_tab.addWidget(header_tab_terminal, 0) - layout_tab.addWidget(button_suspend_logs_widget, 0) - - # Compress items to the left - layout_tab.addWidget(header_spacer, 1) - layout_tab.addWidget(header_aditional_btns, 0) - - layout = QtWidgets.QHBoxLayout(header_widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(header_tab_widget) - - header_widget.setLayout(layout) - - # Overview Page - # TODO add parent - overview_page = QtWidgets.QWidget() - - overview_instance_view = view.InstanceView(parent=overview_page) - overview_instance_view.setAnimated(settings.Animated) - overview_instance_delegate = delegate.InstanceDelegate( - parent=overview_instance_view - ) - instance_model = model.InstanceModel(controller) - instance_sort_proxy = model.InstanceSortProxy() - instance_sort_proxy.setSourceModel(instance_model) - - overview_instance_view.setItemDelegate(overview_instance_delegate) - overview_instance_view.setModel(instance_sort_proxy) - - overview_plugin_view = view.PluginView(parent=overview_page) - overview_plugin_view.setAnimated(settings.Animated) - overview_plugin_delegate = delegate.PluginDelegate( - parent=overview_plugin_view - ) - overview_plugin_view.setItemDelegate(overview_plugin_delegate) - plugin_model = model.PluginModel(controller) - plugin_proxy = model.PluginFilterProxy() - plugin_proxy.setSourceModel(plugin_model) - overview_plugin_view.setModel(plugin_proxy) - - layout = QtWidgets.QHBoxLayout(overview_page) - layout.addWidget(overview_instance_view, 1) - layout.addWidget(overview_plugin_view, 1) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(0) - overview_page.setLayout(layout) - - # Terminal - terminal_container = QtWidgets.QWidget() - - terminal_view = view.TerminalView() - terminal_model = model.TerminalModel() - terminal_proxy = model.TerminalProxy(terminal_view) - terminal_proxy.setSourceModel(terminal_model) - - terminal_view.setModel(terminal_proxy) - terminal_delegate = delegate.TerminalItem() - terminal_view.setItemDelegate(terminal_delegate) - - layout = QtWidgets.QVBoxLayout(terminal_container) - layout.addWidget(terminal_view) - layout.setContentsMargins(5, 5, 5, 5) - layout.setSpacing(0) - - terminal_container.setLayout(layout) - - terminal_page = QtWidgets.QWidget() - layout = QtWidgets.QVBoxLayout(terminal_page) - layout.addWidget(terminal_container) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Add some room between window borders and contents - body_widget = QtWidgets.QWidget(main_widget) - layout = QtWidgets.QHBoxLayout(body_widget) - layout.setContentsMargins(5, 5, 5, 1) - layout.addWidget(overview_page) - layout.addWidget(terminal_page) - - # Comment Box - comment_box = widgets.CommentBox("Comment...", self) - - intent_box = QtWidgets.QComboBox() - - intent_model = model.IntentModel() - intent_box.setModel(intent_model) - - comment_intent_widget = QtWidgets.QWidget() - comment_intent_layout = QtWidgets.QHBoxLayout(comment_intent_widget) - comment_intent_layout.setContentsMargins(0, 0, 0, 0) - comment_intent_layout.setSpacing(5) - comment_intent_layout.addWidget(comment_box) - comment_intent_layout.addWidget(intent_box) - - # Terminal filtering - terminal_filters_widget = widgets.TerminalFilterWidget() - - # Footer - footer_widget = QtWidgets.QWidget(main_widget) - - footer_info = QtWidgets.QLabel(footer_widget) - footer_spacer = QtWidgets.QWidget(footer_widget) - - footer_button_stop = QtWidgets.QPushButton( - awesome["stop"], footer_widget - ) - footer_button_stop.setToolTip("Stop publishing") - footer_button_reset = QtWidgets.QPushButton( - awesome["refresh"], footer_widget - ) - footer_button_reset.setToolTip("Restart publishing") - footer_button_validate = QtWidgets.QPushButton( - awesome["flask"], footer_widget - ) - footer_button_validate.setToolTip("Run validations") - footer_button_play = QtWidgets.QPushButton( - awesome["play"], footer_widget - ) - footer_button_play.setToolTip("Publish") - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(5, 5, 5, 5) - layout.addWidget(footer_info, 0) - layout.addWidget(footer_spacer, 1) - - layout.addWidget(footer_button_stop, 0) - layout.addWidget(footer_button_reset, 0) - layout.addWidget(footer_button_validate, 0) - layout.addWidget(footer_button_play, 0) - - footer_layout = QtWidgets.QVBoxLayout(footer_widget) - footer_layout.addWidget(terminal_filters_widget) - footer_layout.addWidget(comment_intent_widget) - footer_layout.addLayout(layout) - - footer_widget.setProperty("success", -1) - - # Placeholder for when GUI is closing - # TODO(marcus): Fade to black and the the user about what's happening - closing_placeholder = QtWidgets.QWidget(main_widget) - closing_placeholder.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Expanding - ) - closing_placeholder.hide() - - perspective_widget = widgets.PerspectiveWidget(main_widget) - perspective_widget.hide() - - pages_widget = QtWidgets.QWidget(main_widget) - layout = QtWidgets.QVBoxLayout(pages_widget) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(header_widget, 0) - layout.addWidget(body_widget, 1) - - # Main layout - layout = QtWidgets.QVBoxLayout(main_widget) - layout.addWidget(pages_widget, 3) - layout.addWidget(perspective_widget, 3) - layout.addWidget(closing_placeholder, 1) - layout.addWidget(footer_widget, 0) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - main_widget.setLayout(layout) - - self.main_layout = QtWidgets.QVBoxLayout(self) - self.main_layout.setContentsMargins(0, 0, 0, 0) - self.main_layout.setSpacing(0) - self.main_layout.addWidget(main_widget) - - """Setup - - Widgets are referred to in CSS via their object-name. We - use the same mechanism internally to refer to objects; so rather - than storing widgets as self.my_widget, it is referred to as: - - >>> my_widget = self.findChild(QtWidgets.QWidget, "MyWidget") - - This way there is only ever a single method of referring to any widget. - """ - - names = { - # Main - "Header": header_widget, - "Body": body_widget, - "Footer": footer_widget, - - # Pages - "Overview": overview_page, - "Terminal": terminal_page, - - # Tabs - "OverviewTab": header_tab_overview, - "TerminalTab": header_tab_terminal, - - # Views - "TerminalView": terminal_view, - - # Buttons - "SuspendLogsBtn": button_suspend_logs, - "Stop": footer_button_stop, - "Reset": footer_button_reset, - "Validate": footer_button_validate, - "Play": footer_button_play, - - # Misc - "HeaderSpacer": header_spacer, - "FooterSpacer": footer_spacer, - "FooterInfo": footer_info, - "CommentIntentWidget": comment_intent_widget, - "CommentBox": comment_box, - "CommentPlaceholder": comment_box.placeholder, - "ClosingPlaceholder": closing_placeholder, - "IntentBox": intent_box - } - - for name, _widget in names.items(): - _widget.setObjectName(name) - - # Enable CSS on plain QWidget objects - for _widget in ( - pages_widget, - header_widget, - body_widget, - comment_box, - overview_page, - terminal_page, - footer_widget, - button_suspend_logs, - footer_button_stop, - footer_button_reset, - footer_button_validate, - footer_button_play, - footer_spacer, - closing_placeholder - ): - _widget.setAttribute(QtCore.Qt.WA_StyledBackground) - - # Signals - header_tab_overview.toggled.connect( - lambda: self.on_tab_changed("overview") - ) - header_tab_terminal.toggled.connect( - lambda: self.on_tab_changed("terminal") - ) - - overview_instance_view.show_perspective.connect( - self.toggle_perspective_widget - ) - overview_plugin_view.show_perspective.connect( - self.toggle_perspective_widget - ) - - controller.switch_toggleability.connect(self.change_toggleability) - - controller.was_reset.connect(self.on_was_reset) - # This is called synchronously on each process - controller.was_processed.connect(self.on_was_processed) - controller.passed_group.connect(self.on_passed_group) - controller.was_stopped.connect(self.on_was_stopped) - controller.was_finished.connect(self.on_was_finished) - - controller.was_skipped.connect(self.on_was_skipped) - controller.was_acted.connect(self.on_was_acted) - - # NOTE: Listeners to this signal are run in the main thread - controller.about_to_process.connect( - self.on_about_to_process, - QtCore.Qt.DirectConnection - ) - - overview_instance_view.toggled.connect(self.on_instance_toggle) - overview_plugin_view.toggled.connect(self.on_plugin_toggle) - - button_suspend_logs.clicked.connect(self.on_suspend_clicked) - footer_button_stop.clicked.connect(self.on_stop_clicked) - footer_button_reset.clicked.connect(self.on_reset_clicked) - footer_button_validate.clicked.connect(self.on_validate_clicked) - footer_button_play.clicked.connect(self.on_play_clicked) - - comment_box.textChanged.connect(self.on_comment_entered) - comment_box.returnPressed.connect(self.on_play_clicked) - overview_plugin_view.customContextMenuRequested.connect( - self.on_plugin_action_menu_requested - ) - - instance_model.group_created.connect(self.on_instance_group_created) - - self.main_widget = main_widget - - self.pages_widget = pages_widget - self.header_widget = header_widget - self.body_widget = body_widget - - self.terminal_filters_widget = terminal_filters_widget - - self.footer_widget = footer_widget - self.button_suspend_logs = button_suspend_logs - self.footer_button_stop = footer_button_stop - self.footer_button_reset = footer_button_reset - self.footer_button_validate = footer_button_validate - self.footer_button_play = footer_button_play - - self.footer_info = footer_info - - self.overview_instance_view = overview_instance_view - self.overview_plugin_view = overview_plugin_view - self.plugin_model = plugin_model - self.plugin_proxy = plugin_proxy - self.instance_model = instance_model - self.instance_sort_proxy = instance_sort_proxy - - self.presets_button = presets_button - - self.terminal_model = terminal_model - self.terminal_proxy = terminal_proxy - self.terminal_view = terminal_view - - self.comment_main_widget = comment_intent_widget - self.comment_box = comment_box - self.intent_box = intent_box - self.intent_model = intent_model - - self.perspective_widget = perspective_widget - - self.tabs = { - "overview": header_tab_overview, - "terminal": header_tab_terminal - } - self.pages = ( - ("overview", overview_page), - ("terminal", terminal_page) - ) - - current_page = settings.InitialTab or "overview" - self.comment_main_widget.setVisible( - not current_page == "terminal" - ) - self.terminal_filters_widget.setVisible( - current_page == "terminal" - ) - - self._current_page = current_page - self._hidden_for_plugin_process = False - - self.tabs[current_page].setChecked(True) - - self.apply_log_suspend_value( - util.env_variable_to_bool("PYBLISH_SUSPEND_LOGS") - ) - - # ------------------------------------------------------------------------- - # - # Event handlers - # - # ------------------------------------------------------------------------- - def set_presets(self, key): - plugin_settings = self.controller.possible_presets.get(key) - if not plugin_settings: - return - - for plugin_item in self.plugin_model.plugin_items.values(): - if not plugin_item.plugin.optional: - continue - - value = plugin_settings.get( - plugin_item.plugin.__name__, - # if plugin is not in presets then set default value - self.controller.optional_default.get( - plugin_item.plugin.__name__ - ) - ) - if value is None: - continue - - plugin_item.setData(value, QtCore.Qt.CheckStateRole) - - def toggle_perspective_widget(self, index=None): - show = False - if index: - show = True - self.perspective_widget.set_context(index) - - self.pages_widget.setVisible(not show) - self.perspective_widget.setVisible(show) - self.footer_items_visibility() - - def change_toggleability(self, enable_value): - for plugin_item in self.plugin_model.plugin_items.values(): - plugin_item.setData(enable_value, Roles.IsEnabledRole) - - for instance_item in ( - self.instance_model.instance_items.values() - ): - instance_item.setData(enable_value, Roles.IsEnabledRole) - - def _add_intent_to_context(self): - context_value = None - if ( - self.intent_model.has_items - and "intent" not in self.controller.context.data - ): - idx = self.intent_model.index(self.intent_box.currentIndex(), 0) - intent_value = self.intent_model.data(idx, Roles.IntentItemValue) - intent_label = self.intent_model.data(idx, QtCore.Qt.DisplayRole) - if intent_value: - context_value = { - "value": intent_value, - "label": intent_label - } - - # Unset intent if is set to empty value - if context_value is None: - self.controller.context.data.pop("intent", None) - else: - self.controller.context.data["intent"] = context_value - - def on_instance_toggle(self, index, state=None): - """An item is requesting to be toggled""" - if not index.data(Roles.IsOptionalRole): - return self.info("This item is mandatory") - - if self.controller.collect_state != 1: - return self.info("Cannot toggle") - - current_state = index.data(QtCore.Qt.CheckStateRole) - if state is None: - state = not current_state - - instance_id = index.data(Roles.ObjectIdRole) - instance_item = self.instance_model.instance_items[instance_id] - instance_item.setData(state, QtCore.Qt.CheckStateRole) - - self.controller.instance_toggled.emit( - instance_item.instance, current_state, state - ) - - self.update_compatibility() - - def on_instance_group_created(self, index): - _index = self.instance_sort_proxy.mapFromSource(index) - self.overview_instance_view.expand(_index) - - def on_plugin_toggle(self, index, state=None): - """An item is requesting to be toggled""" - if not index.data(Roles.IsOptionalRole): - return self.info("This item is mandatory") - - if self.controller.collect_state != 1: - return self.info("Cannot toggle") - - if state is None: - state = not index.data(QtCore.Qt.CheckStateRole) - - plugin_id = index.data(Roles.ObjectIdRole) - plugin_item = self.plugin_model.plugin_items[plugin_id] - plugin_item.setData(state, QtCore.Qt.CheckStateRole) - - self.update_compatibility() - - def on_tab_changed(self, target): - previous_page = None - target_page = None - direction = None - for name, page in self.pages: - if name == target: - target_page = page - if direction is None: - direction = -1 - elif name == self._current_page: - previous_page = page - if direction is None: - direction = 1 - else: - page.setVisible(False) - - self._current_page = target - self.slide_page(previous_page, target_page, direction) - - def slide_page(self, previous_page, target_page, direction): - if previous_page is None: - for name, page in self.pages: - for _name, _page in self.pages: - if name != _name: - _page.hide() - page.show() - page.hide() - - if ( - previous_page == target_page - or previous_page is None - ): - if not target_page.isVisible(): - target_page.show() - return - - if not settings.Animated: - previous_page.setVisible(False) - target_page.setVisible(True) - return - - width = previous_page.frameGeometry().width() - offset = QtCore.QPoint(direction * width, 0) - - previous_rect = ( - previous_page.frameGeometry().x(), - previous_page.frameGeometry().y(), - width, - previous_page.frameGeometry().height() - ) - curr_pos = previous_page.pos() - - previous_page.hide() - target_page.show() - target_page.update() - target_rect = ( - target_page.frameGeometry().x(), - target_page.frameGeometry().y(), - target_page.frameGeometry().width(), - target_page.frameGeometry().height() - ) - previous_page.show() - - target_page.raise_() - previous_page.setGeometry(*previous_rect) - target_page.setGeometry(*target_rect) - - target_page.move(curr_pos + offset) - - duration = 250 - - anim_old = QtCore.QPropertyAnimation( - previous_page, b"pos", self - ) - anim_old.setDuration(duration) - anim_old.setStartValue(curr_pos) - anim_old.setEndValue(curr_pos - offset) - anim_old.setEasingCurve(QtCore.QEasingCurve.OutQuad) - - anim_new = QtCore.QPropertyAnimation( - target_page, b"pos", self - ) - anim_new.setDuration(duration) - anim_new.setStartValue(curr_pos + offset) - anim_new.setEndValue(curr_pos) - anim_new.setEasingCurve(QtCore.QEasingCurve.OutQuad) - - anim_group = QtCore.QParallelAnimationGroup(self) - anim_group.addAnimation(anim_old) - anim_group.addAnimation(anim_new) - - def slide_finished(): - previous_page.hide() - self.footer_items_visibility() - - anim_group.finished.connect(slide_finished) - anim_group.start() - - def footer_items_visibility( - self, - comment_visible=None, - terminal_filters_visibile=None - ): - target = self._current_page - comment_visibility = ( - not self.perspective_widget.isVisible() - and not target == "terminal" - and self.comment_box.isEnabled() - ) - terminal_filters_visibility = ( - target == "terminal" - or self.perspective_widget.isVisible() - ) - - if comment_visible is not None and comment_visibility: - comment_visibility = comment_visible - - if ( - terminal_filters_visibile is not None - and terminal_filters_visibility - ): - terminal_filters_visibility = terminal_filters_visibile - - duration = 150 - - hiding_widgets = [] - showing_widgets = [] - if (comment_visibility != ( - self.comment_main_widget.isVisible() - )): - if self.comment_main_widget.isVisible(): - hiding_widgets.append(self.comment_main_widget) - else: - showing_widgets.append(self.comment_main_widget) - - if (terminal_filters_visibility != ( - self.terminal_filters_widget.isVisible() - )): - if self.terminal_filters_widget.isVisible(): - hiding_widgets.append(self.terminal_filters_widget) - else: - showing_widgets.append(self.terminal_filters_widget) - - if not hiding_widgets and not showing_widgets: - return - - hiding_widgets_queue = Queue() - showing_widgets_queue = Queue() - widgets_by_pos_y = {} - for widget in hiding_widgets: - key = widget.mapToGlobal(widget.rect().topLeft()).x() - widgets_by_pos_y[key] = widget - - for key in sorted(widgets_by_pos_y.keys()): - widget = widgets_by_pos_y[key] - hiding_widgets_queue.put((widget, )) - - for widget in hiding_widgets: - widget.hide() - - for widget in showing_widgets: - widget.show() - - self.footer_widget.updateGeometry() - widgets_by_pos_y = {} - for widget in showing_widgets: - key = widget.mapToGlobal(widget.rect().topLeft()).x() - widgets_by_pos_y[key] = widget - - for key in reversed(sorted(widgets_by_pos_y.keys())): - widget = widgets_by_pos_y[key] - showing_widgets_queue.put(widget) - - for widget in showing_widgets: - widget.hide() - - for widget in hiding_widgets: - widget.show() - - def process_showing(): - if showing_widgets_queue.empty(): - return - - widget = showing_widgets_queue.get() - widget.show() - - widget_rect = widget.frameGeometry() - second_rect = QtCore.QRect(widget_rect) - second_rect.setTopLeft(second_rect.bottomLeft()) - - animation = QtCore.QPropertyAnimation( - widget, b"geometry", self - ) - animation.setDuration(duration) - animation.setStartValue(second_rect) - animation.setEndValue(widget_rect) - animation.setEasingCurve(QtCore.QEasingCurve.OutQuad) - - animation.finished.connect(process_showing) - animation.start() - - def process_hiding(): - if hiding_widgets_queue.empty(): - return process_showing() - - item = hiding_widgets_queue.get() - if isinstance(item, tuple): - widget = item[0] - hiding_widgets_queue.put(widget) - widget_rect = widget.frameGeometry() - second_rect = QtCore.QRect(widget_rect) - second_rect.setTopLeft(second_rect.bottomLeft()) - - anim = QtCore.QPropertyAnimation( - widget, b"geometry", self - ) - anim.setDuration(duration) - anim.setStartValue(widget_rect) - anim.setEndValue(second_rect) - anim.setEasingCurve(QtCore.QEasingCurve.OutQuad) - - anim.finished.connect(process_hiding) - anim.start() - else: - item.hide() - return process_hiding() - - process_hiding() - - def on_validate_clicked(self): - self.comment_box.setEnabled(False) - self.footer_items_visibility() - self.intent_box.setEnabled(False) - - self._add_intent_to_context() - - self.validate() - - def on_play_clicked(self): - self.comment_box.setEnabled(False) - self.footer_items_visibility() - self.intent_box.setEnabled(False) - - self._add_intent_to_context() - - self.publish() - - def on_reset_clicked(self): - self.reset() - - def on_stop_clicked(self): - self.info("Stopping..") - self.controller.stop() - - # TODO checks - self.footer_button_reset.setEnabled(True) - self.footer_button_play.setEnabled(False) - self.footer_button_stop.setEnabled(False) - - def on_suspend_clicked(self, value=None): - self.apply_log_suspend_value(not self._suspend_logs) - - def apply_log_suspend_value(self, value): - self._suspend_logs = value - if self._current_page == "terminal": - self.tabs["overview"].setChecked(True) - - self.tabs["terminal"].setVisible(not self._suspend_logs) - - def on_comment_entered(self): - """The user has typed a comment.""" - self.controller.context.data["comment"] = self.comment_box.text() - - def on_about_to_process(self, plugin, instance): - """Reflect currently running pair in GUI""" - if instance is None: - instance_id = self.controller.context.id - else: - instance_id = instance.id - - instance_item = ( - self.instance_model.instance_items[instance_id] - ) - instance_item.setData( - {InstanceStates.InProgress: True}, - Roles.PublishFlagsRole - ) - - plugin_item = self.plugin_model.plugin_items[plugin._id] - plugin_item.setData( - {PluginStates.InProgress: True}, - Roles.PublishFlagsRole - ) - - self.info("{} {}".format( - self.tr("Processing"), plugin_item.data(QtCore.Qt.DisplayRole) - )) - - visibility = True - if hasattr(plugin, "hide_ui_on_process") and plugin.hide_ui_on_process: - visibility = False - self._hidden_for_plugin_process = not visibility - - self._ensure_visible(visibility) - - def _ensure_visible(self, visible): - if self.isVisible() == visible: - return - - if not visible: - self.setVisible(visible) - else: - self.show() - self.raise_() - self.activateWindow() - self.showNormal() - - def on_plugin_action_menu_requested(self, pos): - """The user right-clicked on a plug-in - __________ - | | - | Action 1 | - | Action 2 | - | Action 3 | - | | - |__________| - - """ - - index = self.overview_plugin_view.indexAt(pos) - actions = index.data(Roles.PluginValidActionsRole) - - if not actions: - return - - menu = QtWidgets.QMenu(self) - plugin_id = index.data(Roles.ObjectIdRole) - plugin_item = self.plugin_model.plugin_items[plugin_id] - print("plugin is: %s" % plugin_item.plugin) - - for action in actions: - qaction = QtWidgets.QAction(action.label or action.__name__, self) - qaction.triggered.connect(partial(self.act, plugin_item, action)) - menu.addAction(qaction) - - menu.popup(self.overview_plugin_view.viewport().mapToGlobal(pos)) - - def update_compatibility(self): - self.plugin_model.update_compatibility() - self.plugin_proxy.invalidateFilter() - - def on_was_reset(self): - # Append context object to instances model - self.instance_model.append(self.controller.context) - - for plugin in self.controller.plugins: - self.plugin_model.append(plugin) - - self.overview_instance_view.expandAll() - self.overview_plugin_view.expandAll() - - self.presets_button.clearMenu() - if self.controller.possible_presets: - self.presets_button.setEnabled(True) - for key in self.controller.possible_presets: - self.presets_button.addItem( - key, partial(self.set_presets, key) - ) - - self.instance_model.restore_checkstates() - self.plugin_model.restore_checkstates() - - self.perspective_widget.reset() - - # Append placeholder comment from Context - # This allows users to inject a comment from elsewhere, - # or to perhaps provide a placeholder comment/template - # for artists to fill in. - comment = self.controller.context.data.get("comment") - self.comment_box.setText(comment or None) - self.comment_box.setEnabled(True) - self.footer_items_visibility() - - self.intent_box.setEnabled(True) - - # Refresh tab - self.on_tab_changed(self._current_page) - self.update_compatibility() - - self.button_suspend_logs.setEnabled(False) - - self.footer_button_validate.setEnabled(False) - self.footer_button_reset.setEnabled(False) - self.footer_button_stop.setEnabled(True) - self.footer_button_play.setEnabled(False) - - self._update_state() - - def on_passed_group(self, order): - for group_item in self.instance_model.group_items.values(): - group_index = self.instance_sort_proxy.mapFromSource( - group_item.index() - ) - if self.overview_instance_view.isExpanded(group_index): - continue - - if group_item.publish_states & GroupStates.HasError: - self.overview_instance_view.expand(group_index) - - for group_item in self.plugin_model.group_items.values(): - # TODO check only plugins from the group - if group_item.publish_states & GroupStates.HasFinished: - continue - - if order != group_item.order: - continue - - group_index = self.plugin_proxy.mapFromSource(group_item.index()) - if group_item.publish_states & GroupStates.HasError: - self.overview_plugin_view.expand(group_index) - continue - - group_item.setData( - {GroupStates.HasFinished: True}, - Roles.PublishFlagsRole - ) - self.overview_plugin_view.setAnimated(False) - self.overview_plugin_view.collapse(group_index) - - self._update_state() - - def on_was_stopped(self): - self.overview_plugin_view.setAnimated(settings.Animated) - errored = self.controller.errored - if self.controller.collect_state == 0: - self.footer_button_play.setEnabled(False) - self.footer_button_validate.setEnabled(False) - else: - self.footer_button_play.setEnabled(not errored) - self.footer_button_validate.setEnabled( - not errored and not self.controller.validated - ) - self.footer_button_play.setFocus() - - self.footer_button_reset.setEnabled(True) - self.footer_button_stop.setEnabled(False) - if errored: - self.footer_widget.setProperty("success", 0) - self.footer_widget.style().polish(self.footer_widget) - - suspend_log_bool = ( - self.controller.collect_state == 1 - and not self.controller.stopped - ) - self.button_suspend_logs.setEnabled(suspend_log_bool) - - self._update_state() - - if self._hidden_for_plugin_process: - self._hidden_for_plugin_process = False - self._ensure_visible(True) - - def on_was_skipped(self, plugin): - plugin_item = self.plugin_model.plugin_items[plugin.id] - plugin_item.setData( - {PluginStates.WasSkipped: True}, - Roles.PublishFlagsRole - ) - - def on_was_finished(self): - self.overview_plugin_view.setAnimated(settings.Animated) - self.footer_button_play.setEnabled(False) - self.footer_button_validate.setEnabled(False) - self.footer_button_reset.setEnabled(True) - self.footer_button_stop.setEnabled(False) - - if self.controller.errored: - success_val = 0 - self.info(self.tr("Stopped due to error(s), see Terminal.")) - self.comment_box.setEnabled(False) - self.intent_box.setEnabled(False) - - else: - success_val = 1 - self.info(self.tr("Finished successfully!")) - - self.footer_widget.setProperty("success", success_val) - self.footer_widget.style().polish(self.footer_widget) - - for instance_item in ( - self.instance_model.instance_items.values() - ): - instance_item.setData( - {InstanceStates.HasFinished: True}, - Roles.PublishFlagsRole - ) - - for group_item in self.instance_model.group_items.values(): - group_item.setData( - {GroupStates.HasFinished: True}, - Roles.PublishFlagsRole - ) - - self.update_compatibility() - self._update_state() - - def on_was_processed(self, result): - existing_ids = set(self.instance_model.instance_items.keys()) - existing_ids.remove(self.controller.context.id) - for instance in self.controller.context: - if instance.id not in existing_ids: - self.instance_model.append(instance) - else: - existing_ids.remove(instance.id) - - for instance_id in existing_ids: - self.instance_model.remove(instance_id) - - result["records"] = self.terminal_model.prepare_records( - result, - self._suspend_logs - ) - - plugin_item = self.plugin_model.update_with_result(result) - instance_item = self.instance_model.update_with_result(result) - - self.terminal_model.update_with_result(result) - - self.update_compatibility() - - if self.perspective_widget.isVisible(): - self.perspective_widget.update_context( - plugin_item, instance_item - ) - - if self._hidden_for_plugin_process: - self._hidden_for_plugin_process = False - self._ensure_visible(True) - - # ------------------------------------------------------------------------- - # - # Functions - # - # ------------------------------------------------------------------------- - - def reset(self): - """Prepare GUI for reset""" - self.info(self.tr("About to reset..")) - - self.presets_button.setEnabled(False) - self.footer_widget.setProperty("success", -1) - self.footer_widget.style().polish(self.footer_widget) - - self.instance_model.store_checkstates() - self.plugin_model.store_checkstates() - - # Reset current ids to secure no previous instances get mixed in. - self.instance_model.reset() - self.plugin_model.reset() - self.intent_model.reset() - self.terminal_model.reset() - - self.footer_button_stop.setEnabled(False) - self.footer_button_reset.setEnabled(False) - self.footer_button_validate.setEnabled(False) - self.footer_button_play.setEnabled(False) - - self.intent_box.setVisible(self.intent_model.has_items) - if self.intent_model.has_items: - self.intent_box.setCurrentIndex(self.intent_model.default_index) - - self.comment_box.placeholder.setVisible(False) - # Launch controller reset - self.controller.reset() - if not self.comment_box.text(): - self.comment_box.placeholder.setVisible(True) - - def validate(self): - self.info(self.tr("Preparing validate..")) - self.footer_button_stop.setEnabled(True) - self.footer_button_reset.setEnabled(False) - self.footer_button_validate.setEnabled(False) - self.footer_button_play.setEnabled(False) - - self.button_suspend_logs.setEnabled(False) - - self.controller.validate() - - self._update_state() - - def publish(self): - self.info(self.tr("Preparing publish..")) - self.footer_button_stop.setEnabled(True) - self.footer_button_reset.setEnabled(False) - self.footer_button_validate.setEnabled(False) - self.footer_button_play.setEnabled(False) - - self.button_suspend_logs.setEnabled(False) - - self.controller.publish() - - self._update_state() - - def act(self, plugin_item, action): - self.info("%s %s.." % (self.tr("Preparing"), action)) - - self.footer_button_stop.setEnabled(True) - self.footer_button_reset.setEnabled(False) - self.footer_button_validate.setEnabled(False) - self.footer_button_play.setEnabled(False) - - # Cause view to update, but it won't visually - # happen until Qt is given time to idle.. - plugin_item.setData( - PluginActionStates.InProgress, Roles.PluginActionProgressRole - ) - - # Give Qt time to draw - self.controller.act(plugin_item.plugin, action) - - self.info(self.tr("Action prepared.")) - - def on_was_acted(self, result): - self.footer_button_reset.setEnabled(True) - self.footer_button_stop.setEnabled(False) - - # Update action with result - plugin_item = self.plugin_model.plugin_items[result["plugin"].id] - action_state = plugin_item.data(Roles.PluginActionProgressRole) - action_state |= PluginActionStates.HasFinished - result["records"] = self.terminal_model.prepare_records( - result, - self._suspend_logs - ) - - if result.get("error"): - action_state |= PluginActionStates.HasFailed - - plugin_item.setData(action_state, Roles.PluginActionProgressRole) - - self.terminal_model.update_with_result(result) - plugin_item = self.plugin_model.update_with_result(result) - instance_item = self.instance_model.update_with_result(result) - - if self.perspective_widget.isVisible(): - self.perspective_widget.update_context( - plugin_item, instance_item - ) - - def closeEvent(self, event): - """Perform post-flight checks before closing - - Make sure processing of any kind is wrapped up before closing - - """ - - self.info(self.tr("Closing..")) - - if self.controller.is_running: - self.info(self.tr("..as soon as processing is finished..")) - self.controller.stop() - - self.info(self.tr("Cleaning up controller..")) - self.controller.cleanup() - - self.overview_instance_view.setModel(None) - self.overview_plugin_view.setModel(None) - self.terminal_view.setModel(None) - - event.accept() - - def reject(self): - """Handle ESC key""" - - if self.controller.is_running: - self.info(self.tr("Stopping..")) - self.controller.stop() - - # ------------------------------------------------------------------------- - # - # Feedback - # - # ------------------------------------------------------------------------- - - def _update_state(self): - self.footer_info.setText(self.controller.current_state) - - def info(self, message): - """Print user-facing information - - Arguments: - message (str): Text message for the user - - """ - # Include message in terminal - self.terminal_model.append([{ - "label": message, - "type": "info" - }]) - - if settings.PrintInfo: - # Print message to console - util.u_print(message) - - def warning(self, message): - """Block processing and print warning until user hits "Continue" - - Arguments: - message (str): Message to display - - """ - - # TODO(marcus): Implement this. - self.info(message) - - def heads_up(self, title, message, command=None): - """Provide a front-and-center message with optional command - - Arguments: - title (str): Bold and short message - message (str): Extended message - command (optional, callable): Function is provided as a button - - """ - - # TODO(marcus): Implement this. - self.info(message) diff --git a/client/ayon_core/tools/sceneinventory/control.py b/client/ayon_core/tools/sceneinventory/control.py index b890462506..60d9bc77a9 100644 --- a/client/ayon_core/tools/sceneinventory/control.py +++ b/client/ayon_core/tools/sceneinventory/control.py @@ -86,8 +86,9 @@ class SceneInventoryController: self._current_folder_set = True return self._current_folder_id - def get_project_status_items(self): - project_name = self.get_current_project_name() + def get_project_status_items(self, project_name=None): + if project_name is None: + project_name = self.get_current_project_name() return self._projects_model.get_project_status_items( project_name, None ) @@ -105,32 +106,39 @@ class SceneInventoryController: def get_container_items_by_id(self, item_ids): return self._containers_model.get_container_items_by_id(item_ids) - def get_representation_info_items(self, representation_ids): + def get_representation_info_items(self, project_name, representation_ids): return self._containers_model.get_representation_info_items( - representation_ids + project_name, representation_ids ) - def get_version_items(self, product_ids): - return self._containers_model.get_version_items(product_ids) + def get_version_items(self, project_name, product_ids): + return self._containers_model.get_version_items( + project_name, product_ids) # Site Sync methods def is_sitesync_enabled(self): return self._sitesync_model.is_sitesync_enabled() - def get_sites_information(self): - return self._sitesync_model.get_sites_information() + def get_sites_information(self, project_name): + return self._sitesync_model.get_sites_information(project_name) def get_site_provider_icons(self): return self._sitesync_model.get_site_provider_icons() - def get_representations_site_progress(self, representation_ids): + def get_representations_site_progress( + self, project_name, representation_ids + ): return self._sitesync_model.get_representations_site_progress( - representation_ids + project_name, representation_ids ) - def resync_representations(self, representation_ids, site_type): + def resync_representations( + self, project_name, representation_ids, site_type + ): return self._sitesync_model.resync_representations( - representation_ids, site_type + project_name, + representation_ids, + site_type ) # Switch dialog methods diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index b7f79986ac..885553acaf 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -36,6 +36,7 @@ REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23 # This value hold unique value of container that should be used to identify # containers inbetween refresh. ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24 +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 25 class InventoryModel(QtGui.QStandardItemModel): @@ -52,6 +53,7 @@ class InventoryModel(QtGui.QStandardItemModel): "Object name", "Active site", "Remote site", + "Project", ] name_col = column_labels.index("Name") version_col = column_labels.index("Version") @@ -63,6 +65,7 @@ class InventoryModel(QtGui.QStandardItemModel): object_name_col = column_labels.index("Object name") active_site_col = column_labels.index("Active site") remote_site_col = column_labels.index("Remote site") + project_col = column_labels.index("Project") display_role_by_column = { name_col: QtCore.Qt.DisplayRole, version_col: VERSION_LABEL_ROLE, @@ -72,6 +75,7 @@ class InventoryModel(QtGui.QStandardItemModel): product_group_col: PRODUCT_GROUP_NAME_ROLE, loader_col: LOADER_NAME_ROLE, object_name_col: OBJECT_NAME_ROLE, + project_col: PROJECT_NAME_ROLE, active_site_col: ACTIVE_SITE_PROGRESS_ROLE, remote_site_col: REMOTE_SITE_PROGRESS_ROLE, } @@ -85,7 +89,7 @@ class InventoryModel(QtGui.QStandardItemModel): foreground_role_by_column = { name_col: NAME_COLOR_ROLE, version_col: VERSION_COLOR_ROLE, - status_col: STATUS_COLOR_ROLE + status_col: STATUS_COLOR_ROLE, } width_by_column = { name_col: 250, @@ -95,6 +99,7 @@ class InventoryModel(QtGui.QStandardItemModel): product_type_col: 150, product_group_col: 120, loader_col: 150, + project_col: 150, } OUTDATED_COLOR = QtGui.QColor(235, 30, 30) @@ -116,8 +121,8 @@ class InventoryModel(QtGui.QStandardItemModel): self._default_icon_color = get_default_entity_icon_color() - self._last_project_statuses = {} - self._last_status_icons_by_name = {} + self._last_project_statuses = collections.defaultdict(dict) + self._last_status_icons_by_name = collections.defaultdict(dict) def outdated(self, item): return item.get("isOutdated", True) @@ -129,45 +134,73 @@ class InventoryModel(QtGui.QStandardItemModel): self._clear_items() - items_by_repre_id = {} + project_names = set() + repre_ids_by_project = collections.defaultdict(set) + version_items_by_project = collections.defaultdict(dict) + repre_info_by_id_by_project = collections.defaultdict(dict) + item_by_repre_id_by_project = collections.defaultdict( + lambda: collections.defaultdict(list)) for container_item in container_items: # if ( # selected is not None # and container_item.item_id not in selected # ): # continue - repre_id = container_item.representation_id - items = items_by_repre_id.setdefault(repre_id, []) - items.append(container_item) + project_name = container_item.project_name + representation_id = container_item.representation_id + project_names.add(project_name) + repre_ids_by_project[project_name].add(representation_id) + ( + item_by_repre_id_by_project + [project_name] + [representation_id] + ).append(container_item) + + for project_name, representation_ids in repre_ids_by_project.items(): + repre_info = self._controller.get_representation_info_items( + project_name, representation_ids + ) + repre_info_by_id_by_project[project_name] = repre_info + + product_ids = { + repre_info.product_id + for repre_info in repre_info.values() + if repre_info.is_valid + } + version_items = self._controller.get_version_items( + project_name, product_ids + ) + version_items_by_project[project_name] = version_items - repre_id = set(items_by_repre_id.keys()) - repre_info_by_id = self._controller.get_representation_info_items( - repre_id - ) - product_ids = { - repre_info.product_id - for repre_info in repre_info_by_id.values() - if repre_info.is_valid - } - version_items_by_product_id = self._controller.get_version_items( - product_ids - ) # SiteSync addon information - progress_by_id = self._controller.get_representations_site_progress( - repre_id - ) - sites_info = self._controller.get_sites_information() + progress_by_project = { + project_name: self._controller.get_representations_site_progress( + project_name, repre_ids + ) + for project_name, repre_ids in repre_ids_by_project.items() + } + + sites_info_by_project_name = { + project_name: self._controller.get_sites_information(project_name) + for project_name in project_names + } site_icons = { provider: get_qt_icon(icon_def) for provider, icon_def in ( self._controller.get_site_provider_icons().items() ) } - self._last_project_statuses = { - status_item.name: status_item - for status_item in self._controller.get_project_status_items() - } - self._last_status_icons_by_name = {} + last_project_statuses = collections.defaultdict(dict) + for project_name in project_names: + status_items_by_name = { + status_item.name: status_item + for status_item in self._controller.get_project_status_items( + project_name + ) + } + last_project_statuses[project_name] = status_items_by_name + self._last_project_statuses = last_project_statuses + self._last_status_icons_by_name = collections.defaultdict(dict) group_item_icon = qtawesome.icon( "fa.folder", color=self._default_icon_color @@ -187,117 +220,130 @@ class InventoryModel(QtGui.QStandardItemModel): group_item_font = QtGui.QFont() group_item_font.setBold(True) - active_site_icon = site_icons.get(sites_info["active_site_provider"]) - remote_site_icon = site_icons.get(sites_info["remote_site_provider"]) - root_item = self.invisibleRootItem() group_items = [] - for repre_id, container_items in items_by_repre_id.items(): - repre_info = repre_info_by_id[repre_id] - version_label = "N/A" - version_color = None - is_latest = False - is_hero = False - status_name = None - if not repre_info.is_valid: - group_name = "< Entity N/A >" - item_icon = invalid_item_icon + for project_name, items_by_repre_id in ( + item_by_repre_id_by_project.items() + ): + sites_info = sites_info_by_project_name[project_name] + active_site_icon = site_icons.get( + sites_info["active_site_provider"] + ) + remote_site_icon = site_icons.get( + sites_info["remote_site_provider"] + ) - else: - group_name = "{}_{}: ({})".format( - repre_info.folder_path.rsplit("/")[-1], - repre_info.product_name, - repre_info.representation_name + progress_by_id = progress_by_project[project_name] + repre_info_by_id = repre_info_by_id_by_project[project_name] + version_items_by_product_id = ( + version_items_by_project[project_name] + ) + for repre_id, container_items in items_by_repre_id.items(): + repre_info = repre_info_by_id[repre_id] + version_color = None + if not repre_info.is_valid: + version_label = "N/A" + group_name = "< Entity N/A >" + item_icon = invalid_item_icon + is_latest = False + is_hero = False + status_name = None + + else: + group_name = "{}_{}: ({})".format( + repre_info.folder_path.rsplit("/")[-1], + repre_info.product_name, + repre_info.representation_name + ) + item_icon = valid_item_icon + + version_items = ( + version_items_by_product_id[repre_info.product_id] + ) + version_item = version_items[repre_info.version_id] + version_label = format_version(version_item.version) + is_hero = version_item.version < 0 + is_latest = version_item.is_latest + if not version_item.is_latest: + version_color = self.OUTDATED_COLOR + status_name = version_item.status + + ( + status_color, status_short, status_icon + ) = self._get_status_data(project_name, status_name) + + repre_name = ( + repre_info.representation_name or + "" ) - item_icon = valid_item_icon + container_model_items = [] + for container_item in container_items: + object_name = container_item.object_name or "" + unique_name = repre_name + object_name + item = QtGui.QStandardItem() + item.setColumnCount(root_item.columnCount()) + item.setData(container_item.namespace, + QtCore.Qt.DisplayRole) + item.setData(self.GRAYOUT_COLOR, NAME_COLOR_ROLE) + item.setData(self.GRAYOUT_COLOR, VERSION_COLOR_ROLE) + item.setData(item_icon, QtCore.Qt.DecorationRole) + item.setData(repre_info.product_id, PRODUCT_ID_ROLE) + item.setData(container_item.item_id, ITEM_ID_ROLE) + item.setData(version_label, VERSION_LABEL_ROLE) + item.setData(container_item.loader_name, LOADER_NAME_ROLE) + item.setData(container_item.object_name, OBJECT_NAME_ROLE) + item.setData(True, IS_CONTAINER_ITEM_ROLE) + item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE) + container_model_items.append(item) - version_items = ( - version_items_by_product_id[repre_info.product_id] + progress = progress_by_id[repre_id] + active_site_progress = "{}%".format( + max(progress["active_site"], 0) * 100 + ) + remote_site_progress = "{}%".format( + max(progress["remote_site"], 0) * 100 ) - version_item = version_items[repre_info.version_id] - version_label = format_version(version_item.version) - is_hero = version_item.version < 0 - if not version_item.is_latest: - version_color = self.OUTDATED_COLOR - status_name = version_item.status - status_color, status_short, status_icon = self._get_status_data( - status_name - ) + group_item = QtGui.QStandardItem() + group_item.setColumnCount(root_item.columnCount()) + group_item.setData(group_name, QtCore.Qt.DisplayRole) + group_item.setData(group_name, ITEM_UNIQUE_NAME_ROLE) + group_item.setData(group_item_icon, QtCore.Qt.DecorationRole) + group_item.setData(group_item_font, QtCore.Qt.FontRole) + group_item.setData(repre_info.product_id, PRODUCT_ID_ROLE) + group_item.setData(repre_info.product_type, PRODUCT_TYPE_ROLE) + group_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) + group_item.setData(is_latest, VERSION_IS_LATEST_ROLE) + group_item.setData(is_hero, VERSION_IS_HERO_ROLE) + group_item.setData(version_label, VERSION_LABEL_ROLE) + group_item.setData(len(container_items), COUNT_ROLE) + group_item.setData(status_name, STATUS_NAME_ROLE) + group_item.setData(status_short, STATUS_SHORT_ROLE) + group_item.setData(status_color, STATUS_COLOR_ROLE) + group_item.setData(status_icon, STATUS_ICON_ROLE) + group_item.setData(project_name, PROJECT_NAME_ROLE) - repre_name = ( - repre_info.representation_name or "" - ) - container_model_items = [] - for container_item in container_items: - object_name = container_item.object_name or "" - unique_name = repre_name + object_name - - item = QtGui.QStandardItem() - item.setColumnCount(root_item.columnCount()) - item.setData(container_item.namespace, QtCore.Qt.DisplayRole) - item.setData(self.GRAYOUT_COLOR, NAME_COLOR_ROLE) - item.setData(self.GRAYOUT_COLOR, VERSION_COLOR_ROLE) - item.setData(item_icon, QtCore.Qt.DecorationRole) - item.setData(repre_info.product_id, PRODUCT_ID_ROLE) - item.setData(container_item.item_id, ITEM_ID_ROLE) - item.setData(version_label, VERSION_LABEL_ROLE) - item.setData(container_item.loader_name, LOADER_NAME_ROLE) - item.setData(container_item.object_name, OBJECT_NAME_ROLE) - item.setData(True, IS_CONTAINER_ITEM_ROLE) - item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE) - container_model_items.append(item) - - if not container_model_items: - continue - - progress = progress_by_id[repre_id] - active_site_progress = "{}%".format( - max(progress["active_site"], 0) * 100 - ) - remote_site_progress = "{}%".format( - max(progress["remote_site"], 0) * 100 - ) - - group_item = QtGui.QStandardItem() - group_item.setColumnCount(root_item.columnCount()) - group_item.setData(group_name, QtCore.Qt.DisplayRole) - group_item.setData(group_name, ITEM_UNIQUE_NAME_ROLE) - group_item.setData(group_item_icon, QtCore.Qt.DecorationRole) - group_item.setData(group_item_font, QtCore.Qt.FontRole) - group_item.setData(repre_info.product_id, PRODUCT_ID_ROLE) - group_item.setData(repre_info.product_type, PRODUCT_TYPE_ROLE) - group_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE) - group_item.setData(is_latest, VERSION_IS_LATEST_ROLE) - group_item.setData(is_hero, VERSION_IS_HERO_ROLE) - group_item.setData(version_label, VERSION_LABEL_ROLE) - group_item.setData(len(container_items), COUNT_ROLE) - group_item.setData(status_name, STATUS_NAME_ROLE) - group_item.setData(status_short, STATUS_SHORT_ROLE) - group_item.setData(status_color, STATUS_COLOR_ROLE) - group_item.setData(status_icon, STATUS_ICON_ROLE) - - group_item.setData( - active_site_progress, ACTIVE_SITE_PROGRESS_ROLE - ) - group_item.setData( - remote_site_progress, REMOTE_SITE_PROGRESS_ROLE - ) - group_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE) - group_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE) - group_item.setData(False, IS_CONTAINER_ITEM_ROLE) - - if version_color is not None: - group_item.setData(version_color, VERSION_COLOR_ROLE) - - if repre_info.product_group: group_item.setData( - repre_info.product_group, PRODUCT_GROUP_NAME_ROLE + active_site_progress, ACTIVE_SITE_PROGRESS_ROLE ) - group_item.setData(group_icon, PRODUCT_GROUP_ICON_ROLE) + group_item.setData( + remote_site_progress, REMOTE_SITE_PROGRESS_ROLE + ) + group_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE) + group_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE) + group_item.setData(False, IS_CONTAINER_ITEM_ROLE) - group_item.appendRows(container_model_items) - group_items.append(group_item) + if version_color is not None: + group_item.setData(version_color, VERSION_COLOR_ROLE) + + if repre_info.product_group: + group_item.setData( + repre_info.product_group, PRODUCT_GROUP_NAME_ROLE + ) + group_item.setData(group_icon, PRODUCT_GROUP_ICON_ROLE) + + group_item.appendRows(container_model_items) + group_items.append(group_item) if group_items: root_item.appendRows(group_items) @@ -358,17 +404,21 @@ class InventoryModel(QtGui.QStandardItemModel): root_item = self.invisibleRootItem() root_item.removeRows(0, root_item.rowCount()) - def _get_status_data(self, status_name): - status_item = self._last_project_statuses.get(status_name) - status_icon = self._get_status_icon(status_name, status_item) + def _get_status_data(self, project_name, status_name): + status_item = self._last_project_statuses[project_name].get( + status_name + ) + status_icon = self._get_status_icon( + project_name, status_name, status_item + ) status_color = status_short = None if status_item is not None: status_color = status_item.color status_short = status_item.short return status_color, status_short, status_icon - def _get_status_icon(self, status_name, status_item): - icon = self._last_status_icons_by_name.get(status_name) + def _get_status_icon(self, project_name, status_name, status_item): + icon = self._last_status_icons_by_name[project_name].get(status_name) if icon is not None: return icon @@ -381,7 +431,7 @@ class InventoryModel(QtGui.QStandardItemModel): }) if icon is None: icon = QtGui.QIcon() - self._last_status_icons_by_name[status_name] = icon + self._last_status_icons_by_name[project_name][status_name] = icon return icon @@ -425,7 +475,7 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel): state = bool(state) if state != self._filter_outdated: - self._filter_outdated = bool(state) + self._filter_outdated = state self.invalidateFilter() def set_hierarchy_view(self, state): diff --git a/client/ayon_core/tools/sceneinventory/models/containers.py b/client/ayon_core/tools/sceneinventory/models/containers.py index 871455c96b..f841f87c8e 100644 --- a/client/ayon_core/tools/sceneinventory/models/containers.py +++ b/client/ayon_core/tools/sceneinventory/models/containers.py @@ -4,6 +4,7 @@ import collections import ayon_api from ayon_api.graphql import GraphQlQuery +from ayon_core.lib import Logger from ayon_core.host import ILoadHost from ayon_core.tools.common_models.projects import StatusStates @@ -93,22 +94,27 @@ class ContainerItem: loader_name, namespace, object_name, - item_id + item_id, + project_name ): self.representation_id = representation_id self.loader_name = loader_name self.object_name = object_name self.namespace = namespace self.item_id = item_id + self.project_name = project_name @classmethod - def from_container_data(cls, container): + def from_container_data(cls, current_project_name, container): return cls( representation_id=container["representation"], loader_name=container["loader"], namespace=container["namespace"], object_name=container["objectName"], item_id=uuid.uuid4().hex, + project_name=container.get( + "project_name", current_project_name + ) ) @@ -191,6 +197,7 @@ class ContainersModel: self._container_items_by_id = {} self._version_items_by_product_id = {} self._repre_info_by_id = {} + self._log = Logger.get_logger("ContainersModel") def reset(self): self._items_cache = None @@ -219,26 +226,23 @@ class ContainersModel: for item_id in item_ids } - def get_representation_info_items(self, representation_ids): + def get_representation_info_items(self, project_name, representation_ids): output = {} missing_repre_ids = set() for repre_id in representation_ids: try: uuid.UUID(repre_id) - except ValueError: + except (ValueError, TypeError, AttributeError): output[repre_id] = RepresentationInfo.new_invalid() continue - repre_info = self._repre_info_by_id.get(repre_id) if repre_info is None: missing_repre_ids.add(repre_id) else: output[repre_id] = repre_info - if not missing_repre_ids: return output - project_name = self._controller.get_current_project_name() repre_hierarchy_by_id = get_representations_hierarchy( project_name, missing_repre_ids ) @@ -276,10 +280,9 @@ class ContainersModel: output[repre_id] = repre_info return output - def get_version_items(self, product_ids): + def get_version_items(self, project_name, product_ids): if not product_ids: return {} - missing_ids = { product_id for product_id in product_ids @@ -294,7 +297,6 @@ class ContainersModel: def version_sorted(entity): return entity["version"] - project_name = self._controller.get_current_project_name() version_entities_by_product_id = { product_id: [] for product_id in missing_ids @@ -348,34 +350,45 @@ class ContainersModel: return host = self._controller.get_host() - if isinstance(host, ILoadHost): - containers = list(host.get_containers()) - elif hasattr(host, "ls"): - containers = list(host.ls()) - else: - containers = [] + containers = [] + try: + if isinstance(host, ILoadHost): + containers = list(host.get_containers()) + elif hasattr(host, "ls"): + containers = list(host.ls()) + except Exception: + self._log.error("Failed to get containers", exc_info=True) container_items = [] containers_by_id = {} container_items_by_id = {} invalid_ids_mapping = {} + current_project_name = self._controller.get_current_project_name() for container in containers: + if not container: + continue + try: - item = ContainerItem.from_container_data(container) + item = ContainerItem.from_container_data( + current_project_name, container) repre_id = item.representation_id try: uuid.UUID(repre_id) except (ValueError, TypeError, AttributeError): - # Fake not existing representation id so container is shown in UI - # but as invalid + self._log.warning( + "Container contains invalid representation id." + f"\n{container}" + ) + # Fake not existing representation id so container + # is shown in UI but as invalid item.representation_id = invalid_ids_mapping.setdefault( repre_id, uuid.uuid4().hex ) - except Exception as e: + except Exception: # skip item if required data are missing - self._controller.log_error( - f"Failed to create item: {e}" + self._log.warning( + "Failed to create container item", exc_info=True ) continue @@ -383,7 +396,6 @@ class ContainersModel: container_items_by_id[item.item_id] = item container_items.append(item) - self._containers_by_id = containers_by_id self._container_items_by_id = container_items_by_id self._items_cache = container_items diff --git a/client/ayon_core/tools/sceneinventory/models/sitesync.py b/client/ayon_core/tools/sceneinventory/models/sitesync.py index 1a1f08bf02..546d2b15c0 100644 --- a/client/ayon_core/tools/sceneinventory/models/sitesync.py +++ b/client/ayon_core/tools/sceneinventory/models/sitesync.py @@ -11,18 +11,18 @@ class SiteSyncModel: self._sitesync_addon = NOT_SET self._sitesync_enabled = None - self._active_site = NOT_SET - self._remote_site = NOT_SET - self._active_site_provider = NOT_SET - self._remote_site_provider = NOT_SET + self._active_site = {} + self._remote_site = {} + self._active_site_provider = {} + self._remote_site_provider = {} def reset(self): self._sitesync_addon = NOT_SET self._sitesync_enabled = None - self._active_site = NOT_SET - self._remote_site = NOT_SET - self._active_site_provider = NOT_SET - self._remote_site_provider = NOT_SET + self._active_site = {} + self._remote_site = {} + self._active_site_provider = {} + self._remote_site_provider = {} def is_sitesync_enabled(self): """Site sync is enabled. @@ -46,15 +46,21 @@ class SiteSyncModel: sitesync_addon = self._get_sitesync_addon() return sitesync_addon.get_site_icons() - def get_sites_information(self): + def get_sites_information(self, project_name): return { - "active_site": self._get_active_site(), - "active_site_provider": self._get_active_site_provider(), - "remote_site": self._get_remote_site(), - "remote_site_provider": self._get_remote_site_provider() + "active_site": self._get_active_site(project_name), + "remote_site": self._get_remote_site(project_name), + "active_site_provider": self._get_active_site_provider( + project_name + ), + "remote_site_provider": self._get_remote_site_provider( + project_name + ) } - def get_representations_site_progress(self, representation_ids): + def get_representations_site_progress( + self, project_name, representation_ids + ): """Get progress of representations sync.""" representation_ids = set(representation_ids) @@ -68,13 +74,12 @@ class SiteSyncModel: if not self.is_sitesync_enabled(): return output - project_name = self._controller.get_current_project_name() sitesync_addon = self._get_sitesync_addon() repre_entities = ayon_api.get_representations( project_name, representation_ids ) - active_site = self._get_active_site() - remote_site = self._get_remote_site() + active_site = self._get_active_site(project_name) + remote_site = self._get_remote_site(project_name) for repre_entity in repre_entities: repre_output = output[repre_entity["id"]] @@ -86,20 +91,21 @@ class SiteSyncModel: return output - def resync_representations(self, representation_ids, site_type): + def resync_representations( + self, project_name, representation_ids, site_type + ): """ Args: + project_name (str): Project name. representation_ids (Iterable[str]): Representation ids. site_type (Literal[active_site, remote_site]): Site type. """ - - project_name = self._controller.get_current_project_name() sitesync_addon = self._get_sitesync_addon() - active_site = self._get_active_site() - remote_site = self._get_remote_site() + active_site = self._get_active_site(project_name) + remote_site = self._get_remote_site(project_name) progress = self.get_representations_site_progress( - representation_ids + project_name, representation_ids ) for repre_id in representation_ids: repre_progress = progress.get(repre_id) @@ -132,48 +138,49 @@ class SiteSyncModel: self._sitesync_addon = sitesync_addon self._sitesync_enabled = sync_enabled - def _get_active_site(self): - if self._active_site is NOT_SET: - self._cache_sites() - return self._active_site + def _get_active_site(self, project_name): + if project_name not in self._active_site: + self._cache_sites(project_name) + return self._active_site[project_name] - def _get_remote_site(self): - if self._remote_site is NOT_SET: - self._cache_sites() - return self._remote_site + def _get_remote_site(self, project_name): + if project_name not in self._remote_site: + self._cache_sites(project_name) + return self._remote_site[project_name] - def _get_active_site_provider(self): - if self._active_site_provider is NOT_SET: - self._cache_sites() - return self._active_site_provider + def _get_active_site_provider(self, project_name): + if project_name not in self._active_site_provider: + self._cache_sites(project_name) + return self._active_site_provider[project_name] - def _get_remote_site_provider(self): - if self._remote_site_provider is NOT_SET: - self._cache_sites() - return self._remote_site_provider + def _get_remote_site_provider(self, project_name): + if project_name not in self._remote_site_provider: + self._cache_sites(project_name) + return self._remote_site_provider[project_name] - def _cache_sites(self): - active_site = None - remote_site = None - active_site_provider = None - remote_site_provider = None - if self.is_sitesync_enabled(): - sitesync_addon = self._get_sitesync_addon() - project_name = self._controller.get_current_project_name() - active_site = sitesync_addon.get_active_site(project_name) - remote_site = sitesync_addon.get_remote_site(project_name) - active_site_provider = "studio" - remote_site_provider = "studio" - if active_site != "studio": - active_site_provider = sitesync_addon.get_provider_for_site( - project_name, active_site - ) - if remote_site != "studio": - remote_site_provider = sitesync_addon.get_provider_for_site( - project_name, remote_site - ) + def _cache_sites(self, project_name): + self._active_site[project_name] = None + self._remote_site[project_name] = None + self._active_site_provider[project_name] = None + self._remote_site_provider[project_name] = None + if not self.is_sitesync_enabled(): + return - self._active_site = active_site - self._remote_site = remote_site - self._active_site_provider = active_site_provider - self._remote_site_provider = remote_site_provider + sitesync_addon = self._get_sitesync_addon() + active_site = sitesync_addon.get_active_site(project_name) + remote_site = sitesync_addon.get_remote_site(project_name) + active_site_provider = "studio" + remote_site_provider = "studio" + if active_site != "studio": + active_site_provider = sitesync_addon.get_provider_for_site( + project_name, active_site + ) + if remote_site != "studio": + remote_site_provider = sitesync_addon.get_provider_for_site( + project_name, remote_site + ) + + self._active_site[project_name] = active_site + self._remote_site[project_name] = remote_site + self._active_site_provider[project_name] = active_site_provider + self._remote_site_provider[project_name] = remote_site_provider diff --git a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py index 4977ad13c6..a6d88ed44a 100644 --- a/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py +++ b/client/ayon_core/tools/sceneinventory/switch_dialog/dialog.py @@ -46,8 +46,13 @@ class SwitchAssetDialog(QtWidgets.QDialog): switched = QtCore.Signal() - def __init__(self, controller, parent=None, items=None): - super(SwitchAssetDialog, self).__init__(parent) + def __init__(self, controller, project_name, items, parent=None): + super().__init__(parent) + + current_project_name = controller.get_current_project_name() + folder_id = None + if current_project_name == project_name: + folder_id = controller.get_current_folder_id() self.setWindowTitle("Switch selected items ...") @@ -147,11 +152,10 @@ class SwitchAssetDialog(QtWidgets.QDialog): self._init_repre_name = None self._fill_check = False + self._project_name = project_name + self._folder_id = folder_id - self._project_name = controller.get_current_project_name() - self._folder_id = controller.get_current_folder_id() - - self._current_folder_btn.setEnabled(self._folder_id is not None) + self._current_folder_btn.setEnabled(folder_id is not None) self._controller = controller @@ -159,7 +163,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): self._prepare_content_data() def showEvent(self, event): - super(SwitchAssetDialog, self).showEvent(event) + super().showEvent(event) self._show_timer.start() def refresh(self, init_refresh=False): diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index 22ba15fda8..bb95e37d4e 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -192,29 +192,46 @@ class SceneInventoryView(QtWidgets.QTreeView): container_item = container_items_by_id[item_id] active_repre_id = container_item.representation_id break + repre_ids_by_project = collections.defaultdict(set) + for container_item in container_items_by_id.values(): + repre_id = container_item.representation_id + project_name = container_item.project_name + repre_ids_by_project[project_name].add(repre_id) - repre_info_by_id = self._controller.get_representation_info_items({ - container_item.representation_id - for container_item in container_items_by_id.values() - }) - valid_repre_ids = { - repre_id - for repre_id, repre_info in repre_info_by_id.items() - if repre_info.is_valid - } + repre_info_by_project = {} + repre_ids_by_project_name = {} + version_ids_by_project = {} + product_ids_by_project = {} + for project_name, repre_ids in repre_ids_by_project.items(): + repres_info = self._controller.get_representation_info_items( + project_name, repre_ids + ) + + repre_info_by_project[project_name] = repres_info + repre_ids = set() + version_ids = set() + product_ids = set() + for repre_id, repre_info in repres_info.items(): + if not repre_info.is_valid: + continue + repre_ids.add(repre_id) + version_ids.add(repre_info.version_id) + product_ids.add(repre_info.product_id) + + repre_ids_by_project_name[project_name] = repre_ids + version_ids_by_project[project_name] = version_ids + product_ids_by_project[project_name] = product_ids # Exclude items that are "NOT FOUND" since setting versions, updating # and removal won't work for those items. filtered_items = [] - product_ids = set() - version_ids = set() for container_item in container_items_by_id.values(): + project_name = container_item.project_name repre_id = container_item.representation_id + repre_info_by_id = repre_info_by_project.get(project_name, {}) repre_info = repre_info_by_id.get(repre_id) if repre_info and repre_info.is_valid: filtered_items.append(container_item) - version_ids.add(repre_info.version_id) - product_ids.add(repre_info.product_id) # remove remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR) @@ -227,43 +244,51 @@ class SceneInventoryView(QtWidgets.QTreeView): menu.addAction(remove_action) return - version_items_by_product_id = self._controller.get_version_items( - product_ids - ) + version_items_by_project = { + project_name: self._controller.get_version_items( + project_name, product_ids + ) + for project_name, product_ids in product_ids_by_project.items() + } + has_outdated = False has_loaded_hero_versions = False has_available_hero_version = False has_outdated_approved = False last_version_by_product_id = {} - for product_id, version_items_by_id in ( - version_items_by_product_id.items() + for project_name, version_items_by_product_id in ( + version_items_by_project.items() ): - _has_outdated_approved = False - _last_approved_version_item = None - for version_item in version_items_by_id.values(): - if version_item.is_hero: - has_available_hero_version = True - - elif version_item.is_last_approved: - _last_approved_version_item = version_item - _has_outdated_approved = True - - if version_item.version_id not in version_ids: - continue - - if version_item.is_hero: - has_loaded_hero_versions = True - elif not version_item.is_latest: - has_outdated = True - - if ( - _has_outdated_approved - and _last_approved_version_item is not None + version_ids = version_ids_by_project[project_name] + for product_id, version_items_by_id in ( + version_items_by_product_id.items() ): - last_version_by_product_id[product_id] = ( - _last_approved_version_item - ) - has_outdated_approved = True + _has_outdated_approved = False + _last_approved_version_item = None + for version_item in version_items_by_id.values(): + if version_item.is_hero: + has_available_hero_version = True + + elif version_item.is_last_approved: + _last_approved_version_item = version_item + _has_outdated_approved = True + + if version_item.version_id not in version_ids: + continue + + if version_item.is_hero: + has_loaded_hero_versions = True + elif not version_item.is_latest: + has_outdated = True + + if ( + _has_outdated_approved + and _last_approved_version_item is not None + ): + last_version_by_product_id[product_id] = ( + _last_approved_version_item + ) + has_outdated_approved = True switch_to_versioned = None if has_loaded_hero_versions: @@ -284,8 +309,9 @@ class SceneInventoryView(QtWidgets.QTreeView): approved_version_by_item_id = {} if has_outdated_approved: for container_item in container_items_by_id.values(): + project_name = container_item.project_name repre_id = container_item.representation_id - repre_info = repre_info_by_id.get(repre_id) + repre_info = repre_info_by_project[project_name][repre_id] if not repre_info or not repre_info.is_valid: continue version_item = last_version_by_product_id.get( @@ -397,14 +423,15 @@ class SceneInventoryView(QtWidgets.QTreeView): menu.addAction(remove_action) - self._handle_sitesync(menu, valid_repre_ids) + self._handle_sitesync(menu, repre_ids_by_project_name) - def _handle_sitesync(self, menu, repre_ids): + def _handle_sitesync(self, menu, repre_ids_by_project_name): """Adds actions for download/upload when SyncServer is enabled Args: menu (OptionMenu) - repre_ids (list) of object_ids + repre_ids_by_project_name (Dict[str, Set[str]]): Representation + ids by project name. Returns: (OptionMenu) @@ -413,7 +440,7 @@ class SceneInventoryView(QtWidgets.QTreeView): if not self._controller.is_sitesync_enabled(): return - if not repre_ids: + if not repre_ids_by_project_name: return menu.addSeparator() @@ -425,7 +452,10 @@ class SceneInventoryView(QtWidgets.QTreeView): menu ) download_active_action.triggered.connect( - lambda: self._add_sites(repre_ids, "active_site")) + lambda: self._add_sites( + repre_ids_by_project_name, "active_site" + ) + ) upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR) upload_remote_action = QtWidgets.QAction( @@ -434,23 +464,30 @@ class SceneInventoryView(QtWidgets.QTreeView): menu ) upload_remote_action.triggered.connect( - lambda: self._add_sites(repre_ids, "remote_site")) + lambda: self._add_sites( + repre_ids_by_project_name, "remote_site" + ) + ) menu.addAction(download_active_action) menu.addAction(upload_remote_action) - def _add_sites(self, repre_ids, site_type): + def _add_sites(self, repre_ids_by_project_name, site_type): """(Re)sync all 'repre_ids' to specific site. It checks if opposite site has fully available content to limit accidents. (ReSync active when no remote >> losing active content) Args: - repre_ids (list) + repre_ids_by_project_name (Dict[str, Set[str]]): Representation + ids by project name. site_type (Literal[active_site, remote_site]): Site type. - """ - self._controller.resync_representations(repre_ids, site_type) + """ + for project_name, repre_ids in repre_ids_by_project_name.items(): + self._controller.resync_representations( + project_name, repre_ids, site_type + ) self.data_changed.emit() @@ -735,42 +772,68 @@ class SceneInventoryView(QtWidgets.QTreeView): container_items_by_id = self._controller.get_container_items_by_id( item_ids ) - repre_ids = { - container_item.representation_id - for container_item in container_items_by_id.values() - } - repre_info_by_id = self._controller.get_representation_info_items( - repre_ids - ) + project_names = set() + repre_ids_by_project = collections.defaultdict(set) + for container_item in container_items_by_id.values(): + repre_id = container_item.representation_id + project_name = container_item.project_name + project_names.add(project_name) + repre_ids_by_project[project_name].add(repre_id) + + # active_project_name = None + active_repre_info = None + repre_info_by_project = {} + version_items_by_project = {} + for project_name, repre_ids in repre_ids_by_project.items(): + repres_info = self._controller.get_representation_info_items( + project_name, repre_ids + ) + if active_repre_info is None: + # active_project_name = project_name + active_repre_info = repres_info.get(active_repre_id) + + product_ids = { + repre_info.product_id + for repre_info in repres_info.values() + if repre_info.is_valid + } + version_items_by_product_id = self._controller.get_version_items( + project_name, product_ids + ) + + repre_info_by_project[project_name] = repres_info + version_items_by_project[project_name] = ( + version_items_by_product_id + ) - product_ids = { - repre_info.product_id - for repre_info in repre_info_by_id.values() - } - active_repre_info = repre_info_by_id[active_repre_id] active_version_id = active_repre_info.version_id - active_product_id = active_repre_info.product_id - version_items_by_product_id = self._controller.get_version_items( - product_ids - ) - version_items = list( - version_items_by_product_id[active_product_id].values() - ) - versions = {version_item.version for version_item in version_items} - product_ids_by_version = collections.defaultdict(set) - for version_items_by_id in version_items_by_product_id.values(): - for version_item in version_items_by_id.values(): - version = version_item.version - _prod_version = version - if _prod_version < 0: - _prod_version = -1 - product_ids_by_version[_prod_version].add( - version_item.product_id - ) - if version in versions: - continue - versions.add(version) - version_items.append(version_item) + # active_product_id = active_repre_info.product_id + + versions = set() + product_ids = set() + version_items = [] + product_ids_by_version_by_project = {} + for project_name, version_items_by_product_id in ( + version_items_by_project.items() + ): + product_ids_by_version = collections.defaultdict(set) + product_ids_by_version_by_project[project_name] = ( + product_ids_by_version + ) + for version_items_by_id in version_items_by_product_id.values(): + for version_item in version_items_by_id.values(): + version = version_item.version + _prod_version = version + if _prod_version < 0: + _prod_version = -1 + product_ids_by_version[_prod_version].add( + version_item.product_id + ) + product_ids.add(version_item.product_id) + if version in versions: + continue + versions.add(version) + version_items.append(version_item) def version_sorter(item): hero_value = 0 @@ -831,12 +894,15 @@ class SceneInventoryView(QtWidgets.QTreeView): product_version = -1 version = HeroVersionType(version) - product_ids = product_ids_by_version[product_version] - filtered_item_ids = set() for container_item in container_items_by_id.values(): + project_name = container_item.project_name + product_ids_by_version = ( + product_ids_by_version_by_project[project_name] + ) + product_ids = product_ids_by_version[product_version] repre_id = container_item.representation_id - repre_info = repre_info_by_id[repre_id] + repre_info = repre_info_by_project[project_name][repre_id] if repre_info.product_id in product_ids: filtered_item_ids.add(container_item.item_id) @@ -846,14 +912,28 @@ class SceneInventoryView(QtWidgets.QTreeView): def _show_switch_dialog(self, item_ids): """Display Switch dialog""" - containers_by_id = self._controller.get_containers_by_item_ids( + container_items_by_id = self._controller.get_container_items_by_id( item_ids ) - dialog = SwitchAssetDialog( - self._controller, self, list(containers_by_id.values()) - ) - dialog.switched.connect(self.data_changed.emit) - dialog.show() + container_ids_by_project_name = collections.defaultdict(set) + for container_id, container_item in container_items_by_id.items(): + project_name = container_item.project_name + container_ids_by_project_name[project_name].add(container_id) + + for project_name, container_ids in ( + container_ids_by_project_name.items() + ): + containers_by_id = self._controller.get_containers_by_item_ids( + container_ids + ) + dialog = SwitchAssetDialog( + self._controller, + project_name, + list(containers_by_id.values()), + self + ) + dialog.switched.connect(self.data_changed.emit) + dialog.show() def _show_remove_warning_dialog(self, item_ids): """Prompt a dialog to inform the user the action will remove items""" @@ -927,38 +1007,58 @@ class SceneInventoryView(QtWidgets.QTreeView): self._update_containers_to_version(item_ids, version=-1) def _on_switch_to_versioned(self, item_ids): + # Get container items by ID containers_items_by_id = self._controller.get_container_items_by_id( - item_ids - ) - repre_ids = { - container_item.representation_id - for container_item in containers_items_by_id.values() - } - repre_info_by_id = self._controller.get_representation_info_items( - repre_ids - ) - product_ids = { - repre_info.product_id - for repre_info in repre_info_by_id.values() - if repre_info.is_valid - } - version_items_by_product_id = self._controller.get_version_items( - product_ids - ) + item_ids) + # Extract project names and their corresponding representation IDs + repre_ids_by_project = collections.defaultdict(set) + for container_item in containers_items_by_id.values(): + project_name = container_item.project_name + repre_id = container_item.representation_id + repre_ids_by_project[project_name].add(repre_id) + + # Get representation info items by ID + repres_info_by_project = {} + version_items_by_project = {} + for project_name, repre_ids in repre_ids_by_project.items(): + repre_info_by_id = self._controller.get_representation_info_items( + project_name, repre_ids + ) + repres_info_by_project[project_name] = repre_info_by_id + + product_ids = { + repre_info.product_id + for repre_info in repre_info_by_id.values() + if repre_info.is_valid + } + version_items_by_product_id = self._controller.get_version_items( + project_name, product_ids + ) + version_items_by_project[project_name] = ( + version_items_by_product_id + ) update_containers = [] update_versions = [] - for item_id, container_item in containers_items_by_id.items(): + for container_item in containers_items_by_id.values(): + project_name = container_item.project_name repre_id = container_item.representation_id + + repre_info_by_id = repres_info_by_project[project_name] repre_info = repre_info_by_id[repre_id] + + version_items_by_product_id = ( + version_items_by_project[project_name] + ) product_id = repre_info.product_id - version_items_id = version_items_by_product_id[product_id] - version_item = version_items_id.get(repre_info.version_id, {}) + version_items_by_id = version_items_by_product_id[product_id] + version_item = version_items_by_id.get(repre_info.version_id, {}) if not version_item or not version_item.is_hero: continue + version = abs(version_item.version) version_found = False - for version_item in version_items_id.values(): + for version_item in version_items_by_id.values(): if version_item.is_hero: continue if version_item.version == version: @@ -971,8 +1071,8 @@ class SceneInventoryView(QtWidgets.QTreeView): update_containers.append(container_item.item_id) update_versions.append(version) - # Specify version per item to update to - self._update_containers(update_containers, update_versions) + # Specify version per item to update to + self._update_containers(update_containers, update_versions) def _update_containers(self, item_ids, versions): """Helper to update items to given version (or version per item) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 39fcc2cdd3..13ee1eea5c 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -3,12 +3,9 @@ import sys import json import hashlib import platform -import subprocess -import csv import time import signal -import locale -from typing import Optional, Dict, Tuple, Any +from typing import Optional, List, Dict, Tuple, Any import requests from ayon_api.utils import get_default_settings_variant @@ -53,15 +50,101 @@ def _get_server_and_variant( return server_url, variant +def _windows_get_pid_args(pid: int) -> Optional[List[str]]: + import ctypes + from ctypes import wintypes + + # Define constants + PROCESS_COMMANDLINE_INFO = 60 + STATUS_NOT_FOUND = 0xC0000225 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + # Define the UNICODE_STRING structure + class UNICODE_STRING(ctypes.Structure): + _fields_ = [ + ("Length", wintypes.USHORT), + ("MaximumLength", wintypes.USHORT), + ("Buffer", wintypes.LPWSTR) + ] + + shell32 = ctypes.WinDLL("shell32", use_last_error=True) + + CommandLineToArgvW = shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int) + ] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + + output = None + # Open the process + handle = ctypes.windll.kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, False, pid + ) + if not handle: + return output + + try: + buffer_len = wintypes.ULONG() + # Get the right buffer size first + status = ctypes.windll.ntdll.NtQueryInformationProcess( + handle, + PROCESS_COMMANDLINE_INFO, + ctypes.c_void_p(None), + 0, + ctypes.byref(buffer_len) + ) + + if status == STATUS_NOT_FOUND: + return output + + # Create buffer with collected size + buffer = ctypes.create_string_buffer(buffer_len.value) + + # Get the command line + status = ctypes.windll.ntdll.NtQueryInformationProcess( + handle, + PROCESS_COMMANDLINE_INFO, + buffer, + buffer_len, + ctypes.byref(buffer_len) + ) + if status: + return output + # Build the string + tmp = ctypes.cast(buffer, ctypes.POINTER(UNICODE_STRING)).contents + size = tmp.Length // 2 + 1 + cmdline_buffer = ctypes.create_unicode_buffer(size) + ctypes.cdll.msvcrt.wcscpy(cmdline_buffer, tmp.Buffer) + + args_len = ctypes.c_int() + args = CommandLineToArgvW( + cmdline_buffer, ctypes.byref(args_len) + ) + output = [args[idx] for idx in range(args_len.value)] + ctypes.windll.kernel32.LocalFree(args) + + finally: + ctypes.windll.kernel32.CloseHandle(handle) + return output + + def _windows_pid_is_running(pid: int) -> bool: - args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"] - output = subprocess.check_output(args) - encoding = locale.getpreferredencoding() - csv_content = csv.DictReader(output.decode(encoding).splitlines()) - # if "PID" not in csv_content.fieldnames: - # return False - for _ in csv_content: + args = _windows_get_pid_args(pid) + if not args: + return False + executable_path = args[0] + + filename = os.path.basename(executable_path).lower() + if "ayon" in filename: return True + + # Try to handle tray running from code + # - this might be potential danger that kills other python process running + # 'start.py' script (low chance, but still) + if "python" in filename and len(args) > 1: + script_filename = os.path.basename(args[1].lower()) + if script_filename == "start.py": + return True return False diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index f6a8add861..aad89b6081 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -20,9 +20,10 @@ from ayon_core.lib import ( ) from ayon_core.settings import get_studio_settings from ayon_core.addon import ( - ITrayAction, + ITrayAddon, ITrayService, ) +from ayon_core.pipeline import install_ayon_plugins from ayon_core.tools.utils import ( WrappedCallbackItem, get_ayon_qt_app, @@ -32,6 +33,12 @@ from ayon_core.tools.tray.lib import ( remove_tray_server_url, TrayIsRunningError, ) +from ayon_core.tools.launcher.ui import LauncherWindow +from ayon_core.tools.loader.ui import LoaderWindow +from ayon_core.tools.console_interpreter.ui import ConsoleInterpreterWindow +from ayon_core.tools.publisher.publish_report_viewer import ( + PublishReportViewerWindow, +) from .addons_manager import TrayAddonsManager from .host_console_listener import HostListener @@ -82,6 +89,11 @@ class TrayManager: self._outdated_dialog = None + self._launcher_window = None + self._browser_window = None + self._console_window = ConsoleInterpreterWindow() + self._publish_report_viewer_window = PublishReportViewerWindow() + self._update_check_timer = update_check_timer self._update_check_interval = update_check_interval self._main_thread_timer = main_thread_timer @@ -109,12 +121,15 @@ class TrayManager: @property def doubleclick_callback(self): """Double-click callback for Tray icon.""" - return self._addons_manager.get_doubleclick_callback() + callback = self._addons_manager.get_doubleclick_callback() + if callback is None: + callback = self._show_launcher_window + return callback def execute_doubleclick(self): """Execute double click callback in main thread.""" callback = self.doubleclick_callback - if callback: + if callback is not None: self.execute_in_main_thread(callback) def show_tray_message(self, title, message, icon=None, msecs=None): @@ -144,8 +159,34 @@ class TrayManager: return tray_menu = self.tray_widget.menu + # Add launcher at first place + launcher_action = QtWidgets.QAction( + "Launcher", tray_menu + ) + launcher_action.triggered.connect(self._show_launcher_window) + tray_menu.addAction(launcher_action) + + console_action = ITrayAddon.add_action_to_admin_submenu( + "Console", tray_menu + ) + console_action.triggered.connect(self._show_console_window) + + publish_report_viewer_action = ITrayAddon.add_action_to_admin_submenu( + "Publish report viewer", tray_menu + ) + publish_report_viewer_action.triggered.connect( + self._show_publish_report_viewer + ) + self._addons_manager.initialize(tray_menu) + # Add browser action after addon actions + browser_action = QtWidgets.QAction( + "Browser", tray_menu + ) + browser_action.triggered.connect(self._show_browser_window) + tray_menu.addAction(browser_action) + self._addons_manager.add_route( "GET", "/tray", self._web_get_tray_info ) @@ -153,7 +194,7 @@ class TrayManager: "POST", "/tray/message", self._web_show_tray_message ) - admin_submenu = ITrayAction.admin_submenu(tray_menu) + admin_submenu = ITrayAddon.admin_submenu(tray_menu) tray_menu.addMenu(admin_submenu) # Add services if they are @@ -522,6 +563,35 @@ class TrayManager: self._info_widget.raise_() self._info_widget.activateWindow() + def _show_launcher_window(self): + if self._launcher_window is None: + self._launcher_window = LauncherWindow() + + self._launcher_window.show() + self._launcher_window.raise_() + self._launcher_window.activateWindow() + + def _show_browser_window(self): + if self._browser_window is None: + self._browser_window = LoaderWindow() + self._browser_window.setWindowTitle("AYON Browser") + install_ayon_plugins() + + self._browser_window.show() + self._browser_window.raise_() + self._browser_window.activateWindow() + + def _show_console_window(self): + self._console_window.show() + self._console_window.raise_() + self._console_window.activateWindow() + + def _show_publish_report_viewer(self): + self._publish_report_viewer_window.refresh() + self._publish_report_viewer_window.show() + self._publish_report_viewer_window.raise_() + self._publish_report_viewer_window.activateWindow() + class SystemTrayIcon(QtWidgets.QSystemTrayIcon): """Tray widget. diff --git a/client/ayon_core/tools/tray/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py index d2a9b0fc6b..70d1fc8c0f 100644 --- a/client/ayon_core/tools/tray/webserver/server.py +++ b/client/ayon_core/tools/tray/webserver/server.py @@ -28,7 +28,7 @@ def find_free_port( exclude_ports (list, tuple, set): List of ports that won't be checked form entered range. host (str): Host where will check for free ports. Set to - "localhost" by default. + "127.0.0.1" by default. """ if port_from is None: port_from = 8079 @@ -42,7 +42,7 @@ def find_free_port( # Default host is localhost but it is possible to look for other hosts if host is None: - host = "localhost" + host = "127.0.0.1" found_port = None while True: @@ -78,7 +78,7 @@ class WebServerManager: self._log = None self.port = port or 8079 - self.host = host or "localhost" + self.host = host or "127.0.0.1" self.on_stop_callbacks = [] diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 4714e76ea3..9206af9beb 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -5,6 +5,7 @@ from .widgets import ( ComboBox, CustomTextComboBox, PlaceholderLineEdit, + PlaceholderPlainTextEdit, ElideLabel, HintedLineEdit, ExpandingTextEdit, @@ -89,6 +90,7 @@ __all__ = ( "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "PlaceholderPlainTextEdit", "ElideLabel", "HintedLineEdit", "ExpandingTextEdit", diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 1eff746b9e..3d356555f3 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -7,7 +7,7 @@ import os import pyblish.api -from ayon_core.host import ILoadHost +from ayon_core.host import ILoadHost, IPublishHost from ayon_core.lib import Logger from ayon_core.pipeline import registered_host @@ -236,7 +236,7 @@ class HostToolsHelper: from ayon_core.tools.publisher.window import PublisherWindow host = registered_host() - ILoadHost.validate_load_methods(host) + IPublishHost.validate_publish_methods(host) publisher_window = PublisherWindow( controller=controller, diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 200e281664..4b303c0143 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -556,9 +556,10 @@ class _IconsCache: log.info("Didn't find icon \"{}\"".format(icon_name)) elif used_variant != icon_name: - log.debug("Icon \"{}\" was not found \"{}\" is used instead".format( - icon_name, used_variant - )) + log.debug( + f"Icon \"{icon_name}\" was not found" + f" \"{used_variant}\" is used instead" + ) cls._qtawesome_cache[full_icon_name] = icon return icon diff --git a/client/ayon_core/tools/utils/multiselection_combobox.py b/client/ayon_core/tools/utils/multiselection_combobox.py index 34361fca17..a6198abb51 100644 --- a/client/ayon_core/tools/utils/multiselection_combobox.py +++ b/client/ayon_core/tools/utils/multiselection_combobox.py @@ -1,5 +1,7 @@ from qtpy import QtCore, QtGui, QtWidgets +from ayon_core.style import get_objected_colors + from .lib import ( checkstate_int_to_enum, checkstate_enum_to_int, @@ -45,15 +47,16 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): top_bottom_padding = 2 left_right_padding = 3 left_offset = 4 - top_bottom_margins = 2 + top_bottom_margins = 1 item_spacing = 5 item_bg_color = QtGui.QColor("#31424e") + _placeholder_color = None def __init__( self, parent=None, placeholder="", separator=", ", **kwargs ): - super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + super().__init__(parent=parent, **kwargs) self.setObjectName("MultiSelectionComboBox") self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -61,7 +64,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) self._initial_mouse_pos = None self._separator = separator - self._placeholder_text = placeholder + self._placeholder_text = placeholder or "" delegate = ComboItemDelegate(self) self.setItemDelegate(delegate) @@ -74,7 +77,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): return self._placeholder_text def set_placeholder_text(self, text): - self._placeholder_text = text + self._placeholder_text = text or "" self._update_size_hint() def set_custom_text(self, text): @@ -206,19 +209,36 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): combotext = self._placeholder_text else: draw_text = False - if draw_text: - option.currentText = combotext - option.palette.setCurrentColorGroup(QtGui.QPalette.Disabled) - painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) - return - font_metricts = self.fontMetrics() + if draw_text: + color = self._get_placeholder_color() + pen = painter.pen() + pen.setColor(color) + painter.setPen(pen) + + left_x = option.rect.left() + self.left_offset + + font = self.font() + # This is hardcoded point size from styles + font.setPointSize(10) + painter.setFont(font) + + label_rect = QtCore.QRect(option.rect) + label_rect.moveLeft(left_x) + + painter.drawText( + label_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + combotext + ) + return if self._item_height is None: self.updateGeometry() self.update() return + font_metrics = self.fontMetrics() for line, items in self._lines.items(): top_y = ( option.rect.top() @@ -227,7 +247,7 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): ) left_x = option.rect.left() + self.left_offset for item in items: - label_rect = font_metricts.boundingRect(item) + label_rect = font_metrics.boundingRect(item) label_height = label_rect.height() label_rect.moveTop(top_y) @@ -237,22 +257,25 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): label_rect.width() + self.left_right_padding ) - bg_rect = QtCore.QRectF(label_rect) - bg_rect.setWidth( - label_rect.width() + self.left_right_padding - ) - left_x = bg_rect.right() + self.item_spacing + if not draw_text: + bg_rect = QtCore.QRectF(label_rect) + bg_rect.setWidth( + label_rect.width() + self.left_right_padding + ) + left_x = bg_rect.right() + self.item_spacing + + bg_rect.setHeight( + label_height + (2 * self.top_bottom_padding) + ) + bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) + + path = QtGui.QPainterPath() + path.addRoundedRect(bg_rect, 5, 5) + + painter.fillPath(path, self.item_bg_color) label_rect.moveLeft(label_rect.x() + self.left_right_padding) - bg_rect.setHeight(label_height + (2 * self.top_bottom_padding)) - bg_rect.moveTop(bg_rect.top() + self.top_bottom_margins) - - path = QtGui.QPainterPath() - path.addRoundedRect(bg_rect, 5, 5) - - painter.fillPath(path, self.item_bg_color) - painter.drawText( label_rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, @@ -287,11 +310,11 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): line = 0 self._lines = {line: []} - font_metricts = self.fontMetrics() + font_metrics = self.fontMetrics() default_left_x = 0 + self.left_offset left_x = int(default_left_x) for item in items: - rect = font_metricts.boundingRect(item) + rect = font_metrics.boundingRect(item) width = rect.width() + (2 * self.left_right_padding) right_x = left_x + width if right_x > total_width: @@ -382,3 +405,12 @@ class MultiSelectionComboBox(QtWidgets.QComboBox): return event.ignore() return super(MultiSelectionComboBox, self).keyPressEvent(event) + + @classmethod + def _get_placeholder_color(cls): + if cls._placeholder_color is None: + color_obj = get_objected_colors("font") + color = color_obj.get_qcolor() + color.setAlpha(67) + cls._placeholder_color = color + return cls._placeholder_color diff --git a/client/ayon_core/tools/utils/nice_checkbox.py b/client/ayon_core/tools/utils/nice_checkbox.py index 06845c397a..3d9d63b6bc 100644 --- a/client/ayon_core/tools/utils/nice_checkbox.py +++ b/client/ayon_core/tools/utils/nice_checkbox.py @@ -328,6 +328,9 @@ class NiceCheckbox(QtWidgets.QFrame): if frame_rect.width() < 0 or frame_rect.height() < 0: return + frame_rect.setLeft(frame_rect.x() + (frame_rect.width() % 2)) + frame_rect.setTop(frame_rect.y() + (frame_rect.height() % 2)) + painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) @@ -364,18 +367,23 @@ class NiceCheckbox(QtWidgets.QFrame): margin_size_c = 0 checkbox_rect = QtCore.QRect( - frame_rect.x() + margin_size_c, - frame_rect.y() + margin_size_c, - frame_rect.width() - (margin_size_c * 2), - frame_rect.height() - (margin_size_c * 2) + frame_rect.x(), + frame_rect.y(), + frame_rect.width(), + frame_rect.height() ) + if margin_size_c: + checkbox_rect.adjust( + margin_size_c, margin_size_c, + -margin_size_c, -margin_size_c + ) if checkbox_rect.width() > checkbox_rect.height(): radius = floor(checkbox_rect.height() * 0.5) else: radius = floor(checkbox_rect.width() * 0.5) - painter.setPen(QtCore.Qt.transparent) + painter.setPen(QtCore.Qt.NoPen) painter.setBrush(bg_color) painter.drawRoundedRect(checkbox_rect, radius, radius) diff --git a/client/ayon_core/tools/utils/tasks_widget.py b/client/ayon_core/tools/utils/tasks_widget.py index bba7b93925..30846e6cda 100644 --- a/client/ayon_core/tools/utils/tasks_widget.py +++ b/client/ayon_core/tools/utils/tasks_widget.py @@ -24,9 +24,14 @@ class TasksQtModel(QtGui.QStandardItemModel): """ _default_task_icon = None refreshed = QtCore.Signal() + column_labels = ["Tasks"] def __init__(self, controller): - super(TasksQtModel, self).__init__() + super().__init__() + + self.setColumnCount(len(self.column_labels)) + for idx, label in enumerate(self.column_labels): + self.setHeaderData(idx, QtCore.Qt.Horizontal, label) self._controller = controller @@ -53,7 +58,8 @@ class TasksQtModel(QtGui.QStandardItemModel): self._has_content = False self._remove_invalid_items() root_item = self.invisibleRootItem() - root_item.removeRows(0, root_item.rowCount()) + while root_item.rowCount() != 0: + root_item.takeRow(0) def refresh(self): """Refresh tasks for last project and folder.""" @@ -270,7 +276,7 @@ class TasksQtModel(QtGui.QStandardItemModel): task_type_item_by_name, task_type_icon_cache ) - item.setData(task_item.label, QtCore.Qt.DisplayRole) + item.setData(task_item.full_label, 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) @@ -336,19 +342,6 @@ class TasksQtModel(QtGui.QStandardItemModel): return self._has_content - def headerData(self, section, orientation, role): - # Show nice labels in the header - if ( - role == QtCore.Qt.DisplayRole - and orientation == QtCore.Qt.Horizontal - ): - if section == 0: - return "Tasks" - - return super(TasksQtModel, self).headerData( - section, orientation, role - ) - class TasksWidget(QtWidgets.QWidget): """Tasks widget. @@ -365,7 +358,7 @@ class TasksWidget(QtWidgets.QWidget): selection_changed = QtCore.Signal() def __init__(self, controller, parent, handle_expected_selection=False): - super(TasksWidget, self).__init__(parent) + super().__init__(parent) tasks_view = DeselectableTreeView(self) tasks_view.setIndentation(0) diff --git a/client/ayon_core/tools/utils/views.py b/client/ayon_core/tools/utils/views.py index b501f1ff11..d69be9b6a9 100644 --- a/client/ayon_core/tools/utils/views.py +++ b/client/ayon_core/tools/utils/views.py @@ -1,14 +1,12 @@ -from ayon_core.resources import get_image_path from ayon_core.tools.flickcharm import FlickCharm -from qtpy import QtWidgets, QtCore, QtGui, QtSvg +from qtpy import QtWidgets, QtCore, QtGui class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" def mousePressEvent(self, event): - index = self.indexAt(event.pos()) if not index.isValid(): # clear the selection @@ -16,49 +14,14 @@ class DeselectableTreeView(QtWidgets.QTreeView): # clear the current index self.setCurrentIndex(QtCore.QModelIndex()) - QtWidgets.QTreeView.mousePressEvent(self, event) + elif ( + self.selectionModel().isSelected(index) + and len(self.selectionModel().selectedRows()) == 1 + and event.modifiers() == QtCore.Qt.NoModifier + ): + event.setModifiers(QtCore.Qt.ControlModifier) - -class TreeViewSpinner(QtWidgets.QTreeView): - size = 160 - - def __init__(self, parent=None): - super(TreeViewSpinner, self).__init__(parent=parent) - - loading_image_path = get_image_path("spinner-200.svg") - - self.spinner = QtSvg.QSvgRenderer(loading_image_path) - - self.is_loading = False - self.is_empty = True - - def paint_loading(self, event): - rect = event.rect() - rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight()) - rect.moveTo( - rect.x() + rect.width() / 2 - self.size / 2, - rect.y() + rect.height() / 2 - self.size / 2 - ) - rect.setSize(QtCore.QSizeF(self.size, self.size)) - painter = QtGui.QPainter(self.viewport()) - self.spinner.render(painter, rect) - - def paint_empty(self, event): - painter = QtGui.QPainter(self.viewport()) - rect = event.rect() - rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight()) - qtext_opt = QtGui.QTextOption( - QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter - ) - painter.drawText(rect, "No Data", qtext_opt) - - def paintEvent(self, event): - if self.is_loading: - self.paint_loading(event) - elif self.is_empty: - self.paint_empty(event) - else: - super(TreeViewSpinner, self).paintEvent(event) + super().mousePressEvent(event) class TreeView(QtWidgets.QTreeView): diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 4c2b418c41..1074b6d4fb 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -54,7 +54,7 @@ class ComboBox(QtWidgets.QComboBox): """ def __init__(self, *args, **kwargs): - super(ComboBox, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(delegate) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -63,7 +63,7 @@ class ComboBox(QtWidgets.QComboBox): def wheelEvent(self, event): if self.hasFocus(): - return super(ComboBox, self).wheelEvent(event) + return super().wheelEvent(event) class CustomTextComboBox(ComboBox): @@ -71,7 +71,7 @@ class CustomTextComboBox(ComboBox): def __init__(self, *args, **kwargs): self._custom_text = None - super(CustomTextComboBox, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def set_custom_text(self, text=None): if self._custom_text != text: @@ -88,23 +88,48 @@ class CustomTextComboBox(ComboBox): painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) -class PlaceholderLineEdit(QtWidgets.QLineEdit): - """Set placeholder color of QLineEdit in Qt 5.12 and higher.""" - def __init__(self, *args, **kwargs): - super(PlaceholderLineEdit, self).__init__(*args, **kwargs) - # Change placeholder palette color - if hasattr(QtGui.QPalette, "PlaceholderText"): - filter_palette = self.palette() +class _Cache: + _placeholder_color = None + + @classmethod + def get_placeholder_color(cls): + if cls._placeholder_color is None: color_obj = get_objected_colors("font") color = color_obj.get_qcolor() color.setAlpha(67) + cls._placeholder_color = color + return cls._placeholder_color + + +class PlaceholderLineEdit(QtWidgets.QLineEdit): + """Set placeholder color of QLineEdit in Qt 5.12 and higher.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Change placeholder palette color + if hasattr(QtGui.QPalette, "PlaceholderText"): + filter_palette = self.palette() filter_palette.setColor( QtGui.QPalette.PlaceholderText, - color + _Cache.get_placeholder_color() ) self.setPalette(filter_palette) +class PlaceholderPlainTextEdit(QtWidgets.QPlainTextEdit): + """Set placeholder color of QPlainTextEdit in Qt 5.12 and higher.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Change placeholder palette color + if hasattr(QtGui.QPalette, "PlaceholderText"): + viewport = self.viewport() + filter_palette = viewport.palette() + filter_palette.setColor( + QtGui.QPalette.PlaceholderText, + _Cache.get_placeholder_color() + ) + viewport.setPalette(filter_palette) + + class ElideLabel(QtWidgets.QLabel): """Label which elide text. diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index a268a9bd0e..c621a44937 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -184,9 +184,10 @@ class WorkareaModel: return items for filename in os.listdir(workdir): + # We want to support both files and folders. e.g. Silhoutte uses + # folders as its project files. So we do not check whether it is + # a file or not. filepath = os.path.join(workdir, filename) - if not os.path.isfile(filepath): - continue ext = os.path.splitext(filename)[1].lower() if ext not in self._extensions: diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget.py b/client/ayon_core/tools/workfiles/widgets/files_widget.py index 16f0b6fce3..dbe5966c31 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget.py @@ -136,6 +136,8 @@ class FilesWidget(QtWidgets.QWidget): # Initial setup workarea_btn_open.setEnabled(False) + workarea_btn_browse.setEnabled(False) + workarea_btn_save.setEnabled(False) published_btn_copy_n_open.setEnabled(False) published_btn_change_context.setEnabled(False) published_btn_cancel.setVisible(False) @@ -278,8 +280,9 @@ class FilesWidget(QtWidgets.QWidget): self._published_btn_change_context.setEnabled(enabled) def _update_workarea_btns_state(self): - enabled = self._is_save_enabled + enabled = self._is_save_enabled and self._valid_selected_context self._workarea_btn_save.setEnabled(enabled) + self._workarea_btn_browse.setEnabled(self._valid_selected_context) def _on_published_repre_changed(self, event): self._valid_representation_id = event["representation_id"] is not None @@ -294,6 +297,7 @@ class FilesWidget(QtWidgets.QWidget): and self._selected_task_id is not None ) self._update_published_btns_state() + self._update_workarea_btns_state() def _on_published_save_clicked(self): result = self._exec_save_as_dialog() diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 8bcff66f50..1649a059cb 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -113,6 +113,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): main_layout = QtWidgets.QHBoxLayout(self) main_layout.addWidget(pages_widget, 1) + main_layout.setContentsMargins(0, 0, 0, 0) overlay_messages_widget = MessageOverlayObject(self) overlay_invalid_host = InvalidHostOverlay(self) diff --git a/client/ayon_core/vendor/python/scriptsmenu/action.py b/client/ayon_core/vendor/python/scriptsmenu/action.py index 49b08788f9..3ba281fed7 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/action.py +++ b/client/ayon_core/vendor/python/scriptsmenu/action.py @@ -1,6 +1,6 @@ import os -from qtpy import QtWidgets +from qtpy import QtWidgets, QT6 class Action(QtWidgets.QAction): @@ -112,20 +112,21 @@ module.{module_name}()""" Run the command of the instance or copy the command to the active shelf based on the current modifiers. - If callbacks have been registered with fouind modifier integer the + If callbacks have been registered with found modifier integer the function will trigger all callbacks. When a callback function returns a non zero integer it will not execute the action's command - """ # get the current application and its linked keyboard modifiers app = QtWidgets.QApplication.instance() modifiers = app.keyboardModifiers() + if not QT6: + modifiers = int(modifiers) # If the menu has a callback registered for the current modifier # we run the callback instead of the action itself. registered = self._root.registered_callbacks - callbacks = registered.get(int(modifiers), []) + callbacks = registered.get(modifiers, []) for callback in callbacks: signal = callback(self) if signal != 0: diff --git a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py index 496278ac6f..a5503bc63e 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py +++ b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py @@ -4,7 +4,7 @@ import maya.cmds as cmds import maya.mel as mel import scriptsmenu -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtWidgets, QT6 log = logging.getLogger(__name__) @@ -130,7 +130,7 @@ def main(title="Scripts", parent=None, objectName=None): # Register control + shift callback to add to shelf (maya behavior) modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - if int(cmds.about(version=True)) < 2025: + if not QT6: modifiers = int(modifiers) menu.register_callback(modifiers, to_shelf) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 2b2af81e18..de5c199428 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.0.6+dev" +__version__ = "1.1.5+dev" diff --git a/client/pyproject.toml b/client/pyproject.toml index a0be9605b6..edf7f57317 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -15,6 +15,6 @@ qtawesome = "0.7.3" aiohttp-middlewares = "^2.0.0" Click = "^8" OpenTimelineIO = "0.16.0" -opencolorio = "^2.3.2" +opencolorio = "^2.3.2,<2.4.0" Pillow = "9.5.0" websocket-client = ">=0.40.0,<2" diff --git a/package.py b/package.py index 59f0e82be0..250b77fe52 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.6+dev" +version = "1.1.5+dev" client_dir = "ayon_core" diff --git a/poetry.lock b/poetry.lock index be5a3b2c2c..2d040a5f91 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "appdirs" @@ -6,37 +6,59 @@ version = "1.4.4" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +[[package]] +name = "attrs" +version = "25.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, + {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, +] + +[package.extras] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + [[package]] name = "ayon-python-api" -version = "1.0.1" +version = "1.0.12" description = "AYON Python API" optional = false python-versions = "*" +groups = ["dev"] files = [ - {file = "ayon-python-api-1.0.1.tar.gz", hash = "sha256:6a53af84903317e2097f3c6bba0094e90d905d6670fb9c7d3ad3aa9de6552bc1"}, - {file = "ayon_python_api-1.0.1-py3-none-any.whl", hash = "sha256:d4b649ac39c9003cdbd60f172c0d35f05d310fba3a0649b6d16300fe67f967d6"}, + {file = "ayon-python-api-1.0.12.tar.gz", hash = "sha256:8e4c03436df8afdda4c6ad4efce436068771995bb0153a90e003364afa0e7f55"}, + {file = "ayon_python_api-1.0.12-py3-none-any.whl", hash = "sha256:65f61c2595dd6deb26fed5e3fda7baef887f475fa4b21df12513646ddccf4a7d"}, ] [package.dependencies] appdirs = ">=1,<2" requests = ">=2.27.1" -six = ">=1.15" -Unidecode = ">=1.2.0" +Unidecode = ">=1.3.0" [[package]] name = "certifi" -version = "2024.2.2" +version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] [[package]] @@ -45,6 +67,7 @@ version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -52,118 +75,139 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] +[[package]] +name = "clique" +version = "2.0.0" +description = "Manage collections with common numerical component" +optional = false +python-versions = ">=3.0, <4.0" +groups = ["dev"] +files = [ + {file = "clique-2.0.0-py2.py3-none-any.whl", hash = "sha256:45e2a4c6078382e0b217e5e369494279cf03846d95ee601f93290bed5214c22e"}, + {file = "clique-2.0.0.tar.gz", hash = "sha256:6e1115dbf21b1726f4b3db9e9567a662d6bdf72487c4a0a1f8cb7f10cf4f4754"}, +] + +[package.extras] +dev = ["lowdown (>=0.2.0,<1)", "pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] +doc = ["lowdown (>=0.2.0,<1)", "sphinx (>=2,<4)", "sphinx-rtd-theme (>=0.1.6,<1)"] +test = ["pytest (>=2.3.5,<5)", "pytest-cov (>=2,<3)", "pytest-runner (>=2.7,<3)"] + [[package]] name = "codespell" -version = "2.2.6" -description = "Codespell" +version = "2.4.1" +description = "Fix common misspellings in text files" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"}, - {file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"}, + {file = "codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425"}, + {file = "codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5"}, ] [package.extras] dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] hard-encoding-detection = ["chardet"] -toml = ["tomli"] +toml = ["tomli ; python_version < \"3.11\""] types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] [[package]] @@ -172,6 +216,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -179,24 +225,26 @@ files = [ [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" +groups = ["dev"] files = [ - {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, - {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] name = "exceptiongroup" -version = "1.2.0" +version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, - {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [package.extras] @@ -204,29 +252,31 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.13.1" +version = "3.17.0" description = "A platform independent file lock." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, - {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, + {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, + {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] -typing = ["typing-extensions (>=4.8)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] [[package]] name = "identify" -version = "2.5.35" +version = "2.6.7" description = "File identification library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, - {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, + {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"}, + {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"}, ] [package.extras] @@ -234,75 +284,149 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.6" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" +groups = ["dev"] files = [ - {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, - {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] [[package]] -name = "nodeenv" -version = "1.8.0" -description = "Node.js virtual environment builder" +name = "mock" +version = "5.1.0" +description = "Rolling backport of unittest.mock for all Pythons" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = ">=3.6" +groups = ["dev"] files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "mock-5.1.0-py3-none-any.whl", hash = "sha256:18c694e5ae8a208cdb3d2c20a993ca1a7b0efa258c247a1e565150f477f83744"}, + {file = "mock-5.1.0.tar.gz", hash = "sha256:5e96aad5ccda4718e0a229ed94b2024df75cc2d55575ba5762d31f5767b8767d"}, ] -[package.dependencies] -setuptools = "*" +[package.extras] +build = ["blurb", "twine", "wheel"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "opentimelineio" +version = "0.17.0" +description = "Editorial interchange format and API" +optional = false +python-versions = "!=3.9.0,>=3.7" +groups = ["dev"] +files = [ + {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:2dd31a570cabfd6227c1b1dd0cc038da10787492c26c55de058326e21fe8a313"}, + {file = "OpenTimelineIO-0.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5a1da5d4803d1ba5e846b181a9e0f4a392c76b9acc5e08947772bc086f2ebfc0"}, + {file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3527977aec8202789a42d60e1e0dc11b4154f585ef72921760445f43e7967a00"}, + {file = "OpenTimelineIO-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3aafb4c50455832ed2627c2cac654b896473a5c1f8348ddc07c10be5cfbd59"}, + {file = "OpenTimelineIO-0.17.0-cp310-cp310-win32.whl", hash = "sha256:fee45af9f6330773893cd0858e92f8256bb5bde4229b44a76f03e59a9fb1b1b6"}, + {file = "OpenTimelineIO-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:d51887619689c21d67cc4b11b1088f99ae44094513315e7a144be00f1393bfa8"}, + {file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:cbf05c3e8c0187969f79e91f7495d1f0dc3609557874d8e601ba2e072c70ddb1"}, + {file = "OpenTimelineIO-0.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d3430c3f4e88c5365d7b6afbee920b0815b62ecf141abe44cd739c9eedc04284"}, + {file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1912345227b0bd1654c7153863eadbcee60362aa46340678e576e5d2aa3106a"}, + {file = "OpenTimelineIO-0.17.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51e06eb11a868d970c1534e39faf916228d5163bf3598076d408d8f393ab0bd4"}, + {file = "OpenTimelineIO-0.17.0-cp311-cp311-win32.whl", hash = "sha256:5c3a3f4780b25a8c1a80d788becba691d12b629069ad8783d0db21027639276f"}, + {file = "OpenTimelineIO-0.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:43c8726b33af30ba42928972192311ea0f986edbbd5f74651bada182d4fe805c"}, + {file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:9a9af4105a088c0ab131780e49db268db7e37871aac33db842de6b2b16f14e39"}, + {file = "OpenTimelineIO-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e653ad1dd3b85f5c312a742dc24b61b330964aa391dc5bc072fe8b9c85adff1"}, + {file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a77823c27a1b93c6b87682372c3734ac5fddc10bfe53875e657d43c60fb885"}, + {file = "OpenTimelineIO-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4f4efcf3ddd81b62c4feb49a0bcc309b50ffeb6a8c48ab173d169a029006f4d"}, + {file = "OpenTimelineIO-0.17.0-cp312-cp312-win32.whl", hash = "sha256:9872ab74a20bb2bb3a50af04e80fe9238998d67d6be4e30e45aebe25d3eefac6"}, + {file = "OpenTimelineIO-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:c83b78be3312d3152d7e07ab32b0086fe220acc2a5b035b70ad69a787c0ece62"}, + {file = "OpenTimelineIO-0.17.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:0e671a6f2a1f772445bb326c7640dc977cfc3db589fe108a783a0311939cfac8"}, + {file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b931a3189b4ce064f06f15a89fe08ef4de01f7dcf0abc441fe2e02ef2a3311bb"}, + {file = "OpenTimelineIO-0.17.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923cb54d806c981cf1e91916c3e57fba5664c22f37763dd012bad5a5a7bd4db4"}, + {file = "OpenTimelineIO-0.17.0-cp37-cp37m-win32.whl", hash = "sha256:8e16598c5084dcb21df3d83978b0e5f72300af9edd4cdcb85e3b0ba5da0df4e8"}, + {file = "OpenTimelineIO-0.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7eed5033494888fb3f802af50e60559e279b2f398802748872903c2f54efd2c9"}, + {file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:118baa22b9227da5003bee653601a68686ae2823682dcd7d13c88178c63081c3"}, + {file = "OpenTimelineIO-0.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:43389eacdee2169de454e1c79ecfea82f54a9e73b67151427a9b621349a22b7f"}, + {file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17659b1e6aa42ed617a942f7a2bfc6ecc375d0464ec127ce9edf896278ecaee9"}, + {file = "OpenTimelineIO-0.17.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36d5ea8cfbebf3c9013cc680eef5be48bffb515aafa9dc31e99bf66052a4ca3d"}, + {file = "OpenTimelineIO-0.17.0-cp38-cp38-win32.whl", hash = "sha256:cc67c74eb4b73bc0f7d135d3ff3dbbd86b2d451a9b142690a8d1631ad79c46f2"}, + {file = "OpenTimelineIO-0.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:69b39079bee6fa4aff34c6ad6544df394bc7388483fa5ce958ecd16e243a53ad"}, + {file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a33554894dea17c22feec0201991e705c2c90a679ba2a012a0c558a7130df711"}, + {file = "OpenTimelineIO-0.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6b1ad3b3155370245b851b2f7b60006b2ebbb5bb76dd0fdc49bb4dce73fa7d96"}, + {file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:030454a9c0e9e82e5a153119f9afb8f3f4e64a3b27f80ac0dcde44b029fd3f3f"}, + {file = "OpenTimelineIO-0.17.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bce64376a28919533bd4f744ff8885118abefa73f78fd408f95fa7a9489855b6"}, + {file = "OpenTimelineIO-0.17.0-cp39-cp39-win32.whl", hash = "sha256:fa8cdceb25f9003c3c0b5b32baef2c764949d88b867161ddc6f44f48f6bbfa4a"}, + {file = "OpenTimelineIO-0.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:fbcf8a000cd688633c8dc5d22e91912013c67c674329eba603358e3b54da32bf"}, + {file = "opentimelineio-0.17.0.tar.gz", hash = "sha256:10ef324e710457e9977387cd9ef91eb24a9837bfb370aec3330f9c0f146cea85"}, +] + +[package.extras] +dev = ["check-manifest", "coverage (>=4.5)", "flake8 (>=3.5)", "urllib3 (>=1.24.3)"] +view = ["PySide2 (>=5.11,<6.0) ; platform_machine == \"x86_64\"", "PySide6 (>=6.2,<7.0) ; platform_machine == \"aarch64\""] [[package]] name = "packaging" -version = "24.0" +version = "24.2" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] name = "platformdirs" -version = "4.2.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, - {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, ] [package.extras] -docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] [[package]] name = "pluggy" -version = "1.4.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, - {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -311,13 +435,14 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.6.2" +version = "3.8.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, - {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, ] [package.dependencies] @@ -327,15 +452,28 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pyblish-base" +version = "1.8.12" +description = "Plug-in driven automation framework for content" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pyblish-base-1.8.12.tar.gz", hash = "sha256:ebc184eb038864380555227a8b58055dd24ece7e6ef7f16d33416c718512871b"}, + {file = "pyblish_base-1.8.12-py2.py3-none-any.whl", hash = "sha256:2cbe956bfbd4175a2d7d22b344cd345800f4d4437153434ab658fc12646a11e8"}, +] + [[package]] name = "pytest" -version = "8.1.1" +version = "8.3.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, - {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, ] [package.dependencies] @@ -343,97 +481,103 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.4,<2.0" +pluggy = ">=1.5,<2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-print" -version = "1.0.0" +version = "1.0.2" description = "pytest-print adds the printer fixture you can use to print messages to the user (directly to the pytest runner, not stdout)" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "pytest_print-1.0.0-py3-none-any.whl", hash = "sha256:23484f42b906b87e31abd564761efffeb0348a6f83109fb857ee6e8e5df42b69"}, - {file = "pytest_print-1.0.0.tar.gz", hash = "sha256:1fcde9945fba462227a8959271369b10bb7a193be8452162707e63cd60875ca0"}, + {file = "pytest_print-1.0.2-py3-none-any.whl", hash = "sha256:3ae7891085dddc3cd697bd6956787240107fe76d6b5cdcfcd782e33ca6543de9"}, + {file = "pytest_print-1.0.2.tar.gz", hash = "sha256:2780350a7bbe7117f99c5d708dc7b0431beceda021b1fd3f11200670d7f33679"}, ] [package.dependencies] -pytest = ">=7.4" +pytest = ">=8.3.2" [package.extras] -test = ["covdefaults (>=2.3)", "coverage (>=7.3)", "pytest-mock (>=3.11.1)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] [[package]] name = "pyyaml" -version = "6.0.1" +version = "6.0.2" description = "YAML parser and emitter for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.3" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, ] [package.dependencies] @@ -448,66 +592,83 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "ruff" -version = "0.3.3" +version = "0.3.7" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"}, - {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"}, - {file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"}, - {file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"}, - {file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"}, - {file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"}, - {file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"}, - {file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, + {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, + {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, + {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, + {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, + {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, + {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, + {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, ] [[package]] -name = "setuptools" -version = "69.2.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, - {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] name = "tomli" -version = "2.0.1" +version = "2.2.1" description = "A lil' TOML parser" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] @@ -516,6 +677,7 @@ version = "1.3.8" description = "ASCII transliterations of Unicode text" optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, @@ -523,30 +685,32 @@ files = [ [[package]] name = "urllib3" -version = "2.2.1" +version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, - {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.25.1" +version = "20.29.2" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, - {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, + {file = "virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a"}, + {file = "virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728"}, ] [package.dependencies] @@ -555,10 +719,10 @@ filelock = ">=3.12.2,<4" platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.9.1,<3.10" -content-hash = "1bb724694792fbc2b3c05e3355e6c25305d9f4034eb7b1b4b1791ee95427f8d2" +content-hash = "0a399d239c49db714c1166c20286fdd5cd62faf12e45ab85833c4d6ea7a04a2a" diff --git a/pyproject.toml b/pyproject.toml index ca626eff00..94badd2f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,16 @@ [tool.poetry] name = "ayon-core" -version = "1.0.6+dev" +version = "1.1.5+dev" description = "" authors = ["Ynput Team "] readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = ">=3.9.1,<3.10" - -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] # test dependencies pytest = "^8.0" pytest-print = "^1.0" @@ -24,6 +24,11 @@ ruff = "^0.3.3" pre-commit = "^3.6.2" codespell = "^2.2.6" semver = "^3.0.2" +mock = "^5.0.0" +attrs = "^25.0.0" +pyblish-base = "^1.8.7" +clique = "^2.0.0" +opentimelineio = "^0.17.0" [tool.ruff] @@ -68,7 +73,7 @@ target-version = "py39" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F", "W"] +select = ["E", "F", "W"] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 16b1f37187..c9c66e65d9 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -68,6 +68,67 @@ class ContributionLayersModel(BaseSettingsModel): "layer on top.") +class CollectUSDLayerContributionsProfileModel(BaseSettingsModel): + """Profiles to define instance attribute defaults for USD contribution.""" + _layout = "expanded" + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types", + description=( + "The product types to match this profile to. When matched, the" + " settings below would apply to the instance as default" + " attributes." + ), + section="Filter" + ) + task_types: list[str] = SettingsField( + default_factory=list, + title="Task Types", + enum_resolver=task_types_enum, + description=( + "The current create context task type to filter against. This" + " allows to filter the profile to only be valid if currently " + " creating from within that task type." + ), + ) + contribution_enabled: bool = SettingsField( + True, + title="Contribution Enabled (default)", + description=( + "The default state for USD Contribution being marked enabled or" + " disabled for this profile." + ), + section="Instance attribute defaults", + ) + contribution_layer: str = SettingsField( + "", + title="Contribution Department Layer", + description=( + "The default contribution layer to apply the contribution to when" + " matching this profile. The layer name should be in the" + " 'Department Layer Orders' list to get a sensible order." + ), + ) + contribution_apply_as_variant: bool = SettingsField( + True, + title="Apply as variant", + description=( + "The default 'Apply as variant' state for instances matching this" + " profile. Usually enabled for asset contributions and disabled" + " for shot contributions." + ), + ) + contribution_target_product: str = SettingsField( + "usdAsset", + title="Target Product", + description=( + "The default destination product name to apply the contribution to" + " when matching this profile." + " Usually e.g. 'usdAsset' or 'usdShot'." + ), + ) + + class CollectUSDLayerContributionsModel(BaseSettingsModel): enabled: bool = SettingsField(True, title="Enabled") contribution_layers: list[ContributionLayersModel] = SettingsField( @@ -77,6 +138,14 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel): "ordering inside the USD contribution workflow." ) ) + profiles: list[CollectUSDLayerContributionsProfileModel] = SettingsField( + default_factory=list, + title="Profiles", + description=( + "Define attribute defaults for USD Contributions on publish" + " instances." + ) + ) @validator("contribution_layers") def validate_unique_outputs(cls, value): @@ -358,7 +427,10 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): custom_tags: list[str] = SettingsField( default_factory=list, title="Custom Tags", - description="Additional custom tags that will be added to the created representation." + description=( + "Additional custom tags that will be added" + " to the created representation." + ) ) @@ -892,9 +964,11 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=CollectFramesFixDefModel, title="Collect Frames to Fix", ) - CollectUSDLayerContributions: CollectUSDLayerContributionsModel = SettingsField( - default_factory=CollectUSDLayerContributionsModel, - title="Collect USD Layer Contributions", + CollectUSDLayerContributions: CollectUSDLayerContributionsModel = ( + SettingsField( + default_factory=CollectUSDLayerContributionsModel, + title="Collect USD Layer Contributions", + ) ) ValidateEditorialAssetName: ValidateBaseModel = SettingsField( default_factory=ValidateBaseModel, @@ -1003,8 +1077,8 @@ DEFAULT_PUBLISH_VALUES = { {"name": "model", "order": 100}, {"name": "assembly", "order": 150}, {"name": "groom", "order": 175}, - {"name": "look", "order": 300}, - {"name": "rig", "order": 100}, + {"name": "look", "order": 200}, + {"name": "rig", "order": 300}, # Shot layers {"name": "layout", "order": 200}, {"name": "animation", "order": 300}, @@ -1012,6 +1086,48 @@ DEFAULT_PUBLISH_VALUES = { {"name": "fx", "order": 500}, {"name": "lighting", "order": 600}, ], + "profiles": [ + { + "product_types": ["model"], + "task_types": [], + "contribution_enabled": True, + "contribution_layer": "model", + "contribution_apply_as_variant": True, + "contribution_target_product": "usdAsset" + }, + { + "product_types": ["look"], + "task_types": [], + "contribution_enabled": True, + "contribution_layer": "look", + "contribution_apply_as_variant": True, + "contribution_target_product": "usdAsset" + }, + { + "product_types": ["groom"], + "task_types": [], + "contribution_enabled": True, + "contribution_layer": "groom", + "contribution_apply_as_variant": True, + "contribution_target_product": "usdAsset" + }, + { + "product_types": ["rig"], + "task_types": [], + "contribution_enabled": True, + "contribution_layer": "rig", + "contribution_apply_as_variant": True, + "contribution_target_product": "usdAsset" + }, + { + "product_types": ["usd"], + "task_types": [], + "contribution_enabled": True, + "contribution_layer": "assembly", + "contribution_apply_as_variant": False, + "contribution_target_product": "usdShot" + }, + ] }, "ValidateEditorialAssetName": { "enabled": True, @@ -1028,7 +1144,8 @@ DEFAULT_PUBLISH_VALUES = { "maya", "nuke", "photoshop", - "substancepainter" + "substancepainter", + "silhouette", ], "enabled": True, "optional": False, @@ -1048,7 +1165,8 @@ DEFAULT_PUBLISH_VALUES = { "harmony", "photoshop", "aftereffects", - "fusion" + "fusion", + "silhouette", ], "enabled": True, "optional": True, @@ -1214,7 +1332,9 @@ DEFAULT_PUBLISH_VALUES = { "TOP_RIGHT": "{anatomy[version]}", "BOTTOM_LEFT": "{username}", "BOTTOM_CENTERED": "{folder[name]}", - "BOTTOM_RIGHT": "{frame_start}-{current_frame}-{frame_end}", + "BOTTOM_RIGHT": ( + "{frame_start}-{current_frame}-{frame_end}" + ), "filter": { "families": [], "tags": [] @@ -1240,7 +1360,9 @@ DEFAULT_PUBLISH_VALUES = { "TOP_RIGHT": "{anatomy[version]}", "BOTTOM_LEFT": "{username}", "BOTTOM_CENTERED": "{folder[name]}", - "BOTTOM_RIGHT": "{frame_start}-{current_frame}-{frame_end}", + "BOTTOM_RIGHT": ( + "{frame_start}-{current_frame}-{frame_end}" + ), "filter": { "families": [], "tags": [] diff --git a/server/settings/tools.py b/server/settings/tools.py index a2785c1edf..32c72e7a98 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -83,8 +83,8 @@ class CreatorToolModel(BaseSettingsModel): filter_creator_profiles: list[FilterCreatorProfile] = SettingsField( default_factory=list, title="Filter creator profiles", - description="Allowed list of creator labels that will be only shown if " - "profile matches context." + description="Allowed list of creator labels that will be only shown" + " if profile matches context." ) @validator("product_types_smart_select") @@ -426,7 +426,9 @@ DEFAULT_TOOLS_VALUES = { ], "task_types": [], "tasks": [], - "template": "{product[type]}{Task[name]}_{Renderlayer}_{Renderpass}" + "template": ( + "{product[type]}{Task[name]}_{Renderlayer}_{Renderpass}" + ) }, { "product_types": [ @@ -482,6 +484,17 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "tasks": [], "template": "{folder[name]}_{variant}" + }, + { + "product_types": [ + "textureSet" + ], + "hosts": [ + "substancedesigner" + ], + "task_types": [], + "tasks": [], + "template": "T_{folder[name]}{variant}" } ], "filter_creator_profiles": [] @@ -555,6 +568,18 @@ DEFAULT_TOOLS_VALUES = { "task_names": [], "template_name": "simpleUnrealTexture" }, + { + "product_types": [ + "image", + "textures", + ], + "hosts": [ + "substancedesigner" + ], + "task_types": [], + "task_names": [], + "template_name": "simpleUnrealTexture" + }, { "product_types": [ "staticMesh", @@ -601,6 +626,18 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "task_names": [], "template_name": "simpleUnrealTextureHero" + }, + { + "product_types": [ + "image", + "textures" + ], + "hosts": [ + "substancedesigner" + ], + "task_types": [], + "task_names": [], + "template_name": "simpleUnrealTextureHero" } ] } diff --git a/tests/client/ayon_core/lib/test_env_tools.py b/tests/client/ayon_core/lib/test_env_tools.py new file mode 100644 index 0000000000..e7aea7fd7d --- /dev/null +++ b/tests/client/ayon_core/lib/test_env_tools.py @@ -0,0 +1,135 @@ +import unittest +from unittest.mock import patch + +from ayon_core.lib.env_tools import ( + CycleError, + DynamicKeyClashError, + parse_env_variables_structure, + compute_env_variables_structure, +) + +# --- Test data --- +COMPUTE_SRC_ENV = { + "COMPUTE_VERSION": "1.0.0", + # Will be available only for darwin + "COMPUTE_ONE_PLATFORM": { + "darwin": "Compute macOs", + }, + "COMPUTE_LOCATION": { + "darwin": "/compute-app-{COMPUTE_VERSION}", + "linux": "/usr/compute-app-{COMPUTE_VERSION}", + "windows": "C:/Program Files/compute-app-{COMPUTE_VERSION}" + }, + "PATH_LIST": { + "darwin": ["{COMPUTE_LOCATION}/bin", "{COMPUTE_LOCATION}/bin2"], + "linux": ["{COMPUTE_LOCATION}/bin", "{COMPUTE_LOCATION}/bin2"], + "windows": ["{COMPUTE_LOCATION}/bin", "{COMPUTE_LOCATION}/bin2"], + }, + "PATH_STR": { + "darwin": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2", + "linux": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2", + "windows": "{COMPUTE_LOCATION}/bin;{COMPUTE_LOCATION}/bin2", + }, +} + +# --- RESULTS --- +# --- Parse results --- +PARSE_RESULT_WINDOWS = { + "COMPUTE_VERSION": "1.0.0", + "COMPUTE_LOCATION": "C:/Program Files/compute-app-{COMPUTE_VERSION}", + "PATH_LIST": "{COMPUTE_LOCATION}/bin;{COMPUTE_LOCATION}/bin2", + "PATH_STR": "{COMPUTE_LOCATION}/bin;{COMPUTE_LOCATION}/bin2", +} + +PARSE_RESULT_LINUX = { + "COMPUTE_VERSION": "1.0.0", + "COMPUTE_LOCATION": "/usr/compute-app-{COMPUTE_VERSION}", + "PATH_LIST": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2", + "PATH_STR": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2", +} + +PARSE_RESULT_DARWIN = { + "COMPUTE_VERSION": "1.0.0", + "COMPUTE_ONE_PLATFORM": "Compute macOs", + "COMPUTE_LOCATION": "/compute-app-{COMPUTE_VERSION}", + "PATH_LIST": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2", + "PATH_STR": "{COMPUTE_LOCATION}/bin:{COMPUTE_LOCATION}/bin2", +} + +# --- Compute results --- +COMPUTE_RESULT_WINDOWS = { + "COMPUTE_VERSION": "1.0.0", + "COMPUTE_LOCATION": "C:/Program Files/compute-app-1.0.0", + "PATH_LIST": ( + "C:/Program Files/compute-app-1.0.0/bin" + ";C:/Program Files/compute-app-1.0.0/bin2" + ), + "PATH_STR": ( + "C:/Program Files/compute-app-1.0.0/bin" + ";C:/Program Files/compute-app-1.0.0/bin2" + ) +} + +COMPUTE_RESULT_LINUX = { + "COMPUTE_VERSION": "1.0.0", + "COMPUTE_LOCATION": "/usr/compute-app-1.0.0", + "PATH_LIST": "/usr/compute-app-1.0.0/bin:/usr/compute-app-1.0.0/bin2", + "PATH_STR": "/usr/compute-app-1.0.0/bin:/usr/compute-app-1.0.0/bin2" +} + +COMPUTE_RESULT_DARWIN = { + "COMPUTE_VERSION": "1.0.0", + "COMPUTE_ONE_PLATFORM": "Compute macOs", + "COMPUTE_LOCATION": "/compute-app-1.0.0", + "PATH_LIST": "/compute-app-1.0.0/bin:/compute-app-1.0.0/bin2", + "PATH_STR": "/compute-app-1.0.0/bin:/compute-app-1.0.0/bin2" +} + + +class EnvParseCompute(unittest.TestCase): + def test_parse_env(self): + with patch("platform.system", return_value="windows"): + result = parse_env_variables_structure(COMPUTE_SRC_ENV) + assert result == PARSE_RESULT_WINDOWS + + with patch("platform.system", return_value="linux"): + result = parse_env_variables_structure(COMPUTE_SRC_ENV) + assert result == PARSE_RESULT_LINUX + + with patch("platform.system", return_value="darwin"): + result = parse_env_variables_structure(COMPUTE_SRC_ENV) + assert result == PARSE_RESULT_DARWIN + + def test_compute_env(self): + with patch("platform.system", return_value="windows"): + result = compute_env_variables_structure( + parse_env_variables_structure(COMPUTE_SRC_ENV) + ) + assert result == COMPUTE_RESULT_WINDOWS + + with patch("platform.system", return_value="linux"): + result = compute_env_variables_structure( + parse_env_variables_structure(COMPUTE_SRC_ENV) + ) + assert result == COMPUTE_RESULT_LINUX + + with patch("platform.system", return_value="darwin"): + result = compute_env_variables_structure( + parse_env_variables_structure(COMPUTE_SRC_ENV) + ) + assert result == COMPUTE_RESULT_DARWIN + + def test_cycle_error(self): + with self.assertRaises(CycleError): + compute_env_variables_structure({ + "KEY_1": "{KEY_2}", + "KEY_2": "{KEY_1}", + }) + + def test_dynamic_key_error(self): + with self.assertRaises(DynamicKeyClashError): + compute_env_variables_structure({ + "KEY_A": "Occupied", + "SUBKEY": "A", + "KEY_{SUBKEY}": "Resolves as occupied key", + }) diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_24_to_23.976_no_legacy.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_24_to_23.976_no_legacy.json new file mode 100644 index 0000000000..108af0f2c1 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_24_to_23.976_no_legacy.json @@ -0,0 +1,51 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 108.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 883159.0 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 755.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 883750.0 + } + }, + "available_image_bounds": null, + "target_url_base": "/mnt/jobs/yahoo_theDog_1132/IN/FOOTAGE/SCANS_LINEAR/Panasonic Rec 709 to ACESCG/Panasonic P2 /A001_S001_S001_T004/", + "name_prefix": "A001_S001_S001_T004.", + "name_suffix": ".exr", + "start_frame": 883750, + "frame_step": 1, + "rate": 1.0, + "frame_zero_padding": 0, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_speed.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_speed.json new file mode 100644 index 0000000000..80dfa34d4c --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_speed.json @@ -0,0 +1,160 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 909986.0387191772 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "Speed", + "effect_name": "LinearTimeWarp", + "time_scalar": 2.0 + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_retime_2x/sh010\", \"task\": null, \"clip_index\": \"37BA620A-6580-A543-ADF3-5A7133F41BB6\", \"hierarchy\": \"shots/hiero_retime_2x\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_retime_2x\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_retime_2x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_retime_2x\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"c60086c3-9ec3-448a-9bc5-6aa9f6af0fd5\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_retime_2x/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"8cdde735-d5a7-4f95-9cff-ded20ff21135\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 176.0, \"sourceOut\": 196.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_retime_2x/sh010\", \"task\": null, \"clip_index\": \"37BA620A-6580-A543-ADF3-5A7133F41BB6\", \"hierarchy\": \"shots/hiero_retime_2x\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_retime_2x\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_retime_2x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_retime_2x\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"c60086c3-9ec3-448a-9bc5-6aa9f6af0fd5\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"8cdde735-d5a7-4f95-9cff-ded20ff21135\", \"label\": \"/shots/hiero_retime_2x/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"064a92fc-5704-4316-8cc9-780e430ae2e5\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_retime_2x/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"37BA620A-6580-A543-ADF3-5A7133F41BB6\"}", + "label": "AYONdata_3c3f54af", + "note": "AYON data container" + }, + "name": "AYONdata_3c3f54af", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "301", + "foundry.source.filename": "output.%07d.exr 948674-948974", + "foundry.source.filesize": "", + "foundry.source.fragments": "301", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.%07d.exr 948674-948974", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%07d.exr 948674-948974", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "948674", + "foundry.source.timecode": "948674", + "foundry.source.umid": "28c4702f-5af7-4980-52c9-6eb875968890", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "301", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1278,718", + "media.exr.displayWindow": "0,0,1279,719", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2025-01-13 14:26:25", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.0948674.exr", + "media.input.filereader": "exr", + "media.input.filesize": "214941", + "media.input.frame": "1", + "media.input.height": "720", + "media.input.mtime": "2025-01-13 14:26:25", + "media.input.width": "1280", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "b13e3153b31d8f14", + "media.nuke.version": "15.0v5", + "padding": 7 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 301.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 948674.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 948674, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 7, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_speed_resolve.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_speed_resolve.json new file mode 100644 index 0000000000..07daaf1548 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_speed_resolve.json @@ -0,0 +1,369 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1001-1099].tif", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 39.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "", + "effect_name": "", + "time_scalar": 2.0 + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-19": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "981": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-19": { + "Value": 0.8, + "Variant Type": "Double" + }, + "981": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/resolve_2x/sh010\", \"task\": null, \"clip_variant\": \"\", \"clip_index\": \"51983d2a-8a54-45fc-b17d-b837bdcb2545\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"/shots/resolve_2x/sh010\", \"episode\": \"ep01\", \"sequence\": \"resolve_2x\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/resolve_2x\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"resolve_2x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"resolve_2x\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"04cd97b0-7e6e-4f58-b8b1-5f1956d53bfb\", \"reviewTrack\": \"Video 1\", \"label\": \"/shots/resolve_2x/sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"cc8b970c-69c1-4eab-b94f-ae41358a80ba\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 86400, \"clipOut\": 86411, \"clipDuration\": 11, \"sourceIn\": 19, \"sourceOut\": 30, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/resolve_2x/sh010\", \"task\": null, \"clip_variant\": \"\", \"clip_index\": \"51983d2a-8a54-45fc-b17d-b837bdcb2545\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"/shots/resolve_2x/sh010\", \"episode\": \"ep01\", \"sequence\": \"resolve_2x\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/resolve_2x\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"resolve_2x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"resolve_2x\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"04cd97b0-7e6e-4f58-b8b1-5f1956d53bfb\", \"reviewTrack\": \"Video 1\", \"parent_instance_id\": \"cc8b970c-69c1-4eab-b94f-ae41358a80ba\", \"label\": \"/shots/resolve_2x/sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"564ef731-c518-4c8f-918d-b27d0c35856c\", \"creator_attributes\": {\"parentInstance\": \"/shots/resolve_2x/sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"51983d2a-8a54-45fc-b17d-b837bdcb2545\", \"publish\": true}" + }, + "clip_index": "51983d2a-8a54-45fc-b17d-b837bdcb2545", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "51983d2a-8a54-45fc-b17d-b837bdcb2545", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/resolve_2x/sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "/shots/resolve_2x/sh010", + "folderPath": "/shots/resolve_2x/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/resolve_2x", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "resolve_2x", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "564ef731-c518-4c8f-918d-b27d0c35856c", + "label": "/shots/resolve_2x/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "cc8b970c-69c1-4eab-b94f-ae41358a80ba", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "resolve_2x", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video 1", + "sequence": "resolve_2x", + "shot": "sh###", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "uuid": "04cd97b0-7e6e-4f58-b8b1-5f1956d53bfb", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "51983d2a-8a54-45fc-b17d-b837bdcb2545", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 11, + "clipIn": 86400, + "clipOut": 86411, + "fps": "from_selection", + "frameEnd": 1012, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 19, + "sourceOut": 30, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "/shots/resolve_2x/sh010", + "folderPath": "/shots/resolve_2x/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/resolve_2x", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "resolve_2x", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "cc8b970c-69c1-4eab-b94f-ae41358a80ba", + "label": "/shots/resolve_2x/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "resolve_2x", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video 1", + "sequence": "resolve_2x", + "shot": "sh###", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "uuid": "04cd97b0-7e6e-4f58-b8b1-5f1956d53bfb", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AYONData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 24.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1001-1099].tif", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 99.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\data\\img_sequence\\tif", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1001, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_time_warp.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_time_warp.json new file mode 100644 index 0000000000..0876dcd179 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_2x_time_warp.json @@ -0,0 +1,181 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 909986.0387191772 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "Speed", + "effect_name": "LinearTimeWarp", + "time_scalar": 2.0 + }, + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 4.0, + "lookup": [ + 2.0, + 1.7039999923706057, + 1.431999991416931, + 1.2079999942779533, + 1.055999998092652, + 1.0, + 1.056000007629395, + 1.208000022888184, + 1.432000034332276, + 1.7040000305175766, + 2.0 + ] + }, + "name": "TimeWarp6", + "effect_name": "TimeWarp" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_img_seq_tw_speed/sh010\", \"task\": null, \"clip_index\": \"699C12C3-07B7-E74E-A8BC-07554560B91E\", \"hierarchy\": \"shots/hiero_img_seq_tw_speed\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_img_seq_tw_speed\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 0, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_img_seq_tw_speed\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_img_seq_tw_speed\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"731977d8-6f06-415d-9086-b04b58a16ce3\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_img_seq_tw_speed/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"d157ce1c-3157-4a34-a8b5-14c881387239\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 0, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 176.0, \"sourceOut\": 196.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_img_seq_tw_speed/sh010\", \"task\": null, \"clip_index\": \"699C12C3-07B7-E74E-A8BC-07554560B91E\", \"hierarchy\": \"shots/hiero_img_seq_tw_speed\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_img_seq_tw_speed\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 0, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_img_seq_tw_speed\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_img_seq_tw_speed\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"731977d8-6f06-415d-9086-b04b58a16ce3\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"d157ce1c-3157-4a34-a8b5-14c881387239\", \"label\": \"/shots/hiero_img_seq_tw_speed/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"daf5d8e4-5698-4a41-90eb-05eea2992dff\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_img_seq_tw_speed/sh010 shotMain\", \"review\": false, \"reviewableSource\": \"clip_media\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"699C12C3-07B7-E74E-A8BC-07554560B91E\"}", + "label": "AYONdata_9f37cdbf", + "note": "AYON data container" + }, + "name": "AYONdata_9f37cdbf", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "301", + "foundry.source.filename": "output.%07d.exr 948674-948974", + "foundry.source.filesize": "", + "foundry.source.fragments": "301", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.%07d.exr 948674-948974", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%07d.exr 948674-948974", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "948674", + "foundry.source.timecode": "948674", + "foundry.source.umid": "28c4702f-5af7-4980-52c9-6eb875968890", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "301", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1278,718", + "media.exr.displayWindow": "0,0,1279,719", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2025-01-13 14:26:25", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.0948674.exr", + "media.input.filereader": "exr", + "media.input.filesize": "214941", + "media.input.frame": "1", + "media.input.height": "720", + "media.input.mtime": "2025-01-13 14:26:25", + "media.input.width": "1280", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "b13e3153b31d8f14", + "media.nuke.version": "15.0v5", + "padding": 7 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 301.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 948674.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 948674, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 7, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_freeze_frame.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_freeze_frame.json new file mode 100644 index 0000000000..05b48370b2 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_freeze_frame.json @@ -0,0 +1,160 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 5.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 909990.8339241028 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "FreezeFrame.1", + "metadata": {}, + "name": "FreezeFrame", + "effect_name": "FreezeFrame", + "time_scalar": 0.0 + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_freeze_frame/sh010\", \"task\": null, \"clip_index\": \"85ABEEEA-6A90-CE47-9DE2-73BAB11EE31D\", \"hierarchy\": \"shots/hiero_freeze_frame\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_freeze_frame\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_freeze_frame\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_freeze_frame\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"08ba1c0a-fc51-4275-b6c8-1cb81381b043\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_freeze_frame/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"892de813-fc78-4d92-b25f-4ea5c4791bb8\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1006, \"clipIn\": 0, \"clipOut\": 4, \"clipDuration\": 5, \"sourceIn\": 181.0, \"sourceOut\": 181.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_freeze_frame/sh010\", \"task\": null, \"clip_index\": \"85ABEEEA-6A90-CE47-9DE2-73BAB11EE31D\", \"hierarchy\": \"shots/hiero_freeze_frame\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_freeze_frame\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_freeze_frame\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_freeze_frame\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"08ba1c0a-fc51-4275-b6c8-1cb81381b043\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"892de813-fc78-4d92-b25f-4ea5c4791bb8\", \"label\": \"/shots/hiero_freeze_frame/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"24eb8386-4c42-4439-ac41-17ec4efb0073\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_freeze_frame/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"85ABEEEA-6A90-CE47-9DE2-73BAB11EE31D\"}", + "label": "AYONdata_a8304fcf", + "note": "AYON data container" + }, + "name": "AYONdata_a8304fcf", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "301", + "foundry.source.filename": "output.%07d.exr 948674-948974", + "foundry.source.filesize": "", + "foundry.source.fragments": "301", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.%07d.exr 948674-948974", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%07d.exr 948674-948974", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "948674", + "foundry.source.timecode": "948674", + "foundry.source.umid": "28c4702f-5af7-4980-52c9-6eb875968890", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "301", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1278,718", + "media.exr.displayWindow": "0,0,1279,719", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2025-01-13 14:26:25", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.0948674.exr", + "media.input.filereader": "exr", + "media.input.filesize": "214941", + "media.input.frame": "1", + "media.input.height": "720", + "media.input.mtime": "2025-01-13 14:26:25", + "media.input.width": "1280", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "b13e3153b31d8f14", + "media.nuke.version": "15.0v5", + "padding": 7 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 301.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 948674.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 948674, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 7, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_multiple_tws.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_multiple_tws.json new file mode 100644 index 0000000000..88f2dbc86c --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_multiple_tws.json @@ -0,0 +1,216 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 909986.0387191772 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 1.0, + "lookup": [ + -5.0, + -3.9440000305175777, + -2.852000034332275, + -1.6880000228881844, + -0.4160000076293944, + 1.0, + 2.5839999923706056, + 4.311999977111817, + 6.147999965667726, + 8.055999969482421, + 10.0 + ] + }, + "name": "TimeWarp3", + "effect_name": "TimeWarp" + }, + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 1.0, + "lookup": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + }, + "name": "TimeWarp4", + "effect_name": "TimeWarp" + }, + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 1.0, + "lookup": [ + 0.0, + -1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -1.0 + ] + }, + "name": "TimeWarp5", + "effect_name": "TimeWarp" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_seq_max_tw/sh010\", \"task\": null, \"clip_index\": \"4C055A68-8354-474A-A6F8-B0CBF9A537CD\", \"hierarchy\": \"shots/hiero_seq_max_tw\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_seq_max_tw\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"5e82a346-17c4-4ccb-a795-35e1a809b243\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_seq_max_tw/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"9cb2a119-8aa6-487e-a46b-9b9ff25323be\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 176.0, \"sourceOut\": 186.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_seq_max_tw/sh010\", \"task\": null, \"clip_index\": \"4C055A68-8354-474A-A6F8-B0CBF9A537CD\", \"hierarchy\": \"shots/hiero_seq_max_tw\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_seq_max_tw\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"5e82a346-17c4-4ccb-a795-35e1a809b243\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"9cb2a119-8aa6-487e-a46b-9b9ff25323be\", \"label\": \"/shots/hiero_seq_max_tw/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"771e41ed-74b0-4fcc-882c-6a248d45a464\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_seq_max_tw/sh010 shotMain\", \"review\": false, \"reviewableSource\": \"clip_media\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"4C055A68-8354-474A-A6F8-B0CBF9A537CD\"}", + "label": "AYONdata_b6896763", + "note": "AYON data container" + }, + "name": "AYONdata_b6896763", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "301", + "foundry.source.filename": "output.%07d.exr 948674-948974", + "foundry.source.filesize": "", + "foundry.source.fragments": "301", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.%07d.exr 948674-948974", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%07d.exr 948674-948974", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "948674", + "foundry.source.timecode": "948674", + "foundry.source.umid": "28c4702f-5af7-4980-52c9-6eb875968890", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "301", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1278,718", + "media.exr.displayWindow": "0,0,1279,719", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2025-01-13 14:26:25", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.0948674.exr", + "media.input.filereader": "exr", + "media.input.filesize": "214941", + "media.input.frame": "1", + "media.input.height": "720", + "media.input.mtime": "2025-01-13 14:26:25", + "media.input.width": "1280", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "b13e3153b31d8f14", + "media.nuke.version": "15.0v5", + "padding": 7 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 301.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 948674.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 948674, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 7, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_0_7.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_0_7.json new file mode 100644 index 0000000000..0939161817 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_0_7.json @@ -0,0 +1,235 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "img_seq_revsh0010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 41.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1040.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "Speed", + "effect_name": "LinearTimeWarp", + "time_scalar": -0.7 + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "clip_index": "e7fede03-d769-4827-a014-35b50170e914", + "flame_sub_products": { + "io.ayon.creators.flame.plate": { + "active": true, + "clipName": "{sequence}{shot}", + "clipRename": true, + "clipVariant": "", + "clip_index": "e7fede03-d769-4827-a014-35b50170e914", + "countFrom": 10, + "countSteps": 10, + "creator_attributes": { + "parentInstance": "/test_robin/img_seq_rev/img_seq_revsh0010 shot", + "review": false, + "reviewableSource": "clip_media" + }, + "creator_identifier": "io.ayon.creators.flame.plate", + "episode": "ep01", + "export_audio": false, + "folder": "test_robin", + "folderName": "img_seq_revsh0010", + "folderPath": "/test_robin/img_seq_rev/img_seq_revsh0010", + "handleEnd": 5, + "handleStart": 5, + "heroTrack": true, + "hierarchy": "test_robin/img_seq_rev", + "hierarchyData": { + "episode": "ep01", + "folder": "test_robin", + "sequence": "img_seq_rev", + "track": "noname1" + }, + "id": "pyblish.avalon.instance", + "includeHandles": false, + "instance_id": "a06107cd-49ad-48cb-a84f-67f53dfd58ef", + "label": "/test_robin/img_seq_rev/img_seq_revsh0010 plateNoname1", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "896c2dad-03a6-4a18-97f5-ecf8f00a6180", + "parents": [ + { + "entity_name": "test_robin", + "folder_type": "folder" + }, + { + "entity_name": "img_seq_rev", + "folder_type": "sequence" + } + ], + "productName": "plateNoname1", + "productType": "plate", + "publish": true, + "publish_attributes": {}, + "retimedFramerange": true, + "retimedHandles": true, + "reviewTrack": null, + "reviewableSource": null, + "segmentIndex": true, + "sequence": "img_seq_rev", + "shot": "sh####", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "useShotName": false, + "use_selection": true, + "vSyncOn": false, + "vSyncTrack": "*", + "variant": "noname1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.flame.shot": { + "active": true, + "clipName": "{sequence}{shot}", + "clipRename": true, + "clipVariant": "", + "clip_index": "e7fede03-d769-4827-a014-35b50170e914", + "countFrom": 10, + "countSteps": 10, + "creator_attributes": { + "clipDuration": 41, + "clipIn": 1, + "clipOut": 41, + "fps": "from_selection", + "frameEnd": 1042, + "frameStart": 1001, + "handleEnd": 5, + "handleStart": 5, + "includeHandles": false, + "retimedFramerange": true, + "retimedHandles": true, + "sourceIn": 1068, + "sourceOut": 1040, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.flame.shot", + "episode": "ep01", + "export_audio": false, + "folder": "test_robin", + "folderName": "img_seq_revsh0010", + "folderPath": "/test_robin/img_seq_rev/img_seq_revsh0010", + "handleEnd": 5, + "handleStart": 5, + "heroTrack": true, + "hierarchy": "test_robin/img_seq_rev", + "hierarchyData": { + "episode": "ep01", + "folder": "test_robin", + "sequence": "img_seq_rev", + "track": "noname1" + }, + "id": "pyblish.avalon.instance", + "includeHandles": false, + "instance_id": "896c2dad-03a6-4a18-97f5-ecf8f00a6180", + "label": "/test_robin/img_seq_rev/img_seq_revsh0010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "test_robin", + "folder_type": "folder" + }, + { + "entity_name": "img_seq_rev", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish": true, + "publish_attributes": {}, + "retimedFramerange": true, + "retimedHandles": true, + "reviewTrack": null, + "reviewableSource": null, + "segmentIndex": true, + "sequence": "img_seq_rev", + "shot": "sh####", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "useShotName": false, + "use_selection": true, + "vSyncOn": false, + "vSyncTrack": "*", + "variant": "main", + "workfileFrameStart": 1001 + } + }, + "publish": true + }, + "name": "AYONData", + "color": "CYAN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 22.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "isSequence": true, + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "/home/ynput/CODE/testing_flame/test_data/sample_media_robin/Samples media/img_sequence/tif/", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_24_to_23.976fps.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_24_to_23.976fps.json new file mode 100644 index 0000000000..b907f53f3d --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_24_to_23.976fps.json @@ -0,0 +1,59 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 32.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 947726.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "", + "effect_name": "LinearTimeWarp", + "time_scalar": -1.0 + } + ], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 7607.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 941478.0 + } + }, + "available_image_bounds": null, + "target_url_base": "/mnt/jobs/yahoo_theDog_1132/IN/FOOTAGE/SCANS_LINEAR/Panasonic Rec 709 to ACESCG/Panasonic P2 /A001_S001_S001_T012/", + "name_prefix": "A001_S001_S001_T012.", + "name_suffix": ".exr", + "start_frame": 941478, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 0, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_no_tc.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_no_tc.json new file mode 100644 index 0000000000..ccf33413ec --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_reverse_speed_no_tc.json @@ -0,0 +1,369 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1000-1099].tif", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 41.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 20.000000000000004 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "", + "effect_name": "", + "time_scalar": -1.0 + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-39": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "961": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-39": { + "Value": 0.8, + "Variant Type": "Double" + }, + "961": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/reverse_speed/sh010\", \"task\": null, \"clip_variant\": \"\", \"clip_index\": \"2cb93726-2f27-41b0-b7f7-f48998327ce8\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"/shots/reverse_speed/sh010\", \"episode\": \"ep01\", \"sequence\": \"reverse_speed\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/reverse_speed\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"reverse_speed\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"reverse_speed\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"d08c8422-29a9-46e9-9d6d-37e2dd9f9f8b\", \"reviewTrack\": null, \"label\": \"/shots/reverse_speed/sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"087e8c66-3ce7-41bf-a27e-3e5f7abc12fb\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1042, \"clipIn\": 86400, \"clipOut\": 86441, \"clipDuration\": 41, \"sourceIn\": 39, \"sourceOut\": 80, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/reverse_speed/sh010\", \"task\": null, \"clip_variant\": \"\", \"clip_index\": \"2cb93726-2f27-41b0-b7f7-f48998327ce8\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"/shots/reverse_speed/sh010\", \"episode\": \"ep01\", \"sequence\": \"reverse_speed\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/reverse_speed\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"reverse_speed\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"reverse_speed\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"d08c8422-29a9-46e9-9d6d-37e2dd9f9f8b\", \"reviewTrack\": null, \"parent_instance_id\": \"087e8c66-3ce7-41bf-a27e-3e5f7abc12fb\", \"label\": \"/shots/reverse_speed/sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"63b97e7f-834f-490d-bba5-ae0e584f4a17\", \"creator_attributes\": {\"parentInstance\": \"/shots/reverse_speed/sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"2cb93726-2f27-41b0-b7f7-f48998327ce8\", \"publish\": true}" + }, + "clip_index": "2cb93726-2f27-41b0-b7f7-f48998327ce8", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "2cb93726-2f27-41b0-b7f7-f48998327ce8", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/reverse_speed/sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "/shots/reverse_speed/sh010", + "folderPath": "/shots/reverse_speed/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/reverse_speed", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "reverse_speed", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "63b97e7f-834f-490d-bba5-ae0e584f4a17", + "label": "/shots/reverse_speed/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "087e8c66-3ce7-41bf-a27e-3e5f7abc12fb", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "reverse_speed", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "reverse_speed", + "shot": "sh###", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "uuid": "d08c8422-29a9-46e9-9d6d-37e2dd9f9f8b", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "2cb93726-2f27-41b0-b7f7-f48998327ce8", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 41, + "clipIn": 86400, + "clipOut": 86441, + "fps": "from_selection", + "frameEnd": 1042, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 39, + "sourceOut": 80, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "/shots/reverse_speed/sh010", + "folderPath": "/shots/reverse_speed/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/reverse_speed", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "reverse_speed", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "087e8c66-3ce7-41bf-a27e-3e5f7abc12fb", + "label": "/shots/reverse_speed/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "reverse_speed", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "reverse_speed", + "shot": "sh###", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "uuid": "d08c8422-29a9-46e9-9d6d-37e2dd9f9f8b", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AYONData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 59.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1000-1099].tif", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 100.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\data\\img_sequence\\tif", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_tw_beyond_range.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_tw_beyond_range.json new file mode 100644 index 0000000000..1a753098d7 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_tw_beyond_range.json @@ -0,0 +1,174 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 909986.0387191772 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 1.0, + "lookup": [ + -5.0, + -3.9440000305175777, + -2.852000034332275, + -1.6880000228881844, + -0.4160000076293944, + 1.0, + 2.5839999923706056, + 4.311999977111817, + 6.147999965667726, + 8.055999969482421, + 10.0 + ] + }, + "name": "TimeWarp3", + "effect_name": "TimeWarp" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_seq_max_tw/sh010\", \"task\": null, \"clip_index\": \"4C055A68-8354-474A-A6F8-B0CBF9A537CD\", \"hierarchy\": \"shots/hiero_seq_max_tw\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_seq_max_tw\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"5e82a346-17c4-4ccb-a795-35e1a809b243\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_seq_max_tw/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"9cb2a119-8aa6-487e-a46b-9b9ff25323be\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 176.0, \"sourceOut\": 186.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_seq_max_tw/sh010\", \"task\": null, \"clip_index\": \"4C055A68-8354-474A-A6F8-B0CBF9A537CD\", \"hierarchy\": \"shots/hiero_seq_max_tw\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_seq_max_tw\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_max_tw\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"5e82a346-17c4-4ccb-a795-35e1a809b243\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"9cb2a119-8aa6-487e-a46b-9b9ff25323be\", \"label\": \"/shots/hiero_seq_max_tw/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"771e41ed-74b0-4fcc-882c-6a248d45a464\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_seq_max_tw/sh010 shotMain\", \"review\": false, \"reviewableSource\": \"clip_media\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"4C055A68-8354-474A-A6F8-B0CBF9A537CD\"}", + "label": "AYONdata_b6896763", + "note": "AYON data container" + }, + "name": "AYONdata_b6896763", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "301", + "foundry.source.filename": "output.%07d.exr 948674-948974", + "foundry.source.filesize": "", + "foundry.source.fragments": "301", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.%07d.exr 948674-948974", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%07d.exr 948674-948974", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "948674", + "foundry.source.timecode": "948674", + "foundry.source.umid": "28c4702f-5af7-4980-52c9-6eb875968890", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "301", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1278,718", + "media.exr.displayWindow": "0,0,1279,719", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2025-01-13 14:26:25", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.0948674.exr", + "media.input.filereader": "exr", + "media.input.filesize": "214941", + "media.input.frame": "1", + "media.input.height": "720", + "media.input.mtime": "2025-01-13 14:26:25", + "media.input.width": "1280", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "b13e3153b31d8f14", + "media.nuke.version": "15.0v5", + "padding": 7 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 301.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 948674.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 948674, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 7, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_23.976_embedded_long_tc.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_23.976_embedded_long_tc.json new file mode 100644 index 0000000000..01d81508d1 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_23.976_embedded_long_tc.json @@ -0,0 +1,174 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "Main088sh110", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 82.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 1937905.9905694576 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/088/Main088sh110\", \"task\": null, \"clip_index\": \"70C9FA86-76A5-A045-A004-3158FB3F27C5\", \"hierarchy\": \"shots/088\", \"folder\": \"shots\", \"episode\": \"404\", \"sequence\": \"088\", \"track\": \"Main\", \"shot\": \"sh110\", \"reviewableSource\": \"Reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1009, \"handleStart\": 8, \"handleEnd\": 8, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"088\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"404\", \"sequence\": \"088\", \"track\": \"Main\"}, \"heroTrack\": true, \"uuid\": \"8b0d1db8-7094-48ba-b2cd-df0d43cfffda\", \"reviewTrack\": \"Reference\", \"review\": true, \"folderName\": \"Main088sh110\", \"label\": \"/shots/088/Main088sh110 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"f6b7f12c-f3a8-44fd-b4e4-acc63ed80bb1\", \"creator_attributes\": {\"workfileFrameStart\": 1009, \"handleStart\": 8, \"handleEnd\": 8, \"frameStart\": 1009, \"frameEnd\": 1091, \"clipIn\": 80, \"clipOut\": 161, \"clipDuration\": 82, \"sourceIn\": 8.0, \"sourceOut\": 89.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Main\", \"folderPath\": \"/shots/088/Main088sh110\", \"task\": null, \"clip_index\": \"70C9FA86-76A5-A045-A004-3158FB3F27C5\", \"hierarchy\": \"shots/088\", \"folder\": \"shots\", \"episode\": \"404\", \"sequence\": \"088\", \"track\": \"Main\", \"shot\": \"sh110\", \"reviewableSource\": \"Reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1009, \"handleStart\": 8, \"handleEnd\": 8, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"088\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"404\", \"sequence\": \"088\", \"track\": \"Main\"}, \"heroTrack\": true, \"uuid\": \"8b0d1db8-7094-48ba-b2cd-df0d43cfffda\", \"reviewTrack\": \"Reference\", \"review\": true, \"folderName\": \"Main088sh110\", \"parent_instance_id\": \"f6b7f12c-f3a8-44fd-b4e4-acc63ed80bb1\", \"label\": \"/shots/088/Main088sh110 plateMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"64b54c11-7ab1-45ef-b156-9ed5d5552b9b\", \"creator_attributes\": {\"parentInstance\": \"/shots/088/Main088sh110 shotMain\", \"review\": true, \"reviewableSource\": \"Reference\"}, \"publish_attributes\": {}}}, \"clip_index\": \"70C9FA86-76A5-A045-A004-3158FB3F27C5\"}", + "label": "AYONdata_6b797112", + "note": "AYON data container" + }, + "name": "AYONdata_6b797112", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "Input - Sony - Linear - Venice S-Gamut3.Cine", + "ayon.source.height": 2160, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 4096, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "Input - Sony - Linear - Venice S-Gamut3.Cine", + "foundry.source.duration": "98", + "foundry.source.filename": "409_083_0015.%04d.exr 1001-1098", + "foundry.source.filesize": "", + "foundry.source.fragments": "98", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "2160", + "foundry.source.layers": "colour", + "foundry.source.path": "X:/prj/AYON_CIRCUIT_TEST/data/OBX_20240729_P159_DOG_409/EXR/409_083_0015/409_083_0015.%04d.exr 1001-1098", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 368", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "409_083_0015.%04d.exr 1001-1098", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1001", + "foundry.source.timecode": "1937896", + "foundry.source.umid": "4b3e13b3-e465-4df4-cb1f-257091b63815", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "4096", + "foundry.timeline.colorSpace": "Input - Sony - Linear - Venice S-Gamut3.Cine", + "foundry.timeline.duration": "98", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABqAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.camera_camera_type": "AXS-R7", + "media.exr.camera_fps": "23.976", + "media.exr.camera_id": "MPC-3610 0010762 Version6.30", + "media.exr.camera_iso": "2500", + "media.exr.camera_lens_type": "Unknown", + "media.exr.camera_monitor_space": "OBX4_LUT_1_Night.cube", + "media.exr.camera_nd_filter": "1", + "media.exr.camera_roll_angle": "0.3", + "media.exr.camera_shutter_angle": "180.0", + "media.exr.camera_shutter_speed": "0.0208333", + "media.exr.camera_shutter_type": "Speed and Angle", + "media.exr.camera_sl_num": "00011434", + "media.exr.camera_tilt_angle": "-7.4", + "media.exr.camera_type": "Sony", + "media.exr.camera_white_kelvin": "3200", + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.clip_details_codec": "F55_X-OCN_ST_4096_2160", + "media.exr.clip_details_pixel_aspect_ratio": "1", + "media.exr.clip_details_shot_frame_rate": "23.98p", + "media.exr.compression": "0", + "media.exr.compressionName": "none", + "media.exr.dataWindow": "0,0,4095,2159", + "media.exr.displayWindow": "0,0,4095,2159", + "media.exr.lineOrder": "0", + "media.exr.owner": "C272C010_240530HO", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.tech_details_aspect_ratio": "1.8963", + "media.exr.tech_details_cdl_sat": "1", + "media.exr.tech_details_cdl_sop": "(1 1 1)(0 0 0)(1 1 1)", + "media.exr.tech_details_gamma_space": "R709 Video", + "media.exr.tech_details_par": "1", + "media.exr.type": "scanlineimage", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2024-07-30 18:51:38", + "media.input.filename": "X:/prj/AYON_CIRCUIT_TEST/data/OBX_20240729_P159_DOG_409/EXR/409_083_0015/409_083_0015.1001.exr", + "media.input.filereader": "exr", + "media.input.filesize": "53120020", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "2160", + "media.input.mtime": "2024-07-30 18:51:38", + "media.input.timecode": "22:25:45:16", + "media.input.width": "4096", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 98.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 1937896.0 + } + }, + "available_image_bounds": null, + "target_url_base": "X:/prj/AYON_CIRCUIT_TEST/data/OBX_20240729_P159_DOG_409/EXR/409_083_0015\\", + "name_prefix": "409_083_0015.", + "name_suffix": ".exr", + "start_frame": 1001, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_freeze_frame.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_freeze_frame.json new file mode 100644 index 0000000000..de665eaea7 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_freeze_frame.json @@ -0,0 +1,159 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 29.970030784606934 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "FreezeFrame.1", + "metadata": {}, + "name": "FreezeFrame", + "effect_name": "FreezeFrame", + "time_scalar": 0.0 + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_qt_freeze_frame/sh010\", \"task\": null, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\", \"hierarchy\": \"shots/hiero_qt_freeze_frame\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_freeze_frame\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_qt_freeze_frame\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_freeze_frame\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"e896c630-8c44-408f-a1a0-bffbe330dbe9\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_qt_freeze_frame/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"75b9112f-6357-4235-8a74-252467d6553d\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 30.0, \"sourceOut\": 30.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_qt_freeze_frame/sh010\", \"task\": null, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\", \"hierarchy\": \"shots/hiero_qt_freeze_frame\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_freeze_frame\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_qt_freeze_frame\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_freeze_frame\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"e896c630-8c44-408f-a1a0-bffbe330dbe9\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"75b9112f-6357-4235-8a74-252467d6553d\", \"label\": \"/shots/hiero_qt_freeze_frame/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"95912bf0-aa1c-47ae-a821-fef410c32687\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_qt_freeze_frame/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\"}", + "label": "AYONdata_e681ec48", + "note": "AYON data container" + }, + "name": "AYONdata_e681ec48", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Gamma2.2", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "error", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "default (Rec 709)", + "com.apple.quicktime.codec": "H.264", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "Gamma2.2", + "foundry.source.duration": "101", + "foundry.source.filename": "qt_no_tc_24fps.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float32) Open Color IO space: 114", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shoottime": "4294967295", + "foundry.source.shortfilename": "qt_no_tc_24fps.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "0", + "foundry.source.type": "QuickTime H.264", + "foundry.source.umid": "16634e88-6450-4727-6c6e-501f4b31b637", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Gamma2.2", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.samplerate": "Invalid", + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-25 17:16:12", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov", + "media.input.filereader": "mov64", + "media.input.filesize": "14631252", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-25 17:16:16", + "media.input.pixel_aspect": "1", + "media.input.timecode": "00:00:00:00", + "media.input.width": "1920", + "media.quicktime.codec_id": "avc1", + "media.quicktime.codec_name": "h264", + "media.quicktime.encoder": "H.264", + "media.quicktime.nclc_matrix": "BT709", + "media.quicktime.nclc_primaries": "ITU-R BT.709", + "media.quicktime.nclc_transfer_function": "ITU-R BT.709", + "media.quicktime.thefoundry.Application": "Nuke", + "media.quicktime.thefoundry.ApplicationVersion": "15.0v5", + "media.quicktime.thefoundry.Colorspace": "Gamma2.2", + "media.quicktime.thefoundry.Writer": "mov64", + "media.quicktime.thefoundry.YCbCrMatrix": "Rec 709", + "media.stream.pixel_format": "yuv420p", + "uk.co.thefoundry.Application": "Nuke", + "uk.co.thefoundry.ApplicationVersion": "15.0v5", + "uk.co.thefoundry.Colorspace": "Gamma2.2", + "uk.co.thefoundry.Writer": "mov64", + "uk.co.thefoundry.YCbCrMatrix": "Rec 709" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_reverse_speed_0_7.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_reverse_speed_0_7.json new file mode 100644 index 0000000000..3ed27bcf8b --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_reverse_speed_0_7.json @@ -0,0 +1,160 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 29.970030784606934 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "Speed", + "effect_name": "LinearTimeWarp", + "time_scalar": -0.699999988079071 + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_qt_neg07x/sh010\", \"task\": null, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\", \"hierarchy\": \"shots/hiero_qt_neg07x\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg07x\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"clip_media\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_qt_neg07x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg07x\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"d7c96d32-6884-452f-9f8c-2383e20ca2db\", \"reviewTrack\": \"clip_media\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_qt_neg07x/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"dae8823d-d664-4afd-9d9d-be20647ad756\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceOut\": 30.0, \"fps\": \"from_selection\", \"sourceIn\": 0}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_qt_neg07x/sh010\", \"task\": null, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\", \"hierarchy\": \"shots/hiero_qt_neg07x\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg07x\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"clip_media\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_qt_neg07x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg07x\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"d7c96d32-6884-452f-9f8c-2383e20ca2db\", \"reviewTrack\": \"clip_media\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"dae8823d-d664-4afd-9d9d-be20647ad756\", \"label\": \"/shots/hiero_qt_neg07x/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"a1aa49c0-49a1-4499-a3ec-1ac35982d92b\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_qt_neg07x/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"clip_media\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\"}", + "label": "AYONdata_26480dbf", + "note": "AYON data container" + }, + "name": "AYONdata_26480dbf", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Gamma2.2", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "error", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "H.264", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "Gamma2.2", + "foundry.source.duration": "101", + "foundry.source.filename": "qt_no_tc_24fps.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float32) Open Color IO space: 114", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shoottime": "4294967295", + "foundry.source.shortfilename": "qt_no_tc_24fps.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "0", + "foundry.source.type": "QuickTime H.264", + "foundry.source.umid": "16634e88-6450-4727-6c6e-501f4b31b637", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Gamma2.2", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.samplerate": "Invalid", + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-25 17:16:12", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov", + "media.input.filereader": "mov64", + "media.input.filesize": "14631252", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-25 17:16:16", + "media.input.pixel_aspect": "1", + "media.input.timecode": "00:00:00:00", + "media.input.width": "1920", + "media.quicktime.codec_id": "avc1", + "media.quicktime.codec_name": "h264", + "media.quicktime.encoder": "H.264", + "media.quicktime.nclc_matrix": "BT709", + "media.quicktime.nclc_primaries": "ITU-R BT.709", + "media.quicktime.nclc_transfer_function": "ITU-R BT.709", + "media.quicktime.thefoundry.Application": "Nuke", + "media.quicktime.thefoundry.ApplicationVersion": "15.0v5", + "media.quicktime.thefoundry.Colorspace": "Gamma2.2", + "media.quicktime.thefoundry.Writer": "mov64", + "media.quicktime.thefoundry.YCbCrMatrix": "Rec 709", + "media.stream.pixel_format": "yuv420p", + "uk.co.thefoundry.Application": "Nuke", + "uk.co.thefoundry.ApplicationVersion": "15.0v5", + "uk.co.thefoundry.Colorspace": "Gamma2.2", + "uk.co.thefoundry.Writer": "mov64", + "uk.co.thefoundry.YCbCrMatrix": "Rec 709" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_reverse_speed_2x.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_reverse_speed_2x.json new file mode 100644 index 0000000000..467bb8d7a1 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_reverse_speed_2x.json @@ -0,0 +1,160 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 29.970030784606934 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "Speed", + "effect_name": "LinearTimeWarp", + "time_scalar": -2.0 + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_qt_neg_2x/sh010\", \"task\": null, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\", \"hierarchy\": \"shots/hiero_qt_neg_2x\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg_2x\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_qt_neg_2x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg_2x\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"a59e3db6-0b60-41f5-827c-9d280547bf31\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_qt_neg_2x/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"f98ac652-ed03-4985-bad9-5b028ceeddba\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 50.0, \"sourceOut\": 30.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_qt_neg_2x/sh010\", \"task\": null, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\", \"hierarchy\": \"shots/hiero_qt_neg_2x\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg_2x\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_qt_neg_2x\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_qt_neg_2x\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"a59e3db6-0b60-41f5-827c-9d280547bf31\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"f98ac652-ed03-4985-bad9-5b028ceeddba\", \"label\": \"/shots/hiero_qt_neg_2x/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"5c9c047c-43fa-42a1-a00f-e9c9d6e5a3c4\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_qt_neg_2x/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"D812B65C-F1C7-DA48-9060-F932A50B2BB4\"}", + "label": "AYONdata_fd6d196e", + "note": "AYON data container" + }, + "name": "AYONdata_fd6d196e", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Gamma2.2", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "error", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "H.264", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "Gamma2.2", + "foundry.source.duration": "101", + "foundry.source.filename": "qt_no_tc_24fps.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float32) Open Color IO space: 114", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shoottime": "4294967295", + "foundry.source.shortfilename": "qt_no_tc_24fps.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "0", + "foundry.source.type": "QuickTime H.264", + "foundry.source.umid": "16634e88-6450-4727-6c6e-501f4b31b637", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Gamma2.2", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.samplerate": "Invalid", + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-25 17:16:12", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov", + "media.input.filereader": "mov64", + "media.input.filesize": "14631252", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-25 17:16:16", + "media.input.pixel_aspect": "1", + "media.input.timecode": "00:00:00:00", + "media.input.width": "1920", + "media.quicktime.codec_id": "avc1", + "media.quicktime.codec_name": "h264", + "media.quicktime.encoder": "H.264", + "media.quicktime.nclc_matrix": "BT709", + "media.quicktime.nclc_primaries": "ITU-R BT.709", + "media.quicktime.nclc_transfer_function": "ITU-R BT.709", + "media.quicktime.thefoundry.Application": "Nuke", + "media.quicktime.thefoundry.ApplicationVersion": "15.0v5", + "media.quicktime.thefoundry.Colorspace": "Gamma2.2", + "media.quicktime.thefoundry.Writer": "mov64", + "media.quicktime.thefoundry.YCbCrMatrix": "Rec 709", + "media.stream.pixel_format": "yuv420p", + "uk.co.thefoundry.Application": "Nuke", + "uk.co.thefoundry.ApplicationVersion": "15.0v5", + "uk.co.thefoundry.Colorspace": "Gamma2.2", + "uk.co.thefoundry.Writer": "mov64", + "uk.co.thefoundry.YCbCrMatrix": "Rec 709" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/qt_no_tc_24fps.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_timewarp.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_timewarp.json new file mode 100644 index 0000000000..88ee7130f4 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_timewarp.json @@ -0,0 +1,174 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 909986.0387191772 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 4.0, + "lookup": [ + 2.0, + 1.8959999809265136, + 1.767999971389771, + 1.59199997138977, + 1.3439999809265135, + 1.0, + 0.5440000181198119, + -0.007999974250793684, + -0.6319999756813051, + -1.3039999847412114, + -2.0 + ] + }, + "name": "TimeWarp2", + "effect_name": "TimeWarp" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/hiero_seq_tw/sh010\", \"task\": null, \"clip_index\": \"27126150-EDFA-9F45-908C-59F5CD1A94E2\", \"hierarchy\": \"shots/hiero_seq_tw\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_tw\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_seq_tw\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_tw\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"5c0e0d32-fa09-4331-afbb-5b194cfa258c\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/hiero_seq_tw/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"b88fe40d-f92d-42b0-b7f6-7cb7a206e878\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1012, \"clipIn\": 0, \"clipOut\": 10, \"clipDuration\": 11, \"sourceIn\": 176.0, \"sourceOut\": 186.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/hiero_seq_tw/sh010\", \"task\": null, \"clip_index\": \"27126150-EDFA-9F45-908C-59F5CD1A94E2\", \"hierarchy\": \"shots/hiero_seq_tw\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_tw\", \"track\": \"Video_1\", \"shot\": \"sh010\", \"reviewableSource\": \"Video 1\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"hiero_seq_tw\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"hiero_seq_tw\", \"track\": \"Video_1\"}, \"heroTrack\": true, \"uuid\": \"5c0e0d32-fa09-4331-afbb-5b194cfa258c\", \"reviewTrack\": \"Video 1\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"b88fe40d-f92d-42b0-b7f6-7cb7a206e878\", \"label\": \"/shots/hiero_seq_tw/sh010 plateVideo_1\", \"newHierarchyIntegration\": true, \"instance_id\": \"e3ea1467-dfaf-48db-bf3c-6cbbbd2cd972\", \"creator_attributes\": {\"parentInstance\": \"/shots/hiero_seq_tw/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"27126150-EDFA-9F45-908C-59F5CD1A94E2\"}", + "label": "AYONdata_ef8f52f1", + "note": "AYON data container" + }, + "name": "AYONdata_ef8f52f1", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "301", + "foundry.source.filename": "output.%07d.exr 948674-948974", + "foundry.source.filesize": "", + "foundry.source.fragments": "301", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.%07d.exr 948674-948974", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%07d.exr 948674-948974", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "948674", + "foundry.source.timecode": "948674", + "foundry.source.umid": "28c4702f-5af7-4980-52c9-6eb875968890", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "301", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1278,718", + "media.exr.displayWindow": "0,0,1279,719", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2025-01-13 14:26:25", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange/output.0948674.exr", + "media.input.filereader": "exr", + "media.input.filesize": "214941", + "media.input.frame": "1", + "media.input.height": "720", + "media.input.mtime": "2025-01-13 14:26:25", + "media.input.width": "1280", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "b13e3153b31d8f14", + "media.nuke.version": "15.0v5", + "padding": 7 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 301.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 948674.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_long_frameRange\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 948674, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 7, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json b/tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json new file mode 100644 index 0000000000..03ed87569b --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/timeline/timeline.json @@ -0,0 +1,2054 @@ +{ + "OTIO_SCHEMA": "Timeline.1", + "metadata": { + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "dt3", + "foundry.timeline.samplerate": "48000", + "openpype.project.lutSetting16Bit": "ACES - ACEScc", + "openpype.project.lutSetting8Bit": "Output - Rec.709", + "openpype.project.lutSettingFloat": "ACES - ACES2065-1", + "openpype.project.lutSettingLog": "ACES - ACEScc", + "openpype.project.lutSettingViewer": "ACES/Rec.709", + "openpype.project.lutSettingWorkingSpace": "ACES - ACEScg", + "openpype.project.lutUseOCIOForExport": true, + "openpype.project.ocioConfigName": "", + "openpype.project.ocioConfigPath": "C:/Program Files/Nuke12.2v3/plugins/OCIOConfigs/configs/aces_1.1/config.ocio", + "openpype.project.useOCIOEnvironmentOverride": true, + "openpype.timeline.height": 1080, + "openpype.timeline.pixelAspect": 1, + "openpype.timeline.width": 1920 + }, + "name": "sq001", + "global_start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86400.0 + }, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "metadata": {}, + "name": "tracks", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "reference", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referenceclip_mediash010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86400.08874841638 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referencesq01sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 52.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86424.08877306872 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referencesq01sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86476.08882648213 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "referencesq01sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 65.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86527.08887886834 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/sq01/referencesq01sh010\", \"task\": null, \"clip_index\": \"8185DC63-DE17-F143-817B-B34C00CECDDF\", \"hierarchy\": \"shots/sq01\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\"}, \"heroTrack\": true, \"uuid\": \"5fa79821-2a65-4f3e-aec5-05471b0f145e\", \"reviewTrack\": null, \"folderName\": \"referencesq01sh010\", \"label\": \"/shots/sq01/referencesq01sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"813286be-1492-47e2-aa4e-a192fdc4294e\", \"creator_attributes\": {\"fps\": \"from_selection\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1066, \"clipIn\": 127, \"clipOut\": 191, \"clipDuration\": 65, \"sourceIn\": 127.0, \"sourceOut\": 191.0}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateReference\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"reference\", \"folderPath\": \"/shots/sq01/referencesq01sh010\", \"task\": null, \"clip_index\": \"8185DC63-DE17-F143-817B-B34C00CECDDF\", \"hierarchy\": \"shots/sq01\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\", \"shot\": \"sh010\", \"reviewableSource\": null, \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"reference\"}, \"heroTrack\": true, \"uuid\": \"5fa79821-2a65-4f3e-aec5-05471b0f145e\", \"reviewTrack\": null, \"folderName\": \"referencesq01sh010\", \"parent_instance_id\": \"813286be-1492-47e2-aa4e-a192fdc4294e\", \"label\": \"/shots/sq01/referencesq01sh010 plateReference\", \"newHierarchyIntegration\": true, \"instance_id\": \"7a9ef903-ec0c-4c0c-9b84-5d5a8cf8e72c\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/referencesq01sh010 shotMain\", \"review\": false, \"reviewableSource\": \"clip_media\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"8185DC63-DE17-F143-817B-B34C00CECDDF\"}", + "label": "AYONdata_1fa7d197", + "note": "AYON data container" + }, + "name": "AYONdata_1fa7d197", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86592.08894563509 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "P01", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 52.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86535.08888708579 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "60", + "foundry.source.filename": "MER_sq001_sh010_P01.%04d.exr 997-1056", + "foundry.source.filesize": "", + "foundry.source.fragments": "60", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh010_P01/MER_sq001_sh010_P01.%04d.exr 997-1056", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh010_P01.%04d.exr 997-1056", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "86531", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "60", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "8", + "media.exr.compressionName": "DWAA", + "media.exr.dataWindow": "0,0,1919,1079", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.dwaCompressionLevel": "90", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh010_P01/MER_sq001_sh010_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1217052", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1080", + "media.input.mtime": "2022-03-06 10:14:37", + "media.input.timecode": "01:00:05:11", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "ffffffffffffffff", + "media.nuke.version": "12.2v3", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 60.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86531.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh010_P01\\", + "name_prefix": "MER_sq001_sh010_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 172800.17749683277 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + }, + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.shot\": {\"id\": \"ayon.create.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.shot\", \"variant\": \"main\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\", \"shot\": \"sh010\", \"reviewableSource\": \"reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": true, \"uuid\": \"e2bcf862-b8b9-4c2c-806f-5ba6e227f782\", \"reviewTrack\": \"reference\", \"review\": true, \"folderName\": \"sh010\", \"label\": \"/shots/test_align/sh010 shotMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"creator_attributes\": {\"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1052, \"clipIn\": 76, \"clipOut\": 126, \"clipDuration\": 51, \"sourceIn\": 0.0, \"sourceOut\": 50.0, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.plate\": {\"id\": \"ayon.create.instance\", \"productType\": \"plate\", \"productName\": \"plateP01\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"P01\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\", \"shot\": \"sh010\", \"reviewableSource\": \"reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": true, \"uuid\": \"e2bcf862-b8b9-4c2c-806f-5ba6e227f782\", \"reviewTrack\": \"reference\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 plateP01\", \"newHierarchyIntegration\": true, \"instance_id\": \"16ae41aa-20c4-4c63-97c6-7666e4d1d30b\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"reference\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.hiero.audio\": {\"id\": \"ayon.create.instance\", \"productType\": \"audio\", \"productName\": \"audioMain\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.audio\", \"variant\": \"main\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\", \"shot\": \"sh010\", \"reviewableSource\": \"reference\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": true, \"uuid\": \"e2bcf862-b8b9-4c2c-806f-5ba6e227f782\", \"reviewTrack\": \"reference\", \"review\": true, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 audioMain\", \"newHierarchyIntegration\": true, \"instance_id\": \"4f31e750-f665-4ef7-8fab-578ebc606d7e\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"FD37E3CA-F66B-1749-80CD-212210AB1C28\"}", + "label": "AYONdata_86163a19", + "note": "AYON data container" + }, + "name": "AYONdata_86163a19", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1920, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "59", + "foundry.source.filename": "MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.filesize": "", + "foundry.source.fragments": "59", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01/MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "172800", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "59", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "8", + "media.exr.compressionName": "DWAA", + "media.exr.dataWindow": "0,0,1919,1079", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.dwaCompressionLevel": "90", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01/MER_sq001_sh020_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1235182", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1080", + "media.input.mtime": "2022-03-06 10:14:41", + "media.input.timecode": "02:00:00:00", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "ffffffffffffffff", + "media.nuke.version": "12.2v3", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 59.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 172800.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01\\", + "name_prefix": "MER_sq001_sh020_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "P01default_twsh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 65.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 345623.3550172907 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "TimeEffect.1", + "metadata": { + "length": 1.0, + "lookup": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + }, + "name": "TimeWarp1", + "effect_name": "TimeWarp" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 1556, + "ayon.source.pixelAspect": 2.0, + "ayon.source.width": 1828, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "73", + "foundry.source.filename": "MER_sq001_sh040_P01.%04d.exr 997-1069", + "foundry.source.filesize": "", + "foundry.source.fragments": "73", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1556", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh040_P01/MER_sq001_sh040_P01.%04d.exr 997-1069", + "foundry.source.pixelAspect": "2", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh040_P01.%04d.exr 997-1069", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "345619", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1828", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "73", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "9", + "media.exr.compressionName": "DWAB", + "media.exr.dataWindow": "0,0,1827,1555", + "media.exr.displayWindow": "0,0,1827,1555", + "media.exr.dwaCompressionLevel": "80", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "2", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:05", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh040_P01/MER_sq001_sh040_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1170604", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1556", + "media.input.mtime": "2022-03-30 13:47:47", + "media.input.timecode": "04:00:00:19", + "media.input.width": "1828", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "2a4", + "media.nuke.version": "12.2v3", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 73.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 345619.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh040_P01\\", + "name_prefix": "MER_sq001_sh040_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "P02", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 76.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 1.0000010271807451 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.plate\": {\"id\": \"ayon.create.instance\", \"productType\": \"plate\", \"productName\": \"plateP02\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"P02\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"881A3D65-A052-DC45-9D3B-304990BD6488\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P02\", \"shot\": \"sh020\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": false, \"uuid\": \"b7416739-1102-4dfd-bac3-771d43018b84\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 plateP02\", \"newHierarchyIntegration\": true, \"instance_id\": \"6cc84e25-e2fa-4f31-9901-85d75e8fd36a\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"clip_media\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"881A3D65-A052-DC45-9D3B-304990BD6488\"}", + "label": "AYONdata_05836436", + "note": "AYON data container" + }, + "name": "AYONdata_05836436", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACEScg", + "ayon.source.height": 1080, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 2048, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACEScg", + "foundry.source.duration": "200", + "foundry.source.filename": "MER_sq001_sh020_P02.%04d.exr 1-200", + "foundry.source.filesize": "", + "foundry.source.fragments": "200", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P02/MER_sq001_sh020_P02.%04d.exr 1-200", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh020_P02.%04d.exr 1-200", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1", + "foundry.source.timecode": "1", + "foundry.source.umid": "bdfbe576-124a-4200-a1c9-daa2dcc3e952", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "2048", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACEScg", + "foundry.timeline.duration": "200", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAQAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "A:{1 0 1 1},B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "9", + "media.exr.compressionName": "DWAB", + "media.exr.dataWindow": "0,358,2047,904", + "media.exr.displayWindow": "0,0,2047,1079", + "media.exr.dwaCompressionLevel": "80", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P02/MER_sq001_sh020_P02.0001.exr", + "media.input.filereader": "exr", + "media.input.filesize": "453070", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2022-03-30 11:29:25", + "media.input.width": "2048", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 200.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 1.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P02\\", + "name_prefix": "MER_sq001_sh020_P02.", + "name_suffix": ".exr", + "start_frame": 1, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "P03", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 76.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "img_sequence_exr", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 26.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 87311.69068479538 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": "1", + "hiero_source_type": "TrackItem", + "json_metadata": "{\"hiero_sub_products\": {\"io.ayon.creators.hiero.plate\": {\"id\": \"ayon.create.instance\", \"productType\": \"plate\", \"productName\": \"plateP03\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.hiero.plate\", \"variant\": \"P03\", \"folderPath\": \"/shots/test_align/sh010\", \"task\": null, \"clip_index\": \"70A463D1-4FDD-E843-90D9-A2FC3978B06B\", \"hierarchy\": \"shots/test_align\", \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P03\", \"shot\": \"sh030\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"entity_type\": \"folder\", \"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"entity_type\": \"sequence\", \"folder_type\": \"sequence\", \"entity_name\": \"test_align\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"test_align\", \"track\": \"P01\"}, \"heroTrack\": false, \"uuid\": \"cd05c022-94ff-4527-bd3e-1533a8347f99\", \"reviewTrack\": null, \"folderName\": \"sh010\", \"parent_instance_id\": \"888195e5-f432-4032-9ef5-3e3b7897f80d\", \"label\": \"/shots/test_align/sh010 plateP03\", \"newHierarchyIntegration\": true, \"instance_id\": \"fb5ea749-0f9b-43a0-b2b5-6dadc6f6af7e\", \"creator_attributes\": {\"parentInstance\": \"/shots/test_align/sh010 shotMain\", \"review\": true, \"reviewableSource\": \"clip_media\", \"publish_effects\": true}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"70A463D1-4FDD-E843-90D9-A2FC3978B06B\"}", + "label": "AYONdata_0b6cdbd7", + "note": "AYON data container" + }, + "name": "AYONdata_0b6cdbd7", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "ayon.source.colorspace": "ACES - ACES2065-1", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 956, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "84", + "foundry.source.filename": "output.%04d.exr 1000-1083", + "foundry.source.filesize": "", + "foundry.source.fragments": "84", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_embedded_tc/output.%04d.exr 1000-1083", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.exr 1000-1083", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "87399", + "foundry.source.umid": "3cd0643b-4ee3-4d94-46dd-7aac61829c84", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "956", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "84", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "0,0,955,684", + "media.exr.displayWindow": "0,0,955,719", + "media.exr.lineOrder": "0", + "media.exr.nuke.input.frame_rate": "24", + "media.exr.nuke.input.timecode": "01:00:41:15", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2024-09-18 08:28:26", + "media.input.filename": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_embedded_tc/output.1000.exr", + "media.input.filereader": "exr", + "media.input.filesize": "457525", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "720", + "media.input.mtime": "2024-09-18 08:28:26", + "media.input.timecode": "01:00:41:15", + "media.input.width": "956", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "f6b6ac187e7c550c", + "media.nuke.version": "15.0v5", + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 84.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 87399.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/Users/robin/OneDrive/Bureau/dev_ayon/data/img_sequence/exr_embedded_tc\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" + }, + { + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "Audio", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86400.08874841638 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh010", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 52.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86424.08877306872 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sh020", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86476.08882648213 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 65.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86527.08887886834 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "sq001", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 24.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 86592.08894563509 + } + }, + "effects": [], + "markers": [], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": { + "ayon.source.colorspace": "Output - Rec.709", + "ayon.source.height": 720, + "ayon.source.pixelAspect": 1.0, + "ayon.source.width": 1280, + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.mov64_decode_video_levels": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "clip.properties.ycbcrmatrix": "0", + "com.apple.quicktime.codec": "ProRes422(LT)", + "foundry.source.audio": "", + "foundry.source.audiobitdepth": "4", + "foundry.source.colourtransform": "Output - Rec.709", + "foundry.source.duration": "216", + "foundry.source.filename": "sq001.mov", + "foundry.source.filesize": "", + "foundry.source.fragments": "1", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "720", + "foundry.source.layers": "colour", + "foundry.source.numaudiochannels": "2", + "foundry.source.originalsamplerate": "48000", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "48000", + "foundry.source.shoottime": "3732770621", + "foundry.source.shortfilename": "sq001.mov", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "0", + "foundry.source.timecode": "86400", + "foundry.source.timecodedropframe": "0", + "foundry.source.type": "QuickTime ProRes422(LT)", + "foundry.source.umid": "db4a2c91-784e-394f-95e1-7bb75b709c7a", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1280", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "Output - Rec.709", + "foundry.timeline.duration": "216", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAHAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAABzAAAAGW1vdjY0X2RlY29kZV92aWRlb19sZXZlbHMAAAAFaW50MzIAAAAAAAAAFm1vdjY0X2ZpcnN0X3RyYWNrX29ubHkAAAAEYm9vbAEAAAASbW92NjRfeWNiY3JfbWF0cml4AAAABWludDMyAAAAAAAAAAdkZWNvZGVyAAAABWludDMyAAAAAAAAABdtb3Y2NF9tYXRjaF9tZXRhX2Zvcm1hdAAAAARib29sAQAAAA9tb3Y2NF9ub19wcmVmaXgAAAAEYm9vbAA=", + "foundry.timeline.samplerate": "48000" + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 216.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:/projects/AY01_VFX_demo/resources/reference/sq001.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Audio" + } + ] + } +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py new file mode 100644 index 0000000000..20f0c05804 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/test_collect_otio_frame_ranges.py @@ -0,0 +1,128 @@ +import os + +import opentimelineio as otio + +from ayon_core.plugins.publish import collect_otio_frame_ranges + + +_RESOURCE_DIR = os.path.join( + os.path.dirname(__file__), + "resources", + "timeline" +) + + +class MockInstance(): + """ Mock pyblish instance for testing purpose. + """ + def __init__(self, data: dict): + self.data = data + self.context = self + + +def _check_expected_frame_range_values( + clip_name: str, + expected_data: dict, + handle_start: int = 10, + handle_end: int = 10, + retimed: bool = False, +): + file_path = os.path.join(_RESOURCE_DIR, "timeline.json") + otio_timeline = otio.schema.Timeline.from_json_file(file_path) + + for otio_clip in otio_timeline.find_clips(): + if otio_clip.name == clip_name: + break + + instance_data = { + "otioClip": otio_clip, + "handleStart": handle_start, + "handleEnd": handle_end, + "workfileFrameStart": 1001, + } + if retimed: + instance_data["shotDurationFromSource"] = True + + instance = MockInstance(instance_data) + + processor = collect_otio_frame_ranges.CollectOtioRanges() + processor.process(instance) + + # Assert expected data is subset of edited instance. + assert expected_data.items() <= instance.data.items() + + +def test_movie_with_timecode(): + """ + Movie clip (with embedded timecode) + available_range = 86531-86590 23.976fps + source_range = 86535-86586 23.976fps + """ + expected_data = { + 'frameStart': 1001, + 'frameEnd': 1052, + 'clipIn': 24, + 'clipOut': 75, + 'clipInH': 14, + 'clipOutH': 85, + 'sourceStart': 86535, + 'sourceStartH': 86525, + 'sourceEnd': 86586, + 'sourceEndH': 86596, + } + + _check_expected_frame_range_values( + "sh010", + expected_data, + ) + + +def test_image_sequence(): + """ + EXR image sequence. + available_range = 87399-87482 24fps + source_range = 87311-87336 23.976fps + """ + expected_data = { + 'frameStart': 1001, + 'frameEnd': 1026, + 'clipIn': 76, + 'clipOut': 101, + 'clipInH': 66, + 'clipOutH': 111, + 'sourceStart': 87399, + 'sourceStartH': 87389, + 'sourceEnd': 87424, + 'sourceEndH': 87434, + } + + _check_expected_frame_range_values( + "img_sequence_exr", + expected_data, + ) + +def test_media_retimed(): + """ + EXR image sequence. + available_range = 345619-345691 23.976fps + source_range = 345623-345687 23.976fps + TimeWarp = frozen frame. + """ + expected_data = { + 'frameStart': 1001, + 'frameEnd': 1065, + 'clipIn': 127, + 'clipOut': 191, + 'clipInH': 117, + 'clipOutH': 201, + 'sourceStart': 1001, + 'sourceStartH': 1001, + 'sourceEnd': 1065, + 'sourceEndH': 1065, + } + + _check_expected_frame_range_values( + "P01default_twsh010", + expected_data, + retimed=True, + ) diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index ea31e1a260..8ad2e44b06 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -103,17 +103,17 @@ def test_image_sequence_with_embedded_tc_and_handles_out_of_range(): # 10 head black handles generated from gap (991-1000) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 991 " - "C:/result/output.%03d.jpg", + "C:/result/output.%04d.jpg", # 10 tail black handles generated from gap (1102-1111) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 1102 " - "C:/result/output.%03d.jpg", + "C:/result/output.%04d.jpg", # Report from source exr (1001-1101) with enforce framerate "/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i " f"C:\\exr_embedded_tc{os.sep}output.%04d.exr -start_number 1001 " - "C:/result/output.%03d.jpg" + "C:/result/output.%04d.jpg" ] assert calls == expected @@ -130,19 +130,20 @@ def test_image_sequence_and_handles_out_of_range(): expected = [ # 5 head black frames generated from gap (991-995) - "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune " - "stillimage -start_number 991 C:/result/output.%03d.jpg", + "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720" + " -tune stillimage -start_number 991 C:/result/output.%04d.jpg", # 9 tail back frames generated from gap (1097-1105) - "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune " - "stillimage -start_number 1097 C:/result/output.%03d.jpg", + "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720" + " -tune stillimage -start_number 1097 C:/result/output.%04d.jpg", # Report from source tiff (996-1096) # 996-1000 = additional 5 head frames # 1001-1095 = source range conformed to 25fps # 1096-1096 = additional 1 tail frames "/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i " - f"C:\\tif_seq{os.sep}output.%04d.tif -start_number 996 C:/result/output.%03d.jpg" + f"C:\\tif_seq{os.sep}output.%04d.tif -start_number 996" + f" C:/result/output.%04d.jpg" ] assert calls == expected @@ -163,7 +164,7 @@ def test_movie_with_embedded_tc_no_gap_handles(): # - duration = 68fr (source) + 20fr (handles) = 88frames = 3.666s "/path/to/ffmpeg -ss 0.16666666666666666 -t 3.6666666666666665 " "-i C:\\data\\qt_embedded_tc.mov -start_number 991 " - "C:/result/output.%03d.jpg" + "C:/result/output.%04d.jpg" ] assert calls == expected @@ -179,13 +180,13 @@ def test_short_movie_head_gap_handles(): expected = [ # 10 head black frames generated from gap (991-1000) - "/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune " - "stillimage -start_number 991 C:/result/output.%03d.jpg", + "/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720" + " -tune stillimage -start_number 991 C:/result/output.%04d.jpg", # source range + 10 tail frames # duration = 50fr (source) + 10fr (tail handle) = 60 fr = 2.4s - "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4 -start_number 1001 " - "C:/result/output.%03d.jpg" + "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4" + " -start_number 1001 C:/result/output.%04d.jpg" ] assert calls == expected @@ -203,12 +204,13 @@ def test_short_movie_tail_gap_handles(): # 10 tail black frames generated from gap (1067-1076) "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " "color=c=black:s=1280x720 -tune stillimage -start_number 1067 " - "C:/result/output.%03d.jpg", + "C:/result/output.%04d.jpg", # 10 head frames + source range # duration = 10fr (head handle) + 66fr (source) = 76fr = 3.16s "/path/to/ffmpeg -ss 1.0416666666666667 -t 3.1666666666666665 -i " - "C:\\data\\qt_no_tc_24fps.mov -start_number 991 C:/result/output.%03d.jpg" + "C:\\data\\qt_no_tc_24fps.mov -start_number 991" + " C:/result/output.%04d.jpg" ] assert calls == expected @@ -234,62 +236,64 @@ def test_multiple_review_clips_no_gap(): expected = [ # 10 head black frames generated from gap (991-1000) - '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune ' - 'stillimage -start_number 991 C:/result/output.%03d.jpg', + '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi' + ' -i color=c=black:s=1280x720 -tune ' + 'stillimage -start_number 991 C:/result/output.%04d.jpg', - # Alternance 25fps tiff sequence and 24fps exr sequence for 100 frames each + # Alternance 25fps tiff sequence and 24fps exr sequence + # for 100 frames each '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1001 C:/result/output.%03d.jpg', + '-start_number 1001 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1102 C:/result/output.%03d.jpg', + '-start_number 1102 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1199 C:/result/output.%03d.jpg', + '-start_number 1198 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1300 C:/result/output.%03d.jpg', + '-start_number 1299 C:/result/output.%04d.jpg', # Repeated 25fps tiff sequence multiple times till the end '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1397 C:/result/output.%03d.jpg', + '-start_number 1395 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1498 C:/result/output.%03d.jpg', + '-start_number 1496 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1599 C:/result/output.%03d.jpg', + '-start_number 1597 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1700 C:/result/output.%03d.jpg', + '-start_number 1698 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1801 C:/result/output.%03d.jpg', + '-start_number 1799 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 1902 C:/result/output.%03d.jpg', + '-start_number 1900 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2003 C:/result/output.%03d.jpg', + '-start_number 2001 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2104 C:/result/output.%03d.jpg', + '-start_number 2102 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-start_number 2205 C:/result/output.%03d.jpg' + '-start_number 2203 C:/result/output.%04d.jpg' ] assert calls == expected @@ -315,16 +319,17 @@ def test_multiple_review_clips_with_gap(): expected = [ # Gap on review track (12 frames) - '/path/to/ffmpeg -t 0.5 -r 24.0 -f lavfi -i color=c=black:s=1280x720 -tune ' - 'stillimage -start_number 991 C:/result/output.%03d.jpg', + '/path/to/ffmpeg -t 0.5 -r 24.0 -f lavfi' + ' -i color=c=black:s=1280x720 -tune ' + 'stillimage -start_number 991 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1003 C:/result/output.%03d.jpg', + '-start_number 1003 C:/result/output.%04d.jpg', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-start_number 1091 C:/result/output.%03d.jpg' + '-start_number 1091 C:/result/output.%04d.jpg' ] assert calls == expected diff --git a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py index 7f9256c6d8..112d00b3e4 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py +++ b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py @@ -64,6 +64,28 @@ def test_movie_embedded_tc_handle(): ) +def test_movie_23fps_qt_embedded_tc(): + """ + Movie clip (embedded timecode 1h) + available_range = 1937896-1937994 23.976fps + source_range = 1937905-1937987 23.97602462768554fps + """ + expected_data = { + 'mediaIn': 1009, + 'mediaOut': 1090, + 'handleStart': 8, + 'handleEnd': 8, + 'speed': 1.0 + } + + _check_expected_retimed_values( + "qt_23.976_embedded_long_tc.json", + expected_data, + handle_start=8, + handle_end=8, + ) + + def test_movie_retime_effect(): """ Movie clip (embedded timecode 1h) @@ -91,6 +113,151 @@ def test_movie_retime_effect(): ) +def test_movie_reverse_speed_2x(): + """ + Movie clip (no timecode) + available files = 0-100 24fps + source_range = 29.97-40.97 23.976fps + speed = -2.0 + """ + expected_data = { + # not exactly 30 because of 23.976 rouding + # https://github.com/AcademySoftwareFoundation/ + # OpenTimelineIO/issues/1822 + 'mediaIn': 30.000000000000004, + 'mediaOut': 51.02199940144827, + 'handleStart': 20, + 'handleEnd': 20, + 'speed': -2.0, + 'versionData': { + 'retime': True, + 'speed': -2.0, + 'timewarps': [], + 'handleStart': 20, + 'handleEnd': 20, + } + } + + _check_expected_retimed_values( + "qt_reverse_speed_2x.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_movie_reverse_speed_0_7x(): + """ + Movie clip (no timecode) + available files = 0-100 24fps + source_range = 29.97-40.97 23.976fps + speed = -0.7 + """ + expected_data = { + 'handleEnd': 7, + 'handleStart': 7, + 'mediaIn': 30.000000000000004, + 'mediaOut': 36.70769965924555, + 'speed': -0.699999988079071, + 'versionData': { + 'handleEnd': 7, + 'handleStart': 7, + 'retime': True, + 'speed': -0.699999988079071, + 'timewarps': [] + } + } + + _check_expected_retimed_values( + "qt_reverse_speed_0_7.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_movie_frozen_frame(): + """ + Movie clip (no timecode) + available files = 0-100 24fps + source_range = 29.97-40.97 23.976fps + speed = 0.0 + """ + expected_data = { + # not exactly 30 because of OTIO rounding + # https://github.com/AcademySoftwareFoundation/ + # OpenTimelineIO/issues/1822 + 'mediaIn': 30.000000000000004, + 'mediaOut': 30.000000000000004, + 'handleStart': 0, + 'handleEnd': 0, + 'speed': 0.0, + 'versionData': { + 'retime': True, + 'speed': 0.0, + 'timewarps': [], + 'handleStart': 0, + 'handleEnd': 0, + } + } + + _check_expected_retimed_values( + "qt_freeze_frame.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_movie_timewarp(): + """ + Movie clip (no timecode) + available files = 0-100 24fps + source_range = 29.97-40.97 23.976fps + speed = timewarp + """ + expected_data = { + 'handleEnd': 10, + 'handleStart': 0, + 'mediaIn': 948852, + 'mediaOut': 948858, + 'speed': 1.0, + 'versionData': {'handleEnd': 10, + 'handleStart': 0, + 'retime': True, + 'speed': 1.0, + 'timewarps': [ + { + 'Class': 'TimeWarp', + 'length': 4.0, + 'lookup': [ + 0.0, + -0.10400001907348644, + -0.23200002861022906, + -0.4080000286102301, + -0.6560000190734865, + -1.0, + -1.455999981880188, + -2.0079999742507937, + -2.631999975681305, + -3.3039999847412114, + -4.0 + ], + 'name': 'TimeWarp2' + } + ] + } + } + + _check_expected_retimed_values( + "qt_timewarp.json", + expected_data, + handle_start=0, + handle_end=10, + ) + + + def test_img_sequence_no_handles(): """ Img sequence clip (no embedded timecode) @@ -187,3 +354,391 @@ def test_img_sequence_conform_to_23_976fps(): handle_start=0, handle_end=8, ) + + +def test_img_sequence_conform_from_24_to_23_976fps(): + """ + Img sequence clip + available files = 883750-884504 24fps + source_range = 883159-883267 23.976fps + + This test ensures such entries do not trigger + the legacy Hiero export compatibility. + """ + expected_data = { + 'mediaIn': 884043, + 'mediaOut': 884150, + 'handleStart': 0, + 'handleEnd': 0, + 'speed': 1.0 + } + + _check_expected_retimed_values( + "img_seq_24_to_23.976_no_legacy.json", + expected_data, + handle_start=0, + handle_end=0, + ) + + +def test_img_sequence_reverse_speed_no_tc(): + """ + Img sequence clip + available files = 0-100 24fps + source_range = 20-41 24fps + """ + expected_data = { + 'mediaIn': 1020, + 'mediaOut': 1060, + 'handleStart': 0, + 'handleEnd': 0, + 'speed': -1.0, + 'versionData': { + 'retime': True, + 'speed': -1.0, + 'timewarps': [], + 'handleStart': 0, + 'handleEnd': 0 + } + } + + _check_expected_retimed_values( + "img_seq_reverse_speed_no_tc.json", + expected_data, + handle_start=0, + handle_end=0, + ) + +def test_img_sequence_reverse_speed_from_24_to_23_976fps(): + """ + Img sequence clip + available files = 941478-949084 24fps + source_range = 947726-947757 23.976fps + """ + expected_data = { + 'mediaIn': 948674, + 'mediaOut': 948705, + 'handleStart': 10, + 'handleEnd': 10, + 'speed': -1.0, + 'versionData': { + 'retime': True, + 'speed': -1.0, + 'timewarps': [], + 'handleStart': 10, + 'handleEnd': 10 + } + } + + _check_expected_retimed_values( + "img_seq_reverse_speed_24_to_23.976fps.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_img_sequence_reverse_speed_0_7(): + """ + Img sequence clip + available files = 1000-1100 24fps + source_range = 1040-1081 25fps + """ + expected_data = { + 'mediaIn': 1040, + 'mediaOut': 1068, + 'handleStart': 4, + 'handleEnd': 4, + 'speed': -0.7, + 'versionData': { + 'retime': True, + 'speed': -0.7, + 'timewarps': [], + 'handleStart': 4, + 'handleEnd': 4 + } + } + + _check_expected_retimed_values( + "img_seq_reverse_speed_0_7.json", + expected_data, + handle_start=5, + handle_end=5, + ) + + +def test_img_sequence_2x_speed(): + """ + Img sequence clip + available files = 948674-948974 25fps + source_range = 948850-948870 23.976fps + speed = 2.0 + """ + expected_data = { + 'mediaIn': 948850, + 'mediaOut': 948871, + 'handleStart': 20, + 'handleEnd': 20, + 'speed': 2.0, + 'versionData': { + 'retime': True, + 'speed': 2.0, + 'timewarps': [], + 'handleStart': 20, + 'handleEnd': 20 + } + } + + _check_expected_retimed_values( + "img_seq_2x_speed.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_img_sequence_2x_speed_resolve(): + """ + Img sequence clip + available files = 0-99 24fps + source_range = 38-49 24fps + speed = 2.0 + """ + expected_data = { + 'mediaIn': 1040, + 'mediaOut': 1061, + 'handleStart': 20, + 'handleEnd': 20, + 'speed': 2.0, + 'versionData': { + 'retime': True, + 'speed': 2.0, + 'timewarps': [], + 'handleStart': 20, + 'handleEnd': 20 + } + } + + _check_expected_retimed_values( + "img_seq_2x_speed_resolve.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_img_sequence_frozen_frame(): + """ + Img sequence clip + available files = 948674-948974 25fps + source_range = 909990.8339241028 + - 909995.8339241028 23.976fps + speed = 0.0 + """ + expected_data = { + 'mediaIn': 948855, + 'mediaOut': 948855, + 'handleStart': 0, + 'handleEnd': 0, + 'speed': 0.0, + 'versionData': { + 'retime': True, + 'speed': 0.0, + 'timewarps': [], + 'handleStart': 0, + 'handleEnd': 0, + } + } + + _check_expected_retimed_values( + "img_seq_freeze_frame.json", + expected_data, + handle_start=10, + handle_end=10, + ) + + +def test_img_sequence_timewarp_beyond_range(): + """ + Img sequence clip + available files = 948674-948974 25fps + source_range = 909990.8339241028 + - 909995.8339241028 23.976fps + timewarp to get from 948845 to 948870 + """ + expected_data = { + 'mediaIn': 948845, + 'mediaOut': 948870, + 'handleStart': 0, + 'handleEnd': 10, + 'speed': 1.0, + 'versionData': {'handleEnd': 10, + 'handleStart': 0, + 'retime': True, + 'speed': 1.0, + 'timewarps': [ + { + 'Class': 'TimeWarp', + 'length': 1.0, + 'lookup': [ + 0.0, + 1.0559999694824223, + 2.147999965667725, + 3.3119999771118156, + 4.583999992370606, + 6.0, + 7.583999992370606, + 9.311999977111817, + 11.147999965667726, + 13.055999969482421, + 15.0 + ], + 'name': 'TimeWarp3' + } + ] + } + } + + _check_expected_retimed_values( + "img_seq_tw_beyond_range.json", + expected_data, + handle_start=0, + handle_end=10, + ) + + +def test_img_sequence_2X_speed_timewarp(): + """ + Img sequence clip + available files = 948674-948974 25fps + source_range = 909990.8339241028 + - 909995.8339241028 23.976fps + speed: 200% + timewarp to get from 948854 to 948874 + """ + expected_data = { + 'mediaIn': 948854, + 'mediaOut': 948874, + 'handleStart': 0, + 'handleEnd': 20, + 'speed': 2.0, + 'versionData': { + 'handleEnd': 20, + 'handleStart': 0, + 'retime': True, + 'speed': 2.0, + 'timewarps': [ + { + 'Class': 'TimeWarp', + 'length': 4.0, + 'lookup': [ + 0.0, + -0.2960000076293945, + -0.568000008583069, + -0.7920000057220469, + -0.944000001907348, + -1.0, + -0.9439999923706051, + -0.791999977111816, + -0.5679999656677239, + -0.29599996948242335, + 0.0 + ], + 'name': 'TimeWarp6' + } + ] + } + } + + _check_expected_retimed_values( + "img_seq_2x_time_warp.json", + expected_data, + handle_start=0, + handle_end=10, + ) + + +def test_img_sequence_multiple_timewarps(): + """ + Img sequence clip + available files = 948674-948974 25fps + source_range = 909990.8339241028 + - 909995.8339241028 23.976fps + multiple timewarps to get from 948842 to 948864 + """ + expected_data = { + 'mediaIn': 948845, + 'mediaOut': 948867, + 'handleStart': 0, + 'handleEnd': 10, + 'speed': 1.0, + 'versionData': { + 'handleEnd': 10, + 'handleStart': 0, + 'retime': True, + 'speed': 1.0, + 'timewarps': [ + { + 'Class': 'TimeWarp', + 'length': 1.0, + 'lookup': [ + 0.0, + 1.0559999694824223, + 2.147999965667725, + 3.3119999771118156, + 4.583999992370606, + 6.0, + 7.583999992370606, + 9.311999977111817, + 11.147999965667726, + 13.055999969482421, + 15.0 + ], + 'name': 'TimeWarp3' + }, + { + 'Class': 'TimeWarp', + 'length': 1.0, + 'lookup': [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + 'name': 'TimeWarp4' + }, + { + 'Class': 'TimeWarp', + 'length': 1.0, + 'lookup': [ + 0.0, + -1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -1.0 + ], + 'name': 'TimeWarp5' + } + ] + } + } + + _check_expected_retimed_values( + "img_seq_multiple_tws.json", + expected_data, + handle_start=0, + handle_end=10, + ) diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 9a9a9a2eff..8324277713 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -240,6 +240,13 @@ function Run-From-Code { & $Poetry $RunArgs @arguments } +function Run-Tests { + $Poetry = "$RepoRoot\.poetry\bin\poetry.exe" + $RunArgs = @( "run", "pytest", "$($RepoRoot)/tests") + + & $Poetry $RunArgs @arguments +} + function Write-Help { <# .SYNOPSIS @@ -256,6 +263,7 @@ function Write-Help { Write-Info -Text " ruff-fix ", "Run Ruff fix for the repository" -Color White, Cyan Write-Info -Text " codespell ", "Run codespell check for the repository" -Color White, Cyan Write-Info -Text " run ", "Run a poetry command in the repository environment" -Color White, Cyan + Write-Info -Text " run-tests ", "Run ayon-core tests" -Color White, Cyan Write-Host "" } @@ -280,6 +288,9 @@ function Resolve-Function { } elseif ($FunctionName -eq "run") { Set-Cwd Run-From-Code + } elseif ($FunctionName -eq "runtests") { + Set-Cwd + Run-Tests } else { Write-Host "Unknown function ""$FunctionName""" Write-Help diff --git a/tools/manage.sh b/tools/manage.sh index 6b0a4d6978..86ae7155c5 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -158,6 +158,7 @@ default_help() { echo -e " ${BWhite}ruff-fix${RST} ${BCyan}Run Ruff fix for the repository${RST}" echo -e " ${BWhite}codespell${RST} ${BCyan}Run codespell check for the repository${RST}" echo -e " ${BWhite}run${RST} ${BCyan}Run a poetry command in the repository environment${RST}" + echo -e " ${BWhite}run-tests${RST} ${BCyan}Run ayon-core tests${RST}" echo "" } @@ -182,6 +183,12 @@ run_command () { "$POETRY_HOME/bin/poetry" run "$@" } +run_tests () { + echo -e "${BIGreen}>>>${RST} Running tests..." + shift; # will remove first arg ("run-tests") from the "$@" + "$POETRY_HOME/bin/poetry" run pytest ./tests +} + main () { detect_python || return 1 @@ -218,6 +225,10 @@ main () { run_command "$@" || return_code=$? exit $return_code ;; + "runtests") + run_tests "$@" || return_code=$? + exit $return_code + ;; esac if [ "$function_name" != "" ]; then