diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c79ca69fca..7fc253b1b8 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,10 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
+ - 1.6.11
+ - 1.6.10
+ - 1.6.9
+ - 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5
diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py
index bf08ccd48c..bc44fd2d2e 100644
--- a/client/ayon_core/addon/interfaces.py
+++ b/client/ayon_core/addon/interfaces.py
@@ -185,6 +185,15 @@ class IPluginPaths(AYONInterface):
"""
return self._get_plugin_paths_by_type("inventory")
+ def get_loader_action_plugin_paths(self) -> list[str]:
+ """Receive loader action plugin paths.
+
+ Returns:
+ list[str]: Paths to loader action plugins.
+
+ """
+ return []
+
class ITrayAddon(AYONInterface):
"""Addon has special procedures when used in Tray tool.
diff --git a/client/ayon_core/host/constants.py b/client/ayon_core/host/constants.py
index 2564c5d54d..1ca33728d8 100644
--- a/client/ayon_core/host/constants.py
+++ b/client/ayon_core/host/constants.py
@@ -1,11 +1,4 @@
-from enum import Enum
-
-
-class StrEnum(str, Enum):
- """A string-based Enum class that allows for string comparison."""
-
- def __str__(self) -> str:
- return self.value
+from ayon_core.lib import StrEnum
class ContextChangeReason(StrEnum):
diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py
index d5629cbf3d..7627c67f06 100644
--- a/client/ayon_core/lib/__init__.py
+++ b/client/ayon_core/lib/__init__.py
@@ -2,6 +2,7 @@
# flake8: noqa E402
"""AYON lib functions."""
+from ._compatibility import StrEnum
from .local_settings import (
IniSettingRegistry,
JSONSettingRegistry,
@@ -142,6 +143,8 @@ from .ayon_info import (
terminal = Terminal
__all__ = [
+ "StrEnum",
+
"IniSettingRegistry",
"JSONSettingRegistry",
"AYONSecureRegistry",
diff --git a/client/ayon_core/lib/_compatibility.py b/client/ayon_core/lib/_compatibility.py
new file mode 100644
index 0000000000..299ed5e233
--- /dev/null
+++ b/client/ayon_core/lib/_compatibility.py
@@ -0,0 +1,8 @@
+from enum import Enum
+
+
+class StrEnum(str, Enum):
+ """A string-based Enum class that allows for string comparison."""
+
+ def __str__(self) -> str:
+ return self.value
diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py
index cb74fea0f1..36c6429f5e 100644
--- a/client/ayon_core/lib/attribute_definitions.py
+++ b/client/ayon_core/lib/attribute_definitions.py
@@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef):
if value is None:
return copy.deepcopy(self.default)
- return list(self._item_values.intersection(value))
+ return [
+ v
+ for v in value
+ if v in self._item_values
+ ]
def is_value_valid(self, value: Any) -> bool:
"""Check if item is available in possible values."""
diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py
index 127bd3bac4..076ee79665 100644
--- a/client/ayon_core/lib/transcoding.py
+++ b/client/ayon_core/lib/transcoding.py
@@ -110,6 +110,15 @@ def deprecated(new_destination):
return _decorator(func)
+class MissingRGBAChannelsError(ValueError):
+ """Raised when we can't find channels to use as RGBA for conversion in
+ input media.
+
+ This may be other channels than solely RGBA, like Z-channel. The error is
+ raised when no matching 'reviewable' channel was found.
+ """
+
+
def get_transcode_temp_directory():
"""Creates temporary folder for transcoding.
@@ -388,6 +397,10 @@ def get_review_info_by_layer_name(channel_names):
...
]
+ This tries to find suitable outputs good for review purposes, by
+ searching for channel names like RGBA, but also XYZ, Z, N, AR, AG, AB
+ channels.
+
Args:
channel_names (list[str]): List of channel names.
@@ -396,7 +409,6 @@ def get_review_info_by_layer_name(channel_names):
"""
layer_names_order = []
- rgba_by_layer_name = collections.defaultdict(dict)
channels_by_layer_name = collections.defaultdict(dict)
for channel_name in channel_names:
@@ -405,45 +417,95 @@ def get_review_info_by_layer_name(channel_names):
if "." in channel_name:
layer_name, last_part = channel_name.rsplit(".", 1)
- channels_by_layer_name[layer_name][channel_name] = last_part
- if last_part.lower() not in {
- "r", "red",
- "g", "green",
- "b", "blue",
- "a", "alpha"
+ # R, G, B, A or X, Y, Z, N, AR, AG, AB, RED, GREEN, BLUE, ALPHA
+ channel = last_part.upper()
+ if channel not in {
+ # Detect RGBA channels
+ "R", "G", "B", "A",
+ # Support fully written out rgba channel names
+ "RED", "GREEN", "BLUE", "ALPHA",
+ # Allow detecting of x, y and z channels, and normal channels
+ "X", "Y", "Z", "N",
+ # red, green and blue alpha/opacity, for colored mattes
+ "AR", "AG", "AB"
}:
continue
if layer_name not in layer_names_order:
layer_names_order.append(layer_name)
- # R, G, B or A
- channel = last_part[0].upper()
- rgba_by_layer_name[layer_name][channel] = channel_name
+
+ channels_by_layer_name[layer_name][channel] = channel_name
# Put empty layer or 'rgba' to the beginning of the list
# - if input has R, G, B, A channels they should be used for review
- # NOTE They are iterated in reversed order because they're inserted to
- # the beginning of 'layer_names_order' -> last added will be first.
- for name in reversed(["", "rgba"]):
- if name in layer_names_order:
- layer_names_order.remove(name)
- layer_names_order.insert(0, name)
+ def _sort(_layer_name: str) -> int:
+ # Prioritize "" layer name
+ # Prioritize layers with RGB channels
+ if _layer_name == "rgba":
+ return 0
+
+ if _layer_name == "":
+ return 1
+
+ channels = channels_by_layer_name[_layer_name]
+ if all(channel in channels for channel in "RGB"):
+ return 2
+ return 10
+ layer_names_order.sort(key=_sort)
output = []
for layer_name in layer_names_order:
- rgba_layer_info = rgba_by_layer_name[layer_name]
- red = rgba_layer_info.get("R")
- green = rgba_layer_info.get("G")
- blue = rgba_layer_info.get("B")
- if not red or not green or not blue:
+ channel_info = channels_by_layer_name[layer_name]
+
+ alpha = channel_info.get("A")
+
+ # RGB channels
+ if all(channel in channel_info for channel in "RGB"):
+ rgb = "R", "G", "B"
+
+ # RGB channels using fully written out channel names
+ elif all(
+ channel in channel_info
+ for channel in ("RED", "GREEN", "BLUE")
+ ):
+ rgb = "RED", "GREEN", "BLUE"
+ alpha = channel_info.get("ALPHA")
+
+ # XYZ channels (position pass)
+ elif all(channel in channel_info for channel in "XYZ"):
+ rgb = "X", "Y", "Z"
+
+ # Colored mattes (as defined in OpenEXR Channel Name standards)
+ elif all(channel in channel_info for channel in ("AR", "AG", "AB")):
+ rgb = "AR", "AG", "AB"
+
+ # Luminance channel (as defined in OpenEXR Channel Name standards)
+ elif "Y" in channel_info:
+ rgb = "Y", "Y", "Y"
+
+ # Has only Z channel (Z-depth layer)
+ elif "Z" in channel_info:
+ rgb = "Z", "Z", "Z"
+
+ # Has only A channel (Alpha layer)
+ elif "A" in channel_info:
+ rgb = "A", "A", "A"
+ alpha = None
+
+ else:
+ # No reviewable channels found
continue
+
+ red = channel_info[rgb[0]]
+ green = channel_info[rgb[1]]
+ blue = channel_info[rgb[2]]
output.append({
"name": layer_name,
"review_channels": {
"R": red,
"G": green,
"B": blue,
- "A": rgba_layer_info.get("A"),
+ "A": alpha,
}
})
return output
@@ -1467,8 +1529,9 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
review_channels = get_convert_rgb_channels(channel_names)
if review_channels is None:
- raise ValueError(
- "Couldn't find channels that can be used for conversion."
+ raise MissingRGBAChannelsError(
+ "Couldn't find channels that can be used for conversion "
+ f"among channels: {channel_names}."
)
red, green, blue, alpha = review_channels
@@ -1482,7 +1545,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
channels_arg += ",A={}".format(float(alpha_default))
input_channels.append("A")
- input_channels_str = ",".join(input_channels)
+ # Make sure channels are unique, but preserve order to avoid oiiotool crash
+ input_channels_str = ",".join(list(dict.fromkeys(input_channels)))
subimages = oiio_input_info.get("subimages")
input_arg = "-i"
diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py
new file mode 100644
index 0000000000..7af3ac1130
--- /dev/null
+++ b/client/ayon_core/pipeline/actions/__init__.py
@@ -0,0 +1,62 @@
+from .structures import (
+ ActionForm,
+)
+from .utils import (
+ webaction_fields_to_attribute_defs,
+)
+from .loader import (
+ LoaderSelectedType,
+ LoaderActionResult,
+ LoaderActionItem,
+ LoaderActionPlugin,
+ LoaderActionSelection,
+ LoaderActionsContext,
+ SelectionEntitiesCache,
+ LoaderSimpleActionPlugin,
+)
+
+from .launcher import (
+ LauncherAction,
+ LauncherActionSelection,
+ discover_launcher_actions,
+ register_launcher_action,
+ register_launcher_action_path,
+)
+
+from .inventory import (
+ InventoryAction,
+ discover_inventory_actions,
+ register_inventory_action,
+ register_inventory_action_path,
+
+ deregister_inventory_action,
+ deregister_inventory_action_path,
+)
+
+
+__all__ = (
+ "ActionForm",
+ "webaction_fields_to_attribute_defs",
+
+ "LoaderSelectedType",
+ "LoaderActionResult",
+ "LoaderActionItem",
+ "LoaderActionPlugin",
+ "LoaderActionSelection",
+ "LoaderActionsContext",
+ "SelectionEntitiesCache",
+ "LoaderSimpleActionPlugin",
+
+ "LauncherAction",
+ "LauncherActionSelection",
+ "discover_launcher_actions",
+ "register_launcher_action",
+ "register_launcher_action_path",
+
+ "InventoryAction",
+ "discover_inventory_actions",
+ "register_inventory_action",
+ "register_inventory_action_path",
+ "deregister_inventory_action",
+ "deregister_inventory_action_path",
+)
diff --git a/client/ayon_core/pipeline/actions/inventory.py b/client/ayon_core/pipeline/actions/inventory.py
new file mode 100644
index 0000000000..2300119336
--- /dev/null
+++ b/client/ayon_core/pipeline/actions/inventory.py
@@ -0,0 +1,108 @@
+import logging
+
+from ayon_core.pipeline.plugin_discover import (
+ discover,
+ register_plugin,
+ register_plugin_path,
+ deregister_plugin,
+ deregister_plugin_path
+)
+from ayon_core.pipeline.load.utils import get_representation_path_from_context
+
+
+class InventoryAction:
+ """A custom action for the scene inventory tool
+
+ If registered the action will be visible in the Right Mouse Button menu
+ under the submenu "Actions".
+
+ """
+
+ label = None
+ icon = None
+ color = None
+ order = 0
+
+ log = logging.getLogger("InventoryAction")
+ log.propagate = True
+
+ @staticmethod
+ def is_compatible(container):
+ """Override function in a custom class
+
+ This method is specifically used to ensure the action can operate on
+ the container.
+
+ Args:
+ container(dict): the data of a loaded asset, see host.ls()
+
+ Returns:
+ bool
+ """
+ return bool(container.get("objectName"))
+
+ def process(self, containers):
+ """Override function in a custom class
+
+ This method will receive all containers even those which are
+ incompatible. It is advised to create a small filter along the lines
+ of this example:
+
+ valid_containers = filter(self.is_compatible(c) for c in containers)
+
+ The return value will need to be a True-ish value to trigger
+ the data_changed signal in order to refresh the view.
+
+ You can return a list of container names to trigger GUI to select
+ treeview items.
+
+ You can return a dict to carry extra GUI options. For example:
+ {
+ "objectNames": [container names...],
+ "options": {"mode": "toggle",
+ "clear": False}
+ }
+ Currently workable GUI options are:
+ - clear (bool): Clear current selection before selecting by action.
+ Default `True`.
+ - mode (str): selection mode, use one of these:
+ "select", "deselect", "toggle". Default is "select".
+
+ Args:
+ containers (list): list of dictionaries
+
+ Return:
+ bool, list or dict
+
+ """
+ return True
+
+ @classmethod
+ def filepath_from_context(cls, context):
+ return get_representation_path_from_context(context)
+
+
+def discover_inventory_actions():
+ actions = discover(InventoryAction)
+ filtered_actions = []
+ for action in actions:
+ if action is not InventoryAction:
+ filtered_actions.append(action)
+
+ return filtered_actions
+
+
+def register_inventory_action(plugin):
+ return register_plugin(InventoryAction, plugin)
+
+
+def deregister_inventory_action(plugin):
+ deregister_plugin(InventoryAction, plugin)
+
+
+def register_inventory_action_path(path):
+ return register_plugin_path(InventoryAction, path)
+
+
+def deregister_inventory_action_path(path):
+ return deregister_plugin_path(InventoryAction, path)
diff --git a/client/ayon_core/pipeline/actions.py b/client/ayon_core/pipeline/actions/launcher.py
similarity index 78%
rename from client/ayon_core/pipeline/actions.py
rename to client/ayon_core/pipeline/actions/launcher.py
index 6892af4252..8d4b514393 100644
--- a/client/ayon_core/pipeline/actions.py
+++ b/client/ayon_core/pipeline/actions/launcher.py
@@ -8,12 +8,8 @@ from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
- deregister_plugin,
- deregister_plugin_path
)
-from .load.utils import get_representation_path_from_context
-
class LauncherActionSelection:
"""Object helper to pass selection to actions.
@@ -390,79 +386,6 @@ class LauncherAction(object):
pass
-class InventoryAction(object):
- """A custom action for the scene inventory tool
-
- If registered the action will be visible in the Right Mouse Button menu
- under the submenu "Actions".
-
- """
-
- label = None
- icon = None
- color = None
- order = 0
-
- log = logging.getLogger("InventoryAction")
- log.propagate = True
-
- @staticmethod
- def is_compatible(container):
- """Override function in a custom class
-
- This method is specifically used to ensure the action can operate on
- the container.
-
- Args:
- container(dict): the data of a loaded asset, see host.ls()
-
- Returns:
- bool
- """
- return bool(container.get("objectName"))
-
- def process(self, containers):
- """Override function in a custom class
-
- This method will receive all containers even those which are
- incompatible. It is advised to create a small filter along the lines
- of this example:
-
- valid_containers = filter(self.is_compatible(c) for c in containers)
-
- The return value will need to be a True-ish value to trigger
- the data_changed signal in order to refresh the view.
-
- You can return a list of container names to trigger GUI to select
- treeview items.
-
- You can return a dict to carry extra GUI options. For example:
- {
- "objectNames": [container names...],
- "options": {"mode": "toggle",
- "clear": False}
- }
- Currently workable GUI options are:
- - clear (bool): Clear current selection before selecting by action.
- Default `True`.
- - mode (str): selection mode, use one of these:
- "select", "deselect", "toggle". Default is "select".
-
- Args:
- containers (list): list of dictionaries
-
- Return:
- bool, list or dict
-
- """
- return True
-
- @classmethod
- def filepath_from_context(cls, context):
- return get_representation_path_from_context(context)
-
-
-# Launcher action
def discover_launcher_actions():
return discover(LauncherAction)
@@ -473,30 +396,3 @@ def register_launcher_action(plugin):
def register_launcher_action_path(path):
return register_plugin_path(LauncherAction, path)
-
-
-# Inventory action
-def discover_inventory_actions():
- actions = discover(InventoryAction)
- filtered_actions = []
- for action in actions:
- if action is not InventoryAction:
- filtered_actions.append(action)
-
- return filtered_actions
-
-
-def register_inventory_action(plugin):
- return register_plugin(InventoryAction, plugin)
-
-
-def deregister_inventory_action(plugin):
- deregister_plugin(InventoryAction, plugin)
-
-
-def register_inventory_action_path(path):
- return register_plugin_path(InventoryAction, path)
-
-
-def deregister_inventory_action_path(path):
- return deregister_plugin_path(InventoryAction, path)
diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py
new file mode 100644
index 0000000000..92de9c6cf8
--- /dev/null
+++ b/client/ayon_core/pipeline/actions/loader.py
@@ -0,0 +1,864 @@
+"""API for actions for loader tool.
+
+Even though the api is meant for the loader tool, the api should be possible
+ to use in a standalone way out of the loader tool.
+
+To use add actions, make sure your addon does inherit from
+ 'IPluginPaths' and implements 'get_loader_action_plugin_paths' which
+ returns paths to python files with loader actions.
+
+The plugin is used to collect available actions for the given context and to
+ execute them. Selection is defined with 'LoaderActionSelection' object
+ that also contains a cache of entities and project anatomy.
+
+Implementing 'get_action_items' allows the plugin to define what actions
+ are shown and available for the selection. Because for a single selection
+ can be shown multiple actions with the same action identifier, the action
+ items also have 'data' attribute which can be used to store additional
+ data for the action (they have to be json-serializable).
+
+The action is triggered by calling the 'execute_action' method. Which takes
+ the action identifier, the selection, the additional data from the action
+ item and form values from the form if any.
+
+Using 'LoaderActionResult' as the output of 'execute_action' can trigger to
+ show a message in UI or to show an additional form ('ActionForm')
+ which would retrigger the action with the values from the form on
+ submitting. That allows handling of multistep actions.
+
+It is also recommended that the plugin does override the 'identifier'
+ attribute. The identifier has to be unique across all plugins.
+ Class name is used by default.
+
+The selection wrapper currently supports the following types of entity types:
+ - version
+ - representation
+It is planned to add 'folder' and 'task' selection in the future.
+
+NOTE: It is possible to trigger 'execute_action' without ever calling
+ 'get_action_items', that can be handy in automations.
+
+The whole logic is wrapped into 'LoaderActionsContext'. It takes care of
+ the discovery of plugins and wraps the collection and execution of
+ action items. Method 'execute_action' on context also requires plugin
+ identifier.
+
+The flow of the logic is (in the loader tool):
+ 1. User selects entities in the UI.
+ 2. Right-click the selected entities.
+ 3. Use 'LoaderActionsContext' to collect items using 'get_action_items'.
+ 4. Show a menu (with submenus) in the UI.
+ 5. If a user selects an action, the action is triggered using
+ 'execute_action'.
+ 5a. If the action returns 'LoaderActionResult', show a 'message' if it is
+ filled and show a form dialog if 'form' is filled.
+ 5b. If the user submitted the form, trigger the action again with the
+ values from the form and repeat from 5a.
+
+"""
+from __future__ import annotations
+
+import os
+import collections
+import copy
+import logging
+from abc import ABC, abstractmethod
+import typing
+from typing import Optional, Any, Callable
+from dataclasses import dataclass
+
+import ayon_api
+
+from ayon_core import AYON_CORE_ROOT
+from ayon_core.lib import StrEnum, Logger
+from ayon_core.host import AbstractHost
+from ayon_core.addon import AddonsManager, IPluginPaths
+from ayon_core.settings import get_studio_settings, get_project_settings
+from ayon_core.pipeline import Anatomy
+from ayon_core.pipeline.plugin_discover import discover_plugins
+
+from .structures import ActionForm
+
+if typing.TYPE_CHECKING:
+ from typing import Union
+
+ DataBaseType = Union[str, int, float, bool]
+ DataType = dict[str, Union[DataBaseType, list[DataBaseType]]]
+
+_PLACEHOLDER = object()
+
+
+class LoaderSelectedType(StrEnum):
+ """Selected entity type."""
+ # folder = "folder"
+ # task = "task"
+ version = "version"
+ representation = "representation"
+
+
+class SelectionEntitiesCache:
+ """Cache of entities used as helper in the selection wrapper.
+
+ It is possible to get entities based on ids with helper methods to get
+ entities, their parents or their children's entities.
+
+ The goal is to avoid multiple API calls for the same entity in multiple
+ action plugins.
+
+ The cache is based on the selected project. Entities are fetched
+ if are not in cache yet.
+ """
+ def __init__(
+ self,
+ project_name: str,
+ project_entity: Optional[dict[str, Any]] = None,
+ folders_by_id: Optional[dict[str, dict[str, Any]]] = None,
+ tasks_by_id: Optional[dict[str, dict[str, Any]]] = None,
+ products_by_id: Optional[dict[str, dict[str, Any]]] = None,
+ versions_by_id: Optional[dict[str, dict[str, Any]]] = None,
+ representations_by_id: Optional[dict[str, dict[str, Any]]] = None,
+ task_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
+ product_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
+ version_ids_by_product_id: Optional[dict[str, set[str]]] = None,
+ representation_ids_by_version_id: Optional[dict[str, set[str]]] = None,
+ ):
+ self._project_name = project_name
+ self._project_entity = project_entity
+ self._folders_by_id = folders_by_id or {}
+ self._tasks_by_id = tasks_by_id or {}
+ self._products_by_id = products_by_id or {}
+ self._versions_by_id = versions_by_id or {}
+ self._representations_by_id = representations_by_id or {}
+
+ self._task_ids_by_folder_id = task_ids_by_folder_id or {}
+ self._product_ids_by_folder_id = product_ids_by_folder_id or {}
+ self._version_ids_by_product_id = version_ids_by_product_id or {}
+ self._representation_ids_by_version_id = (
+ representation_ids_by_version_id or {}
+ )
+
+ def get_project(self) -> dict[str, Any]:
+ """Get project entity"""
+ if self._project_entity is None:
+ self._project_entity = ayon_api.get_project(self._project_name)
+ return copy.deepcopy(self._project_entity)
+
+ def get_folders(
+ self, folder_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ return self._get_entities(
+ folder_ids,
+ self._folders_by_id,
+ "folder_ids",
+ ayon_api.get_folders,
+ )
+
+ def get_tasks(
+ self, task_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ return self._get_entities(
+ task_ids,
+ self._tasks_by_id,
+ "task_ids",
+ ayon_api.get_tasks,
+ )
+
+ def get_products(
+ self, product_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ return self._get_entities(
+ product_ids,
+ self._products_by_id,
+ "product_ids",
+ ayon_api.get_products,
+ )
+
+ def get_versions(
+ self, version_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ return self._get_entities(
+ version_ids,
+ self._versions_by_id,
+ "version_ids",
+ ayon_api.get_versions,
+ )
+
+ def get_representations(
+ self, representation_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ return self._get_entities(
+ representation_ids,
+ self._representations_by_id,
+ "representation_ids",
+ ayon_api.get_representations,
+ )
+
+ def get_folders_tasks(
+ self, folder_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ task_ids = self._fill_parent_children_ids(
+ folder_ids,
+ "folderId",
+ "folder_ids",
+ self._task_ids_by_folder_id,
+ ayon_api.get_tasks,
+ )
+ return self.get_tasks(task_ids)
+
+ def get_folders_products(
+ self, folder_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ product_ids = self._get_folders_products_ids(folder_ids)
+ return self.get_products(product_ids)
+
+ def get_tasks_versions(
+ self, task_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ folder_ids = {
+ task["folderId"]
+ for task in self.get_tasks(task_ids)
+ }
+ product_ids = self._get_folders_products_ids(folder_ids)
+ output = []
+ for version in self.get_products_versions(product_ids):
+ task_id = version["taskId"]
+ if task_id in task_ids:
+ output.append(version)
+ return output
+
+ def get_products_versions(
+ self, product_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ version_ids = self._fill_parent_children_ids(
+ product_ids,
+ "productId",
+ "product_ids",
+ self._version_ids_by_product_id,
+ ayon_api.get_versions,
+ )
+ return self.get_versions(version_ids)
+
+ def get_versions_representations(
+ self, version_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ repre_ids = self._fill_parent_children_ids(
+ version_ids,
+ "versionId",
+ "version_ids",
+ self._representation_ids_by_version_id,
+ ayon_api.get_representations,
+ )
+ return self.get_representations(repre_ids)
+
+ def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]:
+ folder_ids = {
+ task["folderId"]
+ for task in self.get_tasks(task_ids)
+ }
+ return self.get_folders(folder_ids)
+
+ def get_products_folders(
+ self, product_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ folder_ids = {
+ product["folderId"]
+ for product in self.get_products(product_ids)
+ }
+ return self.get_folders(folder_ids)
+
+ def get_versions_products(
+ self, version_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ product_ids = {
+ version["productId"]
+ for version in self.get_versions(version_ids)
+ }
+ return self.get_products(product_ids)
+
+ def get_versions_tasks(
+ self, version_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ task_ids = {
+ version["taskId"]
+ for version in self.get_versions(version_ids)
+ if version["taskId"]
+ }
+ return self.get_tasks(task_ids)
+
+ def get_representations_versions(
+ self, representation_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ version_ids = {
+ repre["versionId"]
+ for repre in self.get_representations(representation_ids)
+ }
+ return self.get_versions(version_ids)
+
+ def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]:
+ return self._fill_parent_children_ids(
+ folder_ids,
+ "folderId",
+ "folder_ids",
+ self._product_ids_by_folder_id,
+ ayon_api.get_products,
+ )
+
+ def _fill_parent_children_ids(
+ self,
+ entity_ids: set[str],
+ parent_key: str,
+ filter_attr: str,
+ parent_mapping: dict[str, set[str]],
+ getter: Callable,
+ ) -> set[str]:
+ if not entity_ids:
+ return set()
+ children_ids = set()
+ missing_ids = set()
+ for entity_id in entity_ids:
+ _children_ids = parent_mapping.get(entity_id)
+ if _children_ids is None:
+ missing_ids.add(entity_id)
+ else:
+ children_ids.update(_children_ids)
+ if missing_ids:
+ entities_by_parent_id = collections.defaultdict(set)
+ for entity in getter(
+ self._project_name,
+ fields={"id", parent_key},
+ **{filter_attr: missing_ids},
+ ):
+ child_id = entity["id"]
+ children_ids.add(child_id)
+ entities_by_parent_id[entity[parent_key]].add(child_id)
+
+ for entity_id in missing_ids:
+ parent_mapping[entity_id] = entities_by_parent_id[entity_id]
+
+ return children_ids
+
+ def _get_entities(
+ self,
+ entity_ids: set[str],
+ cache_var: dict[str, Any],
+ filter_arg: str,
+ getter: Callable,
+ ) -> list[dict[str, Any]]:
+ if not entity_ids:
+ return []
+
+ output = []
+ missing_ids: set[str] = set()
+ for entity_id in entity_ids:
+ entity = cache_var.get(entity_id)
+ if entity_id not in cache_var:
+ missing_ids.add(entity_id)
+ cache_var[entity_id] = None
+ elif entity:
+ output.append(entity)
+
+ if missing_ids:
+ for entity in getter(
+ self._project_name,
+ **{filter_arg: missing_ids}
+ ):
+ output.append(entity)
+ cache_var[entity["id"]] = entity
+ return output
+
+
+class LoaderActionSelection:
+ """Selection of entities for loader actions.
+
+ Selection tells action plugins what exactly is selected in the tool and
+ which ids.
+
+ Contains entity cache which can be used to get entities by their ids. Or
+ to get project settings and anatomy.
+
+ """
+ def __init__(
+ self,
+ project_name: str,
+ selected_ids: set[str],
+ selected_type: LoaderSelectedType,
+ *,
+ project_anatomy: Optional[Anatomy] = None,
+ project_settings: Optional[dict[str, Any]] = None,
+ entities_cache: Optional[SelectionEntitiesCache] = None,
+ ):
+ self._project_name = project_name
+ self._selected_ids = selected_ids
+ self._selected_type = selected_type
+
+ self._project_anatomy = project_anatomy
+ self._project_settings = project_settings
+
+ if entities_cache is None:
+ entities_cache = SelectionEntitiesCache(project_name)
+ self._entities_cache = entities_cache
+
+ def get_entities_cache(self) -> SelectionEntitiesCache:
+ return self._entities_cache
+
+ def get_project_name(self) -> str:
+ return self._project_name
+
+ def get_selected_ids(self) -> set[str]:
+ return set(self._selected_ids)
+
+ def get_selected_type(self) -> str:
+ return self._selected_type
+
+ def get_project_settings(self) -> dict[str, Any]:
+ if self._project_settings is None:
+ self._project_settings = get_project_settings(self._project_name)
+ return copy.deepcopy(self._project_settings)
+
+ def get_project_anatomy(self) -> Anatomy:
+ if self._project_anatomy is None:
+ self._project_anatomy = Anatomy(
+ self._project_name,
+ project_entity=self.get_entities_cache().get_project(),
+ )
+ return self._project_anatomy
+
+ project_name = property(get_project_name)
+ selected_ids = property(get_selected_ids)
+ selected_type = property(get_selected_type)
+ project_settings = property(get_project_settings)
+ project_anatomy = property(get_project_anatomy)
+ entities = property(get_entities_cache)
+
+ # --- Helper methods ---
+ def versions_selected(self) -> bool:
+ """Selected entity type is version.
+
+ Returns:
+ bool: True if selected entity type is version.
+
+ """
+ return self._selected_type == LoaderSelectedType.version
+
+ def representations_selected(self) -> bool:
+ """Selected entity type is representation.
+
+ Returns:
+ bool: True if selected entity type is representation.
+
+ """
+ return self._selected_type == LoaderSelectedType.representation
+
+ def get_selected_version_entities(self) -> list[dict[str, Any]]:
+ """Retrieve selected version entities.
+
+ An empty list is returned if 'version' is not the selected
+ entity type.
+
+ Returns:
+ list[dict[str, Any]]: List of selected version entities.
+
+ """
+ if self.versions_selected():
+ return self.entities.get_versions(self.selected_ids)
+ return []
+
+ def get_selected_representation_entities(self) -> list[dict[str, Any]]:
+ """Retrieve selected representation entities.
+
+ An empty list is returned if 'representation' is not the selected
+ entity type.
+
+ Returns:
+ list[dict[str, Any]]: List of selected representation entities.
+
+ """
+ if self.representations_selected():
+ return self.entities.get_representations(self.selected_ids)
+ return []
+
+
+@dataclass
+class LoaderActionItem:
+ """Item of loader action.
+
+ Action plugins return these items as possible actions to run for a given
+ context.
+
+ Because the action item can be related to a specific entity
+ and not the whole selection, they also have to define the entity type
+ and ids to be executed on.
+
+ Attributes:
+ label (str): Text shown in UI.
+ order (int): Order of the action in UI.
+ group_label (Optional[str]): Label of the group to which the action
+ belongs.
+ icon (Optional[dict[str, Any]): Icon definition.
+ data (Optional[DataType]): Action item data.
+ identifier (Optional[str]): Identifier of the plugin which
+ created the action item. Is filled automatically. Is not changed
+ if is filled -> can lead to different plugin.
+
+ """
+ label: str
+ order: int = 0
+ group_label: Optional[str] = None
+ icon: Optional[dict[str, Any]] = None
+ data: Optional[DataType] = None
+ # Is filled automatically
+ identifier: str = None
+
+
+@dataclass
+class LoaderActionResult:
+ """Result of loader action execution.
+
+ Attributes:
+ message (Optional[str]): Message to show in UI.
+ success (bool): If the action was successful. Affects color of
+ the message.
+ form (Optional[ActionForm]): Form to show in UI.
+ form_values (Optional[dict[str, Any]]): Values for the form. Can be
+ used if the same form is re-shown e.g. because a user forgot to
+ fill a required field.
+
+ """
+ message: Optional[str] = None
+ success: bool = True
+ form: Optional[ActionForm] = None
+ form_values: Optional[dict[str, Any]] = None
+
+ def to_json_data(self) -> dict[str, Any]:
+ form = self.form
+ if form is not None:
+ form = form.to_json_data()
+ return {
+ "message": self.message,
+ "success": self.success,
+ "form": form,
+ "form_values": self.form_values,
+ }
+
+ @classmethod
+ def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult":
+ form = data["form"]
+ if form is not None:
+ data["form"] = ActionForm.from_json_data(form)
+ return LoaderActionResult(**data)
+
+
+class LoaderActionPlugin(ABC):
+ """Plugin for loader actions.
+
+ Plugin is responsible for getting action items and executing actions.
+
+ """
+ _log: Optional[logging.Logger] = None
+ enabled: bool = True
+
+ def __init__(self, context: "LoaderActionsContext") -> None:
+ self._context = context
+ self.apply_settings(context.get_studio_settings())
+
+ def apply_settings(self, studio_settings: dict[str, Any]) -> None:
+ """Apply studio settings to the plugin.
+
+ Args:
+ studio_settings (dict[str, Any]): Studio settings.
+
+ """
+ pass
+
+ @property
+ def log(self) -> logging.Logger:
+ if self._log is None:
+ self._log = Logger.get_logger(self.__class__.__name__)
+ return self._log
+
+ @property
+ def identifier(self) -> str:
+ """Identifier of the plugin.
+
+ Returns:
+ str: Plugin identifier.
+
+ """
+ return self.__class__.__name__
+
+ @property
+ def host_name(self) -> Optional[str]:
+ """Name of the current host."""
+ return self._context.get_host_name()
+
+ @abstractmethod
+ def get_action_items(
+ self, selection: LoaderActionSelection
+ ) -> list[LoaderActionItem]:
+ """Action items for the selection.
+
+ Args:
+ selection (LoaderActionSelection): Selection.
+
+ Returns:
+ list[LoaderActionItem]: Action items.
+
+ """
+ pass
+
+ @abstractmethod
+ def execute_action(
+ self,
+ selection: LoaderActionSelection,
+ data: Optional[DataType],
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ """Execute an action.
+
+ Args:
+ selection (LoaderActionSelection): Selection wrapper. Can be used
+ to get entities or get context of original selection.
+ data (Optional[DataType]): Additional action item data.
+ form_values (dict[str, Any]): Attribute values.
+
+ Returns:
+ Optional[LoaderActionResult]: Result of the action execution.
+
+ """
+ pass
+
+
+class LoaderActionsContext:
+ """Wrapper for loader actions and their logic.
+
+ Takes care about the public api of loader actions and internal logic like
+ discovery and initialization of plugins.
+
+ """
+ def __init__(
+ self,
+ studio_settings: Optional[dict[str, Any]] = None,
+ addons_manager: Optional[AddonsManager] = None,
+ host: Optional[AbstractHost] = _PLACEHOLDER,
+ ) -> None:
+ self._log = Logger.get_logger(self.__class__.__name__)
+
+ self._addons_manager = addons_manager
+ self._host = host
+
+ # Attributes that are re-cached on reset
+ self._studio_settings = studio_settings
+ self._plugins = None
+
+ def reset(
+ self, studio_settings: Optional[dict[str, Any]] = None
+ ) -> None:
+ """Reset context cache.
+
+ Reset plugins and studio settings to reload them.
+
+ Notes:
+ Does not reset the cache of AddonsManger because there should not
+ be a reason to do so.
+
+ """
+ self._studio_settings = studio_settings
+ self._plugins = None
+
+ def get_addons_manager(self) -> AddonsManager:
+ if self._addons_manager is None:
+ self._addons_manager = AddonsManager(
+ settings=self.get_studio_settings()
+ )
+ return self._addons_manager
+
+ def get_host(self) -> Optional[AbstractHost]:
+ """Get current host integration.
+
+ Returns:
+ Optional[AbstractHost]: Host integration. Can be None if host
+ integration is not registered -> probably not used in the
+ host integration process.
+
+ """
+ if self._host is _PLACEHOLDER:
+ from ayon_core.pipeline import registered_host
+
+ self._host = registered_host()
+ return self._host
+
+ def get_host_name(self) -> Optional[str]:
+ host = self.get_host()
+ if host is None:
+ return None
+ return host.name
+
+ def get_studio_settings(self) -> dict[str, Any]:
+ if self._studio_settings is None:
+ self._studio_settings = get_studio_settings()
+ return copy.deepcopy(self._studio_settings)
+
+ def get_action_items(
+ self, selection: LoaderActionSelection
+ ) -> list[LoaderActionItem]:
+ """Collect action items from all plugins for given selection.
+
+ Args:
+ selection (LoaderActionSelection): Selection wrapper.
+
+ """
+ output = []
+ for plugin_id, plugin in self._get_plugins().items():
+ try:
+ for action_item in plugin.get_action_items(selection):
+ if action_item.identifier is None:
+ action_item.identifier = plugin_id
+ output.append(action_item)
+
+ except Exception:
+ self._log.warning(
+ "Failed to get action items for"
+ f" plugin '{plugin.identifier}'",
+ exc_info=True,
+ )
+ return output
+
+ def execute_action(
+ self,
+ identifier: str,
+ selection: LoaderActionSelection,
+ data: Optional[DataType],
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ """Trigger action execution.
+
+ Args:
+ identifier (str): Identifier of the plugin.
+ selection (LoaderActionSelection): Selection wrapper. Can be used
+ to get what is selected in UI and to get access to entity
+ cache.
+ data (Optional[DataType]): Additional action item data.
+ form_values (dict[str, Any]): Form values related to action.
+ Usually filled if action returned response with form.
+
+ """
+ plugins_by_id = self._get_plugins()
+ plugin = plugins_by_id[identifier]
+ return plugin.execute_action(
+ selection,
+ data,
+ form_values,
+ )
+
+ def _get_plugins(self) -> dict[str, LoaderActionPlugin]:
+ if self._plugins is None:
+ addons_manager = self.get_addons_manager()
+ all_paths = [
+ os.path.join(AYON_CORE_ROOT, "plugins", "loader")
+ ]
+ for addon in addons_manager.addons:
+ if not isinstance(addon, IPluginPaths):
+ continue
+ paths = addon.get_loader_action_plugin_paths()
+ if paths:
+ all_paths.extend(paths)
+
+ result = discover_plugins(LoaderActionPlugin, all_paths)
+ result.log_report()
+ plugins = {}
+ for cls in result.plugins:
+ try:
+ plugin = cls(self)
+ if not plugin.enabled:
+ continue
+
+ plugin_id = plugin.identifier
+ if plugin_id not in plugins:
+ plugins[plugin_id] = plugin
+ continue
+
+ self._log.warning(
+ f"Duplicated plugins identifier found '{plugin_id}'."
+ )
+
+ except Exception:
+ self._log.warning(
+ f"Failed to initialize plugin '{cls.__name__}'",
+ exc_info=True,
+ )
+ self._plugins = plugins
+ return self._plugins
+
+
+class LoaderSimpleActionPlugin(LoaderActionPlugin):
+ """Simple action plugin.
+
+ This action will show exactly one action item defined by attributes
+ on the class.
+
+ Attributes:
+ label: Label of the action item.
+ order: Order of the action item.
+ group_label: Label of the group to which the action belongs.
+ icon: Icon definition shown next to label.
+
+ """
+
+ label: Optional[str] = None
+ order: int = 0
+ group_label: Optional[str] = None
+ icon: Optional[dict[str, Any]] = None
+
+ @abstractmethod
+ def is_compatible(self, selection: LoaderActionSelection) -> bool:
+ """Check if plugin is compatible with selection.
+
+ Args:
+ selection (LoaderActionSelection): Selection information.
+
+ Returns:
+ bool: True if plugin is compatible with selection.
+
+ """
+ pass
+
+ @abstractmethod
+ def execute_simple_action(
+ self,
+ selection: LoaderActionSelection,
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ """Process action based on selection.
+
+ Args:
+ selection (LoaderActionSelection): Selection information.
+ form_values (dict[str, Any]): Values from a form if there are any.
+
+ Returns:
+ Optional[LoaderActionResult]: Result of the action.
+
+ """
+ pass
+
+ def get_action_items(
+ self, selection: LoaderActionSelection
+ ) -> list[LoaderActionItem]:
+ if self.is_compatible(selection):
+ label = self.label or self.__class__.__name__
+ return [
+ LoaderActionItem(
+ label=label,
+ order=self.order,
+ group_label=self.group_label,
+ icon=self.icon,
+ )
+ ]
+ return []
+
+ def execute_action(
+ self,
+ selection: LoaderActionSelection,
+ data: Optional[DataType],
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ return self.execute_simple_action(selection, form_values)
diff --git a/client/ayon_core/pipeline/actions/structures.py b/client/ayon_core/pipeline/actions/structures.py
new file mode 100644
index 0000000000..0283a7a272
--- /dev/null
+++ b/client/ayon_core/pipeline/actions/structures.py
@@ -0,0 +1,60 @@
+from dataclasses import dataclass
+from typing import Optional, Any
+
+from ayon_core.lib.attribute_definitions import (
+ AbstractAttrDef,
+ serialize_attr_defs,
+ deserialize_attr_defs,
+)
+
+
+@dataclass
+class ActionForm:
+ """Form for loader action.
+
+ If an action needs to collect information from a user before or during of
+ the action execution, it can return a response with a form. When the
+ form is submitted, a new execution of the action is triggered.
+
+ It is also possible to just show a label message without the submit
+ button to make sure the user has seen the message.
+
+ Attributes:
+ title (str): Title of the form -> title of the window.
+ fields (list[AbstractAttrDef]): Fields of the form.
+ submit_label (Optional[str]): Label of the submit button. Is hidden
+ if is set to None.
+ submit_icon (Optional[dict[str, Any]]): Icon definition of the submit
+ button.
+ cancel_label (Optional[str]): Label of the cancel button. Is hidden
+ if is set to None. User can still close the window tho.
+ cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel
+ button.
+
+ """
+ title: str
+ fields: list[AbstractAttrDef]
+ submit_label: Optional[str] = "Submit"
+ submit_icon: Optional[dict[str, Any]] = None
+ cancel_label: Optional[str] = "Cancel"
+ cancel_icon: Optional[dict[str, Any]] = None
+
+ def to_json_data(self) -> dict[str, Any]:
+ fields = self.fields
+ if fields is not None:
+ fields = serialize_attr_defs(fields)
+ return {
+ "title": self.title,
+ "fields": fields,
+ "submit_label": self.submit_label,
+ "submit_icon": self.submit_icon,
+ "cancel_label": self.cancel_label,
+ "cancel_icon": self.cancel_icon,
+ }
+
+ @classmethod
+ def from_json_data(cls, data: dict[str, Any]) -> "ActionForm":
+ fields = data["fields"]
+ if fields is not None:
+ data["fields"] = deserialize_attr_defs(fields)
+ return cls(**data)
diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py
new file mode 100644
index 0000000000..3502300ead
--- /dev/null
+++ b/client/ayon_core/pipeline/actions/utils.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+import uuid
+from typing import Any
+
+from ayon_core.lib.attribute_definitions import (
+ AbstractAttrDef,
+ UILabelDef,
+ BoolDef,
+ TextDef,
+ NumberDef,
+ EnumDef,
+ HiddenDef,
+)
+
+
+def webaction_fields_to_attribute_defs(
+ fields: list[dict[str, Any]]
+) -> list[AbstractAttrDef]:
+ """Helper function to convert fields definition from webactions form.
+
+ Convert form fields to attribute definitions to be able to display them
+ using attribute definitions.
+
+ Args:
+ fields (list[dict[str, Any]]): Fields from webaction form.
+
+ Returns:
+ list[AbstractAttrDef]: Converted attribute definitions.
+
+ """
+ attr_defs = []
+ for field in fields:
+ field_type = field["type"]
+ attr_def = None
+ if field_type == "label":
+ label = field.get("value")
+ if label is None:
+ label = field.get("text")
+ attr_def = UILabelDef(
+ label, key=uuid.uuid4().hex
+ )
+ elif field_type == "boolean":
+ value = field["value"]
+ if isinstance(value, str):
+ value = value.lower() == "true"
+
+ attr_def = BoolDef(
+ field["name"],
+ default=value,
+ label=field.get("label"),
+ )
+ elif field_type == "text":
+ attr_def = TextDef(
+ field["name"],
+ default=field.get("value"),
+ label=field.get("label"),
+ placeholder=field.get("placeholder"),
+ multiline=field.get("multiline", False),
+ regex=field.get("regex"),
+ # syntax=field["syntax"],
+ )
+ elif field_type in ("integer", "float"):
+ value = field.get("value")
+ if isinstance(value, str):
+ if field_type == "integer":
+ value = int(value)
+ else:
+ value = float(value)
+ attr_def = NumberDef(
+ field["name"],
+ default=value,
+ label=field.get("label"),
+ decimals=0 if field_type == "integer" else 5,
+ # placeholder=field.get("placeholder"),
+ minimum=field.get("min"),
+ maximum=field.get("max"),
+ )
+ elif field_type in ("select", "multiselect"):
+ attr_def = EnumDef(
+ field["name"],
+ items=field["options"],
+ default=field.get("value"),
+ label=field.get("label"),
+ multiselection=field_type == "multiselect",
+ )
+ elif field_type == "hidden":
+ attr_def = HiddenDef(
+ field["name"],
+ default=field.get("value"),
+ )
+
+ if attr_def is None:
+ print(f"Unknown config field type: {field_type}")
+ attr_def = UILabelDef(
+ f"Unknown field type '{field_type}",
+ key=uuid.uuid4().hex
+ )
+ attr_defs.append(attr_def)
+ return attr_defs
diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py
index b2be377b42..fecb3a5ca4 100644
--- a/client/ayon_core/pipeline/create/structures.py
+++ b/client/ayon_core/pipeline/create/structures.py
@@ -137,6 +137,7 @@ class AttributeValues:
if value is None:
continue
converted_value = attr_def.convert_value(value)
+ # QUESTION Could we just use converted value all the time?
if converted_value == value:
self._data[attr_def.key] = value
@@ -245,11 +246,11 @@ class AttributeValues:
def _update(self, value):
changes = {}
- for key, value in dict(value).items():
- if key in self._data and self._data.get(key) == value:
+ for key, key_value in dict(value).items():
+ if key in self._data and self._data.get(key) == key_value:
continue
- self._data[key] = value
- changes[key] = value
+ self._data[key] = key_value
+ changes[key] = key_value
return changes
def _pop(self, key, default):
diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py
index a5053844b9..265d79b53e 100644
--- a/client/ayon_core/pipeline/farm/pyblish_functions.py
+++ b/client/ayon_core/pipeline/farm/pyblish_functions.py
@@ -591,22 +591,6 @@ def create_instances_for_aov(
# AOV product of its own.
log = Logger.get_logger("farm_publishing")
- additional_color_data = {
- "renderProducts": instance.data["renderProducts"],
- "colorspaceConfig": instance.data["colorspaceConfig"],
- "display": instance.data["colorspaceDisplay"],
- "view": instance.data["colorspaceView"]
- }
-
- # Get templated path from absolute config path.
- anatomy = instance.context.data["anatomy"]
- colorspace_template = instance.data["colorspaceConfig"]
- try:
- additional_color_data["colorspaceTemplate"] = remap_source(
- colorspace_template, anatomy)
- except ValueError as e:
- log.warning(e)
- additional_color_data["colorspaceTemplate"] = colorspace_template
# if there are product to attach to and more than one AOV,
# we cannot proceed.
@@ -618,6 +602,29 @@ def create_instances_for_aov(
"attaching multiple AOVs or renderable cameras to "
"product is not supported yet.")
+ additional_data = {
+ "renderProducts": instance.data["renderProducts"],
+ }
+
+ # Collect color management data if present
+ colorspace_config = instance.data.get("colorspaceConfig")
+ if colorspace_config:
+ additional_data.update({
+ "colorspaceConfig": colorspace_config,
+ # Display/View are optional
+ "display": instance.data.get("colorspaceDisplay"),
+ "view": instance.data.get("colorspaceView")
+ })
+
+ # Get templated path from absolute config path.
+ anatomy = instance.context.data["anatomy"]
+ try:
+ additional_data["colorspaceTemplate"] = remap_source(
+ colorspace_config, anatomy)
+ except ValueError as e:
+ log.warning(e)
+ additional_data["colorspaceTemplate"] = colorspace_config
+
# create instances for every AOV we found in expected files.
# NOTE: this is done for every AOV and every render camera (if
# there are multiple renderable cameras in scene)
@@ -625,7 +632,7 @@ def create_instances_for_aov(
instance,
skeleton,
aov_filter,
- additional_color_data,
+ additional_data,
skip_integration_repre_list,
do_not_add_review,
frames_to_render
@@ -936,16 +943,28 @@ def _create_instances_for_aov(
"stagingDir": staging_dir,
"fps": new_instance.get("fps"),
"tags": ["review"] if preview else [],
- "colorspaceData": {
+ }
+
+ if colorspace and additional_data["colorspaceConfig"]:
+ # Only apply colorspace data if the image has a colorspace
+ colorspace_data: dict = {
"colorspace": colorspace,
"config": {
"path": additional_data["colorspaceConfig"],
"template": additional_data["colorspaceTemplate"]
},
- "display": additional_data["display"],
- "view": additional_data["view"]
}
- }
+ # Display/View are optional
+ display = additional_data.get("display")
+ if display:
+ colorspace_data["display"] = display
+ view = additional_data.get("view")
+ if view:
+ colorspace_data["view"] = view
+
+ rep["colorspaceData"] = colorspace_data
+ else:
+ log.debug("No colorspace data for representation: {}".format(rep))
# support conversion from tiled to scanline
if instance.data.get("convertToScanline"):
@@ -1045,7 +1064,9 @@ def get_resources(project_name, version_entity, extension=None):
filtered.append(repre_entity)
representation = filtered[0]
- directory = get_representation_path(representation)
+ directory = get_representation_path(
+ project_name, representation
+ )
print("Source: ", directory)
resources = sorted(
[
diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py
index 2a33fa119b..b5b09a5dc9 100644
--- a/client/ayon_core/pipeline/load/__init__.py
+++ b/client/ayon_core/pipeline/load/__init__.py
@@ -25,8 +25,8 @@ from .utils import (
get_loader_identifier,
get_loaders_by_name,
- get_representation_path_from_context,
get_representation_path,
+ get_representation_path_from_context,
get_representation_path_with_anatomy,
is_compatible_loader,
@@ -85,8 +85,8 @@ __all__ = (
"get_loader_identifier",
"get_loaders_by_name",
- "get_representation_path_from_context",
"get_representation_path",
+ "get_representation_path_from_context",
"get_representation_path_with_anatomy",
"is_compatible_loader",
diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py
index d1731d4cf9..8aed7b8b52 100644
--- a/client/ayon_core/pipeline/load/utils.py
+++ b/client/ayon_core/pipeline/load/utils.py
@@ -1,11 +1,15 @@
+from __future__ import annotations
+
import os
import uuid
-import platform
+import warnings
import logging
import inspect
import collections
import numbers
-from typing import Optional, Union, Any
+import copy
+from functools import wraps
+from typing import Optional, Union, Any, overload
import ayon_api
@@ -14,9 +18,8 @@ from ayon_core.lib import (
StringTemplate,
TemplateUnsolved,
)
-from ayon_core.pipeline import (
- Anatomy,
-)
+from ayon_core.lib.path_templates import TemplateResult
+from ayon_core.pipeline import Anatomy
log = logging.getLogger(__name__)
@@ -644,15 +647,15 @@ def get_representation_path_from_context(context):
representation = context["representation"]
project_entity = context.get("project")
- root = None
- if (
- project_entity
- and project_entity["name"] != get_current_project_name()
- ):
- anatomy = Anatomy(project_entity["name"])
- root = anatomy.roots
-
- return get_representation_path(representation, root)
+ if project_entity:
+ project_name = project_entity["name"]
+ else:
+ project_name = get_current_project_name()
+ return get_representation_path(
+ project_name,
+ representation,
+ project_entity=project_entity,
+ )
def get_representation_path_with_anatomy(repre_entity, anatomy):
@@ -671,139 +674,248 @@ def get_representation_path_with_anatomy(repre_entity, anatomy):
anatomy (Anatomy): Project anatomy object.
Returns:
- Union[None, TemplateResult]: None if path can't be received
+ TemplateResult: Resolved representation path.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
+
"""
+ return get_representation_path(
+ anatomy.project_name,
+ repre_entity,
+ anatomy=anatomy,
+ )
+
+
+def get_representation_path_with_roots(
+ representation: dict[str, Any],
+ roots: dict[str, str],
+) -> Optional[TemplateResult]:
+ """Get filename from representation with custom root.
+
+ Args:
+ representation(dict): Representation entity.
+ roots (dict[str, str]): Roots to use.
+
+
+ Returns:
+ Optional[TemplateResult]: Resolved representation path.
+
+ """
+ try:
+ template = representation["attrib"]["template"]
+ except KeyError:
+ return None
+
+ try:
+ context = representation["context"]
+
+ _fix_representation_context_compatibility(context)
+
+ context["root"] = roots
+ path = StringTemplate.format_strict_template(
+ template, context
+ )
+ except (TemplateUnsolved, KeyError):
+ # Template references unavailable data
+ return None
+
+ return path.normalized()
+
+
+def _backwards_compatibility_repre_path(func):
+ """Wrapper handling backwards compatibility of 'get_representation_path'.
+
+ Allows 'get_representation_path' to support old and new signatures of the
+ function. The old signature supported passing in representation entity
+ and optional roots. The new signature requires the project name
+ to be passed. In case custom roots should be used, a dedicated function
+ 'get_representation_path_with_roots' is available.
+
+ The wrapper handles passed arguments, and based on kwargs and types
+ of the arguments will call the function which relates to
+ the arguments.
+
+ The function is also marked with an attribute 'version' so other addons
+ can check if the function is using the new signature or is using
+ the old signature. That should allow addons to adapt to new signature.
+ >>> if getattr(get_representation_path, "version", None) == 2:
+ >>> path = get_representation_path(project_name, repre_entity)
+ >>> else:
+ >>> path = get_representation_path(repre_entity)
+
+ The plan to remove backwards compatibility is 1.1.2026.
+
+ """
+ # Add an attribute to the function so addons can check if the new variant
+ # of the function is available.
+ # >>> getattr(get_representation_path, "version", None) == 2
+ # >>> True
+ setattr(func, "version", 2)
+
+ @wraps(func)
+ def inner(*args, **kwargs):
+ from ayon_core.pipeline import get_current_project_name
+
+ # Decide which variant of the function based on passed arguments
+ # will be used.
+ if args:
+ arg_1 = args[0]
+ if isinstance(arg_1, str):
+ return func(*args, **kwargs)
+
+ elif "project_name" in kwargs:
+ return func(*args, **kwargs)
+
+ warnings.warn(
+ (
+ "Used deprecated variant of 'get_representation_path'."
+ " Please change used arguments signature to follow"
+ " new definition. Will be removed 1.1.2026."
+ ),
+ DeprecationWarning,
+ stacklevel=2,
+ )
+
+ # Find out which arguments were passed
+ if args:
+ representation = args[0]
+ else:
+ representation = kwargs.get("representation")
+
+ if len(args) > 1:
+ roots = args[1]
+ else:
+ roots = kwargs.get("root")
+
+ if roots is not None:
+ return get_representation_path_with_roots(
+ representation, roots
+ )
+
+ project_name = (
+ representation["context"].get("project", {}).get("name")
+ )
+ if project_name is None:
+ project_name = get_current_project_name()
+
+ return func(project_name, representation)
+
+ return inner
+
+
+@overload
+def get_representation_path(
+ representation: dict[str, Any],
+ root: Optional[dict[str, Any]] = None,
+) -> TemplateResult:
+ """DEPRECATED Get filled representation path.
+
+ Use 'get_representation_path' using the new function signature.
+
+ Args:
+ representation (dict[str, Any]): Representation entity.
+ root (Optional[dict[str, Any]): Roots to fill the path.
+
+ Returns:
+ TemplateResult: Resolved path to representation.
+
+ Raises:
+ InvalidRepresentationContext: When representation data are probably
+ invalid or not available.
+
+ """
+ pass
+
+
+@overload
+def get_representation_path(
+ project_name: str,
+ repre_entity: dict[str, Any],
+ *,
+ anatomy: Optional[Anatomy] = None,
+ project_entity: Optional[dict[str, Any]] = None,
+) -> TemplateResult:
+ """Get filled representation path.
+
+ Args:
+ project_name (str): Project name.
+ repre_entity (dict[str, Any]): Representation entity.
+ anatomy (Optional[Anatomy]): Project anatomy.
+ project_entity (Optional[dict[str, Any]): Project entity. Is used to
+ initialize Anatomy and is not needed if 'anatomy' is passed in.
+
+ Returns:
+ TemplateResult: Resolved path to representation.
+
+ Raises:
+ InvalidRepresentationContext: When representation data are probably
+ invalid or not available.
+
+ """
+ pass
+
+
+@_backwards_compatibility_repre_path
+def get_representation_path(
+ project_name: str,
+ repre_entity: dict[str, Any],
+ *,
+ anatomy: Optional[Anatomy] = None,
+ project_entity: Optional[dict[str, Any]] = None,
+) -> TemplateResult:
+ """Get filled representation path.
+
+ Args:
+ project_name (str): Project name.
+ repre_entity (dict[str, Any]): Representation entity.
+ anatomy (Optional[Anatomy]): Project anatomy.
+ project_entity (Optional[dict[str, Any]): Project entity. Is used to
+ initialize Anatomy and is not needed if 'anatomy' is passed in.
+
+ Returns:
+ TemplateResult: Resolved path to representation.
+
+ Raises:
+ InvalidRepresentationContext: When representation data are probably
+ invalid or not available.
+
+ """
+ if anatomy is None:
+ anatomy = Anatomy(project_name, project_entity=project_entity)
try:
template = repre_entity["attrib"]["template"]
- except KeyError:
- raise InvalidRepresentationContext((
- "Representation document does not"
- " contain template in data ('data.template')"
- ))
+ except KeyError as exc:
+ raise InvalidRepresentationContext(
+ "Failed to receive template from representation entity."
+ ) from exc
try:
- context = repre_entity["context"]
+ context = copy.deepcopy(repre_entity["context"])
_fix_representation_context_compatibility(context)
context["root"] = anatomy.roots
path = StringTemplate.format_strict_template(template, context)
except TemplateUnsolved as exc:
- raise InvalidRepresentationContext((
- "Couldn't resolve representation template with available data."
- " Reason: {}".format(str(exc))
- ))
+ raise InvalidRepresentationContext(
+ "Failed to resolve representation template with available data."
+ ) from exc
return path.normalized()
-def get_representation_path(representation, root=None):
- """Get filename from representation document
-
- There are three ways of getting the path from representation which are
- tried in following sequence until successful.
- 1. Get template from representation['data']['template'] and data from
- representation['context']. Then format template with the data.
- 2. Get template from project['config'] and format it with default data set
- 3. Get representation['data']['path'] and use it directly
-
- Args:
- representation(dict): representation document from the database
-
- Returns:
- str: fullpath of the representation
-
- """
- if root is None:
- from ayon_core.pipeline import get_current_project_name, Anatomy
-
- anatomy = Anatomy(get_current_project_name())
- return get_representation_path_with_anatomy(
- representation, anatomy
- )
-
- def path_from_representation():
- try:
- template = representation["attrib"]["template"]
- except KeyError:
- return None
-
- try:
- context = representation["context"]
-
- _fix_representation_context_compatibility(context)
-
- context["root"] = root
- path = StringTemplate.format_strict_template(
- template, context
- )
- # Force replacing backslashes with forward slashed if not on
- # windows
- if platform.system().lower() != "windows":
- path = path.replace("\\", "/")
- except (TemplateUnsolved, KeyError):
- # Template references unavailable data
- return None
-
- if not path:
- return path
-
- normalized_path = os.path.normpath(path)
- if os.path.exists(normalized_path):
- return normalized_path
- return path
-
- def path_from_data():
- if "path" not in representation["attrib"]:
- return None
-
- path = representation["attrib"]["path"]
- # Force replacing backslashes with forward slashed if not on
- # windows
- if platform.system().lower() != "windows":
- path = path.replace("\\", "/")
-
- if os.path.exists(path):
- return os.path.normpath(path)
-
- dir_path, file_name = os.path.split(path)
- if not os.path.exists(dir_path):
- return None
-
- base_name, ext = os.path.splitext(file_name)
- file_name_items = None
- if "#" in base_name:
- file_name_items = [part for part in base_name.split("#") if part]
- elif "%" in base_name:
- file_name_items = base_name.split("%")
-
- if not file_name_items:
- return None
-
- filename_start = file_name_items[0]
-
- for _file in os.listdir(dir_path):
- if _file.startswith(filename_start) and _file.endswith(ext):
- return os.path.normpath(path)
-
- return (
- path_from_representation() or path_from_data()
- )
-
-
def get_representation_path_by_names(
- project_name: str,
- folder_path: str,
- product_name: str,
- version_name: str,
- representation_name: str,
- anatomy: Optional[Anatomy] = None) -> Optional[str]:
+ project_name: str,
+ folder_path: str,
+ product_name: str,
+ version_name: Union[int, str],
+ representation_name: str,
+ anatomy: Optional[Anatomy] = None
+) -> Optional[TemplateResult]:
"""Get (latest) filepath for representation for folder and product.
See `get_representation_by_names` for more details.
@@ -820,22 +932,21 @@ def get_representation_path_by_names(
representation_name
)
if not representation:
- return
+ return None
- if not anatomy:
- anatomy = Anatomy(project_name)
-
- if representation:
- path = get_representation_path_with_anatomy(representation, anatomy)
- return str(path).replace("\\", "/")
+ return get_representation_path(
+ project_name,
+ representation,
+ anatomy=anatomy,
+ )
def get_representation_by_names(
- project_name: str,
- folder_path: str,
- product_name: str,
- version_name: Union[int, str],
- representation_name: str,
+ project_name: str,
+ folder_path: str,
+ product_name: str,
+ version_name: Union[int, str],
+ representation_name: str,
) -> Optional[dict]:
"""Get representation entity for asset and subset.
@@ -852,7 +963,7 @@ def get_representation_by_names(
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields=["id"])
if not folder_entity:
- return
+ return None
if isinstance(product_name, dict) and "name" in product_name:
# Allow explicitly passing subset document
@@ -864,7 +975,7 @@ def get_representation_by_names(
folder_id=folder_entity["id"],
fields=["id"])
if not product_entity:
- return
+ return None
if version_name == "hero":
version_entity = ayon_api.get_hero_version_by_product_id(
@@ -876,7 +987,7 @@ def get_representation_by_names(
version_entity = ayon_api.get_version_by_name(
project_name, version_name, product_id=product_entity["id"])
if not version_entity:
- return
+ return None
return ayon_api.get_representation_by_name(
project_name, representation_name, version_id=version_entity["id"])
diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py
index 03da7fce79..dddd6847ec 100644
--- a/client/ayon_core/pipeline/plugin_discover.py
+++ b/client/ayon_core/pipeline/plugin_discover.py
@@ -1,6 +1,9 @@
+from __future__ import annotations
+
import os
import inspect
import traceback
+from typing import Optional
from ayon_core.lib import Logger
from ayon_core.lib.python_module_tools import (
@@ -96,6 +99,70 @@ class DiscoverResult:
log.info(report)
+def discover_plugins(
+ base_class: type,
+ paths: Optional[list[str]] = None,
+ classes: Optional[list[type]] = None,
+ ignored_classes: Optional[list[type]] = None,
+ allow_duplicates: bool = True,
+):
+ """Find and return subclasses of `superclass`
+
+ Args:
+ base_class (type): Class which determines discovered subclasses.
+ paths (Optional[list[str]]): List of paths to look for plug-ins.
+ classes (Optional[list[str]]): List of classes to filter.
+ ignored_classes (list[type]): List of classes that won't be added to
+ the output plugins.
+ allow_duplicates (bool): Validate class name duplications.
+
+ Returns:
+ DiscoverResult: Object holding successfully
+ discovered plugins, ignored plugins, plugins with missing
+ abstract implementation and duplicated plugin.
+
+ """
+ ignored_classes = ignored_classes or []
+ paths = paths or []
+ classes = classes or []
+
+ result = DiscoverResult(base_class)
+
+ all_plugins = list(classes)
+
+ for path in paths:
+ modules, crashed = modules_from_path(path)
+ for (filepath, exc_info) in crashed:
+ result.crashed_file_paths[filepath] = exc_info
+
+ for item in modules:
+ filepath, module = item
+ result.add_module(module)
+ all_plugins.extend(classes_from_module(base_class, module))
+
+ if base_class not in ignored_classes:
+ ignored_classes.append(base_class)
+
+ plugin_names = set()
+ for cls in all_plugins:
+ if cls in ignored_classes:
+ result.ignored_plugins.add(cls)
+ continue
+
+ if inspect.isabstract(cls):
+ result.abstract_plugins.append(cls)
+ continue
+
+ if not allow_duplicates:
+ class_name = cls.__name__
+ if class_name in plugin_names:
+ result.duplicated_plugins.append(cls)
+ continue
+ plugin_names.add(class_name)
+ result.plugins.append(cls)
+ return result
+
+
class PluginDiscoverContext(object):
"""Store and discover registered types nad registered paths to types.
@@ -141,58 +208,17 @@ class PluginDiscoverContext(object):
Union[DiscoverResult, list[Any]]: Object holding successfully
discovered plugins, ignored plugins, plugins with missing
abstract implementation and duplicated plugin.
+
"""
-
- if not ignore_classes:
- ignore_classes = []
-
- result = DiscoverResult(superclass)
- plugin_names = set()
registered_classes = self._registered_plugins.get(superclass) or []
registered_paths = self._registered_plugin_paths.get(superclass) or []
- for cls in registered_classes:
- if cls is superclass or cls in ignore_classes:
- result.ignored_plugins.add(cls)
- continue
-
- if inspect.isabstract(cls):
- result.abstract_plugins.append(cls)
- continue
-
- class_name = cls.__name__
- if class_name in plugin_names:
- result.duplicated_plugins.append(cls)
- continue
- plugin_names.add(class_name)
- result.plugins.append(cls)
-
- # Include plug-ins from registered paths
- for path in registered_paths:
- modules, crashed = modules_from_path(path)
- for item in crashed:
- filepath, exc_info = item
- result.crashed_file_paths[filepath] = exc_info
-
- for item in modules:
- filepath, module = item
- result.add_module(module)
- for cls in classes_from_module(superclass, module):
- if cls is superclass or cls in ignore_classes:
- result.ignored_plugins.add(cls)
- continue
-
- if inspect.isabstract(cls):
- result.abstract_plugins.append(cls)
- continue
-
- if not allow_duplicates:
- class_name = cls.__name__
- if class_name in plugin_names:
- result.duplicated_plugins.append(cls)
- continue
- plugin_names.add(class_name)
-
- result.plugins.append(cls)
+ result = discover_plugins(
+ superclass,
+ paths=registered_paths,
+ classes=registered_classes,
+ ignored_classes=ignore_classes,
+ allow_duplicates=allow_duplicates,
+ )
# Store in memory last result to keep in memory loaded modules
self._last_discovered_results[superclass] = result
diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py
index 52e27baa80..2f9e7250c0 100644
--- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py
+++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py
@@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC):
self._loaders_by_name = get_loaders_by_name()
return self._loaders_by_name
- def get_linked_folder_entities(self, link_type: Optional[str]):
+ def get_linked_folder_entities(
+ self,
+ link_type: Optional[str],
+ folder_path_regex: Optional[str],
+ ):
if not link_type:
return []
project_name = self.project_name
@@ -317,7 +321,11 @@ class AbstractTemplateBuilder(ABC):
if link["entityType"] == "folder"
}
- return list(get_folders(project_name, folder_ids=linked_folder_ids))
+ return list(get_folders(
+ project_name,
+ folder_path_regex=folder_path_regex,
+ folder_ids=linked_folder_ids,
+ ))
def _collect_creators(self):
self._creators_by_name = {
@@ -832,14 +840,24 @@ class AbstractTemplateBuilder(ABC):
host_name = self.host_name
task_name = self.current_task_name
task_type = self.current_task_type
+ folder_path = self.current_folder_path
+ folder_type = None
+ folder_entity = self.current_folder_entity
+ if folder_entity:
+ folder_type = folder_entity["folderType"]
+
+ filter_data = {
+ "task_types": task_type,
+ "task_names": task_name,
+ "folder_types": folder_type,
+ "folder_paths": folder_path,
+ }
build_profiles = self._get_build_profiles()
profile = filter_profiles(
build_profiles,
- {
- "task_types": task_type,
- "task_names": task_name
- }
+ filter_data,
+ logger=self.log
)
if not profile:
raise TemplateProfileNotFound((
@@ -1638,7 +1656,10 @@ class PlaceholderLoadMixin(object):
linked_folder_entity["id"]
for linked_folder_entity in (
self.builder.get_linked_folder_entities(
- link_type=link_type))
+ link_type=link_type,
+ folder_path_regex=folder_path_regex
+ )
+ )
]
if not folder_ids:
@@ -1666,6 +1687,8 @@ class PlaceholderLoadMixin(object):
for version in get_last_versions(
project_name, filtered_product_ids, fields={"id"}
).values()
+ # Version may be none if a product has no versions
+ if version is not None
)
return list(get_representations(
project_name,
diff --git a/client/ayon_core/plugins/load/copy_file.py b/client/ayon_core/plugins/load/copy_file.py
deleted file mode 100644
index 08dad03be3..0000000000
--- a/client/ayon_core/plugins/load/copy_file.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from ayon_core.style import get_default_entity_icon_color
-from ayon_core.pipeline import load
-
-
-class CopyFile(load.LoaderPlugin):
- """Copy the published file to be pasted at the desired location"""
-
- representations = {"*"}
- product_types = {"*"}
-
- label = "Copy File"
- order = 10
- icon = "copy"
- color = get_default_entity_icon_color()
-
- def load(self, context, name=None, namespace=None, data=None):
- path = self.filepath_from_context(context)
- self.log.info("Added copy to clipboard: {0}".format(path))
- self.copy_file_to_clipboard(path)
-
- @staticmethod
- def copy_file_to_clipboard(path):
- from qtpy import QtCore, QtWidgets
-
- clipboard = QtWidgets.QApplication.clipboard()
- assert clipboard, "Must have running QApplication instance"
-
- # Build mime data for clipboard
- data = QtCore.QMimeData()
- url = QtCore.QUrl.fromLocalFile(path)
- data.setUrls([url])
-
- # Set to Clipboard
- clipboard.setMimeData(data)
diff --git a/client/ayon_core/plugins/load/copy_file_path.py b/client/ayon_core/plugins/load/copy_file_path.py
deleted file mode 100644
index fdf31b5e02..0000000000
--- a/client/ayon_core/plugins/load/copy_file_path.py
+++ /dev/null
@@ -1,29 +0,0 @@
-import os
-
-from ayon_core.pipeline import load
-
-
-class CopyFilePath(load.LoaderPlugin):
- """Copy published file path to clipboard"""
- representations = {"*"}
- product_types = {"*"}
-
- label = "Copy File Path"
- order = 20
- icon = "clipboard"
- color = "#999999"
-
- def load(self, context, name=None, namespace=None, data=None):
- path = self.filepath_from_context(context)
- self.log.info("Added file path to clipboard: {0}".format(path))
- self.copy_path_to_clipboard(path)
-
- @staticmethod
- def copy_path_to_clipboard(path):
- from qtpy import QtWidgets
-
- clipboard = QtWidgets.QApplication.clipboard()
- assert clipboard, "Must have running QApplication instance"
-
- # Set to Clipboard
- clipboard.setText(os.path.normpath(path))
diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py
index aef0cf8863..d01a97e2ff 100644
--- a/client/ayon_core/plugins/load/create_hero_version.py
+++ b/client/ayon_core/plugins/load/create_hero_version.py
@@ -75,6 +75,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin):
msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint
+ | QtCore.Qt.WindowType.WindowStaysOnTopHint
)
msgBox.exec_()
diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py
deleted file mode 100644
index 3a42ccba7e..0000000000
--- a/client/ayon_core/plugins/load/delete_old_versions.py
+++ /dev/null
@@ -1,477 +0,0 @@
-import collections
-import os
-import uuid
-from typing import List, Dict, Any
-
-import clique
-import ayon_api
-from ayon_api.operations import OperationsSession
-import qargparse
-from qtpy import QtWidgets, QtCore
-
-from ayon_core import style
-from ayon_core.lib import format_file_size
-from ayon_core.pipeline import load, Anatomy
-from ayon_core.pipeline.load import (
- get_representation_path_with_anatomy,
- InvalidRepresentationContext,
-)
-
-
-class DeleteOldVersions(load.ProductLoaderPlugin):
- """Deletes specific number of old version"""
-
- is_multiple_contexts_compatible = True
- sequence_splitter = "__sequence_splitter__"
-
- representations = ["*"]
- product_types = {"*"}
- tool_names = ["library_loader"]
-
- label = "Delete Old Versions"
- order = 35
- icon = "trash"
- color = "#d8d8d8"
-
- options = [
- qargparse.Integer(
- "versions_to_keep", default=2, min=0, help="Versions to keep:"
- ),
- qargparse.Boolean(
- "remove_publish_folder", help="Remove publish folder:"
- )
- ]
-
- requires_confirmation = True
-
- def delete_whole_dir_paths(self, dir_paths, delete=True):
- size = 0
-
- for dir_path in dir_paths:
- # Delete all files and folders in dir path
- for root, dirs, files in os.walk(dir_path, topdown=False):
- for name in files:
- file_path = os.path.join(root, name)
- size += os.path.getsize(file_path)
- if delete:
- os.remove(file_path)
- self.log.debug("Removed file: {}".format(file_path))
-
- for name in dirs:
- if delete:
- os.rmdir(os.path.join(root, name))
-
- if not delete:
- continue
-
- # Delete even the folder and it's parents folders if they are empty
- while True:
- if not os.path.exists(dir_path):
- dir_path = os.path.dirname(dir_path)
- continue
-
- if len(os.listdir(dir_path)) != 0:
- break
-
- os.rmdir(os.path.join(dir_path))
-
- return size
-
- def path_from_representation(self, representation, anatomy):
- try:
- context = representation["context"]
- except KeyError:
- return (None, None)
-
- try:
- path = get_representation_path_with_anatomy(
- representation, anatomy
- )
- except InvalidRepresentationContext:
- return (None, None)
-
- sequence_path = None
- if "frame" in context:
- context["frame"] = self.sequence_splitter
- sequence_path = get_representation_path_with_anatomy(
- representation, anatomy
- )
-
- if sequence_path:
- sequence_path = sequence_path.normalized()
-
- return (path.normalized(), sequence_path)
-
- def delete_only_repre_files(self, dir_paths, file_paths, delete=True):
- size = 0
-
- for dir_id, dir_path in dir_paths.items():
- dir_files = os.listdir(dir_path)
- collections, remainders = clique.assemble(dir_files)
- for file_path, seq_path in file_paths[dir_id]:
- file_path_base = os.path.split(file_path)[1]
- # Just remove file if `frame` key was not in context or
- # filled path is in remainders (single file sequence)
- if not seq_path or file_path_base in remainders:
- if not os.path.exists(file_path):
- self.log.debug(
- "File was not found: {}".format(file_path)
- )
- continue
-
- size += os.path.getsize(file_path)
-
- if delete:
- os.remove(file_path)
- self.log.debug("Removed file: {}".format(file_path))
-
- if file_path_base in remainders:
- remainders.remove(file_path_base)
- continue
-
- seq_path_base = os.path.split(seq_path)[1]
- head, tail = seq_path_base.split(self.sequence_splitter)
-
- final_col = None
- for collection in collections:
- if head != collection.head or tail != collection.tail:
- continue
- final_col = collection
- break
-
- if final_col is not None:
- # Fill full path to head
- final_col.head = os.path.join(dir_path, final_col.head)
- for _file_path in final_col:
- if os.path.exists(_file_path):
-
- size += os.path.getsize(_file_path)
-
- if delete:
- os.remove(_file_path)
- self.log.debug(
- "Removed file: {}".format(_file_path)
- )
-
- _seq_path = final_col.format("{head}{padding}{tail}")
- self.log.debug("Removed files: {}".format(_seq_path))
- collections.remove(final_col)
-
- elif os.path.exists(file_path):
- size += os.path.getsize(file_path)
-
- if delete:
- os.remove(file_path)
- self.log.debug("Removed file: {}".format(file_path))
- else:
- self.log.debug(
- "File was not found: {}".format(file_path)
- )
-
- # Delete as much as possible parent folders
- if not delete:
- return size
-
- for dir_path in dir_paths.values():
- while True:
- if not os.path.exists(dir_path):
- dir_path = os.path.dirname(dir_path)
- continue
-
- if len(os.listdir(dir_path)) != 0:
- break
-
- self.log.debug("Removed folder: {}".format(dir_path))
- os.rmdir(dir_path)
-
- return size
-
- def message(self, text):
- msgBox = QtWidgets.QMessageBox()
- msgBox.setText(text)
- msgBox.setStyleSheet(style.load_stylesheet())
- msgBox.setWindowFlags(
- msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
- )
- msgBox.exec_()
-
- def _confirm_delete(self,
- contexts: List[Dict[str, Any]],
- versions_to_keep: int) -> bool:
- """Prompt user for a deletion confirmation"""
-
- contexts_list = "\n".join(sorted(
- "- {folder[name]} > {product[name]}".format_map(context)
- for context in contexts
- ))
- num_contexts = len(contexts)
- s = "s" if num_contexts > 1 else ""
- text = (
- "Are you sure you want to delete versions?\n\n"
- f"This will keep only the last {versions_to_keep} "
- f"versions for the {num_contexts} selected product{s}."
- )
- informative_text = "Warning: This will delete files from disk"
- detailed_text = (
- f"Keep only {versions_to_keep} versions for:\n{contexts_list}"
- )
-
- messagebox = QtWidgets.QMessageBox()
- messagebox.setIcon(QtWidgets.QMessageBox.Warning)
- messagebox.setWindowTitle("Delete Old Versions")
- messagebox.setText(text)
- messagebox.setInformativeText(informative_text)
- messagebox.setDetailedText(detailed_text)
- messagebox.setStandardButtons(
- QtWidgets.QMessageBox.Yes
- | QtWidgets.QMessageBox.Cancel
- )
- messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel)
- messagebox.setStyleSheet(style.load_stylesheet())
- messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True)
- return messagebox.exec_() == QtWidgets.QMessageBox.Yes
-
- def get_data(self, context, versions_count):
- product_entity = context["product"]
- folder_entity = context["folder"]
- project_name = context["project"]["name"]
- anatomy = Anatomy(project_name, project_entity=context["project"])
-
- version_fields = ayon_api.get_default_fields_for_type("version")
- version_fields.add("tags")
- versions = list(ayon_api.get_versions(
- project_name,
- product_ids=[product_entity["id"]],
- active=None,
- hero=False,
- fields=version_fields
- ))
- self.log.debug(
- "Version Number ({})".format(len(versions))
- )
- versions_by_parent = collections.defaultdict(list)
- for ent in versions:
- versions_by_parent[ent["productId"]].append(ent)
-
- def sort_func(ent):
- return int(ent["version"])
-
- all_last_versions = []
- for _parent_id, _versions in versions_by_parent.items():
- for idx, version in enumerate(
- sorted(_versions, key=sort_func, reverse=True)
- ):
- if idx >= versions_count:
- break
- all_last_versions.append(version)
-
- self.log.debug("Collected versions ({})".format(len(versions)))
-
- # Filter latest versions
- for version in all_last_versions:
- versions.remove(version)
-
- # Update versions_by_parent without filtered versions
- versions_by_parent = collections.defaultdict(list)
- for ent in versions:
- versions_by_parent[ent["productId"]].append(ent)
-
- # Filter already deleted versions
- versions_to_pop = []
- for version in versions:
- if "deleted" in version["tags"]:
- versions_to_pop.append(version)
-
- for version in versions_to_pop:
- msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format(
- folder_entity["path"],
- product_entity["name"],
- version["version"]
- )
- self.log.debug((
- "Skipping version. Already tagged as inactive. < {} >"
- ).format(msg))
- versions.remove(version)
-
- version_ids = [ent["id"] for ent in versions]
-
- self.log.debug(
- "Filtered versions to delete ({})".format(len(version_ids))
- )
-
- if not version_ids:
- msg = "Skipping processing. Nothing to delete on {}/{}".format(
- folder_entity["path"], product_entity["name"]
- )
- self.log.info(msg)
- print(msg)
- return
-
- repres = list(ayon_api.get_representations(
- project_name, version_ids=version_ids
- ))
-
- self.log.debug(
- "Collected representations to remove ({})".format(len(repres))
- )
-
- dir_paths = {}
- file_paths_by_dir = collections.defaultdict(list)
- for repre in repres:
- file_path, seq_path = self.path_from_representation(
- repre, anatomy
- )
- if file_path is None:
- self.log.debug((
- "Could not format path for represenation \"{}\""
- ).format(str(repre)))
- continue
-
- dir_path = os.path.dirname(file_path)
- dir_id = None
- for _dir_id, _dir_path in dir_paths.items():
- if _dir_path == dir_path:
- dir_id = _dir_id
- break
-
- if dir_id is None:
- dir_id = uuid.uuid4()
- dir_paths[dir_id] = dir_path
-
- file_paths_by_dir[dir_id].append([file_path, seq_path])
-
- dir_ids_to_pop = []
- for dir_id, dir_path in dir_paths.items():
- if os.path.exists(dir_path):
- continue
-
- dir_ids_to_pop.append(dir_id)
-
- # Pop dirs from both dictionaries
- for dir_id in dir_ids_to_pop:
- dir_paths.pop(dir_id)
- paths = file_paths_by_dir.pop(dir_id)
- # TODO report of missing directories?
- paths_msg = ", ".join([
- "'{}'".format(path[0].replace("\\", "/")) for path in paths
- ])
- self.log.debug((
- "Folder does not exist. Deleting its files skipped: {}"
- ).format(paths_msg))
-
- return {
- "dir_paths": dir_paths,
- "file_paths_by_dir": file_paths_by_dir,
- "versions": versions,
- "folder": folder_entity,
- "product": product_entity,
- "archive_product": versions_count == 0
- }
-
- def main(self, project_name, data, remove_publish_folder):
- # Size of files.
- size = 0
- if not data:
- return size
-
- if remove_publish_folder:
- size = self.delete_whole_dir_paths(data["dir_paths"].values())
- else:
- size = self.delete_only_repre_files(
- data["dir_paths"], data["file_paths_by_dir"]
- )
-
- op_session = OperationsSession()
- for version in data["versions"]:
- orig_version_tags = version["tags"]
- version_tags = list(orig_version_tags)
- changes = {}
- if "deleted" not in version_tags:
- version_tags.append("deleted")
- changes["tags"] = version_tags
-
- if version["active"]:
- changes["active"] = False
-
- if not changes:
- continue
- op_session.update_entity(
- project_name, "version", version["id"], changes
- )
-
- op_session.commit()
-
- return size
-
- def load(self, contexts, name=None, namespace=None, options=None):
-
- # Get user options
- versions_to_keep = 2
- remove_publish_folder = False
- if options:
- versions_to_keep = options.get(
- "versions_to_keep", versions_to_keep
- )
- remove_publish_folder = options.get(
- "remove_publish_folder", remove_publish_folder
- )
-
- # Because we do not want this run by accident we will add an extra
- # user confirmation
- if (
- self.requires_confirmation
- and not self._confirm_delete(contexts, versions_to_keep)
- ):
- return
-
- try:
- size = 0
- for count, context in enumerate(contexts):
- data = self.get_data(context, versions_to_keep)
- if not data:
- continue
- project_name = context["project"]["name"]
- size += self.main(project_name, data, remove_publish_folder)
- print("Progressing {}/{}".format(count + 1, len(contexts)))
-
- msg = "Total size of files: {}".format(format_file_size(size))
- self.log.info(msg)
- self.message(msg)
-
- except Exception:
- self.log.error("Failed to delete versions.", exc_info=True)
-
-
-class CalculateOldVersions(DeleteOldVersions):
- """Calculate file size of old versions"""
- label = "Calculate Old Versions"
- order = 30
- tool_names = ["library_loader"]
-
- options = [
- qargparse.Integer(
- "versions_to_keep", default=2, min=0, help="Versions to keep:"
- ),
- qargparse.Boolean(
- "remove_publish_folder", help="Remove publish folder:"
- )
- ]
-
- requires_confirmation = False
-
- def main(self, project_name, data, remove_publish_folder):
- size = 0
-
- if not data:
- return size
-
- if remove_publish_folder:
- size = self.delete_whole_dir_paths(
- data["dir_paths"].values(), delete=False
- )
- else:
- size = self.delete_only_repre_files(
- data["dir_paths"], data["file_paths_by_dir"], delete=False
- )
-
- return size
diff --git a/client/ayon_core/plugins/load/open_file.py b/client/ayon_core/plugins/load/open_file.py
deleted file mode 100644
index 3b5fbbc0c9..0000000000
--- a/client/ayon_core/plugins/load/open_file.py
+++ /dev/null
@@ -1,36 +0,0 @@
-import sys
-import os
-import subprocess
-
-from ayon_core.pipeline import load
-
-
-def open(filepath):
- """Open file with system default executable"""
- if sys.platform.startswith('darwin'):
- subprocess.call(('open', filepath))
- elif os.name == 'nt':
- os.startfile(filepath)
- elif os.name == 'posix':
- subprocess.call(('xdg-open', filepath))
-
-
-class OpenFile(load.LoaderPlugin):
- """Open Image Sequence or Video with system default"""
-
- product_types = {"render2d"}
- representations = {"*"}
-
- label = "Open"
- order = -10
- icon = "play-circle"
- color = "orange"
-
- def load(self, context, name, namespace, data):
-
- path = self.filepath_from_context(context)
- if not os.path.exists(path):
- raise RuntimeError("File not found: {}".format(path))
-
- self.log.info("Opening : {}".format(path))
- open(path)
diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py
deleted file mode 100644
index 0b218d6ea1..0000000000
--- a/client/ayon_core/plugins/load/push_to_project.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import os
-
-from ayon_core import AYON_CORE_ROOT
-from ayon_core.lib import get_ayon_launcher_args, run_detached_process
-from ayon_core.pipeline import load
-from ayon_core.pipeline.load import LoadError
-
-
-class PushToProject(load.ProductLoaderPlugin):
- """Export selected versions to different project"""
-
- is_multiple_contexts_compatible = True
-
- representations = {"*"}
- product_types = {"*"}
-
- label = "Push to project"
- order = 35
- icon = "send"
- color = "#d8d8d8"
-
- def load(self, contexts, name=None, namespace=None, options=None):
- filtered_contexts = [
- context
- for context in contexts
- if context.get("project") and context.get("version")
- ]
- if not filtered_contexts:
- raise LoadError("Nothing to push for your selection")
-
- folder_ids = set(
- context["folder"]["id"]
- for context in filtered_contexts
- )
- if len(folder_ids) > 1:
- raise LoadError("Please select products from single folder")
-
- push_tool_script_path = os.path.join(
- AYON_CORE_ROOT,
- "tools",
- "push_to_project",
- "main.py"
- )
- project_name = filtered_contexts[0]["project"]["name"]
-
- version_ids = {
- context["version"]["id"]
- for context in filtered_contexts
- }
-
- args = get_ayon_launcher_args(
- push_tool_script_path,
- "--project", project_name,
- "--versions", ",".join(version_ids)
- )
- run_detached_process(args)
diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py
new file mode 100644
index 0000000000..a1a98a2bf0
--- /dev/null
+++ b/client/ayon_core/plugins/loader/copy_file.py
@@ -0,0 +1,122 @@
+import os
+import collections
+
+from typing import Optional, Any
+
+from ayon_core.pipeline.load import get_representation_path_with_anatomy
+from ayon_core.pipeline.actions import (
+ LoaderActionPlugin,
+ LoaderActionItem,
+ LoaderActionSelection,
+ LoaderActionResult,
+)
+
+
+class CopyFileActionPlugin(LoaderActionPlugin):
+ """Copy published file path to clipboard"""
+ identifier = "core.copy-action"
+
+ def get_action_items(
+ self, selection: LoaderActionSelection
+ ) -> list[LoaderActionItem]:
+ repres = []
+ if selection.selected_type == "representation":
+ repres = selection.entities.get_representations(
+ selection.selected_ids
+ )
+
+ if selection.selected_type == "version":
+ repres = selection.entities.get_versions_representations(
+ selection.selected_ids
+ )
+
+ output = []
+ if not repres:
+ return output
+
+ repre_ids_by_name = collections.defaultdict(set)
+ for repre in repres:
+ repre_ids_by_name[repre["name"]].add(repre["id"])
+
+ for repre_name, repre_ids in repre_ids_by_name.items():
+ repre_id = next(iter(repre_ids), None)
+ if not repre_id:
+ continue
+ output.append(
+ LoaderActionItem(
+ label=repre_name,
+ order=32,
+ group_label="Copy file path",
+ data={
+ "representation_id": repre_id,
+ "action": "copy-path",
+ },
+ icon={
+ "type": "material-symbols",
+ "name": "content_copy",
+ "color": "#999999",
+ }
+ )
+ )
+ output.append(
+ LoaderActionItem(
+ label=repre_name,
+ order=33,
+ group_label="Copy file",
+ data={
+ "representation_id": repre_id,
+ "action": "copy-file",
+ },
+ icon={
+ "type": "material-symbols",
+ "name": "file_copy",
+ "color": "#999999",
+ }
+ )
+ )
+ return output
+
+ def execute_action(
+ self,
+ selection: LoaderActionSelection,
+ data: dict,
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ from qtpy import QtWidgets, QtCore
+
+ action = data["action"]
+ repre_id = data["representation_id"]
+ repre = next(iter(selection.entities.get_representations({repre_id})))
+ path = get_representation_path_with_anatomy(
+ repre, selection.get_project_anatomy()
+ )
+ self.log.info(f"Added file path to clipboard: {path}")
+
+ clipboard = QtWidgets.QApplication.clipboard()
+ if not clipboard:
+ return LoaderActionResult(
+ "Failed to copy file path to clipboard.",
+ success=False,
+ )
+
+ if action == "copy-path":
+ # Set to Clipboard
+ clipboard.setText(os.path.normpath(path))
+
+ return LoaderActionResult(
+ "Path stored to clipboard...",
+ success=True,
+ )
+
+ # Build mime data for clipboard
+ data = QtCore.QMimeData()
+ url = QtCore.QUrl.fromLocalFile(path)
+ data.setUrls([url])
+
+ # Set to Clipboard
+ clipboard.setMimeData(data)
+
+ return LoaderActionResult(
+ "File added to clipboard...",
+ success=True,
+ )
diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py
new file mode 100644
index 0000000000..ce67df1c0c
--- /dev/null
+++ b/client/ayon_core/plugins/loader/delete_old_versions.py
@@ -0,0 +1,388 @@
+from __future__ import annotations
+
+import os
+import collections
+import json
+import shutil
+from typing import Optional, Any
+
+from ayon_api.operations import OperationsSession
+
+from ayon_core.lib import (
+ format_file_size,
+ AbstractAttrDef,
+ NumberDef,
+ BoolDef,
+ TextDef,
+ UILabelDef,
+)
+from ayon_core.pipeline import Anatomy
+from ayon_core.pipeline.actions import (
+ ActionForm,
+ LoaderActionPlugin,
+ LoaderActionItem,
+ LoaderActionSelection,
+ LoaderActionResult,
+)
+
+
+class DeleteOldVersions(LoaderActionPlugin):
+ """Deletes specific number of old version"""
+
+ is_multiple_contexts_compatible = True
+ sequence_splitter = "__sequence_splitter__"
+
+ requires_confirmation = True
+
+ def get_action_items(
+ self, selection: LoaderActionSelection
+ ) -> list[LoaderActionItem]:
+ # Do not show in hosts
+ if self.host_name is not None:
+ return []
+
+ versions = selection.get_selected_version_entities()
+ if not versions:
+ return []
+
+ product_ids = {
+ version["productId"]
+ for version in versions
+ }
+
+ return [
+ LoaderActionItem(
+ label="Delete Versions",
+ order=35,
+ data={
+ "product_ids": list(product_ids),
+ "action": "delete-versions",
+ },
+ icon={
+ "type": "material-symbols",
+ "name": "delete",
+ "color": "#d8d8d8",
+ }
+ ),
+ LoaderActionItem(
+ label="Calculate Versions size",
+ order=34,
+ data={
+ "product_ids": list(product_ids),
+ "action": "calculate-versions-size",
+ },
+ icon={
+ "type": "material-symbols",
+ "name": "auto_delete",
+ "color": "#d8d8d8",
+ }
+ )
+ ]
+
+ def execute_action(
+ self,
+ selection: LoaderActionSelection,
+ data: dict[str, Any],
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ step = form_values.get("step")
+ action = data["action"]
+ versions_to_keep = form_values.get("versions_to_keep")
+ remove_publish_folder = form_values.get("remove_publish_folder")
+ if step is None:
+ return self._first_step(
+ action,
+ versions_to_keep,
+ remove_publish_folder,
+ )
+
+ if versions_to_keep is None:
+ versions_to_keep = 2
+ if remove_publish_folder is None:
+ remove_publish_folder = False
+
+ product_ids = data["product_ids"]
+ if step == "prepare-data":
+ return self._prepare_data_step(
+ action,
+ versions_to_keep,
+ remove_publish_folder,
+ product_ids,
+ selection,
+ )
+
+ if step == "delete-versions":
+ return self._delete_versions_step(
+ selection.project_name, form_values
+ )
+ return None
+
+ def _first_step(
+ self,
+ action: str,
+ versions_to_keep: Optional[int],
+ remove_publish_folder: Optional[bool],
+ ) -> LoaderActionResult:
+ fields: list[AbstractAttrDef] = [
+ TextDef(
+ "step",
+ visible=False,
+ ),
+ NumberDef(
+ "versions_to_keep",
+ label="Versions to keep",
+ minimum=0,
+ default=2,
+ ),
+ ]
+ if action == "delete-versions":
+ fields.append(
+ BoolDef(
+ "remove_publish_folder",
+ label="Remove publish folder",
+ default=False,
+ )
+ )
+
+ form_values = {
+ key: value
+ for key, value in (
+ ("remove_publish_folder", remove_publish_folder),
+ ("versions_to_keep", versions_to_keep),
+ )
+ if value is not None
+ }
+ form_values["step"] = "prepare-data"
+ return LoaderActionResult(
+ form=ActionForm(
+ title="Delete Old Versions",
+ fields=fields,
+ ),
+ form_values=form_values
+ )
+
+ def _prepare_data_step(
+ self,
+ action: str,
+ versions_to_keep: int,
+ remove_publish_folder: bool,
+ entity_ids: set[str],
+ selection: LoaderActionSelection,
+ ):
+ versions_by_product_id = collections.defaultdict(list)
+ for version in selection.entities.get_products_versions(entity_ids):
+ # Keep hero version
+ if versions_to_keep != 0 and version["version"] < 0:
+ continue
+ versions_by_product_id[version["productId"]].append(version)
+
+ versions_to_delete = []
+ for product_id, versions in versions_by_product_id.items():
+ if versions_to_keep == 0:
+ versions_to_delete.extend(versions)
+ continue
+
+ if len(versions) <= versions_to_keep:
+ continue
+
+ versions.sort(key=lambda v: v["version"])
+ for _ in range(versions_to_keep):
+ if not versions:
+ break
+ versions.pop(-1)
+ versions_to_delete.extend(versions)
+
+ self.log.debug(
+ f"Collected versions to delete ({len(versions_to_delete)})"
+ )
+
+ version_ids = {
+ version["id"]
+ for version in versions_to_delete
+ }
+ if not version_ids:
+ return LoaderActionResult(
+ message="Skipping. Nothing to delete.",
+ success=False,
+ )
+
+ project = selection.entities.get_project()
+ anatomy = Anatomy(project["name"], project_entity=project)
+
+ repres = selection.entities.get_versions_representations(version_ids)
+
+ self.log.debug(
+ f"Collected representations to remove ({len(repres)})"
+ )
+
+ filepaths_by_repre_id = {}
+ repre_ids_by_version_id = {
+ version_id: []
+ for version_id in version_ids
+ }
+ for repre in repres:
+ repre_ids_by_version_id[repre["versionId"]].append(repre["id"])
+ filepaths_by_repre_id[repre["id"]] = [
+ anatomy.fill_root(repre_file["path"])
+ for repre_file in repre["files"]
+ ]
+
+ size = 0
+ for filepaths in filepaths_by_repre_id.values():
+ for filepath in filepaths:
+ if os.path.exists(filepath):
+ size += os.path.getsize(filepath)
+
+ if action == "calculate-versions-size":
+ return LoaderActionResult(
+ message="Calculated size",
+ success=True,
+ form=ActionForm(
+ title="Calculated versions size",
+ fields=[
+ UILabelDef(
+ f"Total size of files: {format_file_size(size)}"
+ ),
+ ],
+ submit_label=None,
+ cancel_label="Close",
+ ),
+ )
+
+ form, form_values = self._get_delete_form(
+ size,
+ remove_publish_folder,
+ list(version_ids),
+ repre_ids_by_version_id,
+ filepaths_by_repre_id,
+ )
+ return LoaderActionResult(
+ form=form,
+ form_values=form_values
+ )
+
+ def _delete_versions_step(
+ self, project_name: str, form_values: dict[str, Any]
+ ) -> LoaderActionResult:
+ delete_data = json.loads(form_values["delete_data"])
+ remove_publish_folder = form_values["remove_publish_folder"]
+ if form_values["delete_value"].lower() != "delete":
+ size = delete_data["size"]
+ form, form_values = self._get_delete_form(
+ size,
+ remove_publish_folder,
+ delete_data["version_ids"],
+ delete_data["repre_ids_by_version_id"],
+ delete_data["filepaths_by_repre_id"],
+ True,
+ )
+ return LoaderActionResult(
+ form=form,
+ form_values=form_values,
+ )
+
+ version_ids = delete_data["version_ids"]
+ repre_ids_by_version_id = delete_data["repre_ids_by_version_id"]
+ filepaths_by_repre_id = delete_data["filepaths_by_repre_id"]
+ op_session = OperationsSession()
+ total_versions = len(version_ids)
+ try:
+ for version_idx, version_id in enumerate(version_ids):
+ self.log.info(
+ f"Progressing version {version_idx + 1}/{total_versions}"
+ )
+ for repre_id in repre_ids_by_version_id[version_id]:
+ for filepath in filepaths_by_repre_id[repre_id]:
+ publish_folder = os.path.dirname(filepath)
+ if remove_publish_folder:
+ if os.path.exists(publish_folder):
+ shutil.rmtree(
+ publish_folder, ignore_errors=True
+ )
+ continue
+
+ if os.path.exists(filepath):
+ os.remove(filepath)
+
+ op_session.delete_entity(
+ project_name, "representation", repre_id
+ )
+ op_session.delete_entity(
+ project_name, "version", version_id
+ )
+ self.log.info("All done")
+
+ except Exception:
+ self.log.error("Failed to delete versions.", exc_info=True)
+ return LoaderActionResult(
+ message="Failed to delete versions.",
+ success=False,
+ )
+
+ finally:
+ op_session.commit()
+
+ return LoaderActionResult(
+ message="Deleted versions",
+ success=True,
+ )
+
+ def _get_delete_form(
+ self,
+ size: int,
+ remove_publish_folder: bool,
+ version_ids: list[str],
+ repre_ids_by_version_id: dict[str, list[str]],
+ filepaths_by_repre_id: dict[str, list[str]],
+ repeated: bool = False,
+ ) -> tuple[ActionForm, dict[str, Any]]:
+ versions_len = len(repre_ids_by_version_id)
+ fields = [
+ UILabelDef(
+ f"Going to delete {versions_len} versions
"
+ f"- total size of files: {format_file_size(size)}
"
+ ),
+ UILabelDef("Are you sure you want to continue?"),
+ TextDef(
+ "delete_value",
+ placeholder="Type 'delete' to confirm...",
+ ),
+ ]
+ if repeated:
+ fields.append(UILabelDef(
+ "*Please fill in '**delete**' to confirm deletion.*"
+ ))
+ fields.extend([
+ TextDef(
+ "delete_data",
+ visible=False,
+ ),
+ TextDef(
+ "step",
+ visible=False,
+ ),
+ BoolDef(
+ "remove_publish_folder",
+ label="Remove publish folder",
+ default=False,
+ visible=False,
+ )
+ ])
+
+ form = ActionForm(
+ title="Delete versions",
+ submit_label="Delete",
+ cancel_label="Close",
+ fields=fields,
+ )
+ form_values = {
+ "delete_data": json.dumps({
+ "size": size,
+ "version_ids": version_ids,
+ "repre_ids_by_version_id": repre_ids_by_version_id,
+ "filepaths_by_repre_id": filepaths_by_repre_id,
+ }),
+ "step": "delete-versions",
+ "remove_publish_folder": remove_publish_folder,
+ }
+ return form, form_values
diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/loader/delivery.py
similarity index 88%
rename from client/ayon_core/plugins/load/delivery.py
rename to client/ayon_core/plugins/loader/delivery.py
index 406040d936..5141bb1d3b 100644
--- a/client/ayon_core/plugins/load/delivery.py
+++ b/client/ayon_core/plugins/loader/delivery.py
@@ -1,5 +1,6 @@
import platform
from collections import defaultdict
+from typing import Optional, Any
import ayon_api
from qtpy import QtWidgets, QtCore, QtGui
@@ -10,7 +11,12 @@ from ayon_core.lib import (
collect_frames,
get_datetime_data,
)
-from ayon_core.pipeline import load, Anatomy
+from ayon_core.pipeline import Anatomy
+from ayon_core.pipeline.actions import (
+ LoaderSimpleActionPlugin,
+ LoaderActionSelection,
+ LoaderActionResult,
+)
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.delivery import (
get_format_dict,
@@ -20,43 +26,72 @@ from ayon_core.pipeline.delivery import (
)
-class Delivery(load.ProductLoaderPlugin):
- """Export selected versions to folder structure from Template"""
-
- is_multiple_contexts_compatible = True
- sequence_splitter = "__sequence_splitter__"
-
- representations = {"*"}
- product_types = {"*"}
- tool_names = ["library_loader"]
-
+class DeliveryAction(LoaderSimpleActionPlugin):
+ identifier = "core.delivery"
label = "Deliver Versions"
order = 35
- icon = "upload"
- color = "#d8d8d8"
+ icon = {
+ "type": "material-symbols",
+ "name": "upload",
+ "color": "#d8d8d8",
+ }
- def message(self, text):
- msgBox = QtWidgets.QMessageBox()
- msgBox.setText(text)
- msgBox.setStyleSheet(style.load_stylesheet())
- msgBox.setWindowFlags(
- msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint
+ def is_compatible(self, selection: LoaderActionSelection) -> bool:
+ if self.host_name is not None:
+ return False
+
+ if not selection.selected_ids:
+ return False
+
+ return (
+ selection.versions_selected()
+ or selection.representations_selected()
)
- msgBox.exec_()
- def load(self, contexts, name=None, namespace=None, options=None):
+ def execute_simple_action(
+ self,
+ selection: LoaderActionSelection,
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ version_ids = set()
+ if selection.selected_type == "representation":
+ versions = selection.entities.get_representations_versions(
+ selection.selected_ids
+ )
+ version_ids = {version["id"] for version in versions}
+
+ if selection.selected_type == "version":
+ version_ids = set(selection.selected_ids)
+
+ if not version_ids:
+ return LoaderActionResult(
+ message="No versions found in your selection",
+ success=False,
+ )
+
try:
- dialog = DeliveryOptionsDialog(contexts, self.log)
+ # TODO run the tool in subprocess
+ dialog = DeliveryOptionsDialog(
+ selection.project_name, version_ids, self.log
+ )
dialog.exec_()
except Exception:
self.log.error("Failed to deliver versions.", exc_info=True)
+ return LoaderActionResult()
+
class DeliveryOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
- def __init__(self, contexts, log=None, parent=None):
- super(DeliveryOptionsDialog, self).__init__(parent=parent)
+ def __init__(
+ self,
+ project_name,
+ version_ids,
+ log=None,
+ parent=None,
+ ):
+ super().__init__(parent=parent)
self.setWindowTitle("AYON - Deliver versions")
icon = QtGui.QIcon(resources.get_ayon_icon_filepath())
@@ -70,13 +105,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
self.setStyleSheet(style.load_stylesheet())
- project_name = contexts[0]["project"]["name"]
self.anatomy = Anatomy(project_name)
self._representations = None
self.log = log
self.currently_uploaded = 0
- self._set_representations(project_name, contexts)
+ self._set_representations(project_name, version_ids)
dropdown = QtWidgets.QComboBox()
self.templates = self._get_templates(self.anatomy)
@@ -316,9 +350,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
return templates
- def _set_representations(self, project_name, contexts):
- version_ids = {context["version"]["id"] for context in contexts}
-
+ def _set_representations(self, project_name, version_ids):
repres = list(ayon_api.get_representations(
project_name, version_ids=version_ids
))
diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py
similarity index 88%
rename from client/ayon_core/plugins/load/export_otio.py
rename to client/ayon_core/plugins/loader/export_otio.py
index 8094490246..c86a72700e 100644
--- a/client/ayon_core/plugins/load/export_otio.py
+++ b/client/ayon_core/plugins/loader/export_otio.py
@@ -2,11 +2,10 @@ import logging
import os
from pathlib import Path
from collections import defaultdict
+from typing import Any, Optional
from qtpy import QtWidgets, QtCore, QtGui
-from ayon_api import get_representations
-from ayon_core.pipeline import load, Anatomy
from ayon_core import resources, style
from ayon_core.lib.transcoding import (
IMAGE_EXTENSIONS,
@@ -16,9 +15,16 @@ from ayon_core.lib import (
get_ffprobe_data,
is_oiio_supported,
)
+from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.tools.utils import show_message_dialog
+from ayon_core.pipeline.actions import (
+ LoaderSimpleActionPlugin,
+ LoaderActionSelection,
+ LoaderActionResult,
+)
+
OTIO = None
FRAME_SPLITTER = "__frame_splitter__"
@@ -30,34 +36,99 @@ def _import_otio():
OTIO = opentimelineio
-class ExportOTIO(load.ProductLoaderPlugin):
- """Export selected versions to OpenTimelineIO."""
-
- is_multiple_contexts_compatible = True
- sequence_splitter = "__sequence_splitter__"
-
- representations = {"*"}
- product_types = {"*"}
- tool_names = ["library_loader"]
-
+class ExportOTIO(LoaderSimpleActionPlugin):
+ identifier = "core.export-otio"
label = "Export OTIO"
+ group_label = None
order = 35
- icon = "save"
- color = "#d8d8d8"
+ icon = {
+ "type": "material-symbols",
+ "name": "save",
+ "color": "#d8d8d8",
+ }
- def load(self, contexts, name=None, namespace=None, options=None):
+ def is_compatible(
+ self, selection: LoaderActionSelection
+ ) -> bool:
+ # Don't show in hosts
+ if self.host_name is not None:
+ return False
+
+ return selection.versions_selected()
+
+ def execute_simple_action(
+ self,
+ selection: LoaderActionSelection,
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
_import_otio()
+ version_ids = set(selection.selected_ids)
+
+ versions_by_id = {
+ version["id"]: version
+ for version in selection.entities.get_versions(version_ids)
+ }
+ product_ids = {
+ version["productId"]
+ for version in versions_by_id.values()
+ }
+ products_by_id = {
+ product["id"]: product
+ for product in selection.entities.get_products(product_ids)
+ }
+ folder_ids = {
+ product["folderId"]
+ for product in products_by_id.values()
+ }
+ folder_by_id = {
+ folder["id"]: folder
+ for folder in selection.entities.get_folders(folder_ids)
+ }
+ repre_entities = selection.entities.get_versions_representations(
+ version_ids
+ )
+
+ version_path_by_id = {}
+ for version in versions_by_id.values():
+ version_id = version["id"]
+ product_id = version["productId"]
+ product = products_by_id[product_id]
+ folder_id = product["folderId"]
+ folder = folder_by_id[folder_id]
+
+ version_path_by_id[version_id] = "/".join([
+ folder["path"],
+ product["name"],
+ version["name"]
+ ])
+
try:
- dialog = ExportOTIOOptionsDialog(contexts, self.log)
+ # TODO this should probably trigger a subprocess?
+ dialog = ExportOTIOOptionsDialog(
+ selection.project_name,
+ versions_by_id,
+ repre_entities,
+ version_path_by_id,
+ self.log
+ )
dialog.exec_()
except Exception:
self.log.error("Failed to export OTIO.", exc_info=True)
+ return LoaderActionResult()
class ExportOTIOOptionsDialog(QtWidgets.QDialog):
"""Dialog to select template where to deliver selected representations."""
- def __init__(self, contexts, log=None, parent=None):
+ def __init__(
+ self,
+ project_name,
+ versions_by_id,
+ repre_entities,
+ version_path_by_id,
+ log=None,
+ parent=None
+ ):
# Not all hosts have OpenTimelineIO available.
self.log = log
@@ -73,30 +144,14 @@ class ExportOTIOOptionsDialog(QtWidgets.QDialog):
| QtCore.Qt.WindowMinimizeButtonHint
)
- project_name = contexts[0]["project"]["name"]
- versions_by_id = {
- context["version"]["id"]: context["version"]
- for context in contexts
- }
- repre_entities = list(get_representations(
- project_name, version_ids=set(versions_by_id)
- ))
version_by_representation_id = {
repre_entity["id"]: versions_by_id[repre_entity["versionId"]]
for repre_entity in repre_entities
}
- version_path_by_id = {}
- representations_by_version_id = {}
- for context in contexts:
- version_id = context["version"]["id"]
- if version_id in version_path_by_id:
- continue
- representations_by_version_id[version_id] = []
- version_path_by_id[version_id] = "/".join([
- context["folder"]["path"],
- context["product"]["name"],
- context["version"]["name"]
- ])
+ representations_by_version_id = {
+ version_id: []
+ for version_id in versions_by_id
+ }
for repre_entity in repre_entities:
representations_by_version_id[repre_entity["versionId"]].append(
diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py
new file mode 100644
index 0000000000..d226786bc2
--- /dev/null
+++ b/client/ayon_core/plugins/loader/open_file.py
@@ -0,0 +1,360 @@
+import os
+import sys
+import subprocess
+import platform
+import collections
+import ctypes
+from typing import Optional, Any, Callable
+
+from ayon_core.pipeline.load import get_representation_path_with_anatomy
+from ayon_core.pipeline.actions import (
+ LoaderActionPlugin,
+ LoaderActionItem,
+ LoaderActionSelection,
+ LoaderActionResult,
+)
+
+
+WINDOWS_USER_REG_PATH = (
+ r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts"
+ r"\{ext}\UserChoice"
+)
+
+
+class _Cache:
+ """Cache extensions information.
+
+ Notes:
+ The cache is cleared when loader tool is refreshed so it might be
+ moved to other place which is not cleared on refresh.
+
+ """
+ supported_exts: set[str] = set()
+ unsupported_exts: set[str] = set()
+
+ @classmethod
+ def is_supported(cls, ext: str) -> bool:
+ return ext in cls.supported_exts
+
+ @classmethod
+ def already_checked(cls, ext: str) -> bool:
+ return (
+ ext in cls.supported_exts
+ or ext in cls.unsupported_exts
+ )
+
+ @classmethod
+ def set_ext_support(cls, ext: str, supported: bool) -> None:
+ if supported:
+ cls.supported_exts.add(ext)
+ else:
+ cls.unsupported_exts.add(ext)
+
+
+def _extension_has_assigned_app_windows(ext: str) -> bool:
+ import winreg
+ progid = None
+ try:
+ with winreg.OpenKey(
+ winreg.HKEY_CURRENT_USER,
+ WINDOWS_USER_REG_PATH.format(ext=ext),
+ ) as k:
+ progid, _ = winreg.QueryValueEx(k, "ProgId")
+ except OSError:
+ pass
+
+ if progid:
+ return True
+
+ try:
+ with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k:
+ progid = winreg.QueryValueEx(k, None)[0]
+ except OSError:
+ pass
+ return bool(progid)
+
+
+def _linux_find_desktop_file(desktop: str) -> Optional[str]:
+ for dirpath in (
+ os.path.expanduser("~/.local/share/applications"),
+ "/usr/share/applications",
+ "/usr/local/share/applications",
+ ):
+ path = os.path.join(dirpath, desktop)
+ if os.path.isfile(path):
+ return path
+ return None
+
+
+def _extension_has_assigned_app_linux(ext: str) -> bool:
+ import mimetypes
+
+ mime, _ = mimetypes.guess_type(f"file{ext}")
+ if not mime:
+ return False
+
+ try:
+ # xdg-mime query default
+ desktop = subprocess.check_output(
+ ["xdg-mime", "query", "default", mime],
+ text=True
+ ).strip() or None
+ except Exception:
+ desktop = None
+
+ if not desktop:
+ return False
+
+ desktop_path = _linux_find_desktop_file(desktop)
+ if not desktop_path:
+ return False
+ if desktop_path and os.path.isfile(desktop_path):
+ return True
+ return False
+
+
+def _extension_has_assigned_app_macos(ext: str) -> bool:
+ # Uses CoreServices/LaunchServices and Uniform Type Identifiers via
+ # ctypes.
+ # Steps: ext -> UTI -> default handler bundle id for role 'all'.
+ cf = ctypes.cdll.LoadLibrary(
+ "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
+ )
+ ls = ctypes.cdll.LoadLibrary(
+ "/System/Library/Frameworks/CoreServices.framework/Frameworks"
+ "/LaunchServices.framework/LaunchServices"
+ )
+
+ # CFType/CFString helpers
+ CFStringRef = ctypes.c_void_p
+ CFAllocatorRef = ctypes.c_void_p
+ CFIndex = ctypes.c_long
+
+ kCFStringEncodingUTF8 = 0x08000100
+
+ cf.CFStringCreateWithCString.argtypes = [
+ CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32
+ ]
+ cf.CFStringCreateWithCString.restype = CFStringRef
+
+ cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32]
+ cf.CFStringGetCStringPtr.restype = ctypes.c_char_p
+
+ cf.CFStringGetCString.argtypes = [
+ CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32
+ ]
+ cf.CFStringGetCString.restype = ctypes.c_bool
+
+ cf.CFRelease.argtypes = [ctypes.c_void_p]
+ cf.CFRelease.restype = None
+
+ try:
+ UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary(
+ "/System/Library/Frameworks/CoreServices.framework/CoreServices"
+ ).UTTypeCreatePreferredIdentifierForTag
+ except OSError:
+ # Fallback path (older systems)
+ UTTypeCreatePreferredIdentifierForTag = (
+ ls.UTTypeCreatePreferredIdentifierForTag
+ )
+ UTTypeCreatePreferredIdentifierForTag.argtypes = [
+ CFStringRef, CFStringRef, CFStringRef
+ ]
+ UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef
+
+ LSRolesMask = ctypes.c_uint
+ kLSRolesAll = 0xFFFFFFFF
+ ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [
+ CFStringRef, LSRolesMask
+ ]
+ ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef
+
+ def cfstr(py_s: str) -> CFStringRef:
+ return cf.CFStringCreateWithCString(
+ None, py_s.encode("utf-8"), kCFStringEncodingUTF8
+ )
+
+ def to_pystr(cf_s: CFStringRef) -> Optional[str]:
+ if not cf_s:
+ return None
+ # Try fast pointer
+ ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8)
+ if ptr:
+ return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
+
+ # Fallback buffer
+ buf_size = 1024
+ buf = ctypes.create_string_buffer(buf_size)
+ ok = cf.CFStringGetCString(
+ cf_s, buf, buf_size, kCFStringEncodingUTF8
+ )
+ if ok:
+ return buf.value.decode("utf-8")
+ return None
+
+ # Convert extension (without dot) to UTI
+ tag_class = cfstr("public.filename-extension")
+ tag_value = cfstr(ext.lstrip("."))
+
+ uti_ref = UTTypeCreatePreferredIdentifierForTag(
+ tag_class, tag_value, None
+ )
+
+ # Clean up temporary CFStrings
+ for ref in (tag_class, tag_value):
+ if ref:
+ cf.CFRelease(ref)
+
+ bundle_id = None
+ if uti_ref:
+ # Get default handler for the UTI
+ default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType(
+ uti_ref, kLSRolesAll
+ )
+ bundle_id = to_pystr(default_bundle_ref)
+ if default_bundle_ref:
+ cf.CFRelease(default_bundle_ref)
+ cf.CFRelease(uti_ref)
+ return bundle_id is not None
+
+
+def _filter_supported_exts(
+ extensions: set[str], test_func: Callable
+) -> set[str]:
+ filtered_exs: set[str] = set()
+ for ext in extensions:
+ if not _Cache.already_checked(ext):
+ _Cache.set_ext_support(ext, test_func(ext))
+ if _Cache.is_supported(ext):
+ filtered_exs.add(ext)
+ return filtered_exs
+
+
+def filter_supported_exts(extensions: set[str]) -> set[str]:
+ if not extensions:
+ return set()
+ platform_name = platform.system().lower()
+ if platform_name == "windows":
+ return _filter_supported_exts(
+ extensions, _extension_has_assigned_app_windows
+ )
+ if platform_name == "linux":
+ return _filter_supported_exts(
+ extensions, _extension_has_assigned_app_linux
+ )
+ if platform_name == "darwin":
+ return _filter_supported_exts(
+ extensions, _extension_has_assigned_app_macos
+ )
+ return set()
+
+
+def open_file(filepath: str) -> None:
+ """Open file with system default executable"""
+ if sys.platform.startswith("darwin"):
+ subprocess.call(("open", filepath))
+ elif os.name == "nt":
+ os.startfile(filepath)
+ elif os.name == "posix":
+ subprocess.call(("xdg-open", filepath))
+
+
+class OpenFileAction(LoaderActionPlugin):
+ """Open Image Sequence or Video with system default"""
+ identifier = "core.open-file"
+
+ def get_action_items(
+ self, selection: LoaderActionSelection
+ ) -> list[LoaderActionItem]:
+ repres = []
+ if selection.selected_type == "representation":
+ repres = selection.entities.get_representations(
+ selection.selected_ids
+ )
+
+ if selection.selected_type == "version":
+ repres = selection.entities.get_versions_representations(
+ selection.selected_ids
+ )
+
+ if not repres:
+ return []
+
+ repres_by_ext = collections.defaultdict(list)
+ for repre in repres:
+ repre_context = repre.get("context")
+ if not repre_context:
+ continue
+ ext = repre_context.get("ext")
+ if not ext:
+ path = repre["attrib"].get("path")
+ if path:
+ ext = os.path.splitext(path)[1]
+
+ if ext:
+ ext = ext.lower()
+ if not ext.startswith("."):
+ ext = f".{ext}"
+ repres_by_ext[ext.lower()].append(repre)
+
+ if not repres_by_ext:
+ return []
+
+ filtered_exts = filter_supported_exts(set(repres_by_ext))
+
+ repre_ids_by_name = collections.defaultdict(set)
+ for ext in filtered_exts:
+ for repre in repres_by_ext[ext]:
+ repre_ids_by_name[repre["name"]].add(repre["id"])
+
+ return [
+ LoaderActionItem(
+ label=repre_name,
+ group_label="Open file",
+ order=30,
+ data={"representation_ids": list(repre_ids)},
+ icon={
+ "type": "material-symbols",
+ "name": "file_open",
+ "color": "#ffffff",
+ }
+ )
+ for repre_name, repre_ids in repre_ids_by_name.items()
+ ]
+
+ def execute_action(
+ self,
+ selection: LoaderActionSelection,
+ data: dict[str, Any],
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ path = None
+ repre_path = None
+ repre_ids = data["representation_ids"]
+ for repre in selection.entities.get_representations(repre_ids):
+ repre_path = get_representation_path_with_anatomy(
+ repre, selection.get_project_anatomy()
+ )
+ if os.path.exists(repre_path):
+ path = repre_path
+ break
+
+ if path is None:
+ if repre_path is None:
+ return LoaderActionResult(
+ "Failed to fill representation path...",
+ success=False,
+ )
+ return LoaderActionResult(
+ "File to open was not found...",
+ success=False,
+ )
+
+ self.log.info(f"Opening: {path}")
+
+ open_file(path)
+
+ return LoaderActionResult(
+ "File was opened...",
+ success=True,
+ )
diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py
new file mode 100644
index 0000000000..d2ade736fd
--- /dev/null
+++ b/client/ayon_core/plugins/loader/push_to_project.py
@@ -0,0 +1,69 @@
+import os
+from typing import Optional, Any
+
+from ayon_core import AYON_CORE_ROOT
+from ayon_core.lib import get_ayon_launcher_args, run_detached_process
+
+from ayon_core.pipeline.actions import (
+ LoaderSimpleActionPlugin,
+ LoaderActionSelection,
+ LoaderActionResult,
+)
+
+
+class PushToProject(LoaderSimpleActionPlugin):
+ identifier = "core.push-to-project"
+ label = "Push to project"
+ order = 35
+ icon = {
+ "type": "material-symbols",
+ "name": "send",
+ "color": "#d8d8d8",
+ }
+
+ def is_compatible(
+ self, selection: LoaderActionSelection
+ ) -> bool:
+ if not selection.versions_selected():
+ return False
+
+ version_ids = set(selection.selected_ids)
+ product_ids = {
+ product["id"]
+ for product in selection.entities.get_versions_products(
+ version_ids
+ )
+ }
+ folder_ids = {
+ folder["id"]
+ for folder in selection.entities.get_products_folders(
+ product_ids
+ )
+ }
+
+ if len(folder_ids) == 1:
+ return True
+ return False
+
+ def execute_simple_action(
+ self,
+ selection: LoaderActionSelection,
+ form_values: dict[str, Any],
+ ) -> Optional[LoaderActionResult]:
+ push_tool_script_path = os.path.join(
+ AYON_CORE_ROOT,
+ "tools",
+ "push_to_project",
+ "main.py"
+ )
+
+ args = get_ayon_launcher_args(
+ push_tool_script_path,
+ "--project", selection.project_name,
+ "--versions", ",".join(selection.selected_ids)
+ )
+ run_detached_process(args)
+ return LoaderActionResult(
+ message="Push to project tool opened...",
+ success=True,
+ )
diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py
index 2949ff1196..273e966cfd 100644
--- a/client/ayon_core/plugins/publish/collect_audio.py
+++ b/client/ayon_core/plugins/publish/collect_audio.py
@@ -52,7 +52,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
context, self.__class__
):
# Skip instances that already have audio filled
- if instance.data.get("audio"):
+ if "audio" in instance.data:
self.log.debug(
"Skipping Audio collection. It is already collected"
)
diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py
index 524381f656..f509ed807a 100644
--- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py
+++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py
@@ -11,20 +11,6 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder + 0.0001
label = "Collect Versions Loaded in Scene"
- hosts = [
- "aftereffects",
- "blender",
- "celaction",
- "fusion",
- "harmony",
- "hiero",
- "houdini",
- "maya",
- "nuke",
- "photoshop",
- "resolve",
- "tvpaint"
- ]
def process(self, context):
host = registered_host()
diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py
index 8b351c7f31..1a2c85e597 100644
--- a/client/ayon_core/plugins/publish/extract_color_transcode.py
+++ b/client/ayon_core/plugins/publish/extract_color_transcode.py
@@ -11,6 +11,7 @@ from ayon_core.lib import (
is_oiio_supported,
)
from ayon_core.lib.transcoding import (
+ MissingRGBAChannelsError,
oiio_color_convert,
)
@@ -111,7 +112,17 @@ class ExtractOIIOTranscode(publish.Extractor):
self.log.warning("Config file doesn't exist, skipping")
continue
+ # Get representation files to convert
+ if isinstance(repre["files"], list):
+ repre_files_to_convert = copy.deepcopy(repre["files"])
+ else:
+ repre_files_to_convert = [repre["files"]]
+
+ # Process each output definition
for output_def in profile_output_defs:
+ # Local copy to avoid accidental mutable changes
+ files_to_convert = list(repre_files_to_convert)
+
output_name = output_def["name"]
new_repre = copy.deepcopy(repre)
@@ -122,11 +133,6 @@ class ExtractOIIOTranscode(publish.Extractor):
)
new_repre["stagingDir"] = new_staging_dir
- if isinstance(new_repre["files"], list):
- files_to_convert = copy.deepcopy(new_repre["files"])
- else:
- files_to_convert = [new_repre["files"]]
-
output_extension = output_def["extension"]
output_extension = output_extension.replace('.', '')
self._rename_in_representation(new_repre,
@@ -168,30 +174,49 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"]
["additional_command_args"])
- files_to_convert = self._translate_to_sequence(
- files_to_convert)
- self.log.debug("Files to convert: {}".format(files_to_convert))
- for file_name in files_to_convert:
+ sequence_files = self._translate_to_sequence(files_to_convert)
+ self.log.debug("Files to convert: {}".format(sequence_files))
+ missing_rgba_review_channels = False
+ for file_name in sequence_files:
+ if isinstance(file_name, clique.Collection):
+ # Convert to filepath that can be directly converted
+ # by oiio like `frame.1001-1025%04d.exr`
+ file_name: str = file_name.format(
+ "{head}{range}{padding}{tail}"
+ )
+
self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir,
file_name)
output_path = self._get_output_file_path(input_path,
new_staging_dir,
output_extension)
+ try:
+ oiio_color_convert(
+ input_path=input_path,
+ output_path=output_path,
+ config_path=config_path,
+ source_colorspace=source_colorspace,
+ target_colorspace=target_colorspace,
+ target_display=target_display,
+ target_view=target_view,
+ source_display=source_display,
+ source_view=source_view,
+ additional_command_args=additional_command_args,
+ logger=self.log
+ )
+ except MissingRGBAChannelsError as exc:
+ missing_rgba_review_channels = True
+ self.log.error(exc)
+ self.log.error(
+ "Skipping OIIO Transcode. Unknown RGBA channels"
+ f" for colorspace conversion in file: {input_path}"
+ )
+ break
- oiio_color_convert(
- input_path=input_path,
- output_path=output_path,
- config_path=config_path,
- source_colorspace=source_colorspace,
- target_colorspace=target_colorspace,
- target_display=target_display,
- target_view=target_view,
- source_display=source_display,
- source_view=source_view,
- additional_command_args=additional_command_args,
- logger=self.log
- )
+ if missing_rgba_review_channels:
+ # Stop processing this representation
+ break
# cleanup temporary transcoded files
for file_name in new_repre["files"]:
@@ -217,11 +242,11 @@ class ExtractOIIOTranscode(publish.Extractor):
added_review = True
# If there is only 1 file outputted then convert list to
- # string, cause that'll indicate that its not a sequence.
+ # string, because that'll indicate that it is not a sequence.
if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0]
- # If the source representation has "review" tag, but its not
+ # If the source representation has "review" tag, but it's not
# part of the output definition tags, then both the
# representations will be transcoded in ExtractReview and
# their outputs will clash in integration.
@@ -271,42 +296,34 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert):
- """Returns original list or list with filename formatted in single
- sequence format.
+ """Returns original list or a clique.Collection of a sequence.
- Uses clique to find frame sequence, in this case it merges all frames
- into sequence format (FRAMESTART-FRAMEEND#) and returns it.
- If sequence not found, it returns original list
+ Uses clique to find frame sequence Collection.
+ If sequence not found, it returns original list.
Args:
files_to_convert (list): list of file names
Returns:
- (list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr]
+ list[str | clique.Collection]: List of filepaths or a list
+ of Collections (usually one, unless there are holes)
"""
pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble(
files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True)
-
if collections:
if len(collections) > 1:
raise ValueError(
"Too many collections {}".format(collections))
collection = collections[0]
- frames = list(collection.indexes)
- if collection.holes().indexes:
- return files_to_convert
-
- # Get the padding from the collection
- # This is the number of digits used in the frame numbers
- padding = collection.padding
-
- frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding)
- file_name = "{}{}{}".format(collection.head, frame_str,
- collection.tail)
-
- files_to_convert = [file_name]
+ # TODO: Technically oiiotool supports holes in the sequence as well
+ # using the dedicated --frames argument to specify the frames.
+ # We may want to use that too so conversions of sequences with
+ # holes will perform faster as well.
+ # Separate the collection so that we have no holes/gaps per
+ # collection.
+ return collection.separate()
return files_to_convert
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 3a450a4f33..1df96b2918 100644
--- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py
+++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py
@@ -1,12 +1,83 @@
+import collections
+import hashlib
import os
import tempfile
+import uuid
+from pathlib import Path
import pyblish
+from ayon_core.lib import get_ffmpeg_tool_args, run_subprocess
-from ayon_core.lib import (
- get_ffmpeg_tool_args,
- run_subprocess
-)
+
+def get_audio_instances(context):
+ """Return only instances which are having audio in families
+
+ Args:
+ context (pyblish.context): context of publisher
+
+ Returns:
+ list: list of selected instances
+ """
+ audio_instances = []
+ for instance in context:
+ if not instance.data.get("parent_instance_id"):
+ continue
+ if (
+ instance.data["productType"] == "audio"
+ or instance.data.get("reviewAudio")
+ ):
+ audio_instances.append(instance)
+ return audio_instances
+
+
+def map_instances_by_parent_id(context):
+ """Create a mapping of instances by their parent id
+
+ Args:
+ context (pyblish.context): context of publisher
+
+ Returns:
+ dict: mapping of instances by their parent id
+ """
+ instances_by_parent_id = collections.defaultdict(list)
+ for instance in context:
+ parent_instance_id = instance.data.get("parent_instance_id")
+ if not parent_instance_id:
+ continue
+ instances_by_parent_id[parent_instance_id].append(instance)
+ return instances_by_parent_id
+
+
+class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin):
+ """Collect audio instance attribute"""
+
+ order = pyblish.api.CollectorOrder
+ label = "Collect Audio Instance Attribute"
+
+ def process(self, context):
+
+ audio_instances = get_audio_instances(context)
+
+ # no need to continue if no audio instances found
+ if not audio_instances:
+ return
+
+ # create mapped instances by parent id
+ instances_by_parent_id = map_instances_by_parent_id(context)
+
+ # distribute audio related attribute
+ for audio_instance in audio_instances:
+ parent_instance_id = audio_instance.data["parent_instance_id"]
+
+ for sibl_instance in instances_by_parent_id[parent_instance_id]:
+ # exclude the same audio instance
+ if sibl_instance.id == audio_instance.id:
+ continue
+ self.log.info(
+ "Adding audio to Sibling instance: "
+ f"{sibl_instance.data['label']}"
+ )
+ sibl_instance.data["audio"] = None
class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
@@ -19,7 +90,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
order = pyblish.api.ExtractorOrder - 0.44
label = "Extract OTIO Audio Tracks"
- hosts = ["hiero", "resolve", "flame"]
+
+ temp_dir_path = None
def process(self, context):
"""Convert otio audio track's content to audio representations
@@ -28,13 +100,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
context (pyblish.Context): context of publisher
"""
# split the long audio file to peces devided by isntances
- audio_instances = self.get_audio_instances(context)
- self.log.debug("Audio instances: {}".format(len(audio_instances)))
+ audio_instances = get_audio_instances(context)
- if len(audio_instances) < 1:
- self.log.info("No audio instances available")
+ # no need to continue if no audio instances found
+ if not audio_instances:
return
+ self.log.debug("Audio instances: {}".format(len(audio_instances)))
+
# get sequence
otio_timeline = context.data["otioTimeline"]
@@ -44,8 +117,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
if not audio_inputs:
return
- # temp file
- audio_temp_fpath = self.create_temp_file("audio")
+ # Convert all available audio into single file for trimming
+ audio_temp_fpath = self.create_temp_file("timeline_audio_track")
# create empty audio with longest duration
empty = self.create_empty(audio_inputs)
@@ -59,19 +132,25 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
# remove empty
os.remove(empty["mediaPath"])
+ # create mapped instances by parent id
+ instances_by_parent_id = map_instances_by_parent_id(context)
+
# cut instance framerange and add to representations
- self.add_audio_to_instances(audio_temp_fpath, audio_instances)
+ self.add_audio_to_instances(
+ audio_temp_fpath, audio_instances, instances_by_parent_id)
# remove full mixed audio file
os.remove(audio_temp_fpath)
- def add_audio_to_instances(self, audio_file, instances):
+ def add_audio_to_instances(
+ self, audio_file, audio_instances, instances_by_parent_id):
created_files = []
- for inst in instances:
- name = inst.data["folderPath"]
+ for audio_instance in audio_instances:
+ folder_path = audio_instance.data["folderPath"]
+ file_suffix = folder_path.replace("/", "-")
- recycling_file = [f for f in created_files if name in f]
- audio_clip = inst.data["otioClip"]
+ recycling_file = [f for f in created_files if file_suffix in f]
+ audio_clip = audio_instance.data["otioClip"]
audio_range = audio_clip.range_in_parent()
duration = audio_range.duration.to_frames()
@@ -84,68 +163,70 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
start_sec = relative_start_time.to_seconds()
duration_sec = audio_range.duration.to_seconds()
- # temp audio file
- audio_fpath = self.create_temp_file(name)
+ # shot related audio file
+ shot_audio_fpath = self.create_temp_file(file_suffix)
cmd = get_ffmpeg_tool_args(
"ffmpeg",
"-ss", str(start_sec),
"-t", str(duration_sec),
"-i", audio_file,
- audio_fpath
+ shot_audio_fpath
)
# run subprocess
self.log.debug("Executing: {}".format(" ".join(cmd)))
run_subprocess(cmd, logger=self.log)
- else:
- audio_fpath = recycling_file.pop()
- if "audio" in (
- inst.data["families"] + [inst.data["productType"]]
- ):
+ # add generated audio file to created files for recycling
+ if shot_audio_fpath not in created_files:
+ created_files.append(shot_audio_fpath)
+ else:
+ shot_audio_fpath = recycling_file.pop()
+
+ # audio file needs to be published as representation
+ if audio_instance.data["productType"] == "audio":
# create empty representation attr
- if "representations" not in inst.data:
- inst.data["representations"] = []
+ if "representations" not in audio_instance.data:
+ audio_instance.data["representations"] = []
# add to representations
- inst.data["representations"].append({
- "files": os.path.basename(audio_fpath),
+ audio_instance.data["representations"].append({
+ "files": os.path.basename(shot_audio_fpath),
"name": "wav",
"ext": "wav",
- "stagingDir": os.path.dirname(audio_fpath),
+ "stagingDir": os.path.dirname(shot_audio_fpath),
"frameStart": 0,
"frameEnd": duration
})
- elif "reviewAudio" in inst.data.keys():
- audio_attr = inst.data.get("audio") or []
+ # audio file needs to be reviewable too
+ elif "reviewAudio" in audio_instance.data.keys():
+ audio_attr = audio_instance.data.get("audio") or []
audio_attr.append({
- "filename": audio_fpath,
+ "filename": shot_audio_fpath,
"offset": 0
})
- inst.data["audio"] = audio_attr
+ audio_instance.data["audio"] = audio_attr
- # add generated audio file to created files for recycling
- if audio_fpath not in created_files:
- created_files.append(audio_fpath)
-
- def get_audio_instances(self, context):
- """Return only instances which are having audio in families
-
- Args:
- context (pyblish.context): context of publisher
-
- Returns:
- list: list of selected instances
- """
- return [
- _i for _i in context
- # filter only those with audio product type or family
- # and also with reviewAudio data key
- if bool("audio" in (
- _i.data.get("families", []) + [_i.data["productType"]])
- ) or _i.data.get("reviewAudio")
- ]
+ # Make sure if the audio instance is having siblink instances
+ # which needs audio for reviewable media so it is also added
+ # to its instance data
+ # Retrieve instance data from parent instance shot instance.
+ parent_instance_id = audio_instance.data["parent_instance_id"]
+ for sibl_instance in instances_by_parent_id[parent_instance_id]:
+ # exclude the same audio instance
+ if sibl_instance.id == audio_instance.id:
+ continue
+ self.log.info(
+ "Adding audio to Sibling instance: "
+ f"{sibl_instance.data['label']}"
+ )
+ audio_attr = sibl_instance.data.get("audio") or []
+ audio_attr.append({
+ "filename": shot_audio_fpath,
+ "offset": 0
+ })
+ sibl_instance.data["audio"] = audio_attr
def get_audio_track_items(self, otio_timeline):
"""Get all audio clips form OTIO audio tracks
@@ -321,19 +402,23 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
os.remove(filters_tmp_filepath)
- def create_temp_file(self, name):
+ def create_temp_file(self, file_suffix):
"""Create temp wav file
Args:
- name (str): name to be used in file name
+ file_suffix (str): name to be used in file name
Returns:
str: temp fpath
"""
- name = name.replace("/", "_")
- return os.path.normpath(
- tempfile.mktemp(
- prefix="pyblish_tmp_{}_".format(name),
- suffix=".wav"
- )
- )
+ extension = ".wav"
+ # get 8 characters
+ hash = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()[:8]
+ file_name = f"{hash}_{file_suffix}{extension}"
+
+ if not self.temp_dir_path:
+ audio_temp_dir_path = tempfile.mkdtemp(prefix="AYON_audio_")
+ self.temp_dir_path = Path(audio_temp_dir_path)
+ self.temp_dir_path.mkdir(parents=True, exist_ok=True)
+
+ return (self.temp_dir_path / file_name).as_posix()
diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py
index 3f2a0dcd3e..16fb22524c 100644
--- a/client/ayon_core/plugins/publish/extract_review.py
+++ b/client/ayon_core/plugins/publish/extract_review.py
@@ -362,14 +362,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not filtered_output_defs:
self.log.debug((
"Repre: {} - All output definitions were filtered"
- " out by single frame filter. Skipping"
+ " out by single frame filter. Skipped."
).format(repre["name"]))
continue
# Skip if file is not set
if first_input_path is None:
self.log.warning((
- "Representation \"{}\" have empty files. Skipped."
+ "Representation \"{}\" has empty files. Skipped."
).format(repre["name"]))
continue
diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py
index b5885178d0..2a43c12af3 100644
--- a/client/ayon_core/plugins/publish/extract_thumbnail.py
+++ b/client/ayon_core/plugins/publish/extract_thumbnail.py
@@ -17,6 +17,7 @@ from ayon_core.lib import (
run_subprocess,
)
from ayon_core.lib.transcoding import (
+ MissingRGBAChannelsError,
oiio_color_convert,
get_oiio_input_and_channel_args,
get_oiio_info_for_input,
@@ -477,7 +478,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return False
input_info = get_oiio_info_for_input(src_path, logger=self.log)
- input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
+ try:
+ input_arg, channels_arg = get_oiio_input_and_channel_args(
+ input_info
+ )
+ except MissingRGBAChannelsError:
+ self.log.debug(
+ "Unable to find relevant reviewable channel for thumbnail "
+ "creation"
+ )
+ return False
oiio_cmd = get_oiio_tool_args(
"oiiotool",
input_arg, src_path,
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 0dc9a5e34d..9db8c49a02 100644
--- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py
+++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py
@@ -1,6 +1,7 @@
from operator import attrgetter
import dataclasses
import os
+import platform
from typing import Any, Dict, List
import pyblish.api
@@ -179,6 +180,8 @@ def get_instance_uri_path(
# Ensure `None` for now is also a string
path = str(path)
+ if platform.system().lower() == "windows":
+ path = path.replace("\\", "/")
return path
diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py
index d18e546392..6182598e14 100644
--- a/client/ayon_core/plugins/publish/integrate.py
+++ b/client/ayon_core/plugins/publish/integrate.py
@@ -457,6 +457,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
else:
version_data[key] = value
+ host_name = instance.context.data["hostName"]
+ version_data["host_name"] = host_name
+
version_entity = new_version_entity(
version_number,
product_entity["id"],
diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py
index 7423d58475..4d8e41199e 100644
--- a/client/ayon_core/tools/attribute_defs/dialog.py
+++ b/client/ayon_core/tools/attribute_defs/dialog.py
@@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.addWidget(attrs_widget, 0)
main_layout.addStretch(1)
main_layout.addWidget(btns_widget, 0)
diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py
index 1e948b2d28..f7766f50ac 100644
--- a/client/ayon_core/tools/attribute_defs/widgets.py
+++ b/client/ayon_core/tools/attribute_defs/widgets.py
@@ -182,6 +182,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
layout.deleteLater()
new_layout = QtWidgets.QGridLayout()
+ new_layout.setContentsMargins(0, 0, 0, 0)
new_layout.setColumnStretch(0, 0)
new_layout.setColumnStretch(1, 1)
self.setLayout(new_layout)
@@ -210,12 +211,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
if not attr_def.visible:
continue
+ col_num = 0
expand_cols = 2
- if attr_def.is_value_def and attr_def.is_label_horizontal:
- expand_cols = 1
-
- col_num = 2 - expand_cols
-
if attr_def.is_value_def and attr_def.label:
label_widget = AttributeDefinitionsLabel(
attr_def.id, attr_def.label, self
@@ -233,9 +230,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
| QtCore.Qt.AlignVCenter
)
layout.addWidget(
- label_widget, row, 0, 1, expand_cols
+ label_widget, row, col_num, 1, 1
)
- if not attr_def.is_label_horizontal:
+ if attr_def.is_label_horizontal:
+ col_num += 1
+ expand_cols = 1
+ else:
row += 1
if attr_def.is_value_def:
diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py
index 250c3b020d..0c1f912fd1 100644
--- a/client/ayon_core/tools/common_models/projects.py
+++ b/client/ayon_core/tools/common_models/projects.py
@@ -1,11 +1,13 @@
from __future__ import annotations
+import json
import contextlib
from abc import ABC, abstractmethod
from typing import Any, Optional
from dataclasses import dataclass
import ayon_api
+from ayon_api.graphql_queries import projects_graphql_query
from ayon_core.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem, NestedCacheItem
@@ -275,7 +277,7 @@ class ProductTypeIconMapping:
return self._definitions_by_name
-def _get_project_items_from_entitiy(
+def _get_project_items_from_entity(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
"""
@@ -290,6 +292,7 @@ def _get_project_items_from_entitiy(
return [
ProjectItem.from_entity(project)
for project in projects
+ if project["active"]
]
@@ -538,8 +541,32 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
return self._projects_cache.get_data()
+ def _fetch_graphql_projects(self) -> list[dict[str, Any]]:
+ """Fetch projects using GraphQl.
+
+ This method was added because ayon_api had a bug in 'get_projects'.
+
+ Returns:
+ list[dict[str, Any]]: List of projects.
+
+ """
+ api = ayon_api.get_server_api_connection()
+ query = projects_graphql_query({"name", "active", "library", "data"})
+
+ projects = []
+ for parsed_data in query.continuous_query(api):
+ for project in parsed_data["projects"]:
+ project_data = project["data"]
+ if project_data is None:
+ project["data"] = {}
+ elif isinstance(project_data, str):
+ project["data"] = json.loads(project_data)
+ projects.append(project)
+ return projects
+
def _query_projects(self) -> list[ProjectItem]:
- projects = ayon_api.get_projects(fields=["name", "active", "library"])
+ projects = self._fetch_graphql_projects()
+
user = ayon_api.get_user()
pinned_projects = (
user
@@ -548,7 +575,7 @@ class ProjectsModel(object):
.get("pinnedProjects")
) or []
pinned_projects = set(pinned_projects)
- project_items = _get_project_items_from_entitiy(list(projects))
+ project_items = _get_project_items_from_entity(list(projects))
for project in project_items:
project.is_pinned = project.name in pinned_projects
return project_items
diff --git a/client/ayon_core/tools/common_models/users.py b/client/ayon_core/tools/common_models/users.py
index f7939e5cd3..42a76d8d7d 100644
--- a/client/ayon_core/tools/common_models/users.py
+++ b/client/ayon_core/tools/common_models/users.py
@@ -1,10 +1,13 @@
import json
import collections
+from typing import Optional
import ayon_api
from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict
-from ayon_core.lib import NestedCacheItem
+from ayon_core.lib import NestedCacheItem, get_ayon_username
+
+NOT_SET = object()
# --- Implementation that should be in ayon-python-api ---
@@ -105,9 +108,18 @@ class UserItem:
class UsersModel:
def __init__(self, controller):
+ self._current_username = NOT_SET
self._controller = controller
self._users_cache = NestedCacheItem(default_factory=list)
+ def get_current_username(self) -> Optional[str]:
+ if self._current_username is NOT_SET:
+ self._current_username = get_ayon_username()
+ return self._current_username
+
+ def reset(self) -> None:
+ self._users_cache.reset()
+
def get_user_items(self, project_name):
"""Get user items.
diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py
index 85b362f9d7..f4656de787 100644
--- a/client/ayon_core/tools/launcher/control.py
+++ b/client/ayon_core/tools/launcher/control.py
@@ -1,10 +1,14 @@
from typing import Optional
-from ayon_core.lib import Logger, get_ayon_username
+from ayon_core.lib import Logger
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.addon import AddonsManager
from ayon_core.settings import get_project_settings, get_studio_settings
-from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
+from ayon_core.tools.common_models import (
+ ProjectsModel,
+ HierarchyModel,
+ UsersModel,
+)
from .abstract import (
AbstractLauncherFrontEnd,
@@ -30,13 +34,12 @@ class BaseLauncherController(
self._addons_manager = None
- self._username = NOT_SET
-
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._actions_model = ActionsModel(self)
self._workfiles_model = WorkfilesModel(self)
+ self._users_model = UsersModel(self)
@property
def log(self):
@@ -209,6 +212,7 @@ class BaseLauncherController(
self._projects_model.reset()
self._hierarchy_model.reset()
+ self._users_model.reset()
self._actions_model.refresh()
self._projects_model.refresh()
@@ -229,8 +233,10 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished")
- def get_my_tasks_entity_ids(self, project_name: str):
- username = self._get_my_username()
+ def get_my_tasks_entity_ids(
+ self, project_name: str
+ ) -> dict[str, list[str]]:
+ username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
@@ -238,10 +244,5 @@ class BaseLauncherController(
project_name, assignees
)
- def _get_my_username(self):
- if self._username is NOT_SET:
- self._username = get_ayon_username()
- return self._username
-
def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller")
diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py
index 31b303ca2b..0e763a208a 100644
--- a/client/ayon_core/tools/launcher/ui/actions_widget.py
+++ b/client/ayon_core/tools/launcher/ui/actions_widget.py
@@ -1,22 +1,12 @@
import time
-import uuid
import collections
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.lib import Logger
-from ayon_core.lib.attribute_definitions import (
- UILabelDef,
- EnumDef,
- TextDef,
- BoolDef,
- NumberDef,
- HiddenDef,
-)
+from ayon_core.pipeline.actions import webaction_fields_to_attribute_defs
from ayon_core.tools.flickcharm import FlickCharm
-from ayon_core.tools.utils import (
- get_qt_icon,
-)
+from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.launcher.abstract import WebactionContext
@@ -1173,74 +1163,7 @@ class ActionsWidget(QtWidgets.QWidget):
float - 'label', 'value', 'placeholder', 'min', 'max'
"""
- attr_defs = []
- for config_field in config_fields:
- field_type = config_field["type"]
- attr_def = None
- if field_type == "label":
- label = config_field.get("value")
- if label is None:
- label = config_field.get("text")
- attr_def = UILabelDef(
- label, key=uuid.uuid4().hex
- )
- elif field_type == "boolean":
- value = config_field["value"]
- if isinstance(value, str):
- value = value.lower() == "true"
-
- attr_def = BoolDef(
- config_field["name"],
- default=value,
- label=config_field.get("label"),
- )
- elif field_type == "text":
- attr_def = TextDef(
- config_field["name"],
- default=config_field.get("value"),
- label=config_field.get("label"),
- placeholder=config_field.get("placeholder"),
- multiline=config_field.get("multiline", False),
- regex=config_field.get("regex"),
- # syntax=config_field["syntax"],
- )
- elif field_type in ("integer", "float"):
- value = config_field.get("value")
- if isinstance(value, str):
- if field_type == "integer":
- value = int(value)
- else:
- value = float(value)
- attr_def = NumberDef(
- config_field["name"],
- default=value,
- label=config_field.get("label"),
- decimals=0 if field_type == "integer" else 5,
- # placeholder=config_field.get("placeholder"),
- minimum=config_field.get("min"),
- maximum=config_field.get("max"),
- )
- elif field_type in ("select", "multiselect"):
- attr_def = EnumDef(
- config_field["name"],
- items=config_field["options"],
- default=config_field.get("value"),
- label=config_field.get("label"),
- multiselection=field_type == "multiselect",
- )
- elif field_type == "hidden":
- attr_def = HiddenDef(
- config_field["name"],
- default=config_field.get("value"),
- )
-
- if attr_def is None:
- print(f"Unknown config field type: {field_type}")
- attr_def = UILabelDef(
- f"Unknown field type '{field_type}",
- key=uuid.uuid4().hex
- )
- attr_defs.append(attr_def)
+ attr_defs = webaction_fields_to_attribute_defs(config_fields)
dialog = AttributeDefinitionsDialog(
attr_defs,
diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py
index 47388d9685..3c8be4679e 100644
--- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py
+++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py
@@ -2,19 +2,47 @@ import qtawesome
from qtpy import QtWidgets, QtCore
from ayon_core.tools.utils import (
- PlaceholderLineEdit,
SquareButton,
RefreshButton,
ProjectsCombobox,
FoldersWidget,
TasksWidget,
- NiceCheckbox,
)
-from ayon_core.tools.utils.lib import checkstate_int_to_enum
+from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget
from .workfiles_page import WorkfilesPage
+class LauncherFoldersWidget(FoldersWidget):
+ focused_in = QtCore.Signal()
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._folders_view.installEventFilter(self)
+
+ def eventFilter(self, obj, event):
+ if event.type() == QtCore.QEvent.FocusIn:
+ self.focused_in.emit()
+ return False
+
+
+class LauncherTasksWidget(TasksWidget):
+ focused_in = QtCore.Signal()
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._tasks_view.installEventFilter(self)
+
+ def deselect(self):
+ sel_model = self._tasks_view.selectionModel()
+ sel_model.clearSelection()
+
+ def eventFilter(self, obj, event):
+ if event.type() == QtCore.QEvent.FocusIn:
+ self.focused_in.emit()
+ return False
+
+
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super().__init__(parent)
@@ -46,34 +74,15 @@ class HierarchyPage(QtWidgets.QWidget):
content_body.setOrientation(QtCore.Qt.Horizontal)
# - filters
- filters_widget = QtWidgets.QWidget(self)
-
- folders_filter_text = PlaceholderLineEdit(filters_widget)
- folders_filter_text.setPlaceholderText("Filter folders...")
-
- my_tasks_tooltip = (
- "Filter folders and task to only those you are assigned to."
- )
- my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget)
- my_tasks_label.setToolTip(my_tasks_tooltip)
-
- my_tasks_checkbox = NiceCheckbox(filters_widget)
- my_tasks_checkbox.setChecked(False)
- my_tasks_checkbox.setToolTip(my_tasks_tooltip)
-
- filters_layout = QtWidgets.QHBoxLayout(filters_widget)
- filters_layout.setContentsMargins(0, 0, 0, 0)
- filters_layout.addWidget(folders_filter_text, 1)
- filters_layout.addWidget(my_tasks_label, 0)
- filters_layout.addWidget(my_tasks_checkbox, 0)
+ filters_widget = FoldersFiltersWidget(self)
# - Folders widget
- folders_widget = FoldersWidget(controller, content_body)
+ folders_widget = LauncherFoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
folders_widget.set_deselectable(True)
# - Tasks widget
- tasks_widget = TasksWidget(controller, content_body)
+ tasks_widget = LauncherTasksWidget(controller, content_body)
# - Third page - Workfiles
workfiles_page = WorkfilesPage(controller, content_body)
@@ -93,17 +102,18 @@ class HierarchyPage(QtWidgets.QWidget):
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
- folders_filter_text.textChanged.connect(self._on_filter_text_changed)
- my_tasks_checkbox.stateChanged.connect(
+ filters_widget.text_changed.connect(self._on_filter_text_changed)
+ filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
+ folders_widget.focused_in.connect(self._on_folders_focus)
+ tasks_widget.focused_in.connect(self._on_tasks_focus)
self._is_visible = False
self._controller = controller
self._btn_back = btn_back
self._projects_combobox = projects_combobox
- self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._workfiles_page = workfiles_page
@@ -126,9 +136,6 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
- self._on_my_tasks_checkbox_state_changed(
- self._my_tasks_checkbox.checkState()
- )
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@@ -139,11 +146,10 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(text)
- def _on_my_tasks_checkbox_state_changed(self, state):
+ def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
- state = checkstate_int_to_enum(state)
- if state == QtCore.Qt.Checked:
+ if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
@@ -151,3 +157,9 @@ class HierarchyPage(QtWidgets.QWidget):
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)
+
+ def _on_folders_focus(self):
+ self._workfiles_page.deselect()
+
+ def _on_tasks_focus(self):
+ self._workfiles_page.deselect()
diff --git a/client/ayon_core/tools/launcher/ui/workfiles_page.py b/client/ayon_core/tools/launcher/ui/workfiles_page.py
index 1ea223031e..d81221f38d 100644
--- a/client/ayon_core/tools/launcher/ui/workfiles_page.py
+++ b/client/ayon_core/tools/launcher/ui/workfiles_page.py
@@ -3,7 +3,7 @@ from typing import Optional
import ayon_api
from qtpy import QtCore, QtWidgets, QtGui
-from ayon_core.tools.utils import get_qt_icon
+from ayon_core.tools.utils import get_qt_icon, DeselectableTreeView
from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
VERSION_ROLE = QtCore.Qt.UserRole + 1
@@ -127,7 +127,7 @@ class WorkfilesModel(QtGui.QStandardItemModel):
return icon
-class WorkfilesView(QtWidgets.QTreeView):
+class WorkfilesView(DeselectableTreeView):
def drawBranches(self, painter, rect, index):
return
@@ -165,6 +165,10 @@ class WorkfilesPage(QtWidgets.QWidget):
def refresh(self) -> None:
self._workfiles_model.refresh()
+ def deselect(self):
+ sel_model = self._workfiles_view.selectionModel()
+ sel_model.clearSelection()
+
def _on_refresh(self) -> None:
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)
diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py
index 9c7934d2db..a11663a56f 100644
--- a/client/ayon_core/tools/loader/abstract.py
+++ b/client/ayon_core/tools/loader/abstract.py
@@ -316,43 +316,34 @@ class ActionItem:
Args:
identifier (str): Action identifier.
label (str): Action label.
- icon (dict[str, Any]): Action icon definition.
- tooltip (str): Action tooltip.
+ group_label (Optional[str]): Group label.
+ icon (Optional[dict[str, Any]]): Action icon definition.
+ tooltip (Optional[str]): Action tooltip.
+ order (int): Action order.
+ data (Optional[dict[str, Any]]): Additional action data.
options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]):
Action options. Note: 'qargparse' is considered as deprecated.
- order (int): Action order.
- project_name (str): Project name.
- folder_ids (list[str]): Folder ids.
- product_ids (list[str]): Product ids.
- version_ids (list[str]): Version ids.
- representation_ids (list[str]): Representation ids.
- """
+ """
def __init__(
self,
- identifier,
- label,
- icon,
- tooltip,
- options,
- order,
- project_name,
- folder_ids,
- product_ids,
- version_ids,
- representation_ids,
+ identifier: str,
+ label: str,
+ group_label: Optional[str],
+ icon: Optional[dict[str, Any]],
+ tooltip: Optional[str],
+ order: int,
+ data: Optional[dict[str, Any]],
+ options: Optional[list],
):
self.identifier = identifier
self.label = label
+ self.group_label = group_label
self.icon = icon
self.tooltip = tooltip
- self.options = options
+ self.data = data
self.order = order
- self.project_name = project_name
- self.folder_ids = folder_ids
- self.product_ids = product_ids
- self.version_ids = version_ids
- self.representation_ids = representation_ids
+ self.options = options
def _options_to_data(self):
options = self.options
@@ -364,30 +355,26 @@ class ActionItem:
# future development of detached UI tools it would be better to be
# prepared for it.
raise NotImplementedError(
- "{}.to_data is not implemented. Use Attribute definitions"
- " from 'ayon_core.lib' instead of 'qargparse'.".format(
- self.__class__.__name__
- )
+ f"{self.__class__.__name__}.to_data is not implemented."
+ " Use Attribute definitions from 'ayon_core.lib'"
+ " instead of 'qargparse'."
)
- def to_data(self):
+ def to_data(self) -> dict[str, Any]:
options = self._options_to_data()
return {
"identifier": self.identifier,
"label": self.label,
+ "group_label": self.group_label,
"icon": self.icon,
"tooltip": self.tooltip,
- "options": options,
"order": self.order,
- "project_name": self.project_name,
- "folder_ids": self.folder_ids,
- "product_ids": self.product_ids,
- "version_ids": self.version_ids,
- "representation_ids": self.representation_ids,
+ "data": self.data,
+ "options": options,
}
@classmethod
- def from_data(cls, data):
+ def from_data(cls, data) -> "ActionItem":
options = data["options"]
if options:
options = deserialize_attr_defs(options)
@@ -666,6 +653,21 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
+ @abstractmethod
+ def get_my_tasks_entity_ids(
+ self, project_name: str
+ ) -> dict[str, list[str]]:
+ """Get entity ids for my tasks.
+
+ Args:
+ project_name (str): Project name.
+
+ Returns:
+ dict[str, list[str]]: Folder and task ids.
+
+ """
+ pass
+
@abstractmethod
def get_available_tags_by_entity_type(
self, project_name: str
@@ -990,43 +992,35 @@ class FrontendLoaderController(_BaseLoaderController):
# Load action items
@abstractmethod
- def get_versions_action_items(self, project_name, version_ids):
+ def get_action_items(
+ self,
+ project_name: str,
+ entity_ids: set[str],
+ entity_type: str,
+ ) -> list[ActionItem]:
"""Action items for versions selection.
Args:
project_name (str): Project name.
- version_ids (Iterable[str]): Version ids.
+ entity_ids (set[str]): Entity ids.
+ entity_type (str): Entity type.
Returns:
list[ActionItem]: List of action items.
+
"""
-
- pass
-
- @abstractmethod
- def get_representations_action_items(
- self, project_name, representation_ids
- ):
- """Action items for representations selection.
-
- Args:
- project_name (str): Project name.
- representation_ids (Iterable[str]): Representation ids.
-
- Returns:
- list[ActionItem]: List of action items.
- """
-
pass
@abstractmethod
def trigger_action_item(
self,
- identifier,
- options,
- project_name,
- version_ids,
- representation_ids
+ identifier: str,
+ project_name: str,
+ selected_ids: set[str],
+ selected_entity_type: str,
+ data: Optional[dict[str, Any]],
+ options: dict[str, Any],
+ form_values: dict[str, Any],
):
"""Trigger action item.
@@ -1044,13 +1038,15 @@ class FrontendLoaderController(_BaseLoaderController):
}
Args:
- identifier (str): Action identifier.
- options (dict[str, Any]): Action option values from UI.
+ identifier (sttr): Plugin identifier.
project_name (str): Project name.
- version_ids (Iterable[str]): Version ids.
- representation_ids (Iterable[str]): Representation ids.
- """
+ selected_ids (set[str]): Selected entity ids.
+ selected_entity_type (str): Selected entity type.
+ data (Optional[dict[str, Any]]): Additional action item data.
+ options (dict[str, Any]): Action option values from UI.
+ form_values (dict[str, Any]): Action form values from UI.
+ """
pass
@abstractmethod
diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py
index 9f159bfb21..2802ad7040 100644
--- a/client/ayon_core/tools/loader/control.py
+++ b/client/ayon_core/tools/loader/control.py
@@ -2,13 +2,17 @@ from __future__ import annotations
import logging
import uuid
-from typing import Optional
+from typing import Optional, Any
import ayon_api
from ayon_core.settings import get_project_settings
from ayon_core.pipeline import get_current_host_name
-from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles
+from ayon_core.lib import (
+ NestedCacheItem,
+ CacheItem,
+ filter_profiles,
+)
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.pipeline import Anatomy, get_current_context
from ayon_core.host import ILoadHost
@@ -18,12 +22,14 @@ from ayon_core.tools.common_models import (
ThumbnailsModel,
TagItem,
ProductTypeIconMapping,
+ UsersModel,
)
from .abstract import (
BackendLoaderController,
FrontendLoaderController,
- ProductTypesFilter
+ ProductTypesFilter,
+ ActionItem,
)
from .models import (
SelectionModel,
@@ -32,6 +38,8 @@ from .models import (
SiteSyncModel
)
+NOT_SET = object()
+
class ExpectedSelection:
def __init__(self, controller):
@@ -124,6 +132,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._loader_actions_model = LoaderActionsModel(self)
self._thumbnails_model = ThumbnailsModel()
self._sitesync_model = SiteSyncModel(self)
+ self._users_model = UsersModel(self)
@property
def log(self):
@@ -160,6 +169,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._projects_model.reset()
self._thumbnails_model.reset()
self._sitesync_model.reset()
+ self._users_model.reset()
self._projects_model.refresh()
@@ -235,6 +245,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
output[folder_id] = label
return output
+ def get_my_tasks_entity_ids(
+ self, project_name: str
+ ) -> dict[str, list[str]]:
+ username = self._users_model.get_current_username()
+ assignees = []
+ if username:
+ assignees.append(username)
+ return self._hierarchy_model.get_entity_ids_for_assignees(
+ project_name, assignees
+ )
+
def get_available_tags_by_entity_type(
self, project_name: str
) -> dict[str, list[str]]:
@@ -296,45 +317,47 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
project_name, product_ids, group_name
)
- def get_versions_action_items(self, project_name, version_ids):
- return self._loader_actions_model.get_versions_action_items(
- project_name, version_ids)
-
- def get_representations_action_items(
- self, project_name, representation_ids):
- action_items = (
- self._loader_actions_model.get_representations_action_items(
- project_name, representation_ids)
+ def get_action_items(
+ self,
+ project_name: str,
+ entity_ids: set[str],
+ entity_type: str,
+ ) -> list[ActionItem]:
+ action_items = self._loader_actions_model.get_action_items(
+ project_name, entity_ids, entity_type
)
- action_items.extend(self._sitesync_model.get_sitesync_action_items(
- project_name, representation_ids)
+ site_sync_items = self._sitesync_model.get_sitesync_action_items(
+ project_name, entity_ids, entity_type
)
-
+ action_items.extend(site_sync_items)
return action_items
def trigger_action_item(
self,
- identifier,
- options,
- project_name,
- version_ids,
- representation_ids
+ identifier: str,
+ project_name: str,
+ selected_ids: set[str],
+ selected_entity_type: str,
+ data: Optional[dict[str, Any]],
+ options: dict[str, Any],
+ form_values: dict[str, Any],
):
if self._sitesync_model.is_sitesync_action(identifier):
self._sitesync_model.trigger_action_item(
- identifier,
project_name,
- representation_ids
+ data,
)
return
self._loader_actions_model.trigger_action_item(
- identifier,
- options,
- project_name,
- version_ids,
- representation_ids
+ identifier=identifier,
+ project_name=project_name,
+ selected_ids=selected_ids,
+ selected_entity_type=selected_entity_type,
+ data=data,
+ options=options,
+ form_values=form_values,
)
# Selection model wrappers
@@ -476,20 +499,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def is_standard_projects_filter_enabled(self):
return self._host is not None
- def _get_project_anatomy(self, project_name):
- if not project_name:
- return None
- cache = self._project_anatomy_cache[project_name]
- if not cache.is_valid:
- cache.update_data(Anatomy(project_name))
- return cache.get_data()
-
- def _create_event_system(self):
- return QueuedEventSystem()
-
- def _emit_event(self, topic, data=None):
- self._event_system.emit(topic, data or {}, "controller")
-
def get_product_types_filter(self):
output = ProductTypesFilter(
is_allow_list=False,
@@ -545,3 +554,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
product_types=profile["filter_product_types"]
)
return output
+
+ def _create_event_system(self):
+ return QueuedEventSystem()
+
+ def _emit_event(self, topic, data=None):
+ self._event_system.emit(topic, data or {}, "controller")
+
+ def _get_project_anatomy(self, project_name):
+ if not project_name:
+ return None
+ cache = self._project_anatomy_cache[project_name]
+ if not cache.is_valid:
+ cache.update_data(Anatomy(project_name))
+ return cache.get_data()
diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py
index b792f92dfd..3db1792247 100644
--- a/client/ayon_core/tools/loader/models/actions.py
+++ b/client/ayon_core/tools/loader/models/actions.py
@@ -5,10 +5,16 @@ import traceback
import inspect
import collections
import uuid
+from typing import Optional, Callable, Any
import ayon_api
-from ayon_core.lib import NestedCacheItem
+from ayon_core.lib import NestedCacheItem, Logger
+from ayon_core.pipeline.actions import (
+ LoaderActionsContext,
+ LoaderActionSelection,
+ SelectionEntitiesCache,
+)
from ayon_core.pipeline.load import (
discover_loader_plugins,
ProductLoaderPlugin,
@@ -23,6 +29,7 @@ from ayon_core.pipeline.load import (
from ayon_core.tools.loader.abstract import ActionItem
ACTIONS_MODEL_SENDER = "actions.model"
+LOADER_PLUGIN_ID = "__loader_plugin__"
NOT_SET = object()
@@ -44,6 +51,7 @@ class LoaderActionsModel:
loaders_cache_lifetime = 30
def __init__(self, controller):
+ self._log = Logger.get_logger(self.__class__.__name__)
self._controller = controller
self._current_context_project = NOT_SET
self._loaders_by_identifier = NestedCacheItem(
@@ -52,6 +60,15 @@ class LoaderActionsModel:
levels=1, lifetime=self.loaders_cache_lifetime)
self._repre_loaders = NestedCacheItem(
levels=1, lifetime=self.loaders_cache_lifetime)
+ self._loader_actions = LoaderActionsContext()
+
+ self._projects_cache = NestedCacheItem(levels=1, lifetime=60)
+ self._folders_cache = NestedCacheItem(levels=2, lifetime=300)
+ self._tasks_cache = NestedCacheItem(levels=2, lifetime=300)
+ self._products_cache = NestedCacheItem(levels=2, lifetime=300)
+ self._versions_cache = NestedCacheItem(levels=2, lifetime=1200)
+ self._representations_cache = NestedCacheItem(levels=2, lifetime=1200)
+ self._repre_parents_cache = NestedCacheItem(levels=2, lifetime=1200)
def reset(self):
"""Reset the model with all cached items."""
@@ -60,64 +77,58 @@ class LoaderActionsModel:
self._loaders_by_identifier.reset()
self._product_loaders.reset()
self._repre_loaders.reset()
+ self._loader_actions.reset()
- def get_versions_action_items(self, project_name, version_ids):
- """Get action items for given version ids.
+ self._folders_cache.reset()
+ self._tasks_cache.reset()
+ self._products_cache.reset()
+ self._versions_cache.reset()
+ self._representations_cache.reset()
+ self._repre_parents_cache.reset()
- Args:
- project_name (str): Project name.
- version_ids (Iterable[str]): Version ids.
+ def get_action_items(
+ self,
+ project_name: str,
+ entity_ids: set[str],
+ entity_type: str,
+ ) -> list[ActionItem]:
+ version_context_by_id = {}
+ repre_context_by_id = {}
+ if entity_type == "representation":
+ (
+ version_context_by_id,
+ repre_context_by_id
+ ) = self._contexts_for_representations(project_name, entity_ids)
- Returns:
- list[ActionItem]: List of action items.
- """
+ if entity_type == "version":
+ (
+ version_context_by_id,
+ repre_context_by_id
+ ) = self._contexts_for_versions(project_name, entity_ids)
- (
- version_context_by_id,
- repre_context_by_id
- ) = self._contexts_for_versions(
- project_name,
- version_ids
- )
- return self._get_action_items_for_contexts(
+ action_items = self._get_action_items_for_contexts(
project_name,
version_context_by_id,
repre_context_by_id
)
-
- def get_representations_action_items(
- self, project_name, representation_ids
- ):
- """Get action items for given representation ids.
-
- Args:
- project_name (str): Project name.
- representation_ids (Iterable[str]): Representation ids.
-
- Returns:
- list[ActionItem]: List of action items.
- """
-
- (
- product_context_by_id,
- repre_context_by_id
- ) = self._contexts_for_representations(
+ action_items.extend(self._get_loader_action_items(
project_name,
- representation_ids
- )
- return self._get_action_items_for_contexts(
- project_name,
- product_context_by_id,
- repre_context_by_id
- )
+ entity_ids,
+ entity_type,
+ version_context_by_id,
+ repre_context_by_id,
+ ))
+ return action_items
def trigger_action_item(
self,
- identifier,
- options,
- project_name,
- version_ids,
- representation_ids
+ identifier: str,
+ project_name: str,
+ selected_ids: set[str],
+ selected_entity_type: str,
+ data: Optional[dict[str, Any]],
+ options: dict[str, Any],
+ form_values: dict[str, Any],
):
"""Trigger action by identifier.
@@ -128,15 +139,21 @@ class LoaderActionsModel:
happened.
Args:
- identifier (str): Loader identifier.
- options (dict[str, Any]): Loader option values.
+ identifier (str): Plugin identifier.
project_name (str): Project name.
- version_ids (Iterable[str]): Version ids.
- representation_ids (Iterable[str]): Representation ids.
- """
+ selected_ids (set[str]): Selected entity ids.
+ selected_entity_type (str): Selected entity type.
+ data (Optional[dict[str, Any]]): Additional action item data.
+ options (dict[str, Any]): Loader option values.
+ form_values (dict[str, Any]): Form values.
+ """
event_data = {
"identifier": identifier,
+ "project_name": project_name,
+ "selected_ids": list(selected_ids),
+ "selected_entity_type": selected_entity_type,
+ "data": data,
"id": uuid.uuid4().hex,
}
self._controller.emit_event(
@@ -144,24 +161,60 @@ class LoaderActionsModel:
event_data,
ACTIONS_MODEL_SENDER,
)
- loader = self._get_loader_by_identifier(project_name, identifier)
- if representation_ids is not None:
- error_info = self._trigger_representation_loader(
- loader,
- options,
- project_name,
- representation_ids,
+ if identifier != LOADER_PLUGIN_ID:
+ result = None
+ crashed = False
+ try:
+ result = self._loader_actions.execute_action(
+ identifier=identifier,
+ selection=LoaderActionSelection(
+ project_name,
+ selected_ids,
+ selected_entity_type,
+ ),
+ data=data,
+ form_values=form_values,
+ )
+
+ except Exception:
+ crashed = True
+ self._log.warning(
+ f"Failed to execute action '{identifier}'",
+ exc_info=True,
+ )
+
+ event_data["result"] = result
+ event_data["crashed"] = crashed
+ self._controller.emit_event(
+ "loader.action.finished",
+ event_data,
+ ACTIONS_MODEL_SENDER,
)
- elif version_ids is not None:
+ return
+
+ loader = self._get_loader_by_identifier(
+ project_name, data["loader"]
+ )
+ entity_type = data["entity_type"]
+ entity_ids = data["entity_ids"]
+ if entity_type == "version":
error_info = self._trigger_version_loader(
loader,
options,
project_name,
- version_ids,
+ entity_ids,
+ )
+ elif entity_type == "representation":
+ error_info = self._trigger_representation_loader(
+ loader,
+ options,
+ project_name,
+ entity_ids,
)
else:
raise NotImplementedError(
- "Invalid arguments to trigger action item")
+ f"Invalid entity type '{entity_type}' to trigger action item"
+ )
event_data["error_info"] = error_info
self._controller.emit_event(
@@ -276,28 +329,26 @@ class LoaderActionsModel:
self,
loader,
contexts,
- project_name,
- folder_ids=None,
- product_ids=None,
- version_ids=None,
- representation_ids=None,
+ entity_ids,
+ entity_type,
repre_name=None,
):
label = self._get_action_label(loader)
if repre_name:
- label = "{} ({})".format(label, repre_name)
+ label = f"{label} ({repre_name})"
return ActionItem(
- get_loader_identifier(loader),
+ LOADER_PLUGIN_ID,
+ data={
+ "entity_ids": entity_ids,
+ "entity_type": entity_type,
+ "loader": get_loader_identifier(loader),
+ },
label=label,
+ group_label=None,
icon=self._get_action_icon(loader),
tooltip=self._get_action_tooltip(loader),
- options=loader.get_options(contexts),
order=loader.order,
- project_name=project_name,
- folder_ids=folder_ids,
- product_ids=product_ids,
- version_ids=version_ids,
- representation_ids=representation_ids,
+ options=loader.get_options(contexts),
)
def _get_loaders(self, project_name):
@@ -351,15 +402,6 @@ class LoaderActionsModel:
loaders_by_identifier = loaders_by_identifier_c.get_data()
return loaders_by_identifier.get(identifier)
- def _actions_sorter(self, action_item):
- """Sort the Loaders by their order and then their name.
-
- Returns:
- tuple[int, str]: Sort keys.
- """
-
- return action_item.order, action_item.label
-
def _contexts_for_versions(self, project_name, version_ids):
"""Get contexts for given version ids.
@@ -385,8 +427,8 @@ class LoaderActionsModel:
if not project_name and not version_ids:
return version_context_by_id, repre_context_by_id
- version_entities = ayon_api.get_versions(
- project_name, version_ids=version_ids
+ version_entities = self._get_versions(
+ project_name, version_ids
)
version_entities_by_id = {}
version_entities_by_product_id = collections.defaultdict(list)
@@ -397,18 +439,18 @@ class LoaderActionsModel:
version_entities_by_product_id[product_id].append(version_entity)
_product_ids = set(version_entities_by_product_id.keys())
- _product_entities = ayon_api.get_products(
- project_name, product_ids=_product_ids
+ _product_entities = self._get_products(
+ project_name, _product_ids
)
product_entities_by_id = {p["id"]: p for p in _product_entities}
_folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
- _folder_entities = ayon_api.get_folders(
- project_name, folder_ids=_folder_ids
+ _folder_entities = self._get_folders(
+ project_name, _folder_ids
)
folder_entities_by_id = {f["id"]: f for f in _folder_entities}
- project_entity = ayon_api.get_project(project_name)
+ project_entity = self._get_project(project_name)
for version_id, version_entity in version_entities_by_id.items():
product_id = version_entity["productId"]
@@ -422,8 +464,15 @@ class LoaderActionsModel:
"version": version_entity,
}
- repre_entities = ayon_api.get_representations(
- project_name, version_ids=version_ids)
+ all_repre_ids = set()
+ for repre_ids in self._get_repre_ids_by_version_ids(
+ project_name, version_ids
+ ).values():
+ all_repre_ids |= repre_ids
+
+ repre_entities = self._get_representations(
+ project_name, all_repre_ids
+ )
for repre_entity in repre_entities:
version_id = repre_entity["versionId"]
version_entity = version_entities_by_id[version_id]
@@ -459,49 +508,54 @@ class LoaderActionsModel:
Returns:
tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
representation contexts.
- """
- product_context_by_id = {}
+ """
+ version_context_by_id = {}
repre_context_by_id = {}
if not project_name and not repre_ids:
- return product_context_by_id, repre_context_by_id
+ return version_context_by_id, repre_context_by_id
- repre_entities = list(ayon_api.get_representations(
- project_name, representation_ids=repre_ids
- ))
+ repre_entities = self._get_representations(
+ project_name, repre_ids
+ )
version_ids = {r["versionId"] for r in repre_entities}
- version_entities = ayon_api.get_versions(
- project_name, version_ids=version_ids
+ version_entities = self._get_versions(
+ project_name, version_ids
)
version_entities_by_id = {
v["id"]: v for v in version_entities
}
product_ids = {v["productId"] for v in version_entities_by_id.values()}
- product_entities = ayon_api.get_products(
- project_name, product_ids=product_ids
+ product_entities = self._get_products(
+ project_name, product_ids
+
)
product_entities_by_id = {
p["id"]: p for p in product_entities
}
folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
- folder_entities = ayon_api.get_folders(
- project_name, folder_ids=folder_ids
+ folder_entities = self._get_folders(
+ project_name, folder_ids
)
folder_entities_by_id = {
f["id"]: f for f in folder_entities
}
- project_entity = ayon_api.get_project(project_name)
+ project_entity = self._get_project(project_name)
- for product_id, product_entity in product_entities_by_id.items():
+ version_context_by_id = {}
+ for version_id, version_entity in version_entities_by_id.items():
+ product_id = version_entity["productId"]
+ product_entity = product_entities_by_id[product_id]
folder_id = product_entity["folderId"]
folder_entity = folder_entities_by_id[folder_id]
- product_context_by_id[product_id] = {
+ version_context_by_id[version_id] = {
"project": project_entity,
"folder": folder_entity,
"product": product_entity,
+ "version": version_entity,
}
for repre_entity in repre_entities:
@@ -519,7 +573,125 @@ class LoaderActionsModel:
"version": version_entity,
"representation": repre_entity,
}
- return product_context_by_id, repre_context_by_id
+ return version_context_by_id, repre_context_by_id
+
+ def _get_project(self, project_name: str) -> dict[str, Any]:
+ cache = self._projects_cache[project_name]
+ if not cache.is_valid:
+ cache.update_data(ayon_api.get_project(project_name))
+ return cache.get_data()
+
+ def _get_folders(
+ self, project_name: str, folder_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ """Get folders by ids."""
+ return self._get_entities(
+ project_name,
+ folder_ids,
+ self._folders_cache,
+ ayon_api.get_folders,
+ "folder_ids",
+ )
+
+ def _get_products(
+ self, project_name: str, product_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ """Get products by ids."""
+ return self._get_entities(
+ project_name,
+ product_ids,
+ self._products_cache,
+ ayon_api.get_products,
+ "product_ids",
+ )
+
+ def _get_versions(
+ self, project_name: str, version_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ """Get versions by ids."""
+ return self._get_entities(
+ project_name,
+ version_ids,
+ self._versions_cache,
+ ayon_api.get_versions,
+ "version_ids",
+ )
+
+ def _get_representations(
+ self, project_name: str, representation_ids: set[str]
+ ) -> list[dict[str, Any]]:
+ """Get representations by ids."""
+ return self._get_entities(
+ project_name,
+ representation_ids,
+ self._representations_cache,
+ ayon_api.get_representations,
+ "representation_ids",
+ )
+
+ def _get_repre_ids_by_version_ids(
+ self, project_name: str, version_ids: set[str]
+ ) -> dict[str, set[str]]:
+ output = {}
+ if not version_ids:
+ return output
+
+ project_cache = self._repre_parents_cache[project_name]
+ missing_ids = set()
+ for version_id in version_ids:
+ cache = project_cache[version_id]
+ if cache.is_valid:
+ output[version_id] = cache.get_data()
+ else:
+ missing_ids.add(version_id)
+
+ if missing_ids:
+ repre_cache = self._representations_cache[project_name]
+ repres_by_parent_id = collections.defaultdict(list)
+ for repre in ayon_api.get_representations(
+ project_name, version_ids=missing_ids
+ ):
+ version_id = repre["versionId"]
+ repre_cache[repre["id"]].update_data(repre)
+ repres_by_parent_id[version_id].append(repre)
+
+ for version_id, repres in repres_by_parent_id.items():
+ repre_ids = {
+ repre["id"]
+ for repre in repres
+ }
+ output[version_id] = set(repre_ids)
+ project_cache[version_id].update_data(repre_ids)
+
+ return output
+
+ def _get_entities(
+ self,
+ project_name: str,
+ entity_ids: set[str],
+ cache: NestedCacheItem,
+ getter: Callable,
+ filter_arg: str,
+ ) -> list[dict[str, Any]]:
+ entities = []
+ if not entity_ids:
+ return entities
+
+ missing_ids = set()
+ project_cache = cache[project_name]
+ for entity_id in entity_ids:
+ entity_cache = project_cache[entity_id]
+ if entity_cache.is_valid:
+ entities.append(entity_cache.get_data())
+ else:
+ missing_ids.add(entity_id)
+
+ if missing_ids:
+ for entity in getter(project_name, **{filter_arg: missing_ids}):
+ entities.append(entity)
+ entity_id = entity["id"]
+ project_cache[entity_id].update_data(entity)
+ return entities
def _get_action_items_for_contexts(
self,
@@ -557,51 +729,137 @@ class LoaderActionsModel:
if not filtered_repre_contexts:
continue
- repre_ids = set()
- repre_version_ids = set()
- repre_product_ids = set()
- repre_folder_ids = set()
- for repre_context in filtered_repre_contexts:
- repre_ids.add(repre_context["representation"]["id"])
- repre_product_ids.add(repre_context["product"]["id"])
- repre_version_ids.add(repre_context["version"]["id"])
- repre_folder_ids.add(repre_context["folder"]["id"])
+ repre_ids = {
+ repre_context["representation"]["id"]
+ for repre_context in filtered_repre_contexts
+ }
item = self._create_loader_action_item(
loader,
repre_contexts,
- project_name=project_name,
- folder_ids=repre_folder_ids,
- product_ids=repre_product_ids,
- version_ids=repre_version_ids,
- representation_ids=repre_ids,
+ repre_ids,
+ "representation",
repre_name=repre_name,
)
action_items.append(item)
# Product Loaders.
- version_ids = set(version_context_by_id.keys())
product_folder_ids = set()
product_ids = set()
for product_context in version_context_by_id.values():
product_ids.add(product_context["product"]["id"])
product_folder_ids.add(product_context["folder"]["id"])
+ version_ids = set(version_context_by_id.keys())
version_contexts = list(version_context_by_id.values())
for loader in product_loaders:
item = self._create_loader_action_item(
loader,
version_contexts,
- project_name=project_name,
- folder_ids=product_folder_ids,
- product_ids=product_ids,
- version_ids=version_ids,
+ version_ids,
+ "version",
)
action_items.append(item)
-
- action_items.sort(key=self._actions_sorter)
return action_items
+ def _get_loader_action_items(
+ self,
+ project_name: str,
+ entity_ids: set[str],
+ entity_type: str,
+ version_context_by_id: dict[str, dict[str, Any]],
+ repre_context_by_id: dict[str, dict[str, Any]],
+ ) -> list[ActionItem]:
+ """
+
+ Args:
+ project_name (str): Project name.
+ entity_ids (set[str]): Selected entity ids.
+ entity_type (str): Selected entity type.
+ version_context_by_id (dict[str, dict[str, Any]]): Version context
+ by id.
+ repre_context_by_id (dict[str, dict[str, Any]]): Representation
+ context by id.
+
+ Returns:
+ list[ActionItem]: List of action items.
+
+ """
+ entities_cache = self._prepare_entities_cache(
+ project_name,
+ entity_type,
+ version_context_by_id,
+ repre_context_by_id,
+ )
+ selection = LoaderActionSelection(
+ project_name,
+ entity_ids,
+ entity_type,
+ entities_cache=entities_cache
+ )
+ items = []
+ for action in self._loader_actions.get_action_items(selection):
+ items.append(ActionItem(
+ action.identifier,
+ label=action.label,
+ group_label=action.group_label,
+ icon=action.icon,
+ tooltip=None, # action.tooltip,
+ order=action.order,
+ data=action.data,
+ options=None, # action.options,
+ ))
+ return items
+
+ def _prepare_entities_cache(
+ self,
+ project_name: str,
+ entity_type: str,
+ version_context_by_id: dict[str, dict[str, Any]],
+ repre_context_by_id: dict[str, dict[str, Any]],
+ ):
+ project_entity = None
+ folders_by_id = {}
+ products_by_id = {}
+ versions_by_id = {}
+ representations_by_id = {}
+ for context in version_context_by_id.values():
+ if project_entity is None:
+ project_entity = context["project"]
+ folder_entity = context["folder"]
+ product_entity = context["product"]
+ version_entity = context["version"]
+ folders_by_id[folder_entity["id"]] = folder_entity
+ products_by_id[product_entity["id"]] = product_entity
+ versions_by_id[version_entity["id"]] = version_entity
+
+ for context in repre_context_by_id.values():
+ repre_entity = context["representation"]
+ representations_by_id[repre_entity["id"]] = repre_entity
+
+ # Mapping has to be for all child entities which is available for
+ # representations only if version is selected
+ representation_ids_by_version_id = {}
+ if entity_type == "version":
+ representation_ids_by_version_id = {
+ version_id: set()
+ for version_id in versions_by_id
+ }
+ for context in repre_context_by_id.values():
+ repre_entity = context["representation"]
+ v_id = repre_entity["versionId"]
+ representation_ids_by_version_id[v_id].add(repre_entity["id"])
+
+ return SelectionEntitiesCache(
+ project_name,
+ project_entity=project_entity,
+ folders_by_id=folders_by_id,
+ products_by_id=products_by_id,
+ versions_by_id=versions_by_id,
+ representations_by_id=representations_by_id,
+ representation_ids_by_version_id=representation_ids_by_version_id,
+ )
+
def _trigger_version_loader(
self,
loader,
@@ -634,12 +892,12 @@ class LoaderActionsModel:
project_name, version_ids=version_ids
))
product_ids = {v["productId"] for v in version_entities}
- product_entities = ayon_api.get_products(
- project_name, product_ids=product_ids
+ product_entities = self._get_products(
+ project_name, product_ids
)
product_entities_by_id = {p["id"]: p for p in product_entities}
folder_ids = {p["folderId"] for p in product_entities_by_id.values()}
- folder_entities = ayon_api.get_folders(
+ folder_entities = self._get_folders(
project_name, folder_ids=folder_ids
)
folder_entities_by_id = {f["id"]: f for f in folder_entities}
diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py
index 7915a75bcf..83a017613d 100644
--- a/client/ayon_core/tools/loader/models/products.py
+++ b/client/ayon_core/tools/loader/models/products.py
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Iterable, Optional
import arrow
import ayon_api
+from ayon_api.graphql_queries import project_graphql_query
from ayon_api.operations import OperationsSession
from ayon_core.lib import NestedCacheItem
@@ -202,7 +203,7 @@ class ProductsModel:
cache = self._product_type_items_cache[project_name]
if not cache.is_valid:
icons_mapping = self._get_product_type_icons(project_name)
- product_types = ayon_api.get_project_product_types(project_name)
+ product_types = self._get_project_product_types(project_name)
cache.update_data([
ProductTypeItem(
product_type["name"],
@@ -462,6 +463,24 @@ class ProductsModel:
PRODUCTS_MODEL_SENDER
)
+ def _get_project_product_types(self, project_name: str) -> list[dict]:
+ """This is a temporary solution for product types fetching.
+
+ There was a bug in ayon_api.get_project(...) which did not use GraphQl
+ but REST instead. That is fixed in ayon-python-api 1.2.6 that will
+ be as part of ayon launcher 1.4.3 release.
+
+ """
+ if not project_name:
+ return []
+ query = project_graphql_query({"productTypes.name"})
+ query.set_variable_value("projectName", project_name)
+ parsed_data = query.query(ayon_api.get_server_api_connection())
+ project = parsed_data["project"]
+ if project is None:
+ return []
+ return project["productTypes"]
+
def _get_product_type_icons(
self, project_name: Optional[str]
) -> ProductTypeIconMapping:
diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py
index 3a54a1b5f8..a7bbda18a3 100644
--- a/client/ayon_core/tools/loader/models/sitesync.py
+++ b/client/ayon_core/tools/loader/models/sitesync.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import collections
+from typing import Any
from ayon_api import (
get_representations,
@@ -246,26 +247,32 @@ class SiteSyncModel:
output[repre_id] = repre_cache.get_data()
return output
- def get_sitesync_action_items(self, project_name, representation_ids):
+ def get_sitesync_action_items(
+ self, project_name, entity_ids, entity_type
+ ):
"""
Args:
project_name (str): Project name.
- representation_ids (Iterable[str]): Representation ids.
+ entity_ids (set[str]): Selected entity ids.
+ entity_type (str): Selected entity type.
Returns:
list[ActionItem]: Actions that can be shown in loader.
+
"""
+ if entity_type != "representation":
+ return []
if not self.is_sitesync_enabled(project_name):
return []
repres_status = self.get_representations_sync_status(
- project_name, representation_ids
+ project_name, entity_ids
)
repre_ids_per_identifier = collections.defaultdict(set)
- for repre_id in representation_ids:
+ for repre_id in entity_ids:
repre_status = repres_status[repre_id]
local_status, remote_status = repre_status
@@ -293,36 +300,32 @@ class SiteSyncModel:
return action_items
- def is_sitesync_action(self, identifier):
+ def is_sitesync_action(self, identifier: str) -> bool:
"""Should be `identifier` handled by SiteSync.
Args:
- identifier (str): Action identifier.
+ identifier (str): Plugin identifier.
Returns:
bool: Should action be handled by SiteSync.
- """
- return identifier in {
- UPLOAD_IDENTIFIER,
- DOWNLOAD_IDENTIFIER,
- REMOVE_IDENTIFIER,
- }
+ """
+ return identifier == "sitesync.loader.action"
def trigger_action_item(
self,
- identifier,
- project_name,
- representation_ids
+ project_name: str,
+ data: dict[str, Any],
):
"""Resets status for site_name or remove local files.
Args:
- identifier (str): Action identifier.
project_name (str): Project name.
- representation_ids (Iterable[str]): Representation ids.
- """
+ data (dict[str, Any]): Action item data.
+ """
+ representation_ids = data["representation_ids"]
+ action_identifier = data["action_identifier"]
active_site = self.get_active_site(project_name)
remote_site = self.get_remote_site(project_name)
@@ -346,17 +349,17 @@ class SiteSyncModel:
for repre_id in representation_ids:
repre_entity = repre_entities_by_id.get(repre_id)
product_type = product_type_by_repre_id[repre_id]
- if identifier == DOWNLOAD_IDENTIFIER:
+ if action_identifier == DOWNLOAD_IDENTIFIER:
self._add_site(
project_name, repre_entity, active_site, product_type
)
- elif identifier == UPLOAD_IDENTIFIER:
+ elif action_identifier == UPLOAD_IDENTIFIER:
self._add_site(
project_name, repre_entity, remote_site, product_type
)
- elif identifier == REMOVE_IDENTIFIER:
+ elif action_identifier == REMOVE_IDENTIFIER:
self._sitesync_addon.remove_site(
project_name,
repre_id,
@@ -476,27 +479,27 @@ class SiteSyncModel:
self,
project_name,
representation_ids,
- identifier,
+ action_identifier,
label,
tooltip,
icon_name
):
return ActionItem(
- identifier,
- label,
+ "sitesync.loader.action",
+ label=label,
+ group_label=None,
icon={
"type": "awesome-font",
"name": icon_name,
"color": "#999999"
},
tooltip=tooltip,
- options={},
order=1,
- project_name=project_name,
- folder_ids=[],
- product_ids=[],
- version_ids=[],
- representation_ids=representation_ids,
+ data={
+ "representation_ids": representation_ids,
+ "action_identifier": action_identifier,
+ },
+ options=None,
)
def _add_site(self, project_name, repre_entity, site_name, product_type):
diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py
index b601cd95bd..cf39bc348c 100644
--- a/client/ayon_core/tools/loader/ui/actions_utils.py
+++ b/client/ayon_core/tools/loader/ui/actions_utils.py
@@ -1,6 +1,7 @@
import uuid
+from typing import Optional, Any
-from qtpy import QtWidgets, QtGui
+from qtpy import QtWidgets, QtGui, QtCore
import qtawesome
from ayon_core.lib.attribute_definitions import AbstractAttrDef
@@ -11,9 +12,29 @@ from ayon_core.tools.utils.widgets import (
OptionDialog,
)
from ayon_core.tools.utils import get_qt_icon
+from ayon_core.tools.loader.abstract import ActionItem
-def show_actions_menu(action_items, global_point, one_item_selected, parent):
+def _actions_sorter(item: tuple[ActionItem, str, str]):
+ """Sort the Loaders by their order and then their name.
+
+ Returns:
+ tuple[int, str]: Sort keys.
+
+ """
+ action_item, group_label, label = item
+ if group_label is None:
+ group_label = label
+ label = ""
+ return action_item.order, group_label, label
+
+
+def show_actions_menu(
+ action_items: list[ActionItem],
+ global_point: QtCore.QPoint,
+ one_item_selected: bool,
+ parent: QtWidgets.QWidget,
+) -> tuple[Optional[ActionItem], Optional[dict[str, Any]]]:
selected_action_item = None
selected_options = None
@@ -26,8 +47,16 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
menu = OptionalMenu(parent)
- action_items_by_id = {}
+ action_items_with_labels = []
for action_item in action_items:
+ action_items_with_labels.append(
+ (action_item, action_item.group_label, action_item.label)
+ )
+
+ group_menu_by_label = {}
+ action_items_by_id = {}
+ for item in sorted(action_items_with_labels, key=_actions_sorter):
+ action_item, _, _ = item
item_id = uuid.uuid4().hex
action_items_by_id[item_id] = action_item
item_options = action_item.options
@@ -50,7 +79,18 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
action.setData(item_id)
- menu.addAction(action)
+ group_label = action_item.group_label
+ if group_label:
+ group_menu = group_menu_by_label.get(group_label)
+ if group_menu is None:
+ group_menu = OptionalMenu(group_label, menu)
+ if icon is not None:
+ group_menu.setIcon(icon)
+ menu.addMenu(group_menu)
+ group_menu_by_label[group_label] = group_menu
+ group_menu.addAction(action)
+ else:
+ menu.addAction(action)
action = menu.exec_(global_point)
if action is not None:
diff --git a/client/ayon_core/tools/loader/ui/folders_widget.py b/client/ayon_core/tools/loader/ui/folders_widget.py
index f238eabcef..6de0b17ea2 100644
--- a/client/ayon_core/tools/loader/ui/folders_widget.py
+++ b/client/ayon_core/tools/loader/ui/folders_widget.py
@@ -1,11 +1,11 @@
+from typing import Optional
+
import qtpy
from qtpy import QtWidgets, QtCore, QtGui
-from ayon_core.tools.utils import (
- RecursiveSortFilterProxyModel,
- DeselectableTreeView,
-)
from ayon_core.style import get_objected_colors
+from ayon_core.tools.utils import DeselectableTreeView
+from ayon_core.tools.utils.folders_widget import FoldersProxyModel
from ayon_core.tools.utils import (
FoldersQtModel,
@@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
QtWidgets.QAbstractItemView.ExtendedSelection)
folders_model = LoaderFoldersModel(controller)
- folders_proxy_model = RecursiveSortFilterProxyModel()
+ folders_proxy_model = FoldersProxyModel()
folders_proxy_model.setSourceModel(folders_model)
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
if name:
self._folders_view.expandAll()
+ def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
+ """Set filter of folder ids.
+
+ Args:
+ folder_ids (list[str]): The list of folder ids.
+
+ """
+ self._folders_proxy_model.set_folder_ids_filter(folder_ids)
+
def set_merged_products_selection(self, items):
"""
diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py
index e5bb75a208..ddd6ce8554 100644
--- a/client/ayon_core/tools/loader/ui/products_widget.py
+++ b/client/ayon_core/tools/loader/ui/products_widget.py
@@ -420,8 +420,9 @@ class ProductsWidget(QtWidgets.QWidget):
if version_id is not None:
version_ids.add(version_id)
- action_items = self._controller.get_versions_action_items(
- project_name, version_ids)
+ action_items = self._controller.get_action_items(
+ project_name, version_ids, "version"
+ )
# Prepare global point where to show the menu
global_point = self._products_view.mapToGlobal(point)
@@ -437,11 +438,13 @@ class ProductsWidget(QtWidgets.QWidget):
return
self._controller.trigger_action_item(
- action_item.identifier,
- options,
- action_item.project_name,
- version_ids=action_item.version_ids,
- representation_ids=action_item.representation_ids,
+ identifier=action_item.identifier,
+ project_name=project_name,
+ selected_ids=version_ids,
+ selected_entity_type="version",
+ data=action_item.data,
+ options=options,
+ form_values={},
)
def _on_selection_change(self):
diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py
index d19ad306a3..33bbf46b34 100644
--- a/client/ayon_core/tools/loader/ui/repres_widget.py
+++ b/client/ayon_core/tools/loader/ui/repres_widget.py
@@ -384,8 +384,8 @@ class RepresentationsWidget(QtWidgets.QWidget):
def _on_context_menu(self, point):
repre_ids = self._get_selected_repre_ids()
- action_items = self._controller.get_representations_action_items(
- self._selected_project_name, repre_ids
+ action_items = self._controller.get_action_items(
+ self._selected_project_name, repre_ids, "representation"
)
global_point = self._repre_view.mapToGlobal(point)
result = show_actions_menu(
@@ -399,9 +399,11 @@ class RepresentationsWidget(QtWidgets.QWidget):
return
self._controller.trigger_action_item(
- action_item.identifier,
- options,
- action_item.project_name,
- version_ids=action_item.version_ids,
- representation_ids=action_item.representation_ids,
+ identifier=action_item.identifier,
+ project_name=self._selected_project_name,
+ selected_ids=repre_ids,
+ selected_entity_type="representation",
+ data=action_item.data,
+ options=options,
+ form_values={},
)
diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py
index cc7e2e9c95..3a38739cf0 100644
--- a/client/ayon_core/tools/loader/ui/tasks_widget.py
+++ b/client/ayon_core/tools/loader/ui/tasks_widget.py
@@ -1,11 +1,11 @@
import collections
import hashlib
+from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import (
- RecursiveSortFilterProxyModel,
DeselectableTreeView,
TasksQtModel,
TASKS_MODEL_SENDER_NAME,
@@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import (
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
+ TasksProxyModel,
)
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
+
# Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--"
@@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel):
return super().data(index, role)
-class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
+class LoaderTasksProxyModel(TasksProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
@@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
return True
return super().lessThan(left, right)
+ def filterAcceptsRow(self, row, parent_index):
+ source_index = self.sourceModel().index(row, 0, parent_index)
+ if source_index.data(ITEM_ID_ROLE) == NO_TASKS_ID:
+ return True
+ return super().filterAcceptsRow(row, parent_index)
+
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
@@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget):
if name:
self._tasks_view.expandAll()
+ def set_task_ids_filter(self, task_ids: Optional[list[str]]):
+ """Set filter of folder ids.
+
+ Args:
+ task_ids (list[str]): The list of folder ids.
+
+ """
+ self._tasks_proxy_model.set_task_ids_filter(task_ids)
+
def refresh(self):
self._tasks_model.refresh()
diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py
index df5beb708f..a6807a1ebb 100644
--- a/client/ayon_core/tools/loader/ui/window.py
+++ b/client/ayon_core/tools/loader/ui/window.py
@@ -1,18 +1,24 @@
from __future__ import annotations
+from typing import Optional
+
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.style import load_stylesheet
+from ayon_core.pipeline.actions import LoaderActionResult
from ayon_core.tools.utils import (
- PlaceholderLineEdit,
+ MessageOverlayObject,
ErrorMessageBox,
ThumbnailPainterWidget,
RefreshButton,
GoToCurrentButton,
+ ProjectsCombobox,
+ get_qt_icon,
+ FoldersFiltersWidget,
)
+from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.utils.lib import center_window
-from ayon_core.tools.utils import ProjectsCombobox
from ayon_core.tools.common_models import StatusItem
from ayon_core.tools.loader.abstract import ProductTypeItem
from ayon_core.tools.loader.control import LoaderController
@@ -141,6 +147,8 @@ class LoaderWindow(QtWidgets.QWidget):
if controller is None:
controller = LoaderController()
+ overlay_object = MessageOverlayObject(self)
+
main_splitter = QtWidgets.QSplitter(self)
context_splitter = QtWidgets.QSplitter(main_splitter)
@@ -170,15 +178,14 @@ class LoaderWindow(QtWidgets.QWidget):
context_top_layout.addWidget(go_to_current_btn, 0)
context_top_layout.addWidget(refresh_btn, 0)
- folders_filter_input = PlaceholderLineEdit(context_widget)
- folders_filter_input.setPlaceholderText("Folder name filter...")
+ filters_widget = FoldersFiltersWidget(context_widget)
folders_widget = LoaderFoldersWidget(controller, context_widget)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(context_top_widget, 0)
- context_layout.addWidget(folders_filter_input, 0)
+ context_layout.addWidget(filters_widget, 0)
context_layout.addWidget(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget)
@@ -247,9 +254,12 @@ class LoaderWindow(QtWidgets.QWidget):
projects_combobox.refreshed.connect(self._on_projects_refresh)
folders_widget.refreshed.connect(self._on_folders_refresh)
products_widget.refreshed.connect(self._on_products_refresh)
- folders_filter_input.textChanged.connect(
+ filters_widget.text_changed.connect(
self._on_folder_filter_change
)
+ filters_widget.my_tasks_changed.connect(
+ self._on_my_tasks_checkbox_state_changed
+ )
search_bar.filter_changed.connect(self._on_filter_change)
product_group_checkbox.stateChanged.connect(
self._on_product_group_change
@@ -294,6 +304,12 @@ class LoaderWindow(QtWidgets.QWidget):
"controller.reset.finished",
self._on_controller_reset_finish,
)
+ controller.register_event_callback(
+ "loader.action.finished",
+ self._on_loader_action_finished,
+ )
+
+ self._overlay_object = overlay_object
self._group_dialog = ProductGroupDialog(controller, self)
@@ -303,7 +319,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._refresh_btn = refresh_btn
self._projects_combobox = projects_combobox
- self._folders_filter_input = folders_filter_input
+ self._filters_widget = filters_widget
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@@ -406,6 +422,20 @@ class LoaderWindow(QtWidgets.QWidget):
if self._reset_on_show:
self.refresh()
+ def _show_toast_message(
+ self,
+ message: str,
+ success: bool = True,
+ message_id: Optional[str] = None,
+ ):
+ message_type = None
+ if not success:
+ message_type = "error"
+
+ self._overlay_object.add_message(
+ message, message_type, message_id=message_id
+ )
+
def _show_group_dialog(self):
project_name = self._projects_combobox.get_selected_project_name()
if not project_name:
@@ -421,9 +451,21 @@ class LoaderWindow(QtWidgets.QWidget):
self._group_dialog.set_product_ids(project_name, product_ids)
self._group_dialog.show()
- def _on_folder_filter_change(self, text):
+ def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
+ def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
+ folder_ids = None
+ task_ids = None
+ if enabled:
+ entity_ids = self._controller.get_my_tasks_entity_ids(
+ self._selected_project_name
+ )
+ folder_ids = entity_ids["folder_ids"]
+ task_ids = entity_ids["task_ids"]
+ self._folders_widget.set_folder_ids_filter(folder_ids)
+ self._tasks_widget.set_task_ids_filter(task_ids)
+
def _on_product_group_change(self):
self._products_widget.set_enable_grouping(
self._product_group_checkbox.isChecked()
@@ -494,6 +536,77 @@ class LoaderWindow(QtWidgets.QWidget):
box = LoadErrorMessageBox(error_info, self)
box.show()
+ def _on_loader_action_finished(self, event):
+ crashed = event["crashed"]
+ if crashed:
+ self._show_toast_message(
+ "Action failed",
+ success=False,
+ )
+ return
+
+ result: Optional[LoaderActionResult] = event["result"]
+ if result is None:
+ return
+
+ if result.message:
+ self._show_toast_message(
+ result.message, result.success
+ )
+
+ if result.form is None:
+ return
+
+ form = result.form
+ dialog = AttributeDefinitionsDialog(
+ form.fields,
+ title=form.title,
+ parent=self,
+ )
+ if result.form_values:
+ dialog.set_values(result.form_values)
+ submit_label = form.submit_label
+ submit_icon = form.submit_icon
+ cancel_label = form.cancel_label
+ cancel_icon = form.cancel_icon
+
+ if submit_icon:
+ submit_icon = get_qt_icon(submit_icon)
+ if cancel_icon:
+ cancel_icon = get_qt_icon(cancel_icon)
+
+ if submit_label:
+ dialog.set_submit_label(submit_label)
+ else:
+ dialog.set_submit_visible(False)
+
+ if submit_icon:
+ dialog.set_submit_icon(submit_icon)
+
+ if cancel_label:
+ dialog.set_cancel_label(cancel_label)
+ else:
+ dialog.set_cancel_visible(False)
+
+ if cancel_icon:
+ dialog.set_cancel_icon(cancel_icon)
+
+ dialog.setMinimumSize(300, 140)
+ result = dialog.exec_()
+ if result != QtWidgets.QDialog.Accepted:
+ return
+
+ form_values = dialog.get_values()
+ self._controller.trigger_action_item(
+ identifier=event["identifier"],
+ project_name=event["project_name"],
+ selected_ids=event["selected_ids"],
+ selected_entity_type=event["selected_entity_type"],
+ options={},
+ data=event["data"],
+ form_values=form_values,
+ )
+
def _on_project_selection_changed(self, event):
self._selected_project_name = event["project_name"]
self._update_filters()
diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py
index 14da15793d..bfd0948519 100644
--- a/client/ayon_core/tools/publisher/abstract.py
+++ b/client/ayon_core/tools/publisher/abstract.py
@@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
"""Get folder id from folder path."""
pass
+ @abstractmethod
+ def get_my_tasks_entity_ids(
+ self, project_name: str
+ ) -> dict[str, list[str]]:
+ """Get entity ids for my tasks.
+
+ Args:
+ project_name (str): Project name.
+
+ Returns:
+ dict[str, list[str]]: Folder and task ids.
+
+ """
+ pass
+
# --- Create ---
@abstractmethod
def get_creator_items(self) -> Dict[str, "CreatorItem"]:
diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py
index 038816c6fc..3d11131dc3 100644
--- a/client/ayon_core/tools/publisher/control.py
+++ b/client/ayon_core/tools/publisher/control.py
@@ -11,7 +11,11 @@ from ayon_core.pipeline import (
registered_host,
get_process_id,
)
-from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
+from ayon_core.tools.common_models import (
+ ProjectsModel,
+ HierarchyModel,
+ UsersModel,
+)
from .models import (
PublishModel,
@@ -101,6 +105,7 @@ class PublisherController(
# Cacher of avalon documents
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
+ self._users_model = UsersModel(self)
@property
def log(self):
@@ -317,6 +322,17 @@ class PublisherController(
return False
return True
+ def get_my_tasks_entity_ids(
+ self, project_name: str
+ ) -> dict[str, list[str]]:
+ username = self._users_model.get_current_username()
+ assignees = []
+ if username:
+ assignees.append(username)
+ return self._hierarchy_model.get_entity_ids_for_assignees(
+ project_name, assignees
+ )
+
# --- Publish specific callbacks ---
def get_context_title(self):
"""Get context title for artist shown at the top of main window."""
@@ -359,6 +375,7 @@ class PublisherController(
self._emit_event("controller.reset.started")
self._hierarchy_model.reset()
+ self._users_model.reset()
# Publish part must be reset after plugins
self._create_model.reset()
diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py
index 5098826b8b..3f5352ae8b 100644
--- a/client/ayon_core/tools/publisher/models/create.py
+++ b/client/ayon_core/tools/publisher/models/create.py
@@ -1,5 +1,6 @@
import logging
import re
+import copy
from typing import (
Union,
List,
@@ -1098,7 +1099,7 @@ class CreateModel:
creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value):
- creator_attributes[key] = value
+ creator_attributes[key] = copy.deepcopy(value)
def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value
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 84786a671e..a9abd56584 100644
--- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py
@@ -202,7 +202,7 @@ class ContextCardWidget(CardWidget):
Is not visually under group widget and is always at the top of card view.
"""
- def __init__(self, parent):
+ def __init__(self, parent: QtWidgets.QWidget):
super().__init__(parent)
self._id = CONTEXT_ID
@@ -211,7 +211,12 @@ class ContextCardWidget(CardWidget):
icon_widget = PublishPixmapLabel(None, self)
icon_widget.setObjectName("ProductTypeIconLabel")
- label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)
+ label_widget = QtWidgets.QLabel(f"{CONTEXT_LABEL}", self)
+ # HTML text will cause that label start catch mouse clicks
+ # - disabling with changing interaction flag
+ label_widget.setTextInteractionFlags(
+ QtCore.Qt.NoTextInteraction
+ )
icon_layout = QtWidgets.QHBoxLayout()
icon_layout.setContentsMargins(5, 5, 5, 5)
@@ -288,6 +293,8 @@ class InstanceCardWidget(CardWidget):
self._last_product_name = None
self._last_variant = None
self._last_label = None
+ self._last_folder_path = None
+ self._last_task_name = None
icon_widget = IconValuePixmapLabel(group_icon, self)
icon_widget.setObjectName("ProductTypeIconLabel")
@@ -383,29 +390,54 @@ class InstanceCardWidget(CardWidget):
self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not valid)
+ @staticmethod
+ def _get_card_widget_sub_label(
+ folder_path: Optional[str],
+ task_name: Optional[str],
+ ) -> str:
+ sublabel = ""
+ if folder_path:
+ folder_name = folder_path.rsplit("/", 1)[-1]
+ sublabel = f"{folder_name}"
+ if task_name:
+ sublabel += f" - {task_name}"
+ return sublabel
+
def _update_product_name(self):
variant = self.instance.variant
product_name = self.instance.product_name
label = self.instance.label
+ folder_path = self.instance.folder_path
+ task_name = self.instance.task_name
if (
variant == self._last_variant
and product_name == self._last_product_name
and label == self._last_label
+ and folder_path == self._last_folder_path
+ and task_name == self._last_task_name
):
return
self._last_variant = variant
self._last_product_name = product_name
self._last_label = label
+ self._last_folder_path = folder_path
+ self._last_task_name = task_name
+
# Make `variant` bold
label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts:
for part in found_parts:
- replacement = "{}".format(part)
+ replacement = f"{part}"
label = label.replace(part, replacement)
+ label = f"{label}"
+ sublabel = self._get_card_widget_sub_label(folder_path, task_name)
+ if sublabel:
+ label += f"
{sublabel}"
+
self._label_widget.setText(label)
# HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag
@@ -702,11 +734,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh(self):
"""Refresh instances in view based on CreatedContext."""
-
self._make_sure_context_widget_exists()
self._update_convertors_group()
-
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
@@ -814,6 +844,8 @@ class InstanceCardView(AbstractInstanceView):
widget.setVisible(False)
widget.deleteLater()
+ sorted_group_names.insert(0, CONTEXT_GROUP)
+
self._parent_id_by_id = parent_id_by_id
self._instance_ids_by_parent_id = instance_ids_by_parent_id
self._group_name_by_instance_id = group_by_instance_id
@@ -881,7 +913,7 @@ class InstanceCardView(AbstractInstanceView):
context_info,
is_parent_active,
group_icon,
- group_widget
+ group_widget,
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)
diff --git a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py
index faf2248181..49d236353f 100644
--- a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py
+++ b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py
@@ -1,10 +1,14 @@
from qtpy import QtWidgets, QtCore
from ayon_core.lib.events import QueuedEventSystem
-from ayon_core.tools.utils import PlaceholderLineEdit, GoToCurrentButton
from ayon_core.tools.common_models import HierarchyExpectedSelection
-from ayon_core.tools.utils import FoldersWidget, TasksWidget
+from ayon_core.tools.utils import (
+ FoldersWidget,
+ TasksWidget,
+ FoldersFiltersWidget,
+ GoToCurrentButton,
+)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_widget = QtWidgets.QWidget(self)
- folder_filter_input = PlaceholderLineEdit(headers_widget)
- folder_filter_input.setPlaceholderText("Filter folders..")
+ filters_widget = FoldersFiltersWidget(headers_widget)
current_context_btn = GoToCurrentButton(headers_widget)
current_context_btn.setToolTip("Go to current context")
@@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_layout = QtWidgets.QHBoxLayout(headers_widget)
headers_layout.setContentsMargins(0, 0, 0, 0)
- headers_layout.addWidget(folder_filter_input, 1)
+ headers_layout.setSpacing(5)
+ headers_layout.addWidget(filters_widget, 1)
headers_layout.addWidget(current_context_btn, 0)
hierarchy_controller = CreateHierarchyController(controller)
@@ -207,15 +211,16 @@ class CreateContextWidget(QtWidgets.QWidget):
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(headers_widget, 0)
+ main_layout.addSpacing(5)
main_layout.addWidget(folders_widget, 2)
main_layout.addWidget(tasks_widget, 1)
folders_widget.selection_changed.connect(self._on_folder_change)
tasks_widget.selection_changed.connect(self._on_task_change)
current_context_btn.clicked.connect(self._on_current_context_click)
- folder_filter_input.textChanged.connect(self._on_folder_filter_change)
+ filters_widget.text_changed.connect(self._on_folder_filter_change)
+ filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
- self._folder_filter_input = folder_filter_input
self._current_context_btn = current_context_btn
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@@ -303,5 +308,17 @@ class CreateContextWidget(QtWidgets.QWidget):
self._last_project_name, folder_id, task_name
)
- def _on_folder_filter_change(self, text):
+ def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
+
+ def _on_my_tasks_change(self, enabled: bool) -> None:
+ folder_ids = None
+ task_ids = None
+ if enabled:
+ entity_ids = self._controller.get_my_tasks_entity_ids(
+ self._last_project_name
+ )
+ folder_ids = entity_ids["folder_ids"]
+ task_ids = entity_ids["task_ids"]
+ self._folders_widget.set_folder_ids_filter(folder_ids)
+ self._tasks_widget.set_task_ids_filter(task_ids)
diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py
index b9b3afd895..d98bc95eb2 100644
--- a/client/ayon_core/tools/publisher/widgets/create_widget.py
+++ b/client/ayon_core/tools/publisher/widgets/create_widget.py
@@ -710,11 +710,13 @@ class CreateWidget(QtWidgets.QWidget):
def _on_first_show(self):
width = self.width()
- part = int(width / 4)
- rem_width = width - part
- self._main_splitter_widget.setSizes([part, rem_width])
- rem_width = rem_width - part
- self._creators_splitter.setSizes([part, rem_width])
+ part = int(width / 9)
+ context_width = part * 3
+ create_sel_width = part * 2
+ rem_width = width - context_width
+ self._main_splitter_widget.setSizes([context_width, rem_width])
+ rem_width -= create_sel_width
+ self._creators_splitter.setSizes([create_sel_width, rem_width])
def showEvent(self, event):
super().showEvent(event)
diff --git a/client/ayon_core/tools/publisher/widgets/folders_dialog.py b/client/ayon_core/tools/publisher/widgets/folders_dialog.py
index d2eb68310e..e0d9c098d8 100644
--- a/client/ayon_core/tools/publisher/widgets/folders_dialog.py
+++ b/client/ayon_core/tools/publisher/widgets/folders_dialog.py
@@ -1,7 +1,10 @@
from qtpy import QtWidgets
from ayon_core.lib.events import QueuedEventSystem
-from ayon_core.tools.utils import PlaceholderLineEdit, FoldersWidget
+from ayon_core.tools.utils import (
+ FoldersWidget,
+ FoldersFiltersWidget,
+)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@@ -43,8 +46,7 @@ class FoldersDialog(QtWidgets.QDialog):
super().__init__(parent)
self.setWindowTitle("Select folder")
- filter_input = PlaceholderLineEdit(self)
- filter_input.setPlaceholderText("Filter folders..")
+ filters_widget = FoldersFiltersWidget(self)
folders_controller = FoldersDialogController(controller)
folders_widget = FoldersWidget(folders_controller, self)
@@ -59,7 +61,8 @@ class FoldersDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
- layout.addWidget(filter_input, 0)
+ layout.setSpacing(5)
+ layout.addWidget(filters_widget, 0)
layout.addWidget(folders_widget, 1)
layout.addLayout(btns_layout, 0)
@@ -68,12 +71,13 @@ class FoldersDialog(QtWidgets.QDialog):
)
folders_widget.double_clicked.connect(self._on_ok_clicked)
- filter_input.textChanged.connect(self._on_filter_change)
+ filters_widget.text_changed.connect(self._on_filter_change)
+ filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._controller = controller
- self._filter_input = filter_input
+ self._filters_widget = filters_widget
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
@@ -88,6 +92,49 @@ class FoldersDialog(QtWidgets.QDialog):
self._first_show = True
self._default_height = 500
+ self._project_name = None
+
+ def showEvent(self, event):
+ """Refresh folders widget on show."""
+ super().showEvent(event)
+ if self._first_show:
+ self._first_show = False
+ self._on_first_show()
+ # Refresh on show
+ self.reset(False)
+
+ def reset(self, force=True):
+ """Reset widget."""
+ if not force and not self._soft_reset_enabled:
+ return
+
+ self._project_name = self._controller.get_current_project_name()
+ if self._soft_reset_enabled:
+ self._soft_reset_enabled = False
+
+ self._folders_widget.set_project_name(self._project_name)
+
+ def get_selected_folder_path(self):
+ """Get selected folder path."""
+ return self._selected_folder_path
+
+ def set_selected_folders(self, folder_paths: list[str]) -> None:
+ """Change preselected folder before showing the dialog.
+
+ This also resets model and clean filter.
+ """
+ self.reset(False)
+ self._filters_widget.set_text("")
+ self._filters_widget.set_my_tasks_checked(False)
+
+ folder_id = None
+ for folder_path in folder_paths:
+ folder_id = self._controller.get_folder_id_from_path(folder_path)
+ if folder_id:
+ break
+ if folder_id:
+ self._folders_widget.set_selected_folder(folder_id)
+
def _on_first_show(self):
center = self.rect().center()
size = self.size()
@@ -103,27 +150,6 @@ class FoldersDialog(QtWidgets.QDialog):
# Change reset enabled so model is reset on show event
self._soft_reset_enabled = True
- def showEvent(self, event):
- """Refresh folders widget on show."""
- super().showEvent(event)
- if self._first_show:
- self._first_show = False
- self._on_first_show()
- # Refresh on show
- self.reset(False)
-
- def reset(self, force=True):
- """Reset widget."""
- if not force and not self._soft_reset_enabled:
- return
-
- if self._soft_reset_enabled:
- self._soft_reset_enabled = False
-
- self._folders_widget.set_project_name(
- self._controller.get_current_project_name()
- )
-
def _on_filter_change(self, text):
"""Trigger change of filter of folders."""
self._folders_widget.set_name_filter(text)
@@ -137,22 +163,11 @@ class FoldersDialog(QtWidgets.QDialog):
)
self.done(1)
- def set_selected_folders(self, folder_paths):
- """Change preselected folder before showing the dialog.
-
- This also resets model and clean filter.
- """
- self.reset(False)
- self._filter_input.setText("")
-
- folder_id = None
- for folder_path in folder_paths:
- folder_id = self._controller.get_folder_id_from_path(folder_path)
- if folder_id:
- break
- if folder_id:
- self._folders_widget.set_selected_folder(folder_id)
-
- def get_selected_folder_path(self):
- """Get selected folder path."""
- return self._selected_folder_path
+ def _on_my_tasks_change(self, enabled: bool) -> None:
+ folder_ids = None
+ if enabled:
+ entity_ids = self._controller.get_my_tasks_entity_ids(
+ self._project_name
+ )
+ folder_ids = entity_ids["folder_ids"]
+ self._folders_widget.set_folder_ids_filter(folder_ids)
diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py
index dc086a3b48..19994f9f62 100644
--- a/client/ayon_core/tools/publisher/window.py
+++ b/client/ayon_core/tools/publisher/window.py
@@ -678,13 +678,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._help_dialog.show()
window = self.window()
- if hasattr(QtWidgets.QApplication, "desktop"):
- desktop = QtWidgets.QApplication.desktop()
- screen_idx = desktop.screenNumber(window)
- screen_geo = desktop.screenGeometry(screen_idx)
- else:
- screen = window.screen()
- screen_geo = screen.geometry()
+ screen = window.screen()
+ screen_geo = screen.geometry()
window_geo = window.geometry()
dialog_x = window_geo.x() + window_geo.width()
diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py
index 111b7c614b..56989927ee 100644
--- a/client/ayon_core/tools/utils/__init__.py
+++ b/client/ayon_core/tools/utils/__init__.py
@@ -76,6 +76,7 @@ from .folders_widget import (
FoldersQtModel,
FOLDERS_MODEL_SENDER_NAME,
SimpleFoldersWidget,
+ FoldersFiltersWidget,
)
from .tasks_widget import (
@@ -160,6 +161,7 @@ __all__ = (
"FoldersQtModel",
"FOLDERS_MODEL_SENDER_NAME",
"SimpleFoldersWidget",
+ "FoldersFiltersWidget",
"TasksWidget",
"TasksQtModel",
diff --git a/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py b/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py
index 542db2831a..c900ad1f48 100644
--- a/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py
+++ b/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py
@@ -1,4 +1,3 @@
-import qtpy
from qtpy import QtWidgets, QtCore, QtGui
@@ -6,7 +5,7 @@ class PickScreenColorWidget(QtWidgets.QWidget):
color_selected = QtCore.Signal(QtGui.QColor)
def __init__(self, parent=None):
- super(PickScreenColorWidget, self).__init__(parent)
+ super().__init__(parent)
self.labels = []
self.magnification = 2
@@ -53,7 +52,7 @@ class PickLabel(QtWidgets.QLabel):
close_session = QtCore.Signal()
def __init__(self, pick_widget):
- super(PickLabel, self).__init__()
+ super().__init__()
self.setMouseTracking(True)
self.pick_widget = pick_widget
@@ -74,14 +73,10 @@ class PickLabel(QtWidgets.QLabel):
self.show()
self.windowHandle().setScreen(screen_obj)
geo = screen_obj.geometry()
- args = (
- QtWidgets.QApplication.desktop().winId(),
+ pix = screen_obj.grabWindow(
+ self.winId(),
geo.x(), geo.y(), geo.width(), geo.height()
)
- if qtpy.API in ("pyqt4", "pyside"):
- pix = QtGui.QPixmap.grabWindow(*args)
- else:
- pix = screen_obj.grabWindow(*args)
if pix.width() > pix.height():
size = pix.height()
diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py
index 7b71dd087c..f506af5352 100644
--- a/client/ayon_core/tools/utils/folders_widget.py
+++ b/client/ayon_core/tools/utils/folders_widget.py
@@ -15,6 +15,8 @@ from ayon_core.tools.common_models import (
from .models import RecursiveSortFilterProxyModel
from .views import TreeView
from .lib import RefreshThread, get_qt_icon
+from .widgets import PlaceholderLineEdit
+from .nice_checkbox import NiceCheckbox
FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
@@ -343,6 +345,8 @@ class FoldersProxyModel(RecursiveSortFilterProxyModel):
def __init__(self):
super().__init__()
+ self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
self._folder_ids_filter = None
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
@@ -794,3 +798,47 @@ class SimpleFoldersWidget(FoldersWidget):
event (Event): Triggered event.
"""
pass
+
+
+class FoldersFiltersWidget(QtWidgets.QWidget):
+ """Helper widget for most commonly used filters in context selection."""
+ text_changed = QtCore.Signal(str)
+ my_tasks_changed = QtCore.Signal(bool)
+
+ def __init__(self, parent: QtWidgets.QWidget) -> None:
+ super().__init__(parent)
+
+ folders_filter_input = PlaceholderLineEdit(self)
+ folders_filter_input.setPlaceholderText("Folder name filter...")
+
+ my_tasks_tooltip = (
+ "Filter folders and task to only those you are assigned to."
+ )
+ my_tasks_label = QtWidgets.QLabel("My tasks", self)
+ my_tasks_label.setToolTip(my_tasks_tooltip)
+
+ my_tasks_checkbox = NiceCheckbox(self)
+ my_tasks_checkbox.setChecked(False)
+ my_tasks_checkbox.setToolTip(my_tasks_tooltip)
+
+ layout = QtWidgets.QHBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(5)
+ layout.addWidget(folders_filter_input, 1)
+ layout.addWidget(my_tasks_label, 0)
+ layout.addWidget(my_tasks_checkbox, 0)
+
+ folders_filter_input.textChanged.connect(self.text_changed)
+ my_tasks_checkbox.stateChanged.connect(self._on_my_tasks_change)
+
+ self._folders_filter_input = folders_filter_input
+ self._my_tasks_checkbox = my_tasks_checkbox
+
+ def set_text(self, text: str) -> None:
+ self._folders_filter_input.setText(text)
+
+ def set_my_tasks_checked(self, checked: bool) -> None:
+ self._my_tasks_checkbox.setChecked(checked)
+
+ def _on_my_tasks_change(self, _state: int) -> None:
+ self.my_tasks_changed.emit(self._my_tasks_checkbox.isChecked())
diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py
index a99c46199b..3308b943f0 100644
--- a/client/ayon_core/tools/utils/lib.py
+++ b/client/ayon_core/tools/utils/lib.py
@@ -53,14 +53,8 @@ def checkstate_enum_to_int(state):
def center_window(window):
"""Move window to center of it's screen."""
-
- if hasattr(QtWidgets.QApplication, "desktop"):
- desktop = QtWidgets.QApplication.desktop()
- screen_idx = desktop.screenNumber(window)
- screen_geo = desktop.screenGeometry(screen_idx)
- else:
- screen = window.screen()
- screen_geo = screen.geometry()
+ screen = window.screen()
+ screen_geo = screen.geometry()
geo = window.frameGeometry()
geo.moveCenter(screen_geo.center())
@@ -554,11 +548,17 @@ class _IconsCache:
elif icon_type == "ayon_url":
url = icon_def["url"].lstrip("/")
url = f"{ayon_api.get_base_url()}/{url}"
- stream = io.BytesIO()
- ayon_api.download_file_to_stream(url, stream)
- pix = QtGui.QPixmap()
- pix.loadFromData(stream.getvalue())
- icon = QtGui.QIcon(pix)
+ try:
+ stream = io.BytesIO()
+ ayon_api.download_file_to_stream(url, stream)
+ pix = QtGui.QPixmap()
+ pix.loadFromData(stream.getvalue())
+ icon = QtGui.QIcon(pix)
+ except Exception:
+ log.warning(
+ "Failed to download image '%s'", url, exc_info=True
+ )
+ icon = None
elif icon_type == "transparent":
size = icon_def.get("size")
diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py
index 4b787ff830..9341e665bc 100644
--- a/client/ayon_core/tools/utils/widgets.py
+++ b/client/ayon_core/tools/utils/widgets.py
@@ -865,24 +865,26 @@ class OptionalMenu(QtWidgets.QMenu):
def mouseReleaseEvent(self, event):
"""Emit option clicked signal if mouse released on it"""
active = self.actionAt(event.pos())
- if active and active.use_option:
+ if isinstance(active, OptionalAction) and active.use_option:
option = active.widget.option
if option.is_hovered(event.globalPos()):
option.clicked.emit()
- super(OptionalMenu, self).mouseReleaseEvent(event)
+ super().mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
"""Add highlight to active action"""
active = self.actionAt(event.pos())
for action in self.actions():
- action.set_highlight(action is active, event.globalPos())
- super(OptionalMenu, self).mouseMoveEvent(event)
+ if isinstance(action, OptionalAction):
+ action.set_highlight(action is active, event.globalPos())
+ super().mouseMoveEvent(event)
def leaveEvent(self, event):
"""Remove highlight from all actions"""
for action in self.actions():
- action.set_highlight(False)
- super(OptionalMenu, self).leaveEvent(event)
+ if isinstance(action, OptionalAction):
+ action.set_highlight(False)
+ super().leaveEvent(event)
class OptionalAction(QtWidgets.QWidgetAction):
@@ -894,7 +896,7 @@ class OptionalAction(QtWidgets.QWidgetAction):
"""
def __init__(self, label, icon, use_option, parent):
- super(OptionalAction, self).__init__(parent)
+ super().__init__(parent)
self.label = label
self.icon = icon
self.use_option = use_option
@@ -955,7 +957,7 @@ class OptionalActionWidget(QtWidgets.QWidget):
"""Main widget class for `OptionalAction`"""
def __init__(self, label, parent=None):
- super(OptionalActionWidget, self).__init__(parent)
+ super().__init__(parent)
body_widget = QtWidgets.QWidget(self)
body_widget.setObjectName("OptionalActionBody")
diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py
index 863d6bb9bc..1b92c0d334 100644
--- a/client/ayon_core/tools/workfiles/abstract.py
+++ b/client/ayon_core/tools/workfiles/abstract.py
@@ -1,8 +1,15 @@
+from __future__ import annotations
+
import os
from abc import ABC, abstractmethod
+import typing
+from typing import Optional
from ayon_core.style import get_default_entity_icon_color
+if typing.TYPE_CHECKING:
+ from ayon_core.host import PublishedWorkfileInfo
+
class FolderItem:
"""Item representing folder entity on a server.
@@ -159,6 +166,17 @@ class WorkareaFilepathResult:
self.filepath = filepath
+class PublishedWorkfileWrap:
+ """Wrapper for workfile info that also contains version comment."""
+ def __init__(
+ self,
+ info: Optional[PublishedWorkfileInfo] = None,
+ comment: Optional[str] = None,
+ ) -> None:
+ self.info = info
+ self.comment = comment
+
+
class AbstractWorkfilesCommon(ABC):
@abstractmethod
def is_host_valid(self):
@@ -787,6 +805,25 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
"""
pass
+ @abstractmethod
+ def get_published_workfile_info(
+ self,
+ folder_id: Optional[str],
+ representation_id: Optional[str],
+ ) -> PublishedWorkfileWrap:
+ """Get published workfile info by representation ID.
+
+ Args:
+ folder_id (Optional[str]): Folder id.
+ representation_id (Optional[str]): Representation id.
+
+ Returns:
+ PublishedWorkfileWrap: Published workfile info or None
+ if not found.
+
+ """
+ pass
+
@abstractmethod
def get_workfile_info(self, folder_id, task_id, rootless_path):
"""Workfile info from database.
diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py
index f0e0f0e416..c399a1bf33 100644
--- a/client/ayon_core/tools/workfiles/control.py
+++ b/client/ayon_core/tools/workfiles/control.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
import os
+from typing import Optional
import ayon_api
@@ -18,6 +21,7 @@ from ayon_core.tools.common_models import (
from .abstract import (
AbstractWorkfilesBackend,
AbstractWorkfilesFrontend,
+ PublishedWorkfileWrap,
)
from .models import SelectionModel, WorkfilesModel
@@ -432,6 +436,15 @@ class BaseWorkfileController(
folder_id, task_id
)
+ def get_published_workfile_info(
+ self,
+ folder_id: Optional[str],
+ representation_id: Optional[str],
+ ) -> PublishedWorkfileWrap:
+ return self._workfiles_model.get_published_workfile_info(
+ folder_id, representation_id
+ )
+
def get_workfile_info(self, folder_id, task_id, rootless_path):
return self._workfiles_model.get_workfile_info(
folder_id, task_id, rootless_path
diff --git a/client/ayon_core/tools/workfiles/models/selection.py b/client/ayon_core/tools/workfiles/models/selection.py
index 9a6440b2a1..65caa287d1 100644
--- a/client/ayon_core/tools/workfiles/models/selection.py
+++ b/client/ayon_core/tools/workfiles/models/selection.py
@@ -17,6 +17,8 @@ class SelectionModel(object):
self._task_name = None
self._task_id = None
self._workfile_path = None
+ self._rootless_workfile_path = None
+ self._workfile_entity_id = None
self._representation_id = None
def get_selected_folder_id(self):
@@ -62,39 +64,49 @@ class SelectionModel(object):
def get_selected_workfile_path(self):
return self._workfile_path
+ def get_selected_workfile_data(self):
+ return {
+ "project_name": self._controller.get_current_project_name(),
+ "path": self._workfile_path,
+ "rootless_path": self._rootless_workfile_path,
+ "folder_id": self._folder_id,
+ "task_name": self._task_name,
+ "task_id": self._task_id,
+ "workfile_entity_id": self._workfile_entity_id,
+ }
+
def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id
):
if path == self._workfile_path:
return
+ self._rootless_workfile_path = rootless_path
self._workfile_path = path
+ self._workfile_entity_id = workfile_entity_id
self._controller.emit_event(
"selection.workarea.changed",
- {
- "project_name": self._controller.get_current_project_name(),
- "path": path,
- "rootless_path": rootless_path,
- "folder_id": self._folder_id,
- "task_name": self._task_name,
- "task_id": self._task_id,
- "workfile_entity_id": workfile_entity_id,
- },
+ self.get_selected_workfile_data(),
self.event_source
)
def get_selected_representation_id(self):
return self._representation_id
+ def get_selected_representation_data(self):
+ return {
+ "project_name": self._controller.get_current_project_name(),
+ "folder_id": self._folder_id,
+ "task_id": self._task_id,
+ "representation_id": self._representation_id,
+ }
+
def set_selected_representation_id(self, representation_id):
if representation_id == self._representation_id:
return
self._representation_id = representation_id
self._controller.emit_event(
"selection.representation.changed",
- {
- "project_name": self._controller.get_current_project_name(),
- "representation_id": representation_id,
- },
+ self.get_selected_representation_data(),
self.event_source
)
diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py
index 5b5591fe43..c15dda2b4f 100644
--- a/client/ayon_core/tools/workfiles/models/workfiles.py
+++ b/client/ayon_core/tools/workfiles/models/workfiles.py
@@ -39,6 +39,7 @@ from ayon_core.pipeline.workfile import (
from ayon_core.pipeline.version_start import get_versioning_start
from ayon_core.tools.workfiles.abstract import (
WorkareaFilepathResult,
+ PublishedWorkfileWrap,
AbstractWorkfilesBackend,
)
@@ -79,6 +80,7 @@ class WorkfilesModel:
# Published workfiles
self._repre_by_id = {}
+ self._version_comment_by_id = {}
self._published_workfile_items_cache = NestedCacheItem(
levels=1, default_factory=list
)
@@ -95,6 +97,7 @@ class WorkfilesModel:
self._workarea_file_items_cache.reset()
self._repre_by_id = {}
+ self._version_comment_by_id = {}
self._published_workfile_items_cache.reset()
self._workfile_entities_by_task_id = {}
@@ -552,13 +555,13 @@ class WorkfilesModel:
)
def get_published_file_items(
- self, folder_id: str, task_id: str
+ self, folder_id: Optional[str], task_id: Optional[str]
) -> list[PublishedWorkfileInfo]:
"""Published workfiles for passed context.
Args:
- folder_id (str): Folder id.
- task_id (str): Task id.
+ folder_id (Optional[str]): Folder id.
+ task_id (Optional[str]): Task id.
Returns:
list[PublishedWorkfileInfo]: List of files for published workfiles.
@@ -586,7 +589,7 @@ class WorkfilesModel:
version_entities = list(ayon_api.get_versions(
project_name,
product_ids=product_ids,
- fields={"id", "author", "taskId"},
+ fields={"id", "author", "taskId", "attrib.comment"},
))
repre_entities = []
@@ -600,6 +603,13 @@ class WorkfilesModel:
repre_entity["id"]: repre_entity
for repre_entity in repre_entities
})
+
+ # Map versions by representation ID for easy lookup
+ self._version_comment_by_id.update({
+ version_entity["id"]: version_entity["attrib"].get("comment")
+ for version_entity in version_entities
+ })
+
project_entity = self._controller.get_project_entity(project_name)
prepared_data = ListPublishedWorkfilesOptionalData(
@@ -626,6 +636,34 @@ class WorkfilesModel:
]
return items
+ def get_published_workfile_info(
+ self,
+ folder_id: Optional[str],
+ representation_id: Optional[str],
+ ) -> PublishedWorkfileWrap:
+ """Get published workfile info by representation ID.
+
+ Args:
+ folder_id (Optional[str]): Folder id.
+ representation_id (Optional[str]): Representation id.
+
+ Returns:
+ PublishedWorkfileWrap: Published workfile info or None
+ if not found.
+
+ """
+ if not representation_id:
+ return PublishedWorkfileWrap()
+
+ # Search through all cached published workfile items
+ for item in self.get_published_file_items(folder_id, None):
+ if item.representation_id == representation_id:
+ comment = self._get_published_workfile_version_comment(
+ representation_id
+ )
+ return PublishedWorkfileWrap(item, comment)
+ return PublishedWorkfileWrap()
+
@property
def _project_name(self) -> str:
return self._controller.get_current_project_name()
@@ -642,6 +680,25 @@ class WorkfilesModel:
self._current_username = get_ayon_username()
return self._current_username
+ def _get_published_workfile_version_comment(
+ self, representation_id: str
+ ) -> Optional[str]:
+ """Get version comment for published workfile.
+
+ Args:
+ representation_id (str): Representation id.
+
+ Returns:
+ Optional[str]: Version comment or None.
+
+ """
+ if not representation_id:
+ return None
+ repre = self._repre_by_id.get(representation_id)
+ if not repre:
+ return None
+ return self._version_comment_by_id.get(repre["versionId"])
+
# --- Host ---
def _open_workfile(self, folder_id: str, task_id: str, filepath: str):
# TODO move to workfiles pipeline
diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py
index b1b91d9721..2929ac780d 100644
--- a/client/ayon_core/tools/workfiles/widgets/side_panel.py
+++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py
@@ -1,6 +1,7 @@
import datetime
+from typing import Optional
-from qtpy import QtWidgets, QtCore
+from qtpy import QtCore, QtWidgets
def file_size_to_string(file_size):
@@ -8,9 +9,9 @@ def file_size_to_string(file_size):
return "N/A"
size = 0
size_ending_mapping = {
- "KB": 1024 ** 1,
- "MB": 1024 ** 2,
- "GB": 1024 ** 3
+ "KB": 1024**1,
+ "MB": 1024**2,
+ "GB": 1024**3,
}
ending = "B"
for _ending, _size in size_ending_mapping.items():
@@ -70,7 +71,12 @@ class SidePanelWidget(QtWidgets.QWidget):
btn_description_save.clicked.connect(self._on_save_click)
controller.register_event_callback(
- "selection.workarea.changed", self._on_selection_change
+ "selection.workarea.changed",
+ self._on_workarea_selection_change
+ )
+ controller.register_event_callback(
+ "selection.representation.changed",
+ self._on_representation_selection_change,
)
self._details_input = details_input
@@ -82,12 +88,13 @@ class SidePanelWidget(QtWidgets.QWidget):
self._task_id = None
self._filepath = None
self._rootless_path = None
+ self._representation_id = None
self._orig_description = ""
self._controller = controller
- self._set_context(None, None, None, None)
+ self._set_context(False, None, None)
- def set_published_mode(self, published_mode):
+ def set_published_mode(self, published_mode: bool) -> None:
"""Change published mode.
Args:
@@ -95,14 +102,37 @@ class SidePanelWidget(QtWidgets.QWidget):
"""
self._description_widget.setVisible(not published_mode)
+ # Clear the context when switching modes to avoid showing stale data
+ if published_mode:
+ self._set_publish_context(
+ self._folder_id,
+ self._task_id,
+ self._representation_id,
+ )
+ else:
+ self._set_workarea_context(
+ self._folder_id,
+ self._task_id,
+ self._rootless_path,
+ self._filepath,
+ )
- def _on_selection_change(self, event):
+ def _on_workarea_selection_change(self, event):
folder_id = event["folder_id"]
task_id = event["task_id"]
filepath = event["path"]
rootless_path = event["rootless_path"]
- self._set_context(folder_id, task_id, rootless_path, filepath)
+ self._set_workarea_context(
+ folder_id, task_id, rootless_path, filepath
+ )
+
+ def _on_representation_selection_change(self, event):
+ folder_id = event["folder_id"]
+ task_id = event["task_id"]
+ representation_id = event["representation_id"]
+
+ self._set_publish_context(folder_id, task_id, representation_id)
def _on_description_change(self):
text = self._description_input.toPlainText()
@@ -118,85 +148,134 @@ class SidePanelWidget(QtWidgets.QWidget):
self._orig_description = description
self._btn_description_save.setEnabled(False)
- def _set_context(self, folder_id, task_id, rootless_path, filepath):
+ def _set_workarea_context(
+ self,
+ folder_id: Optional[str],
+ task_id: Optional[str],
+ rootless_path: Optional[str],
+ filepath: Optional[str],
+ ) -> None:
+ self._rootless_path = rootless_path
+ self._filepath = filepath
+
workfile_info = None
# Check if folder, task and file are selected
if folder_id and task_id and rootless_path:
workfile_info = self._controller.get_workfile_info(
folder_id, task_id, rootless_path
)
- enabled = workfile_info is not None
- self._details_input.setEnabled(enabled)
- self._description_input.setEnabled(enabled)
- self._btn_description_save.setEnabled(enabled)
-
- self._folder_id = folder_id
- self._task_id = task_id
- self._filepath = filepath
- self._rootless_path = rootless_path
-
- # Disable inputs and remove texts if any required arguments are
- # missing
- if not enabled:
+ if workfile_info is None:
self._orig_description = ""
- self._details_input.setPlainText("")
self._description_input.setPlainText("")
+ self._set_context(False, folder_id, task_id)
return
- description = workfile_info.description
- size_value = file_size_to_string(workfile_info.file_size)
+ self._set_context(
+ True,
+ folder_id,
+ task_id,
+ file_created=workfile_info.file_created,
+ file_modified=workfile_info.file_modified,
+ size_value=workfile_info.file_size,
+ created_by=workfile_info.created_by,
+ updated_by=workfile_info.updated_by,
+ )
+
+ description = workfile_info.description
+ self._orig_description = description
+ self._description_input.setPlainText(description)
+
+ def _set_publish_context(
+ self,
+ folder_id: Optional[str],
+ task_id: Optional[str],
+ representation_id: Optional[str],
+ ) -> None:
+ self._representation_id = representation_id
+ published_workfile_wrap = self._controller.get_published_workfile_info(
+ folder_id,
+ representation_id,
+ )
+ info = published_workfile_wrap.info
+ comment = published_workfile_wrap.comment
+ if info is None:
+ self._set_context(False, folder_id, task_id)
+ return
+
+ self._set_context(
+ True,
+ folder_id,
+ task_id,
+ file_created=info.file_created,
+ file_modified=info.file_modified,
+ size_value=info.file_size,
+ created_by=info.author,
+ comment=comment,
+ )
+
+ def _set_context(
+ self,
+ is_valid: bool,
+ folder_id: Optional[str],
+ task_id: Optional[str],
+ *,
+ file_created: Optional[int] = None,
+ file_modified: Optional[int] = None,
+ size_value: Optional[int] = None,
+ created_by: Optional[str] = None,
+ updated_by: Optional[str] = None,
+ comment: Optional[str] = None,
+ ) -> None:
+ self._folder_id = folder_id
+ self._task_id = task_id
+
+ self._details_input.setEnabled(is_valid)
+ self._description_input.setEnabled(is_valid)
+ self._btn_description_save.setEnabled(is_valid)
+ if not is_valid:
+ self._details_input.setPlainText("")
+ return
- # Append html string
datetime_format = "%b %d %Y %H:%M:%S"
- file_created = workfile_info.file_created
- modification_time = workfile_info.file_modified
if file_created:
file_created = datetime.datetime.fromtimestamp(file_created)
- if modification_time:
- modification_time = datetime.datetime.fromtimestamp(
- modification_time)
+ if file_modified:
+ file_modified = datetime.datetime.fromtimestamp(
+ file_modified
+ )
user_items_by_name = self._controller.get_user_items_by_name()
- def convert_username(username):
- user_item = user_items_by_name.get(username)
+ def convert_username(username_v):
+ user_item = user_items_by_name.get(username_v)
if user_item is not None and user_item.full_name:
return user_item.full_name
- return username
+ return username_v
- created_lines = []
- if workfile_info.created_by:
- created_lines.append(
- convert_username(workfile_info.created_by)
- )
- if file_created:
- created_lines.append(file_created.strftime(datetime_format))
+ lines = []
+ if size_value is not None:
+ size_value = file_size_to_string(size_value)
+ lines.append(f"Size:
{size_value}")
- if created_lines:
- created_lines.insert(0, "Created:")
+ # Add version comment for published workfiles
+ if comment:
+ lines.append(f"Comment:
{comment}")
- modified_lines = []
- if workfile_info.updated_by:
- modified_lines.append(
- convert_username(workfile_info.updated_by)
- )
- if modification_time:
- modified_lines.append(
- modification_time.strftime(datetime_format)
- )
- if modified_lines:
- modified_lines.insert(0, "Modified:")
+ if created_by or file_created:
+ lines.append("Created:")
+ if created_by:
+ lines.append(convert_username(created_by))
+ if file_created:
+ lines.append(file_created.strftime(datetime_format))
- lines = (
- "Size:",
- size_value,
- "
".join(created_lines),
- "
".join(modified_lines),
- )
- self._orig_description = description
- self._description_input.setPlainText(description)
+ if updated_by or file_modified:
+ lines.append("Modified:")
+ if updated_by:
+ lines.append(convert_username(updated_by))
+ if file_modified:
+ lines.append(file_modified.strftime(datetime_format))
# Set as empty string
self._details_input.setPlainText("")
diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py
index 00362ea866..811fe602d1 100644
--- a/client/ayon_core/tools/workfiles/widgets/window.py
+++ b/client/ayon_core/tools/workfiles/widgets/window.py
@@ -6,12 +6,11 @@ from ayon_core.tools.utils import (
FoldersWidget,
GoToCurrentButton,
MessageOverlayObject,
- NiceCheckbox,
PlaceholderLineEdit,
RefreshButton,
TasksWidget,
+ FoldersFiltersWidget,
)
-from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.workfiles.control import BaseWorkfileController
from .files_widget import FilesWidget
@@ -69,7 +68,6 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._default_window_flags = flags
self._folders_widget = None
- self._folder_filter_input = None
self._files_widget = None
@@ -178,48 +176,33 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
col_widget = QtWidgets.QWidget(parent)
header_widget = QtWidgets.QWidget(col_widget)
- folder_filter_input = PlaceholderLineEdit(header_widget)
- folder_filter_input.setPlaceholderText("Filter folders..")
+ filters_widget = FoldersFiltersWidget(header_widget)
go_to_current_btn = GoToCurrentButton(header_widget)
refresh_btn = RefreshButton(header_widget)
+ header_layout = QtWidgets.QHBoxLayout(header_widget)
+ header_layout.setContentsMargins(0, 0, 0, 0)
+ header_layout.addWidget(filters_widget, 1)
+ header_layout.addWidget(go_to_current_btn, 0)
+ header_layout.addWidget(refresh_btn, 0)
+
folder_widget = FoldersWidget(
controller, col_widget, handle_expected_selection=True
)
- my_tasks_tooltip = (
- "Filter folders and task to only those you are assigned to."
- )
-
- my_tasks_label = QtWidgets.QLabel("My tasks")
- my_tasks_label.setToolTip(my_tasks_tooltip)
-
- my_tasks_checkbox = NiceCheckbox(folder_widget)
- my_tasks_checkbox.setChecked(False)
- my_tasks_checkbox.setToolTip(my_tasks_tooltip)
-
- header_layout = QtWidgets.QHBoxLayout(header_widget)
- header_layout.setContentsMargins(0, 0, 0, 0)
- header_layout.addWidget(folder_filter_input, 1)
- header_layout.addWidget(go_to_current_btn, 0)
- header_layout.addWidget(refresh_btn, 0)
- header_layout.addWidget(my_tasks_label, 0)
- header_layout.addWidget(my_tasks_checkbox, 0)
-
col_layout = QtWidgets.QVBoxLayout(col_widget)
col_layout.setContentsMargins(0, 0, 0, 0)
col_layout.addWidget(header_widget, 0)
col_layout.addWidget(folder_widget, 1)
- folder_filter_input.textChanged.connect(self._on_folder_filter_change)
- go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
- refresh_btn.clicked.connect(self._on_refresh_clicked)
- my_tasks_checkbox.stateChanged.connect(
+ filters_widget.text_changed.connect(self._on_folder_filter_change)
+ filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
+ go_to_current_btn.clicked.connect(self._on_go_to_current_clicked)
+ refresh_btn.clicked.connect(self._on_refresh_clicked)
- self._folder_filter_input = folder_filter_input
self._folders_widget = folder_widget
return col_widget
@@ -403,11 +386,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
else:
self.close()
- def _on_my_tasks_checkbox_state_changed(self, state):
+ def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
- state = checkstate_int_to_enum(state)
- if state == QtCore.Qt.Checked:
+ if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py
index 6aa30b935a..a3e1a6c939 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.6.7+dev"
+__version__ = "1.6.11+dev"
diff --git a/package.py b/package.py
index ff3fad5b19..62231060f0 100644
--- a/package.py
+++ b/package.py
@@ -1,6 +1,6 @@
name = "core"
title = "Core"
-version = "1.6.7+dev"
+version = "1.6.11+dev"
client_dir = "ayon_core"
diff --git a/pyproject.toml b/pyproject.toml
index 6656f15249..d568edefc0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
-version = "1.6.7+dev"
+version = "1.6.11+dev"
description = ""
authors = ["Ynput Team "]
readme = "README.md"
diff --git a/server/settings/conversion.py b/server/settings/conversion.py
index 34820b5b32..846b91edab 100644
--- a/server/settings/conversion.py
+++ b/server/settings/conversion.py
@@ -1,8 +1,33 @@
+import re
import copy
from typing import Any
from .publish_plugins import DEFAULT_PUBLISH_VALUES
+PRODUCT_NAME_REPL_REGEX = re.compile(r"[^<>{}\[\]a-zA-Z0-9_.]")
+
+
+def _convert_imageio_configs_1_6_5(overrides):
+ product_name_profiles = (
+ overrides
+ .get("tools", {})
+ .get("creator", {})
+ .get("product_name_profiles")
+ )
+ if isinstance(product_name_profiles, list):
+ for item in product_name_profiles:
+ # Remove unsupported product name characters
+ template = item.get("template")
+ if isinstance(template, str):
+ item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template)
+
+ for new_key, old_key in (
+ ("host_names", "hosts"),
+ ("task_names", "tasks"),
+ ):
+ if old_key in item:
+ item[new_key] = item.get(old_key)
+
def _convert_imageio_configs_0_4_5(overrides):
"""Imageio config settings did change to profiles since 0.4.5."""
diff --git a/server/settings/tools.py b/server/settings/tools.py
index f40c7c3627..da3b4ebff8 100644
--- a/server/settings/tools.py
+++ b/server/settings/tools.py
@@ -25,16 +25,27 @@ class ProductNameProfile(BaseSettingsModel):
_layout = "expanded"
product_types: list[str] = SettingsField(
- default_factory=list, title="Product types"
+ default_factory=list,
+ title="Product types",
+ )
+ host_names: list[str] = SettingsField(
+ default_factory=list,
+ title="Host names",
)
- hosts: list[str] = SettingsField(default_factory=list, title="Hosts")
task_types: list[str] = SettingsField(
default_factory=list,
title="Task types",
- enum_resolver=task_types_enum
+ enum_resolver=task_types_enum,
+ )
+ task_names: list[str] = SettingsField(
+ default_factory=list,
+ title="Task names",
+ )
+ template: str = SettingsField(
+ "",
+ title="Template",
+ regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$",
)
- tasks: list[str] = SettingsField(default_factory=list, title="Task names")
- template: str = SettingsField("", title="Template")
class FilterCreatorProfile(BaseSettingsModel):
@@ -433,27 +444,27 @@ DEFAULT_TOOLS_VALUES = {
"product_name_profiles": [
{
"product_types": [],
- "hosts": [],
+ "host_names": [],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "{product[type]}{variant}"
},
{
"product_types": [
"workfile"
],
- "hosts": [],
+ "host_names": [],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "{product[type]}{Task[name]}"
},
{
"product_types": [
"render"
],
- "hosts": [],
+ "host_names": [],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "{product[type]}{Task[name]}{Variant}<_{Aov}>"
},
{
@@ -461,11 +472,11 @@ DEFAULT_TOOLS_VALUES = {
"renderLayer",
"renderPass"
],
- "hosts": [
+ "host_names": [
"tvpaint"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": (
"{product[type]}{Task[name]}_{Renderlayer}_{Renderpass}"
)
@@ -475,65 +486,65 @@ DEFAULT_TOOLS_VALUES = {
"review",
"workfile"
],
- "hosts": [
+ "host_names": [
"aftereffects",
"tvpaint"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "{product[type]}{Task[name]}"
},
{
"product_types": ["render"],
- "hosts": [
+ "host_names": [
"aftereffects"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "{product[type]}{Task[name]}{Composition}{Variant}"
},
{
"product_types": [
"staticMesh"
],
- "hosts": [
+ "host_names": [
"maya"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "S_{folder[name]}{variant}"
},
{
"product_types": [
"skeletalMesh"
],
- "hosts": [
+ "host_names": [
"maya"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "SK_{folder[name]}{variant}"
},
{
"product_types": [
"hda"
],
- "hosts": [
+ "host_names": [
"houdini"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "{folder[name]}_{variant}"
},
{
"product_types": [
"textureSet"
],
- "hosts": [
+ "host_names": [
"substancedesigner"
],
"task_types": [],
- "tasks": [],
+ "task_names": [],
"template": "T_{folder[name]}{variant}"
}
],
diff --git a/tests/client/ayon_core/lib/test_transcoding.py b/tests/client/ayon_core/lib/test_transcoding.py
new file mode 100644
index 0000000000..b9959e2958
--- /dev/null
+++ b/tests/client/ayon_core/lib/test_transcoding.py
@@ -0,0 +1,158 @@
+import unittest
+
+from ayon_core.lib.transcoding import (
+ get_review_info_by_layer_name
+)
+
+
+class GetReviewInfoByLayerName(unittest.TestCase):
+ """Test responses from `get_review_info_by_layer_name`"""
+ def test_rgba_channels(self):
+
+ # RGB is supported
+ info = get_review_info_by_layer_name(["R", "G", "B"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "R",
+ "G": "G",
+ "B": "B",
+ "A": None,
+ }
+ }])
+
+ # rgb is supported
+ info = get_review_info_by_layer_name(["r", "g", "b"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "r",
+ "G": "g",
+ "B": "b",
+ "A": None,
+ }
+ }])
+
+ # diffuse.[RGB] is supported
+ info = get_review_info_by_layer_name(
+ ["diffuse.R", "diffuse.G", "diffuse.B"]
+ )
+ self.assertEqual(info, [{
+ "name": "diffuse",
+ "review_channels": {
+ "R": "diffuse.R",
+ "G": "diffuse.G",
+ "B": "diffuse.B",
+ "A": None,
+ }
+ }])
+
+ info = get_review_info_by_layer_name(["R", "G", "B", "A"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "R",
+ "G": "G",
+ "B": "B",
+ "A": "A",
+ }
+ }])
+
+ def test_z_channel(self):
+
+ info = get_review_info_by_layer_name(["Z"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "Z",
+ "G": "Z",
+ "B": "Z",
+ "A": None,
+ }
+ }])
+
+ info = get_review_info_by_layer_name(["Z", "A"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "Z",
+ "G": "Z",
+ "B": "Z",
+ "A": "A",
+ }
+ }])
+
+ def test_ar_ag_ab_channels(self):
+
+ info = get_review_info_by_layer_name(["AR", "AG", "AB"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "AR",
+ "G": "AG",
+ "B": "AB",
+ "A": None,
+ }
+ }])
+
+ info = get_review_info_by_layer_name(["AR", "AG", "AB", "A"])
+ self.assertEqual(info, [{
+ "name": "",
+ "review_channels": {
+ "R": "AR",
+ "G": "AG",
+ "B": "AB",
+ "A": "A",
+ }
+ }])
+
+ def test_unknown_channels(self):
+ info = get_review_info_by_layer_name(["hello", "world"])
+ self.assertEqual(info, [])
+
+ def test_rgba_priority(self):
+ """Ensure main layer, and RGB channels are prioritized
+
+ If both Z and RGB channels are present for a layer name, then RGB
+ should be prioritized and the Z channel should be ignored.
+
+ Also, the alpha channel from another "layer name" is not used. Note
+ how the diffuse response does not take A channel from the main layer.
+
+ """
+
+ info = get_review_info_by_layer_name([
+ "Z",
+ "diffuse.R", "diffuse.G", "diffuse.B",
+ "R", "G", "B", "A",
+ "specular.R", "specular.G", "specular.B", "specular.A",
+ ])
+ self.assertEqual(info, [
+ {
+ "name": "",
+ "review_channels": {
+ "R": "R",
+ "G": "G",
+ "B": "B",
+ "A": "A",
+ },
+ },
+ {
+ "name": "diffuse",
+ "review_channels": {
+ "R": "diffuse.R",
+ "G": "diffuse.G",
+ "B": "diffuse.B",
+ "A": None,
+ },
+ },
+ {
+ "name": "specular",
+ "review_channels": {
+ "R": "specular.R",
+ "G": "specular.G",
+ "B": "specular.B",
+ "A": "specular.A",
+ },
+ },
+ ])