Merge branch 'develop' into enhancement/support_extract_review_for_substance_painter

This commit is contained in:
Kayla Man 2025-11-21 14:14:16 +08:00 committed by GitHub
commit 17769b5291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 4707 additions and 1873 deletions

View file

@ -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

View file

@ -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.

View file

@ -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):

View file

@ -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",

View file

@ -0,0 +1,8 @@
from enum import Enum
class StrEnum(str, Enum):
"""A string-based Enum class that allows for string comparison."""
def __str__(self) -> str:
return self.value

View file

@ -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."""

View file

@ -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"

View file

@ -0,0 +1,62 @@
from .structures import (
ActionForm,
)
from .utils import (
webaction_fields_to_attribute_defs,
)
from .loader import (
LoaderSelectedType,
LoaderActionResult,
LoaderActionItem,
LoaderActionPlugin,
LoaderActionSelection,
LoaderActionsContext,
SelectionEntitiesCache,
LoaderSimpleActionPlugin,
)
from .launcher import (
LauncherAction,
LauncherActionSelection,
discover_launcher_actions,
register_launcher_action,
register_launcher_action_path,
)
from .inventory import (
InventoryAction,
discover_inventory_actions,
register_inventory_action,
register_inventory_action_path,
deregister_inventory_action,
deregister_inventory_action_path,
)
__all__ = (
"ActionForm",
"webaction_fields_to_attribute_defs",
"LoaderSelectedType",
"LoaderActionResult",
"LoaderActionItem",
"LoaderActionPlugin",
"LoaderActionSelection",
"LoaderActionsContext",
"SelectionEntitiesCache",
"LoaderSimpleActionPlugin",
"LauncherAction",
"LauncherActionSelection",
"discover_launcher_actions",
"register_launcher_action",
"register_launcher_action_path",
"InventoryAction",
"discover_inventory_actions",
"register_inventory_action",
"register_inventory_action_path",
"deregister_inventory_action",
"deregister_inventory_action_path",
)

View file

@ -0,0 +1,108 @@
import logging
from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
register_plugin_path,
deregister_plugin,
deregister_plugin_path
)
from ayon_core.pipeline.load.utils import get_representation_path_from_context
class InventoryAction:
"""A custom action for the scene inventory tool
If registered the action will be visible in the Right Mouse Button menu
under the submenu "Actions".
"""
label = None
icon = None
color = None
order = 0
log = logging.getLogger("InventoryAction")
log.propagate = True
@staticmethod
def is_compatible(container):
"""Override function in a custom class
This method is specifically used to ensure the action can operate on
the container.
Args:
container(dict): the data of a loaded asset, see host.ls()
Returns:
bool
"""
return bool(container.get("objectName"))
def process(self, containers):
"""Override function in a custom class
This method will receive all containers even those which are
incompatible. It is advised to create a small filter along the lines
of this example:
valid_containers = filter(self.is_compatible(c) for c in containers)
The return value will need to be a True-ish value to trigger
the data_changed signal in order to refresh the view.
You can return a list of container names to trigger GUI to select
treeview items.
You can return a dict to carry extra GUI options. For example:
{
"objectNames": [container names...],
"options": {"mode": "toggle",
"clear": False}
}
Currently workable GUI options are:
- clear (bool): Clear current selection before selecting by action.
Default `True`.
- mode (str): selection mode, use one of these:
"select", "deselect", "toggle". Default is "select".
Args:
containers (list): list of dictionaries
Return:
bool, list or dict
"""
return True
@classmethod
def filepath_from_context(cls, context):
return get_representation_path_from_context(context)
def discover_inventory_actions():
actions = discover(InventoryAction)
filtered_actions = []
for action in actions:
if action is not InventoryAction:
filtered_actions.append(action)
return filtered_actions
def register_inventory_action(plugin):
return register_plugin(InventoryAction, plugin)
def deregister_inventory_action(plugin):
deregister_plugin(InventoryAction, plugin)
def register_inventory_action_path(path):
return register_plugin_path(InventoryAction, path)
def deregister_inventory_action_path(path):
return deregister_plugin_path(InventoryAction, path)

View file

@ -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)

View file

@ -0,0 +1,864 @@
"""API for actions for loader tool.
Even though the api is meant for the loader tool, the api should be possible
to use in a standalone way out of the loader tool.
To use add actions, make sure your addon does inherit from
'IPluginPaths' and implements 'get_loader_action_plugin_paths' which
returns paths to python files with loader actions.
The plugin is used to collect available actions for the given context and to
execute them. Selection is defined with 'LoaderActionSelection' object
that also contains a cache of entities and project anatomy.
Implementing 'get_action_items' allows the plugin to define what actions
are shown and available for the selection. Because for a single selection
can be shown multiple actions with the same action identifier, the action
items also have 'data' attribute which can be used to store additional
data for the action (they have to be json-serializable).
The action is triggered by calling the 'execute_action' method. Which takes
the action identifier, the selection, the additional data from the action
item and form values from the form if any.
Using 'LoaderActionResult' as the output of 'execute_action' can trigger to
show a message in UI or to show an additional form ('ActionForm')
which would retrigger the action with the values from the form on
submitting. That allows handling of multistep actions.
It is also recommended that the plugin does override the 'identifier'
attribute. The identifier has to be unique across all plugins.
Class name is used by default.
The selection wrapper currently supports the following types of entity types:
- version
- representation
It is planned to add 'folder' and 'task' selection in the future.
NOTE: It is possible to trigger 'execute_action' without ever calling
'get_action_items', that can be handy in automations.
The whole logic is wrapped into 'LoaderActionsContext'. It takes care of
the discovery of plugins and wraps the collection and execution of
action items. Method 'execute_action' on context also requires plugin
identifier.
The flow of the logic is (in the loader tool):
1. User selects entities in the UI.
2. Right-click the selected entities.
3. Use 'LoaderActionsContext' to collect items using 'get_action_items'.
4. Show a menu (with submenus) in the UI.
5. If a user selects an action, the action is triggered using
'execute_action'.
5a. If the action returns 'LoaderActionResult', show a 'message' if it is
filled and show a form dialog if 'form' is filled.
5b. If the user submitted the form, trigger the action again with the
values from the form and repeat from 5a.
"""
from __future__ import annotations
import os
import collections
import copy
import logging
from abc import ABC, abstractmethod
import typing
from typing import Optional, Any, Callable
from dataclasses import dataclass
import ayon_api
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import StrEnum, Logger
from ayon_core.host import AbstractHost
from ayon_core.addon import AddonsManager, IPluginPaths
from ayon_core.settings import get_studio_settings, get_project_settings
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.plugin_discover import discover_plugins
from .structures import ActionForm
if typing.TYPE_CHECKING:
from typing import Union
DataBaseType = Union[str, int, float, bool]
DataType = dict[str, Union[DataBaseType, list[DataBaseType]]]
_PLACEHOLDER = object()
class LoaderSelectedType(StrEnum):
"""Selected entity type."""
# folder = "folder"
# task = "task"
version = "version"
representation = "representation"
class SelectionEntitiesCache:
"""Cache of entities used as helper in the selection wrapper.
It is possible to get entities based on ids with helper methods to get
entities, their parents or their children's entities.
The goal is to avoid multiple API calls for the same entity in multiple
action plugins.
The cache is based on the selected project. Entities are fetched
if are not in cache yet.
"""
def __init__(
self,
project_name: str,
project_entity: Optional[dict[str, Any]] = None,
folders_by_id: Optional[dict[str, dict[str, Any]]] = None,
tasks_by_id: Optional[dict[str, dict[str, Any]]] = None,
products_by_id: Optional[dict[str, dict[str, Any]]] = None,
versions_by_id: Optional[dict[str, dict[str, Any]]] = None,
representations_by_id: Optional[dict[str, dict[str, Any]]] = None,
task_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
product_ids_by_folder_id: Optional[dict[str, set[str]]] = None,
version_ids_by_product_id: Optional[dict[str, set[str]]] = None,
representation_ids_by_version_id: Optional[dict[str, set[str]]] = None,
):
self._project_name = project_name
self._project_entity = project_entity
self._folders_by_id = folders_by_id or {}
self._tasks_by_id = tasks_by_id or {}
self._products_by_id = products_by_id or {}
self._versions_by_id = versions_by_id or {}
self._representations_by_id = representations_by_id or {}
self._task_ids_by_folder_id = task_ids_by_folder_id or {}
self._product_ids_by_folder_id = product_ids_by_folder_id or {}
self._version_ids_by_product_id = version_ids_by_product_id or {}
self._representation_ids_by_version_id = (
representation_ids_by_version_id or {}
)
def get_project(self) -> dict[str, Any]:
"""Get project entity"""
if self._project_entity is None:
self._project_entity = ayon_api.get_project(self._project_name)
return copy.deepcopy(self._project_entity)
def get_folders(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
folder_ids,
self._folders_by_id,
"folder_ids",
ayon_api.get_folders,
)
def get_tasks(
self, task_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
task_ids,
self._tasks_by_id,
"task_ids",
ayon_api.get_tasks,
)
def get_products(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
product_ids,
self._products_by_id,
"product_ids",
ayon_api.get_products,
)
def get_versions(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
version_ids,
self._versions_by_id,
"version_ids",
ayon_api.get_versions,
)
def get_representations(
self, representation_ids: set[str]
) -> list[dict[str, Any]]:
return self._get_entities(
representation_ids,
self._representations_by_id,
"representation_ids",
ayon_api.get_representations,
)
def get_folders_tasks(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
task_ids = self._fill_parent_children_ids(
folder_ids,
"folderId",
"folder_ids",
self._task_ids_by_folder_id,
ayon_api.get_tasks,
)
return self.get_tasks(task_ids)
def get_folders_products(
self, folder_ids: set[str]
) -> list[dict[str, Any]]:
product_ids = self._get_folders_products_ids(folder_ids)
return self.get_products(product_ids)
def get_tasks_versions(
self, task_ids: set[str]
) -> list[dict[str, Any]]:
folder_ids = {
task["folderId"]
for task in self.get_tasks(task_ids)
}
product_ids = self._get_folders_products_ids(folder_ids)
output = []
for version in self.get_products_versions(product_ids):
task_id = version["taskId"]
if task_id in task_ids:
output.append(version)
return output
def get_products_versions(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
version_ids = self._fill_parent_children_ids(
product_ids,
"productId",
"product_ids",
self._version_ids_by_product_id,
ayon_api.get_versions,
)
return self.get_versions(version_ids)
def get_versions_representations(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
repre_ids = self._fill_parent_children_ids(
version_ids,
"versionId",
"version_ids",
self._representation_ids_by_version_id,
ayon_api.get_representations,
)
return self.get_representations(repre_ids)
def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]:
folder_ids = {
task["folderId"]
for task in self.get_tasks(task_ids)
}
return self.get_folders(folder_ids)
def get_products_folders(
self, product_ids: set[str]
) -> list[dict[str, Any]]:
folder_ids = {
product["folderId"]
for product in self.get_products(product_ids)
}
return self.get_folders(folder_ids)
def get_versions_products(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
product_ids = {
version["productId"]
for version in self.get_versions(version_ids)
}
return self.get_products(product_ids)
def get_versions_tasks(
self, version_ids: set[str]
) -> list[dict[str, Any]]:
task_ids = {
version["taskId"]
for version in self.get_versions(version_ids)
if version["taskId"]
}
return self.get_tasks(task_ids)
def get_representations_versions(
self, representation_ids: set[str]
) -> list[dict[str, Any]]:
version_ids = {
repre["versionId"]
for repre in self.get_representations(representation_ids)
}
return self.get_versions(version_ids)
def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]:
return self._fill_parent_children_ids(
folder_ids,
"folderId",
"folder_ids",
self._product_ids_by_folder_id,
ayon_api.get_products,
)
def _fill_parent_children_ids(
self,
entity_ids: set[str],
parent_key: str,
filter_attr: str,
parent_mapping: dict[str, set[str]],
getter: Callable,
) -> set[str]:
if not entity_ids:
return set()
children_ids = set()
missing_ids = set()
for entity_id in entity_ids:
_children_ids = parent_mapping.get(entity_id)
if _children_ids is None:
missing_ids.add(entity_id)
else:
children_ids.update(_children_ids)
if missing_ids:
entities_by_parent_id = collections.defaultdict(set)
for entity in getter(
self._project_name,
fields={"id", parent_key},
**{filter_attr: missing_ids},
):
child_id = entity["id"]
children_ids.add(child_id)
entities_by_parent_id[entity[parent_key]].add(child_id)
for entity_id in missing_ids:
parent_mapping[entity_id] = entities_by_parent_id[entity_id]
return children_ids
def _get_entities(
self,
entity_ids: set[str],
cache_var: dict[str, Any],
filter_arg: str,
getter: Callable,
) -> list[dict[str, Any]]:
if not entity_ids:
return []
output = []
missing_ids: set[str] = set()
for entity_id in entity_ids:
entity = cache_var.get(entity_id)
if entity_id not in cache_var:
missing_ids.add(entity_id)
cache_var[entity_id] = None
elif entity:
output.append(entity)
if missing_ids:
for entity in getter(
self._project_name,
**{filter_arg: missing_ids}
):
output.append(entity)
cache_var[entity["id"]] = entity
return output
class LoaderActionSelection:
"""Selection of entities for loader actions.
Selection tells action plugins what exactly is selected in the tool and
which ids.
Contains entity cache which can be used to get entities by their ids. Or
to get project settings and anatomy.
"""
def __init__(
self,
project_name: str,
selected_ids: set[str],
selected_type: LoaderSelectedType,
*,
project_anatomy: Optional[Anatomy] = None,
project_settings: Optional[dict[str, Any]] = None,
entities_cache: Optional[SelectionEntitiesCache] = None,
):
self._project_name = project_name
self._selected_ids = selected_ids
self._selected_type = selected_type
self._project_anatomy = project_anatomy
self._project_settings = project_settings
if entities_cache is None:
entities_cache = SelectionEntitiesCache(project_name)
self._entities_cache = entities_cache
def get_entities_cache(self) -> SelectionEntitiesCache:
return self._entities_cache
def get_project_name(self) -> str:
return self._project_name
def get_selected_ids(self) -> set[str]:
return set(self._selected_ids)
def get_selected_type(self) -> str:
return self._selected_type
def get_project_settings(self) -> dict[str, Any]:
if self._project_settings is None:
self._project_settings = get_project_settings(self._project_name)
return copy.deepcopy(self._project_settings)
def get_project_anatomy(self) -> Anatomy:
if self._project_anatomy is None:
self._project_anatomy = Anatomy(
self._project_name,
project_entity=self.get_entities_cache().get_project(),
)
return self._project_anatomy
project_name = property(get_project_name)
selected_ids = property(get_selected_ids)
selected_type = property(get_selected_type)
project_settings = property(get_project_settings)
project_anatomy = property(get_project_anatomy)
entities = property(get_entities_cache)
# --- Helper methods ---
def versions_selected(self) -> bool:
"""Selected entity type is version.
Returns:
bool: True if selected entity type is version.
"""
return self._selected_type == LoaderSelectedType.version
def representations_selected(self) -> bool:
"""Selected entity type is representation.
Returns:
bool: True if selected entity type is representation.
"""
return self._selected_type == LoaderSelectedType.representation
def get_selected_version_entities(self) -> list[dict[str, Any]]:
"""Retrieve selected version entities.
An empty list is returned if 'version' is not the selected
entity type.
Returns:
list[dict[str, Any]]: List of selected version entities.
"""
if self.versions_selected():
return self.entities.get_versions(self.selected_ids)
return []
def get_selected_representation_entities(self) -> list[dict[str, Any]]:
"""Retrieve selected representation entities.
An empty list is returned if 'representation' is not the selected
entity type.
Returns:
list[dict[str, Any]]: List of selected representation entities.
"""
if self.representations_selected():
return self.entities.get_representations(self.selected_ids)
return []
@dataclass
class LoaderActionItem:
"""Item of loader action.
Action plugins return these items as possible actions to run for a given
context.
Because the action item can be related to a specific entity
and not the whole selection, they also have to define the entity type
and ids to be executed on.
Attributes:
label (str): Text shown in UI.
order (int): Order of the action in UI.
group_label (Optional[str]): Label of the group to which the action
belongs.
icon (Optional[dict[str, Any]): Icon definition.
data (Optional[DataType]): Action item data.
identifier (Optional[str]): Identifier of the plugin which
created the action item. Is filled automatically. Is not changed
if is filled -> can lead to different plugin.
"""
label: str
order: int = 0
group_label: Optional[str] = None
icon: Optional[dict[str, Any]] = None
data: Optional[DataType] = None
# Is filled automatically
identifier: str = None
@dataclass
class LoaderActionResult:
"""Result of loader action execution.
Attributes:
message (Optional[str]): Message to show in UI.
success (bool): If the action was successful. Affects color of
the message.
form (Optional[ActionForm]): Form to show in UI.
form_values (Optional[dict[str, Any]]): Values for the form. Can be
used if the same form is re-shown e.g. because a user forgot to
fill a required field.
"""
message: Optional[str] = None
success: bool = True
form: Optional[ActionForm] = None
form_values: Optional[dict[str, Any]] = None
def to_json_data(self) -> dict[str, Any]:
form = self.form
if form is not None:
form = form.to_json_data()
return {
"message": self.message,
"success": self.success,
"form": form,
"form_values": self.form_values,
}
@classmethod
def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult":
form = data["form"]
if form is not None:
data["form"] = ActionForm.from_json_data(form)
return LoaderActionResult(**data)
class LoaderActionPlugin(ABC):
"""Plugin for loader actions.
Plugin is responsible for getting action items and executing actions.
"""
_log: Optional[logging.Logger] = None
enabled: bool = True
def __init__(self, context: "LoaderActionsContext") -> None:
self._context = context
self.apply_settings(context.get_studio_settings())
def apply_settings(self, studio_settings: dict[str, Any]) -> None:
"""Apply studio settings to the plugin.
Args:
studio_settings (dict[str, Any]): Studio settings.
"""
pass
@property
def log(self) -> logging.Logger:
if self._log is None:
self._log = Logger.get_logger(self.__class__.__name__)
return self._log
@property
def identifier(self) -> str:
"""Identifier of the plugin.
Returns:
str: Plugin identifier.
"""
return self.__class__.__name__
@property
def host_name(self) -> Optional[str]:
"""Name of the current host."""
return self._context.get_host_name()
@abstractmethod
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
"""Action items for the selection.
Args:
selection (LoaderActionSelection): Selection.
Returns:
list[LoaderActionItem]: Action items.
"""
pass
@abstractmethod
def execute_action(
self,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Execute an action.
Args:
selection (LoaderActionSelection): Selection wrapper. Can be used
to get entities or get context of original selection.
data (Optional[DataType]): Additional action item data.
form_values (dict[str, Any]): Attribute values.
Returns:
Optional[LoaderActionResult]: Result of the action execution.
"""
pass
class LoaderActionsContext:
"""Wrapper for loader actions and their logic.
Takes care about the public api of loader actions and internal logic like
discovery and initialization of plugins.
"""
def __init__(
self,
studio_settings: Optional[dict[str, Any]] = None,
addons_manager: Optional[AddonsManager] = None,
host: Optional[AbstractHost] = _PLACEHOLDER,
) -> None:
self._log = Logger.get_logger(self.__class__.__name__)
self._addons_manager = addons_manager
self._host = host
# Attributes that are re-cached on reset
self._studio_settings = studio_settings
self._plugins = None
def reset(
self, studio_settings: Optional[dict[str, Any]] = None
) -> None:
"""Reset context cache.
Reset plugins and studio settings to reload them.
Notes:
Does not reset the cache of AddonsManger because there should not
be a reason to do so.
"""
self._studio_settings = studio_settings
self._plugins = None
def get_addons_manager(self) -> AddonsManager:
if self._addons_manager is None:
self._addons_manager = AddonsManager(
settings=self.get_studio_settings()
)
return self._addons_manager
def get_host(self) -> Optional[AbstractHost]:
"""Get current host integration.
Returns:
Optional[AbstractHost]: Host integration. Can be None if host
integration is not registered -> probably not used in the
host integration process.
"""
if self._host is _PLACEHOLDER:
from ayon_core.pipeline import registered_host
self._host = registered_host()
return self._host
def get_host_name(self) -> Optional[str]:
host = self.get_host()
if host is None:
return None
return host.name
def get_studio_settings(self) -> dict[str, Any]:
if self._studio_settings is None:
self._studio_settings = get_studio_settings()
return copy.deepcopy(self._studio_settings)
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
"""Collect action items from all plugins for given selection.
Args:
selection (LoaderActionSelection): Selection wrapper.
"""
output = []
for plugin_id, plugin in self._get_plugins().items():
try:
for action_item in plugin.get_action_items(selection):
if action_item.identifier is None:
action_item.identifier = plugin_id
output.append(action_item)
except Exception:
self._log.warning(
"Failed to get action items for"
f" plugin '{plugin.identifier}'",
exc_info=True,
)
return output
def execute_action(
self,
identifier: str,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Trigger action execution.
Args:
identifier (str): Identifier of the plugin.
selection (LoaderActionSelection): Selection wrapper. Can be used
to get what is selected in UI and to get access to entity
cache.
data (Optional[DataType]): Additional action item data.
form_values (dict[str, Any]): Form values related to action.
Usually filled if action returned response with form.
"""
plugins_by_id = self._get_plugins()
plugin = plugins_by_id[identifier]
return plugin.execute_action(
selection,
data,
form_values,
)
def _get_plugins(self) -> dict[str, LoaderActionPlugin]:
if self._plugins is None:
addons_manager = self.get_addons_manager()
all_paths = [
os.path.join(AYON_CORE_ROOT, "plugins", "loader")
]
for addon in addons_manager.addons:
if not isinstance(addon, IPluginPaths):
continue
paths = addon.get_loader_action_plugin_paths()
if paths:
all_paths.extend(paths)
result = discover_plugins(LoaderActionPlugin, all_paths)
result.log_report()
plugins = {}
for cls in result.plugins:
try:
plugin = cls(self)
if not plugin.enabled:
continue
plugin_id = plugin.identifier
if plugin_id not in plugins:
plugins[plugin_id] = plugin
continue
self._log.warning(
f"Duplicated plugins identifier found '{plugin_id}'."
)
except Exception:
self._log.warning(
f"Failed to initialize plugin '{cls.__name__}'",
exc_info=True,
)
self._plugins = plugins
return self._plugins
class LoaderSimpleActionPlugin(LoaderActionPlugin):
"""Simple action plugin.
This action will show exactly one action item defined by attributes
on the class.
Attributes:
label: Label of the action item.
order: Order of the action item.
group_label: Label of the group to which the action belongs.
icon: Icon definition shown next to label.
"""
label: Optional[str] = None
order: int = 0
group_label: Optional[str] = None
icon: Optional[dict[str, Any]] = None
@abstractmethod
def is_compatible(self, selection: LoaderActionSelection) -> bool:
"""Check if plugin is compatible with selection.
Args:
selection (LoaderActionSelection): Selection information.
Returns:
bool: True if plugin is compatible with selection.
"""
pass
@abstractmethod
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
"""Process action based on selection.
Args:
selection (LoaderActionSelection): Selection information.
form_values (dict[str, Any]): Values from a form if there are any.
Returns:
Optional[LoaderActionResult]: Result of the action.
"""
pass
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
if self.is_compatible(selection):
label = self.label or self.__class__.__name__
return [
LoaderActionItem(
label=label,
order=self.order,
group_label=self.group_label,
icon=self.icon,
)
]
return []
def execute_action(
self,
selection: LoaderActionSelection,
data: Optional[DataType],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
return self.execute_simple_action(selection, form_values)

View file

@ -0,0 +1,60 @@
from dataclasses import dataclass
from typing import Optional, Any
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
serialize_attr_defs,
deserialize_attr_defs,
)
@dataclass
class ActionForm:
"""Form for loader action.
If an action needs to collect information from a user before or during of
the action execution, it can return a response with a form. When the
form is submitted, a new execution of the action is triggered.
It is also possible to just show a label message without the submit
button to make sure the user has seen the message.
Attributes:
title (str): Title of the form -> title of the window.
fields (list[AbstractAttrDef]): Fields of the form.
submit_label (Optional[str]): Label of the submit button. Is hidden
if is set to None.
submit_icon (Optional[dict[str, Any]]): Icon definition of the submit
button.
cancel_label (Optional[str]): Label of the cancel button. Is hidden
if is set to None. User can still close the window tho.
cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel
button.
"""
title: str
fields: list[AbstractAttrDef]
submit_label: Optional[str] = "Submit"
submit_icon: Optional[dict[str, Any]] = None
cancel_label: Optional[str] = "Cancel"
cancel_icon: Optional[dict[str, Any]] = None
def to_json_data(self) -> dict[str, Any]:
fields = self.fields
if fields is not None:
fields = serialize_attr_defs(fields)
return {
"title": self.title,
"fields": fields,
"submit_label": self.submit_label,
"submit_icon": self.submit_icon,
"cancel_label": self.cancel_label,
"cancel_icon": self.cancel_icon,
}
@classmethod
def from_json_data(cls, data: dict[str, Any]) -> "ActionForm":
fields = data["fields"]
if fields is not None:
data["fields"] = deserialize_attr_defs(fields)
return cls(**data)

View file

@ -0,0 +1,100 @@
from __future__ import annotations
import uuid
from typing import Any
from ayon_core.lib.attribute_definitions import (
AbstractAttrDef,
UILabelDef,
BoolDef,
TextDef,
NumberDef,
EnumDef,
HiddenDef,
)
def webaction_fields_to_attribute_defs(
fields: list[dict[str, Any]]
) -> list[AbstractAttrDef]:
"""Helper function to convert fields definition from webactions form.
Convert form fields to attribute definitions to be able to display them
using attribute definitions.
Args:
fields (list[dict[str, Any]]): Fields from webaction form.
Returns:
list[AbstractAttrDef]: Converted attribute definitions.
"""
attr_defs = []
for field in fields:
field_type = field["type"]
attr_def = None
if field_type == "label":
label = field.get("value")
if label is None:
label = field.get("text")
attr_def = UILabelDef(
label, key=uuid.uuid4().hex
)
elif field_type == "boolean":
value = field["value"]
if isinstance(value, str):
value = value.lower() == "true"
attr_def = BoolDef(
field["name"],
default=value,
label=field.get("label"),
)
elif field_type == "text":
attr_def = TextDef(
field["name"],
default=field.get("value"),
label=field.get("label"),
placeholder=field.get("placeholder"),
multiline=field.get("multiline", False),
regex=field.get("regex"),
# syntax=field["syntax"],
)
elif field_type in ("integer", "float"):
value = field.get("value")
if isinstance(value, str):
if field_type == "integer":
value = int(value)
else:
value = float(value)
attr_def = NumberDef(
field["name"],
default=value,
label=field.get("label"),
decimals=0 if field_type == "integer" else 5,
# placeholder=field.get("placeholder"),
minimum=field.get("min"),
maximum=field.get("max"),
)
elif field_type in ("select", "multiselect"):
attr_def = EnumDef(
field["name"],
items=field["options"],
default=field.get("value"),
label=field.get("label"),
multiselection=field_type == "multiselect",
)
elif field_type == "hidden":
attr_def = HiddenDef(
field["name"],
default=field.get("value"),
)
if attr_def is None:
print(f"Unknown config field type: {field_type}")
attr_def = UILabelDef(
f"Unknown field type '{field_type}",
key=uuid.uuid4().hex
)
attr_defs.append(attr_def)
return attr_defs

View file

@ -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):

View file

@ -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(
[

View file

@ -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",

View file

@ -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,64 +674,35 @@ 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.
"""
try:
template = repre_entity["attrib"]["template"]
except KeyError:
raise InvalidRepresentationContext((
"Representation document does not"
" contain template in data ('data.template')"
))
try:
context = 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))
))
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
return get_representation_path(
anatomy.project_name,
repre_entity,
anatomy=anatomy,
)
def path_from_representation():
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:
@ -739,71 +713,209 @@ def get_representation_path(representation, root=None):
_fix_representation_context_compatibility(context)
context["root"] = root
context["root"] = roots
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
return path.normalized()
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
def _backwards_compatibility_repre_path(func):
"""Wrapper handling backwards compatibility of 'get_representation_path'.
path = representation["attrib"]["path"]
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
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.
if os.path.exists(path):
return os.path.normpath(path)
The wrapper handles passed arguments, and based on kwargs and types
of the arguments will call the function which relates to
the arguments.
dir_path, file_name = os.path.split(path)
if not os.path.exists(dir_path):
return None
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)
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("%")
The plan to remove backwards compatibility is 1.1.2026.
if not file_name_items:
return None
"""
# 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)
filename_start = file_name_items[0]
@wraps(func)
def inner(*args, **kwargs):
from ayon_core.pipeline import get_current_project_name
for _file in os.listdir(dir_path):
if _file.startswith(filename_start) and _file.endswith(ext):
return os.path.normpath(path)
# 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)
return (
path_from_representation() or path_from_data()
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 as exc:
raise InvalidRepresentationContext(
"Failed to receive template from representation entity."
) from exc
try:
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(
"Failed to resolve representation template with available data."
) from exc
return path.normalized()
def get_representation_path_by_names(
project_name: str,
folder_path: str,
product_name: str,
version_name: str,
version_name: Union[int, str],
representation_name: str,
anatomy: Optional[Anatomy] = None) -> Optional[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,14 +932,13 @@ 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(
@ -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"])

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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))

View file

@ -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_()

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,122 @@
import os
import collections
from typing import Optional, Any
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.actions import (
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
class CopyFileActionPlugin(LoaderActionPlugin):
"""Copy published file path to clipboard"""
identifier = "core.copy-action"
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
repres = []
if selection.selected_type == "representation":
repres = selection.entities.get_representations(
selection.selected_ids
)
if selection.selected_type == "version":
repres = selection.entities.get_versions_representations(
selection.selected_ids
)
output = []
if not repres:
return output
repre_ids_by_name = collections.defaultdict(set)
for repre in repres:
repre_ids_by_name[repre["name"]].add(repre["id"])
for repre_name, repre_ids in repre_ids_by_name.items():
repre_id = next(iter(repre_ids), None)
if not repre_id:
continue
output.append(
LoaderActionItem(
label=repre_name,
order=32,
group_label="Copy file path",
data={
"representation_id": repre_id,
"action": "copy-path",
},
icon={
"type": "material-symbols",
"name": "content_copy",
"color": "#999999",
}
)
)
output.append(
LoaderActionItem(
label=repre_name,
order=33,
group_label="Copy file",
data={
"representation_id": repre_id,
"action": "copy-file",
},
icon={
"type": "material-symbols",
"name": "file_copy",
"color": "#999999",
}
)
)
return output
def execute_action(
self,
selection: LoaderActionSelection,
data: dict,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
from qtpy import QtWidgets, QtCore
action = data["action"]
repre_id = data["representation_id"]
repre = next(iter(selection.entities.get_representations({repre_id})))
path = get_representation_path_with_anatomy(
repre, selection.get_project_anatomy()
)
self.log.info(f"Added file path to clipboard: {path}")
clipboard = QtWidgets.QApplication.clipboard()
if not clipboard:
return LoaderActionResult(
"Failed to copy file path to clipboard.",
success=False,
)
if action == "copy-path":
# Set to Clipboard
clipboard.setText(os.path.normpath(path))
return LoaderActionResult(
"Path stored to clipboard...",
success=True,
)
# Build mime data for clipboard
data = QtCore.QMimeData()
url = QtCore.QUrl.fromLocalFile(path)
data.setUrls([url])
# Set to Clipboard
clipboard.setMimeData(data)
return LoaderActionResult(
"File added to clipboard...",
success=True,
)

View file

@ -0,0 +1,388 @@
from __future__ import annotations
import os
import collections
import json
import shutil
from typing import Optional, Any
from ayon_api.operations import OperationsSession
from ayon_core.lib import (
format_file_size,
AbstractAttrDef,
NumberDef,
BoolDef,
TextDef,
UILabelDef,
)
from ayon_core.pipeline import Anatomy
from ayon_core.pipeline.actions import (
ActionForm,
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
class DeleteOldVersions(LoaderActionPlugin):
"""Deletes specific number of old version"""
is_multiple_contexts_compatible = True
sequence_splitter = "__sequence_splitter__"
requires_confirmation = True
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
# Do not show in hosts
if self.host_name is not None:
return []
versions = selection.get_selected_version_entities()
if not versions:
return []
product_ids = {
version["productId"]
for version in versions
}
return [
LoaderActionItem(
label="Delete Versions",
order=35,
data={
"product_ids": list(product_ids),
"action": "delete-versions",
},
icon={
"type": "material-symbols",
"name": "delete",
"color": "#d8d8d8",
}
),
LoaderActionItem(
label="Calculate Versions size",
order=34,
data={
"product_ids": list(product_ids),
"action": "calculate-versions-size",
},
icon={
"type": "material-symbols",
"name": "auto_delete",
"color": "#d8d8d8",
}
)
]
def execute_action(
self,
selection: LoaderActionSelection,
data: dict[str, Any],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
step = form_values.get("step")
action = data["action"]
versions_to_keep = form_values.get("versions_to_keep")
remove_publish_folder = form_values.get("remove_publish_folder")
if step is None:
return self._first_step(
action,
versions_to_keep,
remove_publish_folder,
)
if versions_to_keep is None:
versions_to_keep = 2
if remove_publish_folder is None:
remove_publish_folder = False
product_ids = data["product_ids"]
if step == "prepare-data":
return self._prepare_data_step(
action,
versions_to_keep,
remove_publish_folder,
product_ids,
selection,
)
if step == "delete-versions":
return self._delete_versions_step(
selection.project_name, form_values
)
return None
def _first_step(
self,
action: str,
versions_to_keep: Optional[int],
remove_publish_folder: Optional[bool],
) -> LoaderActionResult:
fields: list[AbstractAttrDef] = [
TextDef(
"step",
visible=False,
),
NumberDef(
"versions_to_keep",
label="Versions to keep",
minimum=0,
default=2,
),
]
if action == "delete-versions":
fields.append(
BoolDef(
"remove_publish_folder",
label="Remove publish folder",
default=False,
)
)
form_values = {
key: value
for key, value in (
("remove_publish_folder", remove_publish_folder),
("versions_to_keep", versions_to_keep),
)
if value is not None
}
form_values["step"] = "prepare-data"
return LoaderActionResult(
form=ActionForm(
title="Delete Old Versions",
fields=fields,
),
form_values=form_values
)
def _prepare_data_step(
self,
action: str,
versions_to_keep: int,
remove_publish_folder: bool,
entity_ids: set[str],
selection: LoaderActionSelection,
):
versions_by_product_id = collections.defaultdict(list)
for version in selection.entities.get_products_versions(entity_ids):
# Keep hero version
if versions_to_keep != 0 and version["version"] < 0:
continue
versions_by_product_id[version["productId"]].append(version)
versions_to_delete = []
for product_id, versions in versions_by_product_id.items():
if versions_to_keep == 0:
versions_to_delete.extend(versions)
continue
if len(versions) <= versions_to_keep:
continue
versions.sort(key=lambda v: v["version"])
for _ in range(versions_to_keep):
if not versions:
break
versions.pop(-1)
versions_to_delete.extend(versions)
self.log.debug(
f"Collected versions to delete ({len(versions_to_delete)})"
)
version_ids = {
version["id"]
for version in versions_to_delete
}
if not version_ids:
return LoaderActionResult(
message="Skipping. Nothing to delete.",
success=False,
)
project = selection.entities.get_project()
anatomy = Anatomy(project["name"], project_entity=project)
repres = selection.entities.get_versions_representations(version_ids)
self.log.debug(
f"Collected representations to remove ({len(repres)})"
)
filepaths_by_repre_id = {}
repre_ids_by_version_id = {
version_id: []
for version_id in version_ids
}
for repre in repres:
repre_ids_by_version_id[repre["versionId"]].append(repre["id"])
filepaths_by_repre_id[repre["id"]] = [
anatomy.fill_root(repre_file["path"])
for repre_file in repre["files"]
]
size = 0
for filepaths in filepaths_by_repre_id.values():
for filepath in filepaths:
if os.path.exists(filepath):
size += os.path.getsize(filepath)
if action == "calculate-versions-size":
return LoaderActionResult(
message="Calculated size",
success=True,
form=ActionForm(
title="Calculated versions size",
fields=[
UILabelDef(
f"Total size of files: {format_file_size(size)}"
),
],
submit_label=None,
cancel_label="Close",
),
)
form, form_values = self._get_delete_form(
size,
remove_publish_folder,
list(version_ids),
repre_ids_by_version_id,
filepaths_by_repre_id,
)
return LoaderActionResult(
form=form,
form_values=form_values
)
def _delete_versions_step(
self, project_name: str, form_values: dict[str, Any]
) -> LoaderActionResult:
delete_data = json.loads(form_values["delete_data"])
remove_publish_folder = form_values["remove_publish_folder"]
if form_values["delete_value"].lower() != "delete":
size = delete_data["size"]
form, form_values = self._get_delete_form(
size,
remove_publish_folder,
delete_data["version_ids"],
delete_data["repre_ids_by_version_id"],
delete_data["filepaths_by_repre_id"],
True,
)
return LoaderActionResult(
form=form,
form_values=form_values,
)
version_ids = delete_data["version_ids"]
repre_ids_by_version_id = delete_data["repre_ids_by_version_id"]
filepaths_by_repre_id = delete_data["filepaths_by_repre_id"]
op_session = OperationsSession()
total_versions = len(version_ids)
try:
for version_idx, version_id in enumerate(version_ids):
self.log.info(
f"Progressing version {version_idx + 1}/{total_versions}"
)
for repre_id in repre_ids_by_version_id[version_id]:
for filepath in filepaths_by_repre_id[repre_id]:
publish_folder = os.path.dirname(filepath)
if remove_publish_folder:
if os.path.exists(publish_folder):
shutil.rmtree(
publish_folder, ignore_errors=True
)
continue
if os.path.exists(filepath):
os.remove(filepath)
op_session.delete_entity(
project_name, "representation", repre_id
)
op_session.delete_entity(
project_name, "version", version_id
)
self.log.info("All done")
except Exception:
self.log.error("Failed to delete versions.", exc_info=True)
return LoaderActionResult(
message="Failed to delete versions.",
success=False,
)
finally:
op_session.commit()
return LoaderActionResult(
message="Deleted versions",
success=True,
)
def _get_delete_form(
self,
size: int,
remove_publish_folder: bool,
version_ids: list[str],
repre_ids_by_version_id: dict[str, list[str]],
filepaths_by_repre_id: dict[str, list[str]],
repeated: bool = False,
) -> tuple[ActionForm, dict[str, Any]]:
versions_len = len(repre_ids_by_version_id)
fields = [
UILabelDef(
f"Going to delete {versions_len} versions<br/>"
f"- total size of files: {format_file_size(size)}<br/>"
),
UILabelDef("Are you sure you want to continue?"),
TextDef(
"delete_value",
placeholder="Type 'delete' to confirm...",
),
]
if repeated:
fields.append(UILabelDef(
"*Please fill in '**delete**' to confirm deletion.*"
))
fields.extend([
TextDef(
"delete_data",
visible=False,
),
TextDef(
"step",
visible=False,
),
BoolDef(
"remove_publish_folder",
label="Remove publish folder",
default=False,
visible=False,
)
])
form = ActionForm(
title="Delete versions",
submit_label="Delete",
cancel_label="Close",
fields=fields,
)
form_values = {
"delete_data": json.dumps({
"size": size,
"version_ids": version_ids,
"repre_ids_by_version_id": repre_ids_by_version_id,
"filepaths_by_repre_id": filepaths_by_repre_id,
}),
"step": "delete-versions",
"remove_publish_folder": remove_publish_folder,
}
return form, form_values

View file

@ -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()
)
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,
)
msgBox.exec_()
def load(self, contexts, name=None, namespace=None, options=None):
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
))

View file

@ -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(

View file

@ -0,0 +1,360 @@
import os
import sys
import subprocess
import platform
import collections
import ctypes
from typing import Optional, Any, Callable
from ayon_core.pipeline.load import get_representation_path_with_anatomy
from ayon_core.pipeline.actions import (
LoaderActionPlugin,
LoaderActionItem,
LoaderActionSelection,
LoaderActionResult,
)
WINDOWS_USER_REG_PATH = (
r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts"
r"\{ext}\UserChoice"
)
class _Cache:
"""Cache extensions information.
Notes:
The cache is cleared when loader tool is refreshed so it might be
moved to other place which is not cleared on refresh.
"""
supported_exts: set[str] = set()
unsupported_exts: set[str] = set()
@classmethod
def is_supported(cls, ext: str) -> bool:
return ext in cls.supported_exts
@classmethod
def already_checked(cls, ext: str) -> bool:
return (
ext in cls.supported_exts
or ext in cls.unsupported_exts
)
@classmethod
def set_ext_support(cls, ext: str, supported: bool) -> None:
if supported:
cls.supported_exts.add(ext)
else:
cls.unsupported_exts.add(ext)
def _extension_has_assigned_app_windows(ext: str) -> bool:
import winreg
progid = None
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
WINDOWS_USER_REG_PATH.format(ext=ext),
) as k:
progid, _ = winreg.QueryValueEx(k, "ProgId")
except OSError:
pass
if progid:
return True
try:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k:
progid = winreg.QueryValueEx(k, None)[0]
except OSError:
pass
return bool(progid)
def _linux_find_desktop_file(desktop: str) -> Optional[str]:
for dirpath in (
os.path.expanduser("~/.local/share/applications"),
"/usr/share/applications",
"/usr/local/share/applications",
):
path = os.path.join(dirpath, desktop)
if os.path.isfile(path):
return path
return None
def _extension_has_assigned_app_linux(ext: str) -> bool:
import mimetypes
mime, _ = mimetypes.guess_type(f"file{ext}")
if not mime:
return False
try:
# xdg-mime query default <mime>
desktop = subprocess.check_output(
["xdg-mime", "query", "default", mime],
text=True
).strip() or None
except Exception:
desktop = None
if not desktop:
return False
desktop_path = _linux_find_desktop_file(desktop)
if not desktop_path:
return False
if desktop_path and os.path.isfile(desktop_path):
return True
return False
def _extension_has_assigned_app_macos(ext: str) -> bool:
# Uses CoreServices/LaunchServices and Uniform Type Identifiers via
# ctypes.
# Steps: ext -> UTI -> default handler bundle id for role 'all'.
cf = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
)
ls = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreServices.framework/Frameworks"
"/LaunchServices.framework/LaunchServices"
)
# CFType/CFString helpers
CFStringRef = ctypes.c_void_p
CFAllocatorRef = ctypes.c_void_p
CFIndex = ctypes.c_long
kCFStringEncodingUTF8 = 0x08000100
cf.CFStringCreateWithCString.argtypes = [
CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32
]
cf.CFStringCreateWithCString.restype = CFStringRef
cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32]
cf.CFStringGetCStringPtr.restype = ctypes.c_char_p
cf.CFStringGetCString.argtypes = [
CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32
]
cf.CFStringGetCString.restype = ctypes.c_bool
cf.CFRelease.argtypes = [ctypes.c_void_p]
cf.CFRelease.restype = None
try:
UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary(
"/System/Library/Frameworks/CoreServices.framework/CoreServices"
).UTTypeCreatePreferredIdentifierForTag
except OSError:
# Fallback path (older systems)
UTTypeCreatePreferredIdentifierForTag = (
ls.UTTypeCreatePreferredIdentifierForTag
)
UTTypeCreatePreferredIdentifierForTag.argtypes = [
CFStringRef, CFStringRef, CFStringRef
]
UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef
LSRolesMask = ctypes.c_uint
kLSRolesAll = 0xFFFFFFFF
ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [
CFStringRef, LSRolesMask
]
ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef
def cfstr(py_s: str) -> CFStringRef:
return cf.CFStringCreateWithCString(
None, py_s.encode("utf-8"), kCFStringEncodingUTF8
)
def to_pystr(cf_s: CFStringRef) -> Optional[str]:
if not cf_s:
return None
# Try fast pointer
ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8)
if ptr:
return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
# Fallback buffer
buf_size = 1024
buf = ctypes.create_string_buffer(buf_size)
ok = cf.CFStringGetCString(
cf_s, buf, buf_size, kCFStringEncodingUTF8
)
if ok:
return buf.value.decode("utf-8")
return None
# Convert extension (without dot) to UTI
tag_class = cfstr("public.filename-extension")
tag_value = cfstr(ext.lstrip("."))
uti_ref = UTTypeCreatePreferredIdentifierForTag(
tag_class, tag_value, None
)
# Clean up temporary CFStrings
for ref in (tag_class, tag_value):
if ref:
cf.CFRelease(ref)
bundle_id = None
if uti_ref:
# Get default handler for the UTI
default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType(
uti_ref, kLSRolesAll
)
bundle_id = to_pystr(default_bundle_ref)
if default_bundle_ref:
cf.CFRelease(default_bundle_ref)
cf.CFRelease(uti_ref)
return bundle_id is not None
def _filter_supported_exts(
extensions: set[str], test_func: Callable
) -> set[str]:
filtered_exs: set[str] = set()
for ext in extensions:
if not _Cache.already_checked(ext):
_Cache.set_ext_support(ext, test_func(ext))
if _Cache.is_supported(ext):
filtered_exs.add(ext)
return filtered_exs
def filter_supported_exts(extensions: set[str]) -> set[str]:
if not extensions:
return set()
platform_name = platform.system().lower()
if platform_name == "windows":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_windows
)
if platform_name == "linux":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_linux
)
if platform_name == "darwin":
return _filter_supported_exts(
extensions, _extension_has_assigned_app_macos
)
return set()
def open_file(filepath: str) -> None:
"""Open file with system default executable"""
if sys.platform.startswith("darwin"):
subprocess.call(("open", filepath))
elif os.name == "nt":
os.startfile(filepath)
elif os.name == "posix":
subprocess.call(("xdg-open", filepath))
class OpenFileAction(LoaderActionPlugin):
"""Open Image Sequence or Video with system default"""
identifier = "core.open-file"
def get_action_items(
self, selection: LoaderActionSelection
) -> list[LoaderActionItem]:
repres = []
if selection.selected_type == "representation":
repres = selection.entities.get_representations(
selection.selected_ids
)
if selection.selected_type == "version":
repres = selection.entities.get_versions_representations(
selection.selected_ids
)
if not repres:
return []
repres_by_ext = collections.defaultdict(list)
for repre in repres:
repre_context = repre.get("context")
if not repre_context:
continue
ext = repre_context.get("ext")
if not ext:
path = repre["attrib"].get("path")
if path:
ext = os.path.splitext(path)[1]
if ext:
ext = ext.lower()
if not ext.startswith("."):
ext = f".{ext}"
repres_by_ext[ext.lower()].append(repre)
if not repres_by_ext:
return []
filtered_exts = filter_supported_exts(set(repres_by_ext))
repre_ids_by_name = collections.defaultdict(set)
for ext in filtered_exts:
for repre in repres_by_ext[ext]:
repre_ids_by_name[repre["name"]].add(repre["id"])
return [
LoaderActionItem(
label=repre_name,
group_label="Open file",
order=30,
data={"representation_ids": list(repre_ids)},
icon={
"type": "material-symbols",
"name": "file_open",
"color": "#ffffff",
}
)
for repre_name, repre_ids in repre_ids_by_name.items()
]
def execute_action(
self,
selection: LoaderActionSelection,
data: dict[str, Any],
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
path = None
repre_path = None
repre_ids = data["representation_ids"]
for repre in selection.entities.get_representations(repre_ids):
repre_path = get_representation_path_with_anatomy(
repre, selection.get_project_anatomy()
)
if os.path.exists(repre_path):
path = repre_path
break
if path is None:
if repre_path is None:
return LoaderActionResult(
"Failed to fill representation path...",
success=False,
)
return LoaderActionResult(
"File to open was not found...",
success=False,
)
self.log.info(f"Opening: {path}")
open_file(path)
return LoaderActionResult(
"File was opened...",
success=True,
)

View file

@ -0,0 +1,69 @@
import os
from typing import Optional, Any
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import get_ayon_launcher_args, run_detached_process
from ayon_core.pipeline.actions import (
LoaderSimpleActionPlugin,
LoaderActionSelection,
LoaderActionResult,
)
class PushToProject(LoaderSimpleActionPlugin):
identifier = "core.push-to-project"
label = "Push to project"
order = 35
icon = {
"type": "material-symbols",
"name": "send",
"color": "#d8d8d8",
}
def is_compatible(
self, selection: LoaderActionSelection
) -> bool:
if not selection.versions_selected():
return False
version_ids = set(selection.selected_ids)
product_ids = {
product["id"]
for product in selection.entities.get_versions_products(
version_ids
)
}
folder_ids = {
folder["id"]
for folder in selection.entities.get_products_folders(
product_ids
)
}
if len(folder_ids) == 1:
return True
return False
def execute_simple_action(
self,
selection: LoaderActionSelection,
form_values: dict[str, Any],
) -> Optional[LoaderActionResult]:
push_tool_script_path = os.path.join(
AYON_CORE_ROOT,
"tools",
"push_to_project",
"main.py"
)
args = get_ayon_launcher_args(
push_tool_script_path,
"--project", selection.project_name,
"--versions", ",".join(selection.selected_ids)
)
run_detached_process(args)
return LoaderActionResult(
message="Push to project tool opened...",
success=True,
)

View file

@ -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"
)

View file

@ -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()

View file

@ -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,17 +174,24 @@ 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,
@ -192,6 +205,18 @@ class ExtractOIIOTranscode(publish.Extractor):
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
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

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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"],

View file

@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.addWidget(attrs_widget, 0)
main_layout.addStretch(1)
main_layout.addWidget(btns_widget, 0)

View file

@ -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:

View file

@ -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

View file

@ -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.

View file

@ -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")

View file

@ -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,

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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.
Args:
project_name (str): Project name.
version_ids (Iterable[str]): Version ids.
Returns:
list[ActionItem]: List of action items.
"""
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()
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_versions(
project_name,
version_ids
)
return self._get_action_items_for_contexts(
) = self._contexts_for_representations(project_name, entity_ids)
if entity_type == "version":
(
version_context_by_id,
repre_context_by_id
) = self._contexts_for_versions(project_name, entity_ids)
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,
if identifier != LOADER_PLUGIN_ID:
result = None
crashed = False
try:
result = self._loader_actions.execute_action(
identifier=identifier,
selection=LoaderActionSelection(
project_name,
representation_ids,
selected_ids,
selected_entity_type,
),
data=data,
form_values=form_values,
)
elif version_ids is not None:
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,
)
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}

View file

@ -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:

View file

@ -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):

View file

@ -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,6 +79,17 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent):
action.setData(item_id)
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)

View file

@ -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):
"""

View file

@ -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):

View file

@ -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={},
)

View file

@ -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()

View file

@ -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()

View file

@ -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"]:

View file

@ -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()

View file

@ -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

View file

@ -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"<span>{CONTEXT_LABEL}</span>", 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"<b>{folder_name}</b>"
if task_name:
sublabel += f" - <i>{task_name}</i>"
return sublabel
def _update_product_name(self):
variant = self.instance.variant
product_name = self.instance.product_name
label = self.instance.label
folder_path = self.instance.folder_path
task_name = self.instance.task_name
if (
variant == self._last_variant
and product_name == self._last_product_name
and label == self._last_label
and folder_path == self._last_folder_path
and task_name == self._last_task_name
):
return
self._last_variant = variant
self._last_product_name = product_name
self._last_label = label
self._last_folder_path = folder_path
self._last_task_name = task_name
# Make `variant` bold
label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts:
for part in found_parts:
replacement = "<b>{}</b>".format(part)
replacement = f"<b>{part}</b>"
label = label.replace(part, replacement)
label = f"<span>{label}</span>"
sublabel = self._get_card_widget_sub_label(folder_path, task_name)
if sublabel:
label += f"<br/><span style=\"font-size: 8pt;\">{sublabel}</span>"
self._label_widget.setText(label)
# HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag
@ -702,11 +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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -678,11 +678,6 @@ 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()

View file

@ -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",

View file

@ -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()

View file

@ -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())

View file

@ -53,12 +53,6 @@ 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()
@ -554,11 +548,17 @@ class _IconsCache:
elif icon_type == "ayon_url":
url = icon_def["url"].lstrip("/")
url = f"{ayon_api.get_base_url()}/{url}"
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")

View file

@ -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():
if isinstance(action, OptionalAction):
action.set_highlight(action is active, event.globalPos())
super(OptionalMenu, self).mouseMoveEvent(event)
super().mouseMoveEvent(event)
def leaveEvent(self, event):
"""Remove highlight from all actions"""
for action in self.actions():
if isinstance(action, OptionalAction):
action.set_highlight(False)
super(OptionalMenu, self).leaveEvent(event)
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")

View file

@ -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.

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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):
@ -10,7 +11,7 @@ def file_size_to_string(file_size):
size_ending_mapping = {
"KB": 1024**1,
"MB": 1024**2,
"GB": 1024 ** 3
"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)
)
lines = []
if size_value is not None:
size_value = file_size_to_string(size_value)
lines.append(f"<b>Size:</b><br/>{size_value}")
# Add version comment for published workfiles
if comment:
lines.append(f"<b>Comment:</b><br/>{comment}")
if created_by or file_created:
lines.append("<b>Created:</b>")
if created_by:
lines.append(convert_username(created_by))
if file_created:
created_lines.append(file_created.strftime(datetime_format))
lines.append(file_created.strftime(datetime_format))
if created_lines:
created_lines.insert(0, "<b>Created:</b>")
modified_lines = []
if workfile_info.updated_by:
modified_lines.append(
convert_username(workfile_info.updated_by)
)
if modification_time:
modified_lines.append(
modification_time.strftime(datetime_format)
)
if modified_lines:
modified_lines.insert(0, "<b>Modified:</b>")
lines = (
"<b>Size:</b>",
size_value,
"<br/>".join(created_lines),
"<br/>".join(modified_lines),
)
self._orig_description = description
self._description_input.setPlainText(description)
if updated_by or file_modified:
lines.append("<b>Modified:</b>")
if updated_by:
lines.append(convert_username(updated_by))
if file_modified:
lines.append(file_modified.strftime(datetime_format))
# Set as empty string
self._details_input.setPlainText("")

View file

@ -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
)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.6.7+dev"
__version__ = "1.6.11+dev"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.6.7+dev"
version = "1.6.11+dev"
client_dir = "ayon_core"

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.6.7+dev"
version = "1.6.11+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"

View file

@ -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."""

View file

@ -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}"
}
],

View file

@ -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",
},
},
])