Merge branch 'develop' into enhancement/1416-loader-actions

# Conflicts:
#	client/ayon_core/tools/loader/ui/window.py
This commit is contained in:
Jakub Trllo 2025-11-12 18:32:48 +01:00
commit 30dda67e7c
50 changed files with 1554 additions and 611 deletions

View file

@ -35,6 +35,8 @@ body:
label: Version label: Version
description: What version are you running? Look to AYON Tray description: What version are you running? Look to AYON Tray
options: options:
- 1.6.9
- 1.6.8
- 1.6.7 - 1.6.7
- 1.6.6 - 1.6.6
- 1.6.5 - 1.6.5

View file

@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef):
if value is None: if value is None:
return copy.deepcopy(self.default) 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: def is_value_valid(self, value: Any) -> bool:
"""Check if item is available in possible values.""" """Check if item is available in possible values."""

View file

@ -110,6 +110,15 @@ def deprecated(new_destination):
return _decorator(func) 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(): def get_transcode_temp_directory():
"""Creates temporary folder for transcoding. """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: Args:
channel_names (list[str]): List of channel names. channel_names (list[str]): List of channel names.
@ -396,7 +409,6 @@ def get_review_info_by_layer_name(channel_names):
""" """
layer_names_order = [] layer_names_order = []
rgba_by_layer_name = collections.defaultdict(dict)
channels_by_layer_name = collections.defaultdict(dict) channels_by_layer_name = collections.defaultdict(dict)
for channel_name in channel_names: for channel_name in channel_names:
@ -405,45 +417,95 @@ def get_review_info_by_layer_name(channel_names):
if "." in channel_name: if "." in channel_name:
layer_name, last_part = channel_name.rsplit(".", 1) layer_name, last_part = channel_name.rsplit(".", 1)
channels_by_layer_name[layer_name][channel_name] = last_part # R, G, B, A or X, Y, Z, N, AR, AG, AB, RED, GREEN, BLUE, ALPHA
if last_part.lower() not in { channel = last_part.upper()
"r", "red", if channel not in {
"g", "green", # Detect RGBA channels
"b", "blue", "R", "G", "B", "A",
"a", "alpha" # 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 continue
if layer_name not in layer_names_order: if layer_name not in layer_names_order:
layer_names_order.append(layer_name) layer_names_order.append(layer_name)
# R, G, B or A
channel = last_part[0].upper() channels_by_layer_name[layer_name][channel] = channel_name
rgba_by_layer_name[layer_name][channel] = channel_name
# Put empty layer or 'rgba' to the beginning of the list # 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 # - 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 def _sort(_layer_name: str) -> int:
# the beginning of 'layer_names_order' -> last added will be first. # Prioritize "" layer name
for name in reversed(["", "rgba"]): # Prioritize layers with RGB channels
if name in layer_names_order: if _layer_name == "rgba":
layer_names_order.remove(name) return 0
layer_names_order.insert(0, name)
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 = [] output = []
for layer_name in layer_names_order: for layer_name in layer_names_order:
rgba_layer_info = rgba_by_layer_name[layer_name] channel_info = channels_by_layer_name[layer_name]
red = rgba_layer_info.get("R")
green = rgba_layer_info.get("G") alpha = channel_info.get("A")
blue = rgba_layer_info.get("B")
if not red or not green or not blue: # 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 continue
red = channel_info[rgb[0]]
green = channel_info[rgb[1]]
blue = channel_info[rgb[2]]
output.append({ output.append({
"name": layer_name, "name": layer_name,
"review_channels": { "review_channels": {
"R": red, "R": red,
"G": green, "G": green,
"B": blue, "B": blue,
"A": rgba_layer_info.get("A"), "A": alpha,
} }
}) })
return output 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) review_channels = get_convert_rgb_channels(channel_names)
if review_channels is None: if review_channels is None:
raise ValueError( raise MissingRGBAChannelsError(
"Couldn't find channels that can be used for conversion." "Couldn't find channels that can be used for conversion "
f"among channels: {channel_names}."
) )
red, green, blue, alpha = review_channels 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)) channels_arg += ",A={}".format(float(alpha_default))
input_channels.append("A") 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") subimages = oiio_input_info.get("subimages")
input_arg = "-i" input_arg = "-i"

View file

@ -137,6 +137,7 @@ class AttributeValues:
if value is None: if value is None:
continue continue
converted_value = attr_def.convert_value(value) converted_value = attr_def.convert_value(value)
# QUESTION Could we just use converted value all the time?
if converted_value == value: if converted_value == value:
self._data[attr_def.key] = value self._data[attr_def.key] = value
@ -245,11 +246,11 @@ class AttributeValues:
def _update(self, value): def _update(self, value):
changes = {} changes = {}
for key, value in dict(value).items(): for key, key_value in dict(value).items():
if key in self._data and self._data.get(key) == value: if key in self._data and self._data.get(key) == key_value:
continue continue
self._data[key] = value self._data[key] = key_value
changes[key] = value changes[key] = key_value
return changes return changes
def _pop(self, key, default): def _pop(self, key, default):

View file

@ -1045,7 +1045,9 @@ def get_resources(project_name, version_entity, extension=None):
filtered.append(repre_entity) filtered.append(repre_entity)
representation = filtered[0] representation = filtered[0]
directory = get_representation_path(representation) directory = get_representation_path(
project_name, representation
)
print("Source: ", directory) print("Source: ", directory)
resources = sorted( resources = sorted(
[ [

View file

@ -25,8 +25,8 @@ from .utils import (
get_loader_identifier, get_loader_identifier,
get_loaders_by_name, get_loaders_by_name,
get_representation_path_from_context,
get_representation_path, get_representation_path,
get_representation_path_from_context,
get_representation_path_with_anatomy, get_representation_path_with_anatomy,
is_compatible_loader, is_compatible_loader,
@ -85,8 +85,8 @@ __all__ = (
"get_loader_identifier", "get_loader_identifier",
"get_loaders_by_name", "get_loaders_by_name",
"get_representation_path_from_context",
"get_representation_path", "get_representation_path",
"get_representation_path_from_context",
"get_representation_path_with_anatomy", "get_representation_path_with_anatomy",
"is_compatible_loader", "is_compatible_loader",

View file

@ -1,11 +1,15 @@
from __future__ import annotations
import os import os
import uuid import uuid
import platform import warnings
import logging import logging
import inspect import inspect
import collections import collections
import numbers import numbers
from typing import Optional, Union, Any import copy
from functools import wraps
from typing import Optional, Union, Any, overload
import ayon_api import ayon_api
@ -14,9 +18,8 @@ from ayon_core.lib import (
StringTemplate, StringTemplate,
TemplateUnsolved, TemplateUnsolved,
) )
from ayon_core.pipeline import ( from ayon_core.lib.path_templates import TemplateResult
Anatomy, from ayon_core.pipeline import Anatomy
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -644,15 +647,15 @@ def get_representation_path_from_context(context):
representation = context["representation"] representation = context["representation"]
project_entity = context.get("project") project_entity = context.get("project")
root = None if project_entity:
if ( project_name = project_entity["name"]
project_entity else:
and project_entity["name"] != get_current_project_name() project_name = get_current_project_name()
): return get_representation_path(
anatomy = Anatomy(project_entity["name"]) project_name,
root = anatomy.roots representation,
project_entity=project_entity,
return get_representation_path(representation, root) )
def get_representation_path_with_anatomy(repre_entity, anatomy): def get_representation_path_with_anatomy(repre_entity, anatomy):
@ -671,139 +674,248 @@ def get_representation_path_with_anatomy(repre_entity, anatomy):
anatomy (Anatomy): Project anatomy object. anatomy (Anatomy): Project anatomy object.
Returns: Returns:
Union[None, TemplateResult]: None if path can't be received TemplateResult: Resolved representation path.
Raises: Raises:
InvalidRepresentationContext: When representation data are probably InvalidRepresentationContext: When representation data are probably
invalid or not available. invalid or not available.
""" """
return get_representation_path(
anatomy.project_name,
repre_entity,
anatomy=anatomy,
)
def get_representation_path_with_roots(
representation: dict[str, Any],
roots: dict[str, str],
) -> Optional[TemplateResult]:
"""Get filename from representation with custom root.
Args:
representation(dict): Representation entity.
roots (dict[str, str]): Roots to use.
Returns:
Optional[TemplateResult]: Resolved representation path.
"""
try:
template = representation["attrib"]["template"]
except KeyError:
return None
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = roots
path = StringTemplate.format_strict_template(
template, context
)
except (TemplateUnsolved, KeyError):
# Template references unavailable data
return None
return path.normalized()
def _backwards_compatibility_repre_path(func):
"""Wrapper handling backwards compatibility of 'get_representation_path'.
Allows 'get_representation_path' to support old and new signatures of the
function. The old signature supported passing in representation entity
and optional roots. The new signature requires the project name
to be passed. In case custom roots should be used, a dedicated function
'get_representation_path_with_roots' is available.
The wrapper handles passed arguments, and based on kwargs and types
of the arguments will call the function which relates to
the arguments.
The function is also marked with an attribute 'version' so other addons
can check if the function is using the new signature or is using
the old signature. That should allow addons to adapt to new signature.
>>> if getattr(get_representation_path, "version", None) == 2:
>>> path = get_representation_path(project_name, repre_entity)
>>> else:
>>> path = get_representation_path(repre_entity)
The plan to remove backwards compatibility is 1.1.2026.
"""
# Add an attribute to the function so addons can check if the new variant
# of the function is available.
# >>> getattr(get_representation_path, "version", None) == 2
# >>> True
setattr(func, "version", 2)
@wraps(func)
def inner(*args, **kwargs):
from ayon_core.pipeline import get_current_project_name
# Decide which variant of the function based on passed arguments
# will be used.
if args:
arg_1 = args[0]
if isinstance(arg_1, str):
return func(*args, **kwargs)
elif "project_name" in kwargs:
return func(*args, **kwargs)
warnings.warn(
(
"Used deprecated variant of 'get_representation_path'."
" Please change used arguments signature to follow"
" new definition. Will be removed 1.1.2026."
),
DeprecationWarning,
stacklevel=2,
)
# Find out which arguments were passed
if args:
representation = args[0]
else:
representation = kwargs.get("representation")
if len(args) > 1:
roots = args[1]
else:
roots = kwargs.get("root")
if roots is not None:
return get_representation_path_with_roots(
representation, roots
)
project_name = (
representation["context"].get("project", {}).get("name")
)
if project_name is None:
project_name = get_current_project_name()
return func(project_name, representation)
return inner
@overload
def get_representation_path(
representation: dict[str, Any],
root: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""DEPRECATED Get filled representation path.
Use 'get_representation_path' using the new function signature.
Args:
representation (dict[str, Any]): Representation entity.
root (Optional[dict[str, Any]): Roots to fill the path.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
pass
@overload
def get_representation_path(
project_name: str,
repre_entity: dict[str, Any],
*,
anatomy: Optional[Anatomy] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""Get filled representation path.
Args:
project_name (str): Project name.
repre_entity (dict[str, Any]): Representation entity.
anatomy (Optional[Anatomy]): Project anatomy.
project_entity (Optional[dict[str, Any]): Project entity. Is used to
initialize Anatomy and is not needed if 'anatomy' is passed in.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
pass
@_backwards_compatibility_repre_path
def get_representation_path(
project_name: str,
repre_entity: dict[str, Any],
*,
anatomy: Optional[Anatomy] = None,
project_entity: Optional[dict[str, Any]] = None,
) -> TemplateResult:
"""Get filled representation path.
Args:
project_name (str): Project name.
repre_entity (dict[str, Any]): Representation entity.
anatomy (Optional[Anatomy]): Project anatomy.
project_entity (Optional[dict[str, Any]): Project entity. Is used to
initialize Anatomy and is not needed if 'anatomy' is passed in.
Returns:
TemplateResult: Resolved path to representation.
Raises:
InvalidRepresentationContext: When representation data are probably
invalid or not available.
"""
if anatomy is None:
anatomy = Anatomy(project_name, project_entity=project_entity)
try: try:
template = repre_entity["attrib"]["template"] template = repre_entity["attrib"]["template"]
except KeyError: except KeyError as exc:
raise InvalidRepresentationContext(( raise InvalidRepresentationContext(
"Representation document does not" "Failed to receive template from representation entity."
" contain template in data ('data.template')" ) from exc
))
try: try:
context = repre_entity["context"] context = copy.deepcopy(repre_entity["context"])
_fix_representation_context_compatibility(context) _fix_representation_context_compatibility(context)
context["root"] = anatomy.roots context["root"] = anatomy.roots
path = StringTemplate.format_strict_template(template, context) path = StringTemplate.format_strict_template(template, context)
except TemplateUnsolved as exc: except TemplateUnsolved as exc:
raise InvalidRepresentationContext(( raise InvalidRepresentationContext(
"Couldn't resolve representation template with available data." "Failed to resolve representation template with available data."
" Reason: {}".format(str(exc)) ) from exc
))
return path.normalized() return path.normalized()
def get_representation_path(representation, root=None):
"""Get filename from representation document
There are three ways of getting the path from representation which are
tried in following sequence until successful.
1. Get template from representation['data']['template'] and data from
representation['context']. Then format template with the data.
2. Get template from project['config'] and format it with default data set
3. Get representation['data']['path'] and use it directly
Args:
representation(dict): representation document from the database
Returns:
str: fullpath of the representation
"""
if root is None:
from ayon_core.pipeline import get_current_project_name, Anatomy
anatomy = Anatomy(get_current_project_name())
return get_representation_path_with_anatomy(
representation, anatomy
)
def path_from_representation():
try:
template = representation["attrib"]["template"]
except KeyError:
return None
try:
context = representation["context"]
_fix_representation_context_compatibility(context)
context["root"] = root
path = StringTemplate.format_strict_template(
template, context
)
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
except (TemplateUnsolved, KeyError):
# Template references unavailable data
return None
if not path:
return path
normalized_path = os.path.normpath(path)
if os.path.exists(normalized_path):
return normalized_path
return path
def path_from_data():
if "path" not in representation["attrib"]:
return None
path = representation["attrib"]["path"]
# Force replacing backslashes with forward slashed if not on
# windows
if platform.system().lower() != "windows":
path = path.replace("\\", "/")
if os.path.exists(path):
return os.path.normpath(path)
dir_path, file_name = os.path.split(path)
if not os.path.exists(dir_path):
return None
base_name, ext = os.path.splitext(file_name)
file_name_items = None
if "#" in base_name:
file_name_items = [part for part in base_name.split("#") if part]
elif "%" in base_name:
file_name_items = base_name.split("%")
if not file_name_items:
return None
filename_start = file_name_items[0]
for _file in os.listdir(dir_path):
if _file.startswith(filename_start) and _file.endswith(ext):
return os.path.normpath(path)
return (
path_from_representation() or path_from_data()
)
def get_representation_path_by_names( def get_representation_path_by_names(
project_name: str, project_name: str,
folder_path: str, folder_path: str,
product_name: str, product_name: str,
version_name: str, version_name: Union[int, str],
representation_name: 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. """Get (latest) filepath for representation for folder and product.
See `get_representation_by_names` for more details. See `get_representation_by_names` for more details.
@ -820,22 +932,21 @@ def get_representation_path_by_names(
representation_name representation_name
) )
if not representation: if not representation:
return return None
if not anatomy: return get_representation_path(
anatomy = Anatomy(project_name) project_name,
representation,
if representation: anatomy=anatomy,
path = get_representation_path_with_anatomy(representation, anatomy) )
return str(path).replace("\\", "/")
def get_representation_by_names( def get_representation_by_names(
project_name: str, project_name: str,
folder_path: str, folder_path: str,
product_name: str, product_name: str,
version_name: Union[int, str], version_name: Union[int, str],
representation_name: str, representation_name: str,
) -> Optional[dict]: ) -> Optional[dict]:
"""Get representation entity for asset and subset. """Get representation entity for asset and subset.
@ -852,7 +963,7 @@ def get_representation_by_names(
folder_entity = ayon_api.get_folder_by_path( folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields=["id"]) project_name, folder_path, fields=["id"])
if not folder_entity: if not folder_entity:
return return None
if isinstance(product_name, dict) and "name" in product_name: if isinstance(product_name, dict) and "name" in product_name:
# Allow explicitly passing subset document # Allow explicitly passing subset document
@ -864,7 +975,7 @@ def get_representation_by_names(
folder_id=folder_entity["id"], folder_id=folder_entity["id"],
fields=["id"]) fields=["id"])
if not product_entity: if not product_entity:
return return None
if version_name == "hero": if version_name == "hero":
version_entity = ayon_api.get_hero_version_by_product_id( 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( version_entity = ayon_api.get_version_by_name(
project_name, version_name, product_id=product_entity["id"]) project_name, version_name, product_id=product_entity["id"])
if not version_entity: if not version_entity:
return return None
return ayon_api.get_representation_by_name( return ayon_api.get_representation_by_name(
project_name, representation_name, version_id=version_entity["id"]) project_name, representation_name, version_id=version_entity["id"])

View file

@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC):
self._loaders_by_name = get_loaders_by_name() self._loaders_by_name = get_loaders_by_name()
return self._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: if not link_type:
return [] return []
project_name = self.project_name project_name = self.project_name
@ -317,7 +321,11 @@ class AbstractTemplateBuilder(ABC):
if link["entityType"] == "folder" 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): def _collect_creators(self):
self._creators_by_name = { self._creators_by_name = {
@ -1638,7 +1646,10 @@ class PlaceholderLoadMixin(object):
linked_folder_entity["id"] linked_folder_entity["id"]
for linked_folder_entity in ( for linked_folder_entity in (
self.builder.get_linked_folder_entities( self.builder.get_linked_folder_entities(
link_type=link_type)) link_type=link_type,
folder_path_regex=folder_path_regex
)
)
] ]
if not folder_ids: if not folder_ids:

View file

@ -75,6 +75,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin):
msgBox.setStyleSheet(style.load_stylesheet()) msgBox.setStyleSheet(style.load_stylesheet())
msgBox.setWindowFlags( msgBox.setWindowFlags(
msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint
| QtCore.Qt.WindowType.WindowStaysOnTopHint
) )
msgBox.exec_() msgBox.exec_()

View file

@ -52,7 +52,7 @@ class CollectAudio(pyblish.api.ContextPlugin):
context, self.__class__ context, self.__class__
): ):
# Skip instances that already have audio filled # Skip instances that already have audio filled
if instance.data.get("audio"): if "audio" in instance.data:
self.log.debug( self.log.debug(
"Skipping Audio collection. It is already collected" "Skipping Audio collection. It is already collected"
) )

View file

@ -11,6 +11,7 @@ from ayon_core.lib import (
is_oiio_supported, is_oiio_supported,
) )
from ayon_core.lib.transcoding import ( from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert, oiio_color_convert,
) )
@ -111,7 +112,17 @@ class ExtractOIIOTranscode(publish.Extractor):
self.log.warning("Config file doesn't exist, skipping") self.log.warning("Config file doesn't exist, skipping")
continue 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: 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"] output_name = output_def["name"]
new_repre = copy.deepcopy(repre) new_repre = copy.deepcopy(repre)
@ -122,11 +133,6 @@ class ExtractOIIOTranscode(publish.Extractor):
) )
new_repre["stagingDir"] = new_staging_dir 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_def["extension"]
output_extension = output_extension.replace('.', '') output_extension = output_extension.replace('.', '')
self._rename_in_representation(new_repre, self._rename_in_representation(new_repre,
@ -168,30 +174,49 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"] additional_command_args = (output_def["oiiotool_args"]
["additional_command_args"]) ["additional_command_args"])
files_to_convert = self._translate_to_sequence( sequence_files = self._translate_to_sequence(files_to_convert)
files_to_convert) self.log.debug("Files to convert: {}".format(sequence_files))
self.log.debug("Files to convert: {}".format(files_to_convert)) missing_rgba_review_channels = False
for file_name in files_to_convert: 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)) self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir, input_path = os.path.join(original_staging_dir,
file_name) file_name)
output_path = self._get_output_file_path(input_path, output_path = self._get_output_file_path(input_path,
new_staging_dir, new_staging_dir,
output_extension) output_extension)
try:
oiio_color_convert(
input_path=input_path,
output_path=output_path,
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
logger=self.log
)
except MissingRGBAChannelsError as exc:
missing_rgba_review_channels = True
self.log.error(exc)
self.log.error(
"Skipping OIIO Transcode. Unknown RGBA channels"
f" for colorspace conversion in file: {input_path}"
)
break
oiio_color_convert( if missing_rgba_review_channels:
input_path=input_path, # Stop processing this representation
output_path=output_path, break
config_path=config_path,
source_colorspace=source_colorspace,
target_colorspace=target_colorspace,
target_display=target_display,
target_view=target_view,
source_display=source_display,
source_view=source_view,
additional_command_args=additional_command_args,
logger=self.log
)
# cleanup temporary transcoded files # cleanup temporary transcoded files
for file_name in new_repre["files"]: for file_name in new_repre["files"]:
@ -217,11 +242,11 @@ class ExtractOIIOTranscode(publish.Extractor):
added_review = True added_review = True
# If there is only 1 file outputted then convert list to # 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: if len(new_repre["files"]) == 1:
new_repre["files"] = new_repre["files"][0] 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 # part of the output definition tags, then both the
# representations will be transcoded in ExtractReview and # representations will be transcoded in ExtractReview and
# their outputs will clash in integration. # their outputs will clash in integration.
@ -271,42 +296,34 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert): def _translate_to_sequence(self, files_to_convert):
"""Returns original list or list with filename formatted in single """Returns original list or a clique.Collection of a sequence.
sequence format.
Uses clique to find frame sequence, in this case it merges all frames Uses clique to find frame sequence Collection.
into sequence format (FRAMESTART-FRAMEEND#) and returns it. If sequence not found, it returns original list.
If sequence not found, it returns original list
Args: Args:
files_to_convert (list): list of file names files_to_convert (list): list of file names
Returns: 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"]] pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble( collections, _ = clique.assemble(
files_to_convert, patterns=pattern, files_to_convert, patterns=pattern,
assume_padded_when_ambiguous=True) assume_padded_when_ambiguous=True)
if collections: if collections:
if len(collections) > 1: if len(collections) > 1:
raise ValueError( raise ValueError(
"Too many collections {}".format(collections)) "Too many collections {}".format(collections))
collection = collections[0] collection = collections[0]
frames = list(collection.indexes) # TODO: Technically oiiotool supports holes in the sequence as well
if collection.holes().indexes: # using the dedicated --frames argument to specify the frames.
return files_to_convert # We may want to use that too so conversions of sequences with
# holes will perform faster as well.
# Get the padding from the collection # Separate the collection so that we have no holes/gaps per
# This is the number of digits used in the frame numbers # collection.
padding = collection.padding return collection.separate()
frame_str = "{}-{}%0{}d".format(frames[0], frames[-1], padding)
file_name = "{}{}{}".format(collection.head, frame_str,
collection.tail)
files_to_convert = [file_name]
return files_to_convert return files_to_convert

View file

@ -1,12 +1,83 @@
import collections
import hashlib
import os import os
import tempfile import tempfile
import uuid
from pathlib import Path
import pyblish import pyblish
from ayon_core.lib import get_ffmpeg_tool_args, run_subprocess
from ayon_core.lib import (
get_ffmpeg_tool_args, def get_audio_instances(context):
run_subprocess """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): class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
@ -19,7 +90,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
order = pyblish.api.ExtractorOrder - 0.44 order = pyblish.api.ExtractorOrder - 0.44
label = "Extract OTIO Audio Tracks" label = "Extract OTIO Audio Tracks"
hosts = ["hiero", "resolve", "flame"]
temp_dir_path = None
def process(self, context): def process(self, context):
"""Convert otio audio track's content to audio representations """Convert otio audio track's content to audio representations
@ -28,13 +100,14 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
context (pyblish.Context): context of publisher context (pyblish.Context): context of publisher
""" """
# split the long audio file to peces devided by isntances # split the long audio file to peces devided by isntances
audio_instances = self.get_audio_instances(context) audio_instances = get_audio_instances(context)
self.log.debug("Audio instances: {}".format(len(audio_instances)))
if len(audio_instances) < 1: # no need to continue if no audio instances found
self.log.info("No audio instances available") if not audio_instances:
return return
self.log.debug("Audio instances: {}".format(len(audio_instances)))
# get sequence # get sequence
otio_timeline = context.data["otioTimeline"] otio_timeline = context.data["otioTimeline"]
@ -44,8 +117,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
if not audio_inputs: if not audio_inputs:
return return
# temp file # Convert all available audio into single file for trimming
audio_temp_fpath = self.create_temp_file("audio") audio_temp_fpath = self.create_temp_file("timeline_audio_track")
# create empty audio with longest duration # create empty audio with longest duration
empty = self.create_empty(audio_inputs) empty = self.create_empty(audio_inputs)
@ -59,19 +132,25 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
# remove empty # remove empty
os.remove(empty["mediaPath"]) 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 # 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 # remove full mixed audio file
os.remove(audio_temp_fpath) 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 = [] created_files = []
for inst in instances: for audio_instance in audio_instances:
name = inst.data["folderPath"] folder_path = audio_instance.data["folderPath"]
file_suffix = folder_path.replace("/", "-")
recycling_file = [f for f in created_files if name in f] recycling_file = [f for f in created_files if file_suffix in f]
audio_clip = inst.data["otioClip"] audio_clip = audio_instance.data["otioClip"]
audio_range = audio_clip.range_in_parent() audio_range = audio_clip.range_in_parent()
duration = audio_range.duration.to_frames() duration = audio_range.duration.to_frames()
@ -84,68 +163,70 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
start_sec = relative_start_time.to_seconds() start_sec = relative_start_time.to_seconds()
duration_sec = audio_range.duration.to_seconds() duration_sec = audio_range.duration.to_seconds()
# temp audio file # shot related audio file
audio_fpath = self.create_temp_file(name) shot_audio_fpath = self.create_temp_file(file_suffix)
cmd = get_ffmpeg_tool_args( cmd = get_ffmpeg_tool_args(
"ffmpeg", "ffmpeg",
"-ss", str(start_sec), "-ss", str(start_sec),
"-t", str(duration_sec), "-t", str(duration_sec),
"-i", audio_file, "-i", audio_file,
audio_fpath shot_audio_fpath
) )
# run subprocess # run subprocess
self.log.debug("Executing: {}".format(" ".join(cmd))) self.log.debug("Executing: {}".format(" ".join(cmd)))
run_subprocess(cmd, logger=self.log) run_subprocess(cmd, logger=self.log)
else:
audio_fpath = recycling_file.pop()
if "audio" in ( # add generated audio file to created files for recycling
inst.data["families"] + [inst.data["productType"]] 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 # create empty representation attr
if "representations" not in inst.data: if "representations" not in audio_instance.data:
inst.data["representations"] = [] audio_instance.data["representations"] = []
# add to representations # add to representations
inst.data["representations"].append({ audio_instance.data["representations"].append({
"files": os.path.basename(audio_fpath), "files": os.path.basename(shot_audio_fpath),
"name": "wav", "name": "wav",
"ext": "wav", "ext": "wav",
"stagingDir": os.path.dirname(audio_fpath), "stagingDir": os.path.dirname(shot_audio_fpath),
"frameStart": 0, "frameStart": 0,
"frameEnd": duration "frameEnd": duration
}) })
elif "reviewAudio" in inst.data.keys(): # audio file needs to be reviewable too
audio_attr = inst.data.get("audio") or [] elif "reviewAudio" in audio_instance.data.keys():
audio_attr = audio_instance.data.get("audio") or []
audio_attr.append({ audio_attr.append({
"filename": audio_fpath, "filename": shot_audio_fpath,
"offset": 0 "offset": 0
}) })
inst.data["audio"] = audio_attr audio_instance.data["audio"] = audio_attr
# add generated audio file to created files for recycling # Make sure if the audio instance is having siblink instances
if audio_fpath not in created_files: # which needs audio for reviewable media so it is also added
created_files.append(audio_fpath) # to its instance data
# Retrieve instance data from parent instance shot instance.
def get_audio_instances(self, context): parent_instance_id = audio_instance.data["parent_instance_id"]
"""Return only instances which are having audio in families for sibl_instance in instances_by_parent_id[parent_instance_id]:
# exclude the same audio instance
Args: if sibl_instance.id == audio_instance.id:
context (pyblish.context): context of publisher continue
self.log.info(
Returns: "Adding audio to Sibling instance: "
list: list of selected instances f"{sibl_instance.data['label']}"
""" )
return [ audio_attr = sibl_instance.data.get("audio") or []
_i for _i in context audio_attr.append({
# filter only those with audio product type or family "filename": shot_audio_fpath,
# and also with reviewAudio data key "offset": 0
if bool("audio" in ( })
_i.data.get("families", []) + [_i.data["productType"]]) sibl_instance.data["audio"] = audio_attr
) or _i.data.get("reviewAudio")
]
def get_audio_track_items(self, otio_timeline): def get_audio_track_items(self, otio_timeline):
"""Get all audio clips form OTIO audio tracks """Get all audio clips form OTIO audio tracks
@ -321,19 +402,23 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
os.remove(filters_tmp_filepath) os.remove(filters_tmp_filepath)
def create_temp_file(self, name): def create_temp_file(self, file_suffix):
"""Create temp wav file """Create temp wav file
Args: Args:
name (str): name to be used in file name file_suffix (str): name to be used in file name
Returns: Returns:
str: temp fpath str: temp fpath
""" """
name = name.replace("/", "_") extension = ".wav"
return os.path.normpath( # get 8 characters
tempfile.mktemp( hash = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()[:8]
prefix="pyblish_tmp_{}_".format(name), file_name = f"{hash}_{file_suffix}{extension}"
suffix=".wav"
) 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

@ -361,14 +361,14 @@ class ExtractReview(pyblish.api.InstancePlugin):
if not filtered_output_defs: if not filtered_output_defs:
self.log.debug(( self.log.debug((
"Repre: {} - All output definitions were filtered" "Repre: {} - All output definitions were filtered"
" out by single frame filter. Skipping" " out by single frame filter. Skipped."
).format(repre["name"])) ).format(repre["name"]))
continue continue
# Skip if file is not set # Skip if file is not set
if first_input_path is None: if first_input_path is None:
self.log.warning(( self.log.warning((
"Representation \"{}\" have empty files. Skipped." "Representation \"{}\" has empty files. Skipped."
).format(repre["name"])) ).format(repre["name"]))
continue continue

View file

@ -17,6 +17,7 @@ from ayon_core.lib import (
run_subprocess, run_subprocess,
) )
from ayon_core.lib.transcoding import ( from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert, oiio_color_convert,
get_oiio_input_and_channel_args, get_oiio_input_and_channel_args,
get_oiio_info_for_input, get_oiio_info_for_input,
@ -477,7 +478,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return False return False
input_info = get_oiio_info_for_input(src_path, logger=self.log) 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( oiio_cmd = get_oiio_tool_args(
"oiiotool", "oiiotool",
input_arg, src_path, input_arg, src_path,

View file

@ -1,6 +1,7 @@
from operator import attrgetter from operator import attrgetter
import dataclasses import dataclasses
import os import os
import platform
from typing import Any, Dict, List from typing import Any, Dict, List
import pyblish.api import pyblish.api
@ -179,6 +180,8 @@ def get_instance_uri_path(
# Ensure `None` for now is also a string # Ensure `None` for now is also a string
path = str(path) path = str(path)
if platform.system().lower() == "windows":
path = path.replace("\\", "/")
return path return path

View file

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json
import contextlib import contextlib
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Optional from typing import Any, Optional
from dataclasses import dataclass from dataclasses import dataclass
import ayon_api 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.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem, NestedCacheItem from ayon_core.lib import CacheItem, NestedCacheItem
@ -275,7 +277,7 @@ class ProductTypeIconMapping:
return self._definitions_by_name return self._definitions_by_name
def _get_project_items_from_entitiy( def _get_project_items_from_entity(
projects: list[dict[str, Any]] projects: list[dict[str, Any]]
) -> list[ProjectItem]: ) -> list[ProjectItem]:
""" """
@ -290,6 +292,7 @@ def _get_project_items_from_entitiy(
return [ return [
ProjectItem.from_entity(project) ProjectItem.from_entity(project)
for project in projects for project in projects
if project["active"]
] ]
@ -538,8 +541,32 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items) self._projects_cache.update_data(project_items)
return self._projects_cache.get_data() 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]: 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() user = ayon_api.get_user()
pinned_projects = ( pinned_projects = (
user user
@ -548,7 +575,7 @@ class ProjectsModel(object):
.get("pinnedProjects") .get("pinnedProjects")
) or [] ) or []
pinned_projects = set(pinned_projects) 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: for project in project_items:
project.is_pinned = project.name in pinned_projects project.is_pinned = project.name in pinned_projects
return project_items return project_items

View file

@ -1,10 +1,13 @@
import json import json
import collections import collections
from typing import Optional
import ayon_api import ayon_api
from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict 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 --- # --- Implementation that should be in ayon-python-api ---
@ -105,9 +108,18 @@ class UserItem:
class UsersModel: class UsersModel:
def __init__(self, controller): def __init__(self, controller):
self._current_username = NOT_SET
self._controller = controller self._controller = controller
self._users_cache = NestedCacheItem(default_factory=list) 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): def get_user_items(self, project_name):
"""Get user items. """Get user items.

View file

@ -1,10 +1,14 @@
from typing import Optional 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.lib.events import QueuedEventSystem
from ayon_core.addon import AddonsManager from ayon_core.addon import AddonsManager
from ayon_core.settings import get_project_settings, get_studio_settings 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 ( from .abstract import (
AbstractLauncherFrontEnd, AbstractLauncherFrontEnd,
@ -30,13 +34,12 @@ class BaseLauncherController(
self._addons_manager = None self._addons_manager = None
self._username = NOT_SET
self._selection_model = LauncherSelectionModel(self) self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self) self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self) self._hierarchy_model = HierarchyModel(self)
self._actions_model = ActionsModel(self) self._actions_model = ActionsModel(self)
self._workfiles_model = WorkfilesModel(self) self._workfiles_model = WorkfilesModel(self)
self._users_model = UsersModel(self)
@property @property
def log(self): def log(self):
@ -209,6 +212,7 @@ class BaseLauncherController(
self._projects_model.reset() self._projects_model.reset()
self._hierarchy_model.reset() self._hierarchy_model.reset()
self._users_model.reset()
self._actions_model.refresh() self._actions_model.refresh()
self._projects_model.refresh() self._projects_model.refresh()
@ -229,8 +233,10 @@ class BaseLauncherController(
self._emit_event("controller.refresh.actions.finished") self._emit_event("controller.refresh.actions.finished")
def get_my_tasks_entity_ids(self, project_name: str): def get_my_tasks_entity_ids(
username = self._get_my_username() self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = [] assignees = []
if username: if username:
assignees.append(username) assignees.append(username)
@ -238,10 +244,5 @@ class BaseLauncherController(
project_name, assignees project_name, assignees
) )
def _get_my_username(self):
if self._username is NOT_SET:
self._username = get_ayon_username()
return self._username
def _emit_event(self, topic, data=None): def _emit_event(self, topic, data=None):
self.emit_event(topic, data, "controller") self.emit_event(topic, data, "controller")

View file

@ -2,19 +2,47 @@ import qtawesome
from qtpy import QtWidgets, QtCore from qtpy import QtWidgets, QtCore
from ayon_core.tools.utils import ( from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton, SquareButton,
RefreshButton, RefreshButton,
ProjectsCombobox, ProjectsCombobox,
FoldersWidget, FoldersWidget,
TasksWidget, 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 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): class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent): def __init__(self, controller, parent):
super().__init__(parent) super().__init__(parent)
@ -46,34 +74,15 @@ class HierarchyPage(QtWidgets.QWidget):
content_body.setOrientation(QtCore.Qt.Horizontal) content_body.setOrientation(QtCore.Qt.Horizontal)
# - filters # - filters
filters_widget = QtWidgets.QWidget(self) filters_widget = FoldersFiltersWidget(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)
# - Folders widget # - Folders widget
folders_widget = FoldersWidget(controller, content_body) folders_widget = LauncherFoldersWidget(controller, content_body)
folders_widget.set_header_visible(True) folders_widget.set_header_visible(True)
folders_widget.set_deselectable(True) folders_widget.set_deselectable(True)
# - Tasks widget # - Tasks widget
tasks_widget = TasksWidget(controller, content_body) tasks_widget = LauncherTasksWidget(controller, content_body)
# - Third page - Workfiles # - Third page - Workfiles
workfiles_page = WorkfilesPage(controller, content_body) workfiles_page = WorkfilesPage(controller, content_body)
@ -93,17 +102,18 @@ class HierarchyPage(QtWidgets.QWidget):
btn_back.clicked.connect(self._on_back_clicked) btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked) refresh_btn.clicked.connect(self._on_refresh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed) filters_widget.text_changed.connect(self._on_filter_text_changed)
my_tasks_checkbox.stateChanged.connect( filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed 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._is_visible = False
self._controller = controller self._controller = controller
self._btn_back = btn_back self._btn_back = btn_back
self._projects_combobox = projects_combobox self._projects_combobox = projects_combobox
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget self._folders_widget = folders_widget
self._tasks_widget = tasks_widget self._tasks_widget = tasks_widget
self._workfiles_page = workfiles_page self._workfiles_page = workfiles_page
@ -126,9 +136,6 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh() self._folders_widget.refresh()
self._tasks_widget.refresh() self._tasks_widget.refresh()
self._workfiles_page.refresh() self._workfiles_page.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)
def _on_back_clicked(self): def _on_back_clicked(self):
self._controller.set_selected_project(None) self._controller.set_selected_project(None)
@ -139,11 +146,10 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text): def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(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 folder_ids = None
task_ids = None task_ids = None
state = checkstate_int_to_enum(state) if enabled:
if state == QtCore.Qt.Checked:
entity_ids = self._controller.get_my_tasks_entity_ids( entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name self._project_name
) )
@ -151,3 +157,9 @@ class HierarchyPage(QtWidgets.QWidget):
task_ids = entity_ids["task_ids"] task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids) self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_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 import ayon_api
from qtpy import QtCore, QtWidgets, QtGui 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 from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
VERSION_ROLE = QtCore.Qt.UserRole + 1 VERSION_ROLE = QtCore.Qt.UserRole + 1
@ -127,7 +127,7 @@ class WorkfilesModel(QtGui.QStandardItemModel):
return icon return icon
class WorkfilesView(QtWidgets.QTreeView): class WorkfilesView(DeselectableTreeView):
def drawBranches(self, painter, rect, index): def drawBranches(self, painter, rect, index):
return return
@ -165,6 +165,10 @@ class WorkfilesPage(QtWidgets.QWidget):
def refresh(self) -> None: def refresh(self) -> None:
self._workfiles_model.refresh() self._workfiles_model.refresh()
def deselect(self):
sel_model = self._workfiles_view.selectionModel()
sel_model.clearSelection()
def _on_refresh(self) -> None: def _on_refresh(self) -> None:
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder) self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)

View file

@ -653,6 +653,21 @@ class FrontendLoaderController(_BaseLoaderController):
""" """
pass 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 @abstractmethod
def get_available_tags_by_entity_type( def get_available_tags_by_entity_type(
self, project_name: str self, project_name: str

View file

@ -8,7 +8,11 @@ import ayon_api
from ayon_core.settings import get_project_settings from ayon_core.settings import get_project_settings
from ayon_core.pipeline import get_current_host_name 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.lib.events import QueuedEventSystem
from ayon_core.pipeline import Anatomy, get_current_context from ayon_core.pipeline import Anatomy, get_current_context
from ayon_core.host import ILoadHost from ayon_core.host import ILoadHost
@ -18,6 +22,7 @@ from ayon_core.tools.common_models import (
ThumbnailsModel, ThumbnailsModel,
TagItem, TagItem,
ProductTypeIconMapping, ProductTypeIconMapping,
UsersModel,
) )
from .abstract import ( from .abstract import (
@ -33,6 +38,8 @@ from .models import (
SiteSyncModel SiteSyncModel
) )
NOT_SET = object()
class ExpectedSelection: class ExpectedSelection:
def __init__(self, controller): def __init__(self, controller):
@ -125,6 +132,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._loader_actions_model = LoaderActionsModel(self) self._loader_actions_model = LoaderActionsModel(self)
self._thumbnails_model = ThumbnailsModel() self._thumbnails_model = ThumbnailsModel()
self._sitesync_model = SiteSyncModel(self) self._sitesync_model = SiteSyncModel(self)
self._users_model = UsersModel(self)
@property @property
def log(self): def log(self):
@ -161,6 +169,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._projects_model.reset() self._projects_model.reset()
self._thumbnails_model.reset() self._thumbnails_model.reset()
self._sitesync_model.reset() self._sitesync_model.reset()
self._users_model.reset()
self._projects_model.refresh() self._projects_model.refresh()
@ -236,6 +245,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
output[folder_id] = label output[folder_id] = label
return output 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( def get_available_tags_by_entity_type(
self, project_name: str self, project_name: str
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
@ -479,20 +499,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def is_standard_projects_filter_enabled(self): def is_standard_projects_filter_enabled(self):
return self._host is not None 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): def get_product_types_filter(self):
output = ProductTypesFilter( output = ProductTypesFilter(
is_allow_list=False, is_allow_list=False,
@ -548,3 +554,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
product_types=profile["filter_product_types"] product_types=profile["filter_product_types"]
) )
return output 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

@ -1,11 +1,11 @@
from typing import Optional
import qtpy import qtpy
from qtpy import QtWidgets, QtCore, QtGui 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.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 ( from ayon_core.tools.utils import (
FoldersQtModel, FoldersQtModel,
@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
QtWidgets.QAbstractItemView.ExtendedSelection) QtWidgets.QAbstractItemView.ExtendedSelection)
folders_model = LoaderFoldersModel(controller) folders_model = LoaderFoldersModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model = FoldersProxyModel()
folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSourceModel(folders_model)
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
if name: if name:
self._folders_view.expandAll() 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): def set_merged_products_selection(self, items):
""" """

View file

@ -1,11 +1,11 @@
import collections import collections
import hashlib import hashlib
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import ( from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView, DeselectableTreeView,
TasksQtModel, TasksQtModel,
TASKS_MODEL_SENDER_NAME, TASKS_MODEL_SENDER_NAME,
@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import (
ITEM_NAME_ROLE, ITEM_NAME_ROLE,
PARENT_ID_ROLE, PARENT_ID_ROLE,
TASK_TYPE_ROLE, TASK_TYPE_ROLE,
TasksProxyModel,
) )
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
# Role that can't clash with default 'tasks_widget' roles # Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100 FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--" NO_TASKS_ID = "--no-task--"
@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel):
return super().data(index, role) return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): class LoaderTasksProxyModel(TasksProxyModel):
def lessThan(self, left, right): def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID: if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False return False
@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
return True return True
return super().lessThan(left, right) 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): class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal() refreshed = QtCore.Signal()
@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget):
if name: if name:
self._tasks_view.expandAll() 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): def refresh(self):
self._tasks_model.refresh() self._tasks_model.refresh()

View file

@ -8,7 +8,6 @@ from ayon_core.resources import get_ayon_icon_filepath
from ayon_core.style import load_stylesheet from ayon_core.style import load_stylesheet
from ayon_core.pipeline.actions import LoaderActionResult from ayon_core.pipeline.actions import LoaderActionResult
from ayon_core.tools.utils import ( from ayon_core.tools.utils import (
PlaceholderLineEdit,
MessageOverlayObject, MessageOverlayObject,
ErrorMessageBox, ErrorMessageBox,
ThumbnailPainterWidget, ThumbnailPainterWidget,
@ -16,6 +15,7 @@ from ayon_core.tools.utils import (
GoToCurrentButton, GoToCurrentButton,
ProjectsCombobox, ProjectsCombobox,
get_qt_icon, get_qt_icon,
FoldersFiltersWidget,
) )
from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog
from ayon_core.tools.utils.lib import center_window from ayon_core.tools.utils.lib import center_window
@ -178,15 +178,14 @@ class LoaderWindow(QtWidgets.QWidget):
context_top_layout.addWidget(go_to_current_btn, 0) context_top_layout.addWidget(go_to_current_btn, 0)
context_top_layout.addWidget(refresh_btn, 0) context_top_layout.addWidget(refresh_btn, 0)
folders_filter_input = PlaceholderLineEdit(context_widget) filters_widget = FoldersFiltersWidget(context_widget)
folders_filter_input.setPlaceholderText("Folder name filter...")
folders_widget = LoaderFoldersWidget(controller, context_widget) folders_widget = LoaderFoldersWidget(controller, context_widget)
context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0) context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.addWidget(context_top_widget, 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) context_layout.addWidget(folders_widget, 1)
tasks_widget = LoaderTasksWidget(controller, context_widget) tasks_widget = LoaderTasksWidget(controller, context_widget)
@ -255,9 +254,12 @@ class LoaderWindow(QtWidgets.QWidget):
projects_combobox.refreshed.connect(self._on_projects_refresh) projects_combobox.refreshed.connect(self._on_projects_refresh)
folders_widget.refreshed.connect(self._on_folders_refresh) folders_widget.refreshed.connect(self._on_folders_refresh)
products_widget.refreshed.connect(self._on_products_refresh) products_widget.refreshed.connect(self._on_products_refresh)
folders_filter_input.textChanged.connect( filters_widget.text_changed.connect(
self._on_folder_filter_change 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) search_bar.filter_changed.connect(self._on_filter_change)
product_group_checkbox.stateChanged.connect( product_group_checkbox.stateChanged.connect(
self._on_product_group_change self._on_product_group_change
@ -317,7 +319,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._refresh_btn = refresh_btn self._refresh_btn = refresh_btn
self._projects_combobox = projects_combobox self._projects_combobox = projects_combobox
self._folders_filter_input = folders_filter_input self._filters_widget = filters_widget
self._folders_widget = folders_widget self._folders_widget = folders_widget
self._tasks_widget = tasks_widget self._tasks_widget = tasks_widget
@ -449,9 +451,21 @@ class LoaderWindow(QtWidgets.QWidget):
self._group_dialog.set_product_ids(project_name, product_ids) self._group_dialog.set_product_ids(project_name, product_ids)
self._group_dialog.show() 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) 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): def _on_product_group_change(self):
self._products_widget.set_enable_grouping( self._products_widget.set_enable_grouping(
self._product_group_checkbox.isChecked() self._product_group_checkbox.isChecked()

View file

@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
"""Get folder id from folder path.""" """Get folder id from folder path."""
pass 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 --- # --- Create ---
@abstractmethod @abstractmethod
def get_creator_items(self) -> Dict[str, "CreatorItem"]: def get_creator_items(self) -> Dict[str, "CreatorItem"]:

View file

@ -11,7 +11,11 @@ from ayon_core.pipeline import (
registered_host, registered_host,
get_process_id, 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 ( from .models import (
PublishModel, PublishModel,
@ -101,6 +105,7 @@ class PublisherController(
# Cacher of avalon documents # Cacher of avalon documents
self._projects_model = ProjectsModel(self) self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self) self._hierarchy_model = HierarchyModel(self)
self._users_model = UsersModel(self)
@property @property
def log(self): def log(self):
@ -317,6 +322,17 @@ class PublisherController(
return False return False
return True 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 --- # --- Publish specific callbacks ---
def get_context_title(self): def get_context_title(self):
"""Get context title for artist shown at the top of main window.""" """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._emit_event("controller.reset.started")
self._hierarchy_model.reset() self._hierarchy_model.reset()
self._users_model.reset()
# Publish part must be reset after plugins # Publish part must be reset after plugins
self._create_model.reset() self._create_model.reset()

View file

@ -1,5 +1,6 @@
import logging import logging
import re import re
import copy
from typing import ( from typing import (
Union, Union,
List, List,
@ -1098,7 +1099,7 @@ class CreateModel:
creator_attributes[key] = attr_def.default creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value): elif attr_def.is_value_valid(value):
creator_attributes[key] = value creator_attributes[key] = copy.deepcopy(value)
def _set_instances_publish_attr_values( def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value 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. 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) super().__init__(parent)
self._id = CONTEXT_ID self._id = CONTEXT_ID
@ -211,7 +211,7 @@ class ContextCardWidget(CardWidget):
icon_widget = PublishPixmapLabel(None, self) icon_widget = PublishPixmapLabel(None, self)
icon_widget.setObjectName("ProductTypeIconLabel") icon_widget.setObjectName("ProductTypeIconLabel")
label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) label_widget = QtWidgets.QLabel(f"<span>{CONTEXT_LABEL}</span>", self)
icon_layout = QtWidgets.QHBoxLayout() icon_layout = QtWidgets.QHBoxLayout()
icon_layout.setContentsMargins(5, 5, 5, 5) icon_layout.setContentsMargins(5, 5, 5, 5)
@ -288,6 +288,8 @@ class InstanceCardWidget(CardWidget):
self._last_product_name = None self._last_product_name = None
self._last_variant = None self._last_variant = None
self._last_label = None self._last_label = None
self._last_folder_path = None
self._last_task_name = None
icon_widget = IconValuePixmapLabel(group_icon, self) icon_widget = IconValuePixmapLabel(group_icon, self)
icon_widget.setObjectName("ProductTypeIconLabel") icon_widget.setObjectName("ProductTypeIconLabel")
@ -383,29 +385,54 @@ class InstanceCardWidget(CardWidget):
self._icon_widget.setVisible(valid) self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not 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): def _update_product_name(self):
variant = self.instance.variant variant = self.instance.variant
product_name = self.instance.product_name product_name = self.instance.product_name
label = self.instance.label label = self.instance.label
folder_path = self.instance.folder_path
task_name = self.instance.task_name
if ( if (
variant == self._last_variant variant == self._last_variant
and product_name == self._last_product_name and product_name == self._last_product_name
and label == self._last_label and label == self._last_label
and folder_path == self._last_folder_path
and task_name == self._last_task_name
): ):
return return
self._last_variant = variant self._last_variant = variant
self._last_product_name = product_name self._last_product_name = product_name
self._last_label = label self._last_label = label
self._last_folder_path = folder_path
self._last_task_name = task_name
# Make `variant` bold # Make `variant` bold
label = html_escape(self.instance.label) label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE)) found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts: if found_parts:
for part in found_parts: for part in found_parts:
replacement = "<b>{}</b>".format(part) replacement = f"<b>{part}</b>"
label = label.replace(part, replacement) 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) self._label_widget.setText(label)
# HTML text will cause that label start catch mouse clicks # HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag # - disabling with changing interaction flag
@ -702,11 +729,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh(self): def refresh(self):
"""Refresh instances in view based on CreatedContext.""" """Refresh instances in view based on CreatedContext."""
self._make_sure_context_widget_exists() self._make_sure_context_widget_exists()
self._update_convertors_group() self._update_convertors_group()
context_info_by_id = self._controller.get_instances_context_info() context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group # Prepare instances by group and identifiers by group
@ -814,6 +839,8 @@ class InstanceCardView(AbstractInstanceView):
widget.setVisible(False) widget.setVisible(False)
widget.deleteLater() widget.deleteLater()
sorted_group_names.insert(0, CONTEXT_GROUP)
self._parent_id_by_id = parent_id_by_id self._parent_id_by_id = parent_id_by_id
self._instance_ids_by_parent_id = instance_ids_by_parent_id self._instance_ids_by_parent_id = instance_ids_by_parent_id
self._group_name_by_instance_id = group_by_instance_id self._group_name_by_instance_id = group_by_instance_id
@ -881,7 +908,7 @@ class InstanceCardView(AbstractInstanceView):
context_info, context_info,
is_parent_active, is_parent_active,
group_icon, group_icon,
group_widget group_widget,
) )
widget.selected.connect(self._on_widget_selection) widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed) widget.active_changed.connect(self._on_active_changed)

View file

@ -1,10 +1,14 @@
from qtpy import QtWidgets, QtCore from qtpy import QtWidgets, QtCore
from ayon_core.lib.events import QueuedEventSystem 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.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 from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_widget = QtWidgets.QWidget(self) headers_widget = QtWidgets.QWidget(self)
folder_filter_input = PlaceholderLineEdit(headers_widget) filters_widget = FoldersFiltersWidget(headers_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
current_context_btn = GoToCurrentButton(headers_widget) current_context_btn = GoToCurrentButton(headers_widget)
current_context_btn.setToolTip("Go to current context") current_context_btn.setToolTip("Go to current context")
@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_layout = QtWidgets.QHBoxLayout(headers_widget) headers_layout = QtWidgets.QHBoxLayout(headers_widget)
headers_layout.setContentsMargins(0, 0, 0, 0) 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) headers_layout.addWidget(current_context_btn, 0)
hierarchy_controller = CreateHierarchyController(controller) hierarchy_controller = CreateHierarchyController(controller)
@ -207,15 +211,16 @@ class CreateContextWidget(QtWidgets.QWidget):
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0) main_layout.setSpacing(0)
main_layout.addWidget(headers_widget, 0) main_layout.addWidget(headers_widget, 0)
main_layout.addSpacing(5)
main_layout.addWidget(folders_widget, 2) main_layout.addWidget(folders_widget, 2)
main_layout.addWidget(tasks_widget, 1) main_layout.addWidget(tasks_widget, 1)
folders_widget.selection_changed.connect(self._on_folder_change) folders_widget.selection_changed.connect(self._on_folder_change)
tasks_widget.selection_changed.connect(self._on_task_change) tasks_widget.selection_changed.connect(self._on_task_change)
current_context_btn.clicked.connect(self._on_current_context_click) 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._current_context_btn = current_context_btn
self._folders_widget = folders_widget self._folders_widget = folders_widget
self._tasks_widget = tasks_widget self._tasks_widget = tasks_widget
@ -303,5 +308,17 @@ class CreateContextWidget(QtWidgets.QWidget):
self._last_project_name, folder_id, task_name 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) 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): def _on_first_show(self):
width = self.width() width = self.width()
part = int(width / 4) part = int(width / 9)
rem_width = width - part context_width = part * 3
self._main_splitter_widget.setSizes([part, rem_width]) create_sel_width = part * 2
rem_width = rem_width - part rem_width = width - context_width
self._creators_splitter.setSizes([part, rem_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): def showEvent(self, event):
super().showEvent(event) super().showEvent(event)

View file

@ -1,7 +1,10 @@
from qtpy import QtWidgets from qtpy import QtWidgets
from ayon_core.lib.events import QueuedEventSystem 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 from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -43,8 +46,7 @@ class FoldersDialog(QtWidgets.QDialog):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Select folder") self.setWindowTitle("Select folder")
filter_input = PlaceholderLineEdit(self) filters_widget = FoldersFiltersWidget(self)
filter_input.setPlaceholderText("Filter folders..")
folders_controller = FoldersDialogController(controller) folders_controller = FoldersDialogController(controller)
folders_widget = FoldersWidget(folders_controller, self) folders_widget = FoldersWidget(folders_controller, self)
@ -59,7 +61,8 @@ class FoldersDialog(QtWidgets.QDialog):
btns_layout.addWidget(cancel_btn) btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self) layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0) layout.setSpacing(5)
layout.addWidget(filters_widget, 0)
layout.addWidget(folders_widget, 1) layout.addWidget(folders_widget, 1)
layout.addLayout(btns_layout, 0) layout.addLayout(btns_layout, 0)
@ -68,12 +71,13 @@ class FoldersDialog(QtWidgets.QDialog):
) )
folders_widget.double_clicked.connect(self._on_ok_clicked) 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) ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked)
self._controller = controller self._controller = controller
self._filter_input = filter_input self._filters_widget = filters_widget
self._ok_btn = ok_btn self._ok_btn = ok_btn
self._cancel_btn = cancel_btn self._cancel_btn = cancel_btn
@ -88,6 +92,49 @@ class FoldersDialog(QtWidgets.QDialog):
self._first_show = True self._first_show = True
self._default_height = 500 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): def _on_first_show(self):
center = self.rect().center() center = self.rect().center()
size = self.size() size = self.size()
@ -103,27 +150,6 @@ class FoldersDialog(QtWidgets.QDialog):
# Change reset enabled so model is reset on show event # Change reset enabled so model is reset on show event
self._soft_reset_enabled = True 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): def _on_filter_change(self, text):
"""Trigger change of filter of folders.""" """Trigger change of filter of folders."""
self._folders_widget.set_name_filter(text) self._folders_widget.set_name_filter(text)
@ -137,22 +163,11 @@ class FoldersDialog(QtWidgets.QDialog):
) )
self.done(1) self.done(1)
def set_selected_folders(self, folder_paths): def _on_my_tasks_change(self, enabled: bool) -> None:
"""Change preselected folder before showing the dialog. folder_ids = None
if enabled:
This also resets model and clean filter. entity_ids = self._controller.get_my_tasks_entity_ids(
""" self._project_name
self.reset(False) )
self._filter_input.setText("") folder_ids = entity_ids["folder_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
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

View file

@ -678,13 +678,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._help_dialog.show() self._help_dialog.show()
window = self.window() window = self.window()
if hasattr(QtWidgets.QApplication, "desktop"): screen = window.screen()
desktop = QtWidgets.QApplication.desktop() screen_geo = screen.geometry()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
window_geo = window.geometry() window_geo = window.geometry()
dialog_x = window_geo.x() + window_geo.width() dialog_x = window_geo.x() + window_geo.width()

View file

@ -41,6 +41,7 @@ class PushToContextController:
self._process_item_id = None self._process_item_id = None
self._use_original_name = False self._use_original_name = False
self._version_up = False
self.set_source(project_name, version_ids) self.set_source(project_name, version_ids)
@ -212,7 +213,7 @@ class PushToContextController:
self._user_values.variant, self._user_values.variant,
comment=self._user_values.comment, comment=self._user_values.comment,
new_folder_name=self._user_values.new_folder_name, new_folder_name=self._user_values.new_folder_name,
dst_version=1, version_up=self._version_up,
use_original_name=self._use_original_name, use_original_name=self._use_original_name,
) )
item_ids.append(item_id) item_ids.append(item_id)
@ -229,6 +230,9 @@ class PushToContextController:
thread.start() thread.start()
return item_ids return item_ids
def set_version_up(self, state):
self._version_up = state
def wait_for_process_thread(self): def wait_for_process_thread(self):
if self._process_thread is None: if self._process_thread is None:
return return

View file

@ -89,7 +89,7 @@ class ProjectPushItem:
variant, variant,
comment, comment,
new_folder_name, new_folder_name,
dst_version, version_up,
item_id=None, item_id=None,
use_original_name=False use_original_name=False
): ):
@ -100,7 +100,7 @@ class ProjectPushItem:
self.dst_project_name = dst_project_name self.dst_project_name = dst_project_name
self.dst_folder_id = dst_folder_id self.dst_folder_id = dst_folder_id
self.dst_task_name = dst_task_name self.dst_task_name = dst_task_name
self.dst_version = dst_version self.version_up = version_up
self.variant = variant self.variant = variant
self.new_folder_name = new_folder_name self.new_folder_name = new_folder_name
self.comment = comment or "" self.comment = comment or ""
@ -118,7 +118,7 @@ class ProjectPushItem:
str(self.dst_folder_id), str(self.dst_folder_id),
str(self.new_folder_name), str(self.new_folder_name),
str(self.dst_task_name), str(self.dst_task_name),
str(self.dst_version), str(self.version_up),
self.use_original_name self.use_original_name
]) ])
return self._repr_value return self._repr_value
@ -133,7 +133,7 @@ class ProjectPushItem:
"dst_project_name": self.dst_project_name, "dst_project_name": self.dst_project_name,
"dst_folder_id": self.dst_folder_id, "dst_folder_id": self.dst_folder_id,
"dst_task_name": self.dst_task_name, "dst_task_name": self.dst_task_name,
"dst_version": self.dst_version, "version_up": self.version_up,
"variant": self.variant, "variant": self.variant,
"comment": self.comment, "comment": self.comment,
"new_folder_name": self.new_folder_name, "new_folder_name": self.new_folder_name,
@ -948,10 +948,22 @@ class ProjectPushItemProcess:
self._product_entity = product_entity self._product_entity = product_entity
return product_entity return product_entity
src_attrib = self._src_product_entity["attrib"]
dst_attrib = {}
for key in {
"description",
"productGroup",
}:
value = src_attrib.get(key)
if value:
dst_attrib[key] = value
product_entity = new_product_entity( product_entity = new_product_entity(
product_name, product_name,
product_type, product_type,
folder_id, folder_id,
attribs=dst_attrib
) )
self._operations.create_entity( self._operations.create_entity(
project_name, "product", product_entity project_name, "product", product_entity
@ -962,7 +974,7 @@ class ProjectPushItemProcess:
"""Make sure version document exits in database.""" """Make sure version document exits in database."""
project_name = self._item.dst_project_name project_name = self._item.dst_project_name
version = self._item.dst_version version_up = self._item.version_up
src_version_entity = self._src_version_entity src_version_entity = self._src_version_entity
product_entity = self._product_entity product_entity = self._product_entity
product_id = product_entity["id"] product_id = product_entity["id"]
@ -990,27 +1002,29 @@ class ProjectPushItemProcess:
"description", "description",
"intent", "intent",
}: }:
if key in src_attrib: value = src_attrib.get(key)
dst_attrib[key] = src_attrib[key] if value:
dst_attrib[key] = value
if version is None: last_version_entity = ayon_api.get_last_version_by_product_id(
last_version_entity = ayon_api.get_last_version_by_product_id( project_name, product_id
project_name, product_id )
if last_version_entity is None:
dst_version = get_versioning_start(
project_name,
self.host_name,
task_name=self._task_info.get("name"),
task_type=self._task_info.get("taskType"),
product_type=product_type,
product_name=product_entity["name"],
) )
if last_version_entity: else:
version = int(last_version_entity["version"]) + 1 dst_version = int(last_version_entity["version"])
else: if version_up:
version = get_versioning_start( dst_version += 1
project_name,
self.host_name,
task_name=self._task_info.get("name"),
task_type=self._task_info.get("taskType"),
product_type=product_type,
product_name=product_entity["name"],
)
existing_version_entity = ayon_api.get_version_by_name( existing_version_entity = ayon_api.get_version_by_name(
project_name, version, product_id project_name, dst_version, product_id
) )
thumbnail_id = self._copy_version_thumbnail() thumbnail_id = self._copy_version_thumbnail()
@ -1032,7 +1046,7 @@ class ProjectPushItemProcess:
copied_status = self._get_transferable_status(src_version_entity) copied_status = self._get_transferable_status(src_version_entity)
version_entity = new_version_entity( version_entity = new_version_entity(
version, dst_version,
product_id, product_id,
author=src_version_entity["author"], author=src_version_entity["author"],
status=copied_status, status=copied_status,
@ -1380,7 +1394,7 @@ class IntegrateModel:
variant, variant,
comment, comment,
new_folder_name, new_folder_name,
dst_version, version_up,
use_original_name use_original_name
): ):
"""Create new item for integration. """Create new item for integration.
@ -1394,7 +1408,7 @@ class IntegrateModel:
variant (str): Variant name. variant (str): Variant name.
comment (Union[str, None]): Comment. comment (Union[str, None]): Comment.
new_folder_name (Union[str, None]): New folder name. new_folder_name (Union[str, None]): New folder name.
dst_version (int): Destination version number. version_up (bool): Should destination product be versioned up
use_original_name (bool): If original product names should be used use_original_name (bool): If original product names should be used
Returns: Returns:
@ -1411,7 +1425,7 @@ class IntegrateModel:
variant, variant,
comment=comment, comment=comment,
new_folder_name=new_folder_name, new_folder_name=new_folder_name,
dst_version=dst_version, version_up=version_up,
use_original_name=use_original_name use_original_name=use_original_name
) )
process_item = ProjectPushItemProcess(self, item) process_item = ProjectPushItemProcess(self, item)

View file

@ -144,6 +144,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
variant_input.setPlaceholderText("< Variant >") variant_input.setPlaceholderText("< Variant >")
variant_input.setObjectName("ValidatedLineEdit") variant_input.setObjectName("ValidatedLineEdit")
version_up_checkbox = NiceCheckbox(True, parent=inputs_widget)
comment_input = PlaceholderLineEdit(inputs_widget) comment_input = PlaceholderLineEdit(inputs_widget)
comment_input.setPlaceholderText("< Publish comment >") comment_input.setPlaceholderText("< Publish comment >")
@ -153,7 +155,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("New folder name", folder_name_input)
inputs_layout.addRow("Variant", variant_input) inputs_layout.addRow("Variant", variant_input)
inputs_layout.addRow( inputs_layout.addRow(
"Use original product names", original_names_checkbox) "Use original product names", original_names_checkbox
)
inputs_layout.addRow(
"Version up existing Product", version_up_checkbox
)
inputs_layout.addRow("Comment", comment_input) inputs_layout.addRow("Comment", comment_input)
main_splitter.addWidget(context_widget) main_splitter.addWidget(context_widget)
@ -209,8 +215,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
"Show error detail dialog to copy full error." "Show error detail dialog to copy full error."
) )
original_names_checkbox.setToolTip( original_names_checkbox.setToolTip(
"Required for multi copy, doesn't allow changes " "Required for multi copy, doesn't allow changes variant values."
"variant values." )
version_up_checkbox.setToolTip(
"Version up existing product. If not selected version will be "
"updated."
) )
overlay_close_btn = QtWidgets.QPushButton( overlay_close_btn = QtWidgets.QPushButton(
@ -259,6 +268,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
library_only_checkbox.stateChanged.connect(self._on_library_only_change) library_only_checkbox.stateChanged.connect(self._on_library_only_change)
original_names_checkbox.stateChanged.connect( original_names_checkbox.stateChanged.connect(
self._on_original_names_change) self._on_original_names_change)
version_up_checkbox.stateChanged.connect(
self._on_version_up_checkbox_change)
publish_btn.clicked.connect(self._on_select_click) publish_btn.clicked.connect(self._on_select_click)
cancel_btn.clicked.connect(self._on_close_click) cancel_btn.clicked.connect(self._on_close_click)
@ -308,6 +319,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._folder_name_input = folder_name_input self._folder_name_input = folder_name_input
self._comment_input = comment_input self._comment_input = comment_input
self._use_original_names_checkbox = original_names_checkbox self._use_original_names_checkbox = original_names_checkbox
self._library_only_checkbox = library_only_checkbox
self._publish_btn = publish_btn self._publish_btn = publish_btn
@ -328,6 +340,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._new_folder_name_input_text = None self._new_folder_name_input_text = None
self._variant_input_text = None self._variant_input_text = None
self._comment_input_text = None self._comment_input_text = None
self._version_up_checkbox = version_up_checkbox
self._first_show = True self._first_show = True
self._show_timer = show_timer self._show_timer = show_timer
@ -344,6 +357,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
show_detail_btn.setVisible(False) show_detail_btn.setVisible(False)
overlay_close_btn.setVisible(False) overlay_close_btn.setVisible(False)
overlay_try_btn.setVisible(False) overlay_try_btn.setVisible(False)
version_up_checkbox.setChecked(False)
# Support of public api function of controller # Support of public api function of controller
def set_source(self, project_name, version_ids): def set_source(self, project_name, version_ids):
@ -376,7 +390,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._invalidate_new_folder_name( self._invalidate_new_folder_name(
new_folder_name, user_values["is_new_folder_name_valid"] new_folder_name, user_values["is_new_folder_name_valid"]
) )
self._controller._invalidate()
self._projects_combobox.refresh() self._projects_combobox.refresh()
def _on_first_show(self): def _on_first_show(self):
@ -415,14 +428,18 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._comment_input_text = text self._comment_input_text = text
self._user_input_changed_timer.start() self._user_input_changed_timer.start()
def _on_library_only_change(self, state: int) -> None: def _on_library_only_change(self) -> None:
"""Change toggle state, reset filter, recalculate dropdown""" """Change toggle state, reset filter, recalculate dropdown"""
state = bool(state) is_checked = self._library_only_checkbox.isChecked()
self._projects_combobox.set_standard_filter_enabled(state) self._projects_combobox.set_standard_filter_enabled(is_checked)
def _on_original_names_change(self, state: int) -> None: def _on_original_names_change(self) -> None:
use_original_name = bool(state) is_checked = self._use_original_names_checkbox.isChecked()
self._invalidate_use_original_names(use_original_name) self._invalidate_use_original_names(is_checked)
def _on_version_up_checkbox_change(self) -> None:
is_checked = self._version_up_checkbox.isChecked()
self._controller.set_version_up(is_checked)
def _on_user_input_timer(self): def _on_user_input_timer(self):
folder_name_enabled = self._new_folder_name_enabled folder_name_enabled = self._new_folder_name_enabled

View file

@ -76,6 +76,7 @@ from .folders_widget import (
FoldersQtModel, FoldersQtModel,
FOLDERS_MODEL_SENDER_NAME, FOLDERS_MODEL_SENDER_NAME,
SimpleFoldersWidget, SimpleFoldersWidget,
FoldersFiltersWidget,
) )
from .tasks_widget import ( from .tasks_widget import (
@ -160,6 +161,7 @@ __all__ = (
"FoldersQtModel", "FoldersQtModel",
"FOLDERS_MODEL_SENDER_NAME", "FOLDERS_MODEL_SENDER_NAME",
"SimpleFoldersWidget", "SimpleFoldersWidget",
"FoldersFiltersWidget",
"TasksWidget", "TasksWidget",
"TasksQtModel", "TasksQtModel",

View file

@ -1,4 +1,3 @@
import qtpy
from qtpy import QtWidgets, QtCore, QtGui from qtpy import QtWidgets, QtCore, QtGui
@ -6,7 +5,7 @@ class PickScreenColorWidget(QtWidgets.QWidget):
color_selected = QtCore.Signal(QtGui.QColor) color_selected = QtCore.Signal(QtGui.QColor)
def __init__(self, parent=None): def __init__(self, parent=None):
super(PickScreenColorWidget, self).__init__(parent) super().__init__(parent)
self.labels = [] self.labels = []
self.magnification = 2 self.magnification = 2
@ -53,7 +52,7 @@ class PickLabel(QtWidgets.QLabel):
close_session = QtCore.Signal() close_session = QtCore.Signal()
def __init__(self, pick_widget): def __init__(self, pick_widget):
super(PickLabel, self).__init__() super().__init__()
self.setMouseTracking(True) self.setMouseTracking(True)
self.pick_widget = pick_widget self.pick_widget = pick_widget
@ -74,14 +73,10 @@ class PickLabel(QtWidgets.QLabel):
self.show() self.show()
self.windowHandle().setScreen(screen_obj) self.windowHandle().setScreen(screen_obj)
geo = screen_obj.geometry() geo = screen_obj.geometry()
args = ( pix = screen_obj.grabWindow(
QtWidgets.QApplication.desktop().winId(), self.winId(),
geo.x(), geo.y(), geo.width(), geo.height() 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(): if pix.width() > pix.height():
size = pix.height() size = pix.height()

View file

@ -15,6 +15,8 @@ from ayon_core.tools.common_models import (
from .models import RecursiveSortFilterProxyModel from .models import RecursiveSortFilterProxyModel
from .views import TreeView from .views import TreeView
from .lib import RefreshThread, get_qt_icon from .lib import RefreshThread, get_qt_icon
from .widgets import PlaceholderLineEdit
from .nice_checkbox import NiceCheckbox
FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
@ -343,6 +345,8 @@ class FoldersProxyModel(RecursiveSortFilterProxyModel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
self._folder_ids_filter = None self._folder_ids_filter = None
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]): def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
@ -794,3 +798,47 @@ class SimpleFoldersWidget(FoldersWidget):
event (Event): Triggered event. event (Event): Triggered event.
""" """
pass 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,14 +53,8 @@ def checkstate_enum_to_int(state):
def center_window(window): def center_window(window):
"""Move window to center of it's screen.""" """Move window to center of it's screen."""
screen = window.screen()
if hasattr(QtWidgets.QApplication, "desktop"): screen_geo = screen.geometry()
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
geo = window.frameGeometry() geo = window.frameGeometry()
geo.moveCenter(screen_geo.center()) geo.moveCenter(screen_geo.center())

View file

@ -1,8 +1,15 @@
from __future__ import annotations
import os import os
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import typing
from typing import Optional
from ayon_core.style import get_default_entity_icon_color from ayon_core.style import get_default_entity_icon_color
if typing.TYPE_CHECKING:
from ayon_core.host import PublishedWorkfileInfo
class FolderItem: class FolderItem:
"""Item representing folder entity on a server. """Item representing folder entity on a server.
@ -159,6 +166,17 @@ class WorkareaFilepathResult:
self.filepath = filepath 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): class AbstractWorkfilesCommon(ABC):
@abstractmethod @abstractmethod
def is_host_valid(self): def is_host_valid(self):
@ -787,6 +805,25 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
""" """
pass 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 @abstractmethod
def get_workfile_info(self, folder_id, task_id, rootless_path): def get_workfile_info(self, folder_id, task_id, rootless_path):
"""Workfile info from database. """Workfile info from database.

View file

@ -1,4 +1,7 @@
from __future__ import annotations
import os import os
from typing import Optional
import ayon_api import ayon_api
@ -18,6 +21,7 @@ from ayon_core.tools.common_models import (
from .abstract import ( from .abstract import (
AbstractWorkfilesBackend, AbstractWorkfilesBackend,
AbstractWorkfilesFrontend, AbstractWorkfilesFrontend,
PublishedWorkfileWrap,
) )
from .models import SelectionModel, WorkfilesModel from .models import SelectionModel, WorkfilesModel
@ -432,6 +436,15 @@ class BaseWorkfileController(
folder_id, task_id 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): def get_workfile_info(self, folder_id, task_id, rootless_path):
return self._workfiles_model.get_workfile_info( return self._workfiles_model.get_workfile_info(
folder_id, task_id, rootless_path folder_id, task_id, rootless_path

View file

@ -17,6 +17,8 @@ class SelectionModel(object):
self._task_name = None self._task_name = None
self._task_id = None self._task_id = None
self._workfile_path = None self._workfile_path = None
self._rootless_workfile_path = None
self._workfile_entity_id = None
self._representation_id = None self._representation_id = None
def get_selected_folder_id(self): def get_selected_folder_id(self):
@ -62,39 +64,49 @@ class SelectionModel(object):
def get_selected_workfile_path(self): def get_selected_workfile_path(self):
return self._workfile_path 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( def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id self, rootless_path, path, workfile_entity_id
): ):
if path == self._workfile_path: if path == self._workfile_path:
return return
self._rootless_workfile_path = rootless_path
self._workfile_path = path self._workfile_path = path
self._workfile_entity_id = workfile_entity_id
self._controller.emit_event( self._controller.emit_event(
"selection.workarea.changed", "selection.workarea.changed",
{ self.get_selected_workfile_data(),
"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.event_source self.event_source
) )
def get_selected_representation_id(self): def get_selected_representation_id(self):
return self._representation_id 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): def set_selected_representation_id(self, representation_id):
if representation_id == self._representation_id: if representation_id == self._representation_id:
return return
self._representation_id = representation_id self._representation_id = representation_id
self._controller.emit_event( self._controller.emit_event(
"selection.representation.changed", "selection.representation.changed",
{ self.get_selected_representation_data(),
"project_name": self._controller.get_current_project_name(),
"representation_id": representation_id,
},
self.event_source 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.pipeline.version_start import get_versioning_start
from ayon_core.tools.workfiles.abstract import ( from ayon_core.tools.workfiles.abstract import (
WorkareaFilepathResult, WorkareaFilepathResult,
PublishedWorkfileWrap,
AbstractWorkfilesBackend, AbstractWorkfilesBackend,
) )
@ -79,6 +80,7 @@ class WorkfilesModel:
# Published workfiles # Published workfiles
self._repre_by_id = {} self._repre_by_id = {}
self._version_comment_by_id = {}
self._published_workfile_items_cache = NestedCacheItem( self._published_workfile_items_cache = NestedCacheItem(
levels=1, default_factory=list levels=1, default_factory=list
) )
@ -95,6 +97,7 @@ class WorkfilesModel:
self._workarea_file_items_cache.reset() self._workarea_file_items_cache.reset()
self._repre_by_id = {} self._repre_by_id = {}
self._version_comment_by_id = {}
self._published_workfile_items_cache.reset() self._published_workfile_items_cache.reset()
self._workfile_entities_by_task_id = {} self._workfile_entities_by_task_id = {}
@ -552,13 +555,13 @@ class WorkfilesModel:
) )
def get_published_file_items( def get_published_file_items(
self, folder_id: str, task_id: str self, folder_id: Optional[str], task_id: Optional[str]
) -> list[PublishedWorkfileInfo]: ) -> list[PublishedWorkfileInfo]:
"""Published workfiles for passed context. """Published workfiles for passed context.
Args: Args:
folder_id (str): Folder id. folder_id (Optional[str]): Folder id.
task_id (str): Task id. task_id (Optional[str]): Task id.
Returns: Returns:
list[PublishedWorkfileInfo]: List of files for published workfiles. list[PublishedWorkfileInfo]: List of files for published workfiles.
@ -586,7 +589,7 @@ class WorkfilesModel:
version_entities = list(ayon_api.get_versions( version_entities = list(ayon_api.get_versions(
project_name, project_name,
product_ids=product_ids, product_ids=product_ids,
fields={"id", "author", "taskId"}, fields={"id", "author", "taskId", "attrib.comment"},
)) ))
repre_entities = [] repre_entities = []
@ -600,6 +603,13 @@ class WorkfilesModel:
repre_entity["id"]: repre_entity repre_entity["id"]: repre_entity
for repre_entity in repre_entities 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) project_entity = self._controller.get_project_entity(project_name)
prepared_data = ListPublishedWorkfilesOptionalData( prepared_data = ListPublishedWorkfilesOptionalData(
@ -626,6 +636,34 @@ class WorkfilesModel:
] ]
return items 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 @property
def _project_name(self) -> str: def _project_name(self) -> str:
return self._controller.get_current_project_name() return self._controller.get_current_project_name()
@ -642,6 +680,25 @@ class WorkfilesModel:
self._current_username = get_ayon_username() self._current_username = get_ayon_username()
return self._current_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 --- # --- Host ---
def _open_workfile(self, folder_id: str, task_id: str, filepath: str): def _open_workfile(self, folder_id: str, task_id: str, filepath: str):
# TODO move to workfiles pipeline # TODO move to workfiles pipeline

View file

@ -1,6 +1,7 @@
import datetime import datetime
from typing import Optional
from qtpy import QtWidgets, QtCore from qtpy import QtCore, QtWidgets
def file_size_to_string(file_size): def file_size_to_string(file_size):
@ -8,9 +9,9 @@ def file_size_to_string(file_size):
return "N/A" return "N/A"
size = 0 size = 0
size_ending_mapping = { size_ending_mapping = {
"KB": 1024 ** 1, "KB": 1024**1,
"MB": 1024 ** 2, "MB": 1024**2,
"GB": 1024 ** 3 "GB": 1024**3,
} }
ending = "B" ending = "B"
for _ending, _size in size_ending_mapping.items(): 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) btn_description_save.clicked.connect(self._on_save_click)
controller.register_event_callback( 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 self._details_input = details_input
@ -82,12 +88,13 @@ class SidePanelWidget(QtWidgets.QWidget):
self._task_id = None self._task_id = None
self._filepath = None self._filepath = None
self._rootless_path = None self._rootless_path = None
self._representation_id = None
self._orig_description = "" self._orig_description = ""
self._controller = controller 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. """Change published mode.
Args: Args:
@ -95,14 +102,37 @@ class SidePanelWidget(QtWidgets.QWidget):
""" """
self._description_widget.setVisible(not published_mode) 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"] folder_id = event["folder_id"]
task_id = event["task_id"] task_id = event["task_id"]
filepath = event["path"] filepath = event["path"]
rootless_path = event["rootless_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): def _on_description_change(self):
text = self._description_input.toPlainText() text = self._description_input.toPlainText()
@ -118,85 +148,134 @@ class SidePanelWidget(QtWidgets.QWidget):
self._orig_description = description self._orig_description = description
self._btn_description_save.setEnabled(False) 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 workfile_info = None
# Check if folder, task and file are selected # Check if folder, task and file are selected
if folder_id and task_id and rootless_path: if folder_id and task_id and rootless_path:
workfile_info = self._controller.get_workfile_info( workfile_info = self._controller.get_workfile_info(
folder_id, task_id, rootless_path folder_id, task_id, rootless_path
) )
enabled = workfile_info is not None
self._details_input.setEnabled(enabled) if workfile_info is None:
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:
self._orig_description = "" self._orig_description = ""
self._details_input.setPlainText("")
self._description_input.setPlainText("") self._description_input.setPlainText("")
self._set_context(False, folder_id, task_id)
return return
description = workfile_info.description self._set_context(
size_value = file_size_to_string(workfile_info.file_size) 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" datetime_format = "%b %d %Y %H:%M:%S"
file_created = workfile_info.file_created
modification_time = workfile_info.file_modified
if file_created: if file_created:
file_created = datetime.datetime.fromtimestamp(file_created) file_created = datetime.datetime.fromtimestamp(file_created)
if modification_time: if file_modified:
modification_time = datetime.datetime.fromtimestamp( file_modified = datetime.datetime.fromtimestamp(
modification_time) file_modified
)
user_items_by_name = self._controller.get_user_items_by_name() user_items_by_name = self._controller.get_user_items_by_name()
def convert_username(username): def convert_username(username_v):
user_item = user_items_by_name.get(username) user_item = user_items_by_name.get(username_v)
if user_item is not None and user_item.full_name: if user_item is not None and user_item.full_name:
return user_item.full_name return user_item.full_name
return username return username_v
created_lines = [] lines = []
if workfile_info.created_by: if size_value is not None:
created_lines.append( size_value = file_size_to_string(size_value)
convert_username(workfile_info.created_by) lines.append(f"<b>Size:</b><br/>{size_value}")
)
if file_created:
created_lines.append(file_created.strftime(datetime_format))
if created_lines: # Add version comment for published workfiles
created_lines.insert(0, "<b>Created:</b>") if comment:
lines.append(f"<b>Comment:</b><br/>{comment}")
modified_lines = [] if created_by or file_created:
if workfile_info.updated_by: lines.append("<b>Created:</b>")
modified_lines.append( if created_by:
convert_username(workfile_info.updated_by) lines.append(convert_username(created_by))
) if file_created:
if modification_time: lines.append(file_created.strftime(datetime_format))
modified_lines.append(
modification_time.strftime(datetime_format)
)
if modified_lines:
modified_lines.insert(0, "<b>Modified:</b>")
lines = ( if updated_by or file_modified:
"<b>Size:</b>", lines.append("<b>Modified:</b>")
size_value, if updated_by:
"<br/>".join(created_lines), lines.append(convert_username(updated_by))
"<br/>".join(modified_lines), if file_modified:
) lines.append(file_modified.strftime(datetime_format))
self._orig_description = description
self._description_input.setPlainText(description)
# Set as empty string # Set as empty string
self._details_input.setPlainText("") self._details_input.setPlainText("")

View file

@ -6,12 +6,11 @@ from ayon_core.tools.utils import (
FoldersWidget, FoldersWidget,
GoToCurrentButton, GoToCurrentButton,
MessageOverlayObject, MessageOverlayObject,
NiceCheckbox,
PlaceholderLineEdit, PlaceholderLineEdit,
RefreshButton, RefreshButton,
TasksWidget, TasksWidget,
FoldersFiltersWidget,
) )
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.workfiles.control import BaseWorkfileController from ayon_core.tools.workfiles.control import BaseWorkfileController
from .files_widget import FilesWidget from .files_widget import FilesWidget
@ -69,7 +68,6 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._default_window_flags = flags self._default_window_flags = flags
self._folders_widget = None self._folders_widget = None
self._folder_filter_input = None
self._files_widget = None self._files_widget = None
@ -178,48 +176,33 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
col_widget = QtWidgets.QWidget(parent) col_widget = QtWidgets.QWidget(parent)
header_widget = QtWidgets.QWidget(col_widget) header_widget = QtWidgets.QWidget(col_widget)
folder_filter_input = PlaceholderLineEdit(header_widget) filters_widget = FoldersFiltersWidget(header_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
go_to_current_btn = GoToCurrentButton(header_widget) go_to_current_btn = GoToCurrentButton(header_widget)
refresh_btn = RefreshButton(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( folder_widget = FoldersWidget(
controller, col_widget, handle_expected_selection=True 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 = QtWidgets.QVBoxLayout(col_widget)
col_layout.setContentsMargins(0, 0, 0, 0) col_layout.setContentsMargins(0, 0, 0, 0)
col_layout.addWidget(header_widget, 0) col_layout.addWidget(header_widget, 0)
col_layout.addWidget(folder_widget, 1) col_layout.addWidget(folder_widget, 1)
folder_filter_input.textChanged.connect(self._on_folder_filter_change) filters_widget.text_changed.connect(self._on_folder_filter_change)
go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) filters_widget.my_tasks_changed.connect(
refresh_btn.clicked.connect(self._on_refresh_clicked)
my_tasks_checkbox.stateChanged.connect(
self._on_my_tasks_checkbox_state_changed 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 self._folders_widget = folder_widget
return col_widget return col_widget
@ -403,11 +386,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
else: else:
self.close() 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 folder_ids = None
task_ids = None task_ids = None
state = checkstate_int_to_enum(state) if enabled:
if state == QtCore.Qt.Checked:
entity_ids = self._controller.get_my_tasks_entity_ids( entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name self._project_name
) )

View file

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

View file

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

View file

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

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