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
description: What version are you running? Look to AYON Tray
options:
- 1.6.9
- 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5

View file

@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef):
if value is None:
return copy.deepcopy(self.default)
return list(self._item_values.intersection(value))
return [
v
for v in value
if v in self._item_values
]
def is_value_valid(self, value: Any) -> bool:
"""Check if item is available in possible values."""

View file

@ -110,6 +110,15 @@ def deprecated(new_destination):
return _decorator(func)
class MissingRGBAChannelsError(ValueError):
"""Raised when we can't find channels to use as RGBA for conversion in
input media.
This may be other channels than solely RGBA, like Z-channel. The error is
raised when no matching 'reviewable' channel was found.
"""
def get_transcode_temp_directory():
"""Creates temporary folder for transcoding.
@ -388,6 +397,10 @@ def get_review_info_by_layer_name(channel_names):
...
]
This tries to find suitable outputs good for review purposes, by
searching for channel names like RGBA, but also XYZ, Z, N, AR, AG, AB
channels.
Args:
channel_names (list[str]): List of channel names.
@ -396,7 +409,6 @@ def get_review_info_by_layer_name(channel_names):
"""
layer_names_order = []
rgba_by_layer_name = collections.defaultdict(dict)
channels_by_layer_name = collections.defaultdict(dict)
for channel_name in channel_names:
@ -405,45 +417,95 @@ def get_review_info_by_layer_name(channel_names):
if "." in channel_name:
layer_name, last_part = channel_name.rsplit(".", 1)
channels_by_layer_name[layer_name][channel_name] = last_part
if last_part.lower() not in {
"r", "red",
"g", "green",
"b", "blue",
"a", "alpha"
# R, G, B, A or X, Y, Z, N, AR, AG, AB, RED, GREEN, BLUE, ALPHA
channel = last_part.upper()
if channel not in {
# Detect RGBA channels
"R", "G", "B", "A",
# Support fully written out rgba channel names
"RED", "GREEN", "BLUE", "ALPHA",
# Allow detecting of x, y and z channels, and normal channels
"X", "Y", "Z", "N",
# red, green and blue alpha/opacity, for colored mattes
"AR", "AG", "AB"
}:
continue
if layer_name not in layer_names_order:
layer_names_order.append(layer_name)
# R, G, B or A
channel = last_part[0].upper()
rgba_by_layer_name[layer_name][channel] = channel_name
channels_by_layer_name[layer_name][channel] = channel_name
# Put empty layer or 'rgba' to the beginning of the list
# - if input has R, G, B, A channels they should be used for review
# NOTE They are iterated in reversed order because they're inserted to
# the beginning of 'layer_names_order' -> last added will be first.
for name in reversed(["", "rgba"]):
if name in layer_names_order:
layer_names_order.remove(name)
layer_names_order.insert(0, name)
def _sort(_layer_name: str) -> int:
# Prioritize "" layer name
# Prioritize layers with RGB channels
if _layer_name == "rgba":
return 0
if _layer_name == "":
return 1
channels = channels_by_layer_name[_layer_name]
if all(channel in channels for channel in "RGB"):
return 2
return 10
layer_names_order.sort(key=_sort)
output = []
for layer_name in layer_names_order:
rgba_layer_info = rgba_by_layer_name[layer_name]
red = rgba_layer_info.get("R")
green = rgba_layer_info.get("G")
blue = rgba_layer_info.get("B")
if not red or not green or not blue:
channel_info = channels_by_layer_name[layer_name]
alpha = channel_info.get("A")
# RGB channels
if all(channel in channel_info for channel in "RGB"):
rgb = "R", "G", "B"
# RGB channels using fully written out channel names
elif all(
channel in channel_info
for channel in ("RED", "GREEN", "BLUE")
):
rgb = "RED", "GREEN", "BLUE"
alpha = channel_info.get("ALPHA")
# XYZ channels (position pass)
elif all(channel in channel_info for channel in "XYZ"):
rgb = "X", "Y", "Z"
# Colored mattes (as defined in OpenEXR Channel Name standards)
elif all(channel in channel_info for channel in ("AR", "AG", "AB")):
rgb = "AR", "AG", "AB"
# Luminance channel (as defined in OpenEXR Channel Name standards)
elif "Y" in channel_info:
rgb = "Y", "Y", "Y"
# Has only Z channel (Z-depth layer)
elif "Z" in channel_info:
rgb = "Z", "Z", "Z"
# Has only A channel (Alpha layer)
elif "A" in channel_info:
rgb = "A", "A", "A"
alpha = None
else:
# No reviewable channels found
continue
red = channel_info[rgb[0]]
green = channel_info[rgb[1]]
blue = channel_info[rgb[2]]
output.append({
"name": layer_name,
"review_channels": {
"R": red,
"G": green,
"B": blue,
"A": rgba_layer_info.get("A"),
"A": alpha,
}
})
return output
@ -1467,8 +1529,9 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
review_channels = get_convert_rgb_channels(channel_names)
if review_channels is None:
raise ValueError(
"Couldn't find channels that can be used for conversion."
raise MissingRGBAChannelsError(
"Couldn't find channels that can be used for conversion "
f"among channels: {channel_names}."
)
red, green, blue, alpha = review_channels
@ -1482,7 +1545,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None):
channels_arg += ",A={}".format(float(alpha_default))
input_channels.append("A")
input_channels_str = ",".join(input_channels)
# Make sure channels are unique, but preserve order to avoid oiiotool crash
input_channels_str = ",".join(list(dict.fromkeys(input_channels)))
subimages = oiio_input_info.get("subimages")
input_arg = "-i"

View file

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

View file

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

View file

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

View file

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

View file

@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC):
self._loaders_by_name = get_loaders_by_name()
return self._loaders_by_name
def get_linked_folder_entities(self, link_type: Optional[str]):
def get_linked_folder_entities(
self,
link_type: Optional[str],
folder_path_regex: Optional[str],
):
if not link_type:
return []
project_name = self.project_name
@ -317,7 +321,11 @@ class AbstractTemplateBuilder(ABC):
if link["entityType"] == "folder"
}
return list(get_folders(project_name, folder_ids=linked_folder_ids))
return list(get_folders(
project_name,
folder_path_regex=folder_path_regex,
folder_ids=linked_folder_ids,
))
def _collect_creators(self):
self._creators_by_name = {
@ -1638,7 +1646,10 @@ class PlaceholderLoadMixin(object):
linked_folder_entity["id"]
for linked_folder_entity in (
self.builder.get_linked_folder_entities(
link_type=link_type))
link_type=link_type,
folder_path_regex=folder_path_regex
)
)
]
if not folder_ids:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,7 @@ from ayon_core.lib import (
run_subprocess,
)
from ayon_core.lib.transcoding import (
MissingRGBAChannelsError,
oiio_color_convert,
get_oiio_input_and_channel_args,
get_oiio_info_for_input,
@ -477,7 +478,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
return False
input_info = get_oiio_info_for_input(src_path, logger=self.log)
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
try:
input_arg, channels_arg = get_oiio_input_and_channel_args(
input_info
)
except MissingRGBAChannelsError:
self.log.debug(
"Unable to find relevant reviewable channel for thumbnail "
"creation"
)
return False
oiio_cmd = get_oiio_tool_args(
"oiiotool",
input_arg, src_path,

View file

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

View file

@ -1,11 +1,13 @@
from __future__ import annotations
import json
import contextlib
from abc import ABC, abstractmethod
from typing import Any, Optional
from dataclasses import dataclass
import ayon_api
from ayon_api.graphql_queries import projects_graphql_query
from ayon_core.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem, NestedCacheItem
@ -275,7 +277,7 @@ class ProductTypeIconMapping:
return self._definitions_by_name
def _get_project_items_from_entitiy(
def _get_project_items_from_entity(
projects: list[dict[str, Any]]
) -> list[ProjectItem]:
"""
@ -290,6 +292,7 @@ def _get_project_items_from_entitiy(
return [
ProjectItem.from_entity(project)
for project in projects
if project["active"]
]
@ -538,8 +541,32 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
return self._projects_cache.get_data()
def _fetch_graphql_projects(self) -> list[dict[str, Any]]:
"""Fetch projects using GraphQl.
This method was added because ayon_api had a bug in 'get_projects'.
Returns:
list[dict[str, Any]]: List of projects.
"""
api = ayon_api.get_server_api_connection()
query = projects_graphql_query({"name", "active", "library", "data"})
projects = []
for parsed_data in query.continuous_query(api):
for project in parsed_data["projects"]:
project_data = project["data"]
if project_data is None:
project["data"] = {}
elif isinstance(project_data, str):
project["data"] = json.loads(project_data)
projects.append(project)
return projects
def _query_projects(self) -> list[ProjectItem]:
projects = ayon_api.get_projects(fields=["name", "active", "library"])
projects = self._fetch_graphql_projects()
user = ayon_api.get_user()
pinned_projects = (
user
@ -548,7 +575,7 @@ class ProjectsModel(object):
.get("pinnedProjects")
) or []
pinned_projects = set(pinned_projects)
project_items = _get_project_items_from_entitiy(list(projects))
project_items = _get_project_items_from_entity(list(projects))
for project in project_items:
project.is_pinned = project.name in pinned_projects
return project_items

View file

@ -1,10 +1,13 @@
import json
import collections
from typing import Optional
import ayon_api
from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict
from ayon_core.lib import NestedCacheItem
from ayon_core.lib import NestedCacheItem, get_ayon_username
NOT_SET = object()
# --- Implementation that should be in ayon-python-api ---
@ -105,9 +108,18 @@ class UserItem:
class UsersModel:
def __init__(self, controller):
self._current_username = NOT_SET
self._controller = controller
self._users_cache = NestedCacheItem(default_factory=list)
def get_current_username(self) -> Optional[str]:
if self._current_username is NOT_SET:
self._current_username = get_ayon_username()
return self._current_username
def reset(self) -> None:
self._users_cache.reset()
def get_user_items(self, project_name):
"""Get user items.

View file

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

View file

@ -2,19 +2,47 @@ import qtawesome
from qtpy import QtWidgets, QtCore
from ayon_core.tools.utils import (
PlaceholderLineEdit,
SquareButton,
RefreshButton,
ProjectsCombobox,
FoldersWidget,
TasksWidget,
NiceCheckbox,
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget
from .workfiles_page import WorkfilesPage
class LauncherFoldersWidget(FoldersWidget):
focused_in = QtCore.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._folders_view.installEventFilter(self)
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focused_in.emit()
return False
class LauncherTasksWidget(TasksWidget):
focused_in = QtCore.Signal()
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._tasks_view.installEventFilter(self)
def deselect(self):
sel_model = self._tasks_view.selectionModel()
sel_model.clearSelection()
def eventFilter(self, obj, event):
if event.type() == QtCore.QEvent.FocusIn:
self.focused_in.emit()
return False
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
super().__init__(parent)
@ -46,34 +74,15 @@ class HierarchyPage(QtWidgets.QWidget):
content_body.setOrientation(QtCore.Qt.Horizontal)
# - filters
filters_widget = QtWidgets.QWidget(self)
folders_filter_text = PlaceholderLineEdit(filters_widget)
folders_filter_text.setPlaceholderText("Filter folders...")
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", filters_widget)
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(filters_widget)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
filters_layout = QtWidgets.QHBoxLayout(filters_widget)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.addWidget(folders_filter_text, 1)
filters_layout.addWidget(my_tasks_label, 0)
filters_layout.addWidget(my_tasks_checkbox, 0)
filters_widget = FoldersFiltersWidget(self)
# - Folders widget
folders_widget = FoldersWidget(controller, content_body)
folders_widget = LauncherFoldersWidget(controller, content_body)
folders_widget.set_header_visible(True)
folders_widget.set_deselectable(True)
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
tasks_widget = LauncherTasksWidget(controller, content_body)
# - Third page - Workfiles
workfiles_page = WorkfilesPage(controller, content_body)
@ -93,17 +102,18 @@ class HierarchyPage(QtWidgets.QWidget):
btn_back.clicked.connect(self._on_back_clicked)
refresh_btn.clicked.connect(self._on_refresh_clicked)
folders_filter_text.textChanged.connect(self._on_filter_text_changed)
my_tasks_checkbox.stateChanged.connect(
filters_widget.text_changed.connect(self._on_filter_text_changed)
filters_widget.my_tasks_changed.connect(
self._on_my_tasks_checkbox_state_changed
)
folders_widget.focused_in.connect(self._on_folders_focus)
tasks_widget.focused_in.connect(self._on_tasks_focus)
self._is_visible = False
self._controller = controller
self._btn_back = btn_back
self._projects_combobox = projects_combobox
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._workfiles_page = workfiles_page
@ -126,9 +136,6 @@ class HierarchyPage(QtWidgets.QWidget):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)
def _on_back_clicked(self):
self._controller.set_selected_project(None)
@ -139,11 +146,10 @@ class HierarchyPage(QtWidgets.QWidget):
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filter(text)
def _on_my_tasks_checkbox_state_changed(self, state):
def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
state = checkstate_int_to_enum(state)
if state == QtCore.Qt.Checked:
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._project_name
)
@ -151,3 +157,9 @@ class HierarchyPage(QtWidgets.QWidget):
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)
def _on_folders_focus(self):
self._workfiles_page.deselect()
def _on_tasks_focus(self):
self._workfiles_page.deselect()

View file

@ -3,7 +3,7 @@ from typing import Optional
import ayon_api
from qtpy import QtCore, QtWidgets, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils import get_qt_icon, DeselectableTreeView
from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
VERSION_ROLE = QtCore.Qt.UserRole + 1
@ -127,7 +127,7 @@ class WorkfilesModel(QtGui.QStandardItemModel):
return icon
class WorkfilesView(QtWidgets.QTreeView):
class WorkfilesView(DeselectableTreeView):
def drawBranches(self, painter, rect, index):
return
@ -165,6 +165,10 @@ class WorkfilesPage(QtWidgets.QWidget):
def refresh(self) -> None:
self._workfiles_model.refresh()
def deselect(self):
sel_model = self._workfiles_view.selectionModel()
sel_model.clearSelection()
def _on_refresh(self) -> None:
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)

View file

@ -653,6 +653,21 @@ class FrontendLoaderController(_BaseLoaderController):
"""
pass
@abstractmethod
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, list[str]]: Folder and task ids.
"""
pass
@abstractmethod
def get_available_tags_by_entity_type(
self, project_name: str

View file

@ -8,7 +8,11 @@ import ayon_api
from ayon_core.settings import get_project_settings
from ayon_core.pipeline import get_current_host_name
from ayon_core.lib import NestedCacheItem, CacheItem, filter_profiles
from ayon_core.lib import (
NestedCacheItem,
CacheItem,
filter_profiles,
)
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.pipeline import Anatomy, get_current_context
from ayon_core.host import ILoadHost
@ -18,6 +22,7 @@ from ayon_core.tools.common_models import (
ThumbnailsModel,
TagItem,
ProductTypeIconMapping,
UsersModel,
)
from .abstract import (
@ -33,6 +38,8 @@ from .models import (
SiteSyncModel
)
NOT_SET = object()
class ExpectedSelection:
def __init__(self, controller):
@ -125,6 +132,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._loader_actions_model = LoaderActionsModel(self)
self._thumbnails_model = ThumbnailsModel()
self._sitesync_model = SiteSyncModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -161,6 +169,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
self._projects_model.reset()
self._thumbnails_model.reset()
self._sitesync_model.reset()
self._users_model.reset()
self._projects_model.refresh()
@ -236,6 +245,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
output[folder_id] = label
return output
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
def get_available_tags_by_entity_type(
self, project_name: str
) -> dict[str, list[str]]:
@ -479,20 +499,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def is_standard_projects_filter_enabled(self):
return self._host is not None
def _get_project_anatomy(self, project_name):
if not project_name:
return None
cache = self._project_anatomy_cache[project_name]
if not cache.is_valid:
cache.update_data(Anatomy(project_name))
return cache.get_data()
def _create_event_system(self):
return QueuedEventSystem()
def _emit_event(self, topic, data=None):
self._event_system.emit(topic, data or {}, "controller")
def get_product_types_filter(self):
output = ProductTypesFilter(
is_allow_list=False,
@ -548,3 +554,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
product_types=profile["filter_product_types"]
)
return output
def _create_event_system(self):
return QueuedEventSystem()
def _emit_event(self, topic, data=None):
self._event_system.emit(topic, data or {}, "controller")
def _get_project_anatomy(self, project_name):
if not project_name:
return None
cache = self._project_anatomy_cache[project_name]
if not cache.is_valid:
cache.update_data(Anatomy(project_name))
return cache.get_data()

View file

@ -1,11 +1,11 @@
from typing import Optional
import qtpy
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
)
from ayon_core.style import get_objected_colors
from ayon_core.tools.utils import DeselectableTreeView
from ayon_core.tools.utils.folders_widget import FoldersProxyModel
from ayon_core.tools.utils import (
FoldersQtModel,
@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
QtWidgets.QAbstractItemView.ExtendedSelection)
folders_model = LoaderFoldersModel(controller)
folders_proxy_model = RecursiveSortFilterProxyModel()
folders_proxy_model = FoldersProxyModel()
folders_proxy_model.setSourceModel(folders_model)
folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
if name:
self._folders_view.expandAll()
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
folder_ids (list[str]): The list of folder ids.
"""
self._folders_proxy_model.set_folder_ids_filter(folder_ids)
def set_merged_products_selection(self, items):
"""

View file

@ -1,11 +1,11 @@
import collections
import hashlib
from typing import Optional
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TasksQtModel,
TASKS_MODEL_SENDER_NAME,
@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import (
ITEM_NAME_ROLE,
PARENT_ID_ROLE,
TASK_TYPE_ROLE,
TasksProxyModel,
)
from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon
# Role that can't clash with default 'tasks_widget' roles
FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100
NO_TASKS_ID = "--no-task--"
@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel):
return super().data(index, role)
class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
class LoaderTasksProxyModel(TasksProxyModel):
def lessThan(self, left, right):
if left.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return False
@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel):
return True
return super().lessThan(left, right)
def filterAcceptsRow(self, row, parent_index):
source_index = self.sourceModel().index(row, 0, parent_index)
if source_index.data(ITEM_ID_ROLE) == NO_TASKS_ID:
return True
return super().filterAcceptsRow(row, parent_index)
class LoaderTasksWidget(QtWidgets.QWidget):
refreshed = QtCore.Signal()
@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget):
if name:
self._tasks_view.expandAll()
def set_task_ids_filter(self, task_ids: Optional[list[str]]):
"""Set filter of folder ids.
Args:
task_ids (list[str]): The list of folder ids.
"""
self._tasks_proxy_model.set_task_ids_filter(task_ids)
def refresh(self):
self._tasks_model.refresh()

View file

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

View file

@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
"""Get folder id from folder path."""
pass
@abstractmethod
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
"""Get entity ids for my tasks.
Args:
project_name (str): Project name.
Returns:
dict[str, list[str]]: Folder and task ids.
"""
pass
# --- Create ---
@abstractmethod
def get_creator_items(self) -> Dict[str, "CreatorItem"]:

View file

@ -11,7 +11,11 @@ from ayon_core.pipeline import (
registered_host,
get_process_id,
)
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from ayon_core.tools.common_models import (
ProjectsModel,
HierarchyModel,
UsersModel,
)
from .models import (
PublishModel,
@ -101,6 +105,7 @@ class PublisherController(
# Cacher of avalon documents
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._users_model = UsersModel(self)
@property
def log(self):
@ -317,6 +322,17 @@ class PublisherController(
return False
return True
def get_my_tasks_entity_ids(
self, project_name: str
) -> dict[str, list[str]]:
username = self._users_model.get_current_username()
assignees = []
if username:
assignees.append(username)
return self._hierarchy_model.get_entity_ids_for_assignees(
project_name, assignees
)
# --- Publish specific callbacks ---
def get_context_title(self):
"""Get context title for artist shown at the top of main window."""
@ -359,6 +375,7 @@ class PublisherController(
self._emit_event("controller.reset.started")
self._hierarchy_model.reset()
self._users_model.reset()
# Publish part must be reset after plugins
self._create_model.reset()

View file

@ -1,5 +1,6 @@
import logging
import re
import copy
from typing import (
Union,
List,
@ -1098,7 +1099,7 @@ class CreateModel:
creator_attributes[key] = attr_def.default
elif attr_def.is_value_valid(value):
creator_attributes[key] = value
creator_attributes[key] = copy.deepcopy(value)
def _set_instances_publish_attr_values(
self, instance_ids, plugin_name, key, value

View file

@ -202,7 +202,7 @@ class ContextCardWidget(CardWidget):
Is not visually under group widget and is always at the top of card view.
"""
def __init__(self, parent):
def __init__(self, parent: QtWidgets.QWidget):
super().__init__(parent)
self._id = CONTEXT_ID
@ -211,7 +211,7 @@ class ContextCardWidget(CardWidget):
icon_widget = PublishPixmapLabel(None, self)
icon_widget.setObjectName("ProductTypeIconLabel")
label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)
label_widget = QtWidgets.QLabel(f"<span>{CONTEXT_LABEL}</span>", self)
icon_layout = QtWidgets.QHBoxLayout()
icon_layout.setContentsMargins(5, 5, 5, 5)
@ -288,6 +288,8 @@ class InstanceCardWidget(CardWidget):
self._last_product_name = None
self._last_variant = None
self._last_label = None
self._last_folder_path = None
self._last_task_name = None
icon_widget = IconValuePixmapLabel(group_icon, self)
icon_widget.setObjectName("ProductTypeIconLabel")
@ -383,29 +385,54 @@ class InstanceCardWidget(CardWidget):
self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not valid)
@staticmethod
def _get_card_widget_sub_label(
folder_path: Optional[str],
task_name: Optional[str],
) -> str:
sublabel = ""
if folder_path:
folder_name = folder_path.rsplit("/", 1)[-1]
sublabel = f"<b>{folder_name}</b>"
if task_name:
sublabel += f" - <i>{task_name}</i>"
return sublabel
def _update_product_name(self):
variant = self.instance.variant
product_name = self.instance.product_name
label = self.instance.label
folder_path = self.instance.folder_path
task_name = self.instance.task_name
if (
variant == self._last_variant
and product_name == self._last_product_name
and label == self._last_label
and folder_path == self._last_folder_path
and task_name == self._last_task_name
):
return
self._last_variant = variant
self._last_product_name = product_name
self._last_label = label
self._last_folder_path = folder_path
self._last_task_name = task_name
# Make `variant` bold
label = html_escape(self.instance.label)
found_parts = set(re.findall(variant, label, re.IGNORECASE))
if found_parts:
for part in found_parts:
replacement = "<b>{}</b>".format(part)
replacement = f"<b>{part}</b>"
label = label.replace(part, replacement)
label = f"<span>{label}</span>"
sublabel = self._get_card_widget_sub_label(folder_path, task_name)
if sublabel:
label += f"<br/><span style=\"font-size: 8pt;\">{sublabel}</span>"
self._label_widget.setText(label)
# HTML text will cause that label start catch mouse clicks
# - disabling with changing interaction flag
@ -702,11 +729,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh(self):
"""Refresh instances in view based on CreatedContext."""
self._make_sure_context_widget_exists()
self._update_convertors_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
@ -814,6 +839,8 @@ class InstanceCardView(AbstractInstanceView):
widget.setVisible(False)
widget.deleteLater()
sorted_group_names.insert(0, CONTEXT_GROUP)
self._parent_id_by_id = parent_id_by_id
self._instance_ids_by_parent_id = instance_ids_by_parent_id
self._group_name_by_instance_id = group_by_instance_id
@ -881,7 +908,7 @@ class InstanceCardView(AbstractInstanceView):
context_info,
is_parent_active,
group_icon,
group_widget
group_widget,
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)

View file

@ -1,10 +1,14 @@
from qtpy import QtWidgets, QtCore
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.tools.utils import PlaceholderLineEdit, GoToCurrentButton
from ayon_core.tools.common_models import HierarchyExpectedSelection
from ayon_core.tools.utils import FoldersWidget, TasksWidget
from ayon_core.tools.utils import (
FoldersWidget,
TasksWidget,
FoldersFiltersWidget,
GoToCurrentButton,
)
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_widget = QtWidgets.QWidget(self)
folder_filter_input = PlaceholderLineEdit(headers_widget)
folder_filter_input.setPlaceholderText("Filter folders..")
filters_widget = FoldersFiltersWidget(headers_widget)
current_context_btn = GoToCurrentButton(headers_widget)
current_context_btn.setToolTip("Go to current context")
@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget):
headers_layout = QtWidgets.QHBoxLayout(headers_widget)
headers_layout.setContentsMargins(0, 0, 0, 0)
headers_layout.addWidget(folder_filter_input, 1)
headers_layout.setSpacing(5)
headers_layout.addWidget(filters_widget, 1)
headers_layout.addWidget(current_context_btn, 0)
hierarchy_controller = CreateHierarchyController(controller)
@ -207,15 +211,16 @@ class CreateContextWidget(QtWidgets.QWidget):
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_layout.addWidget(headers_widget, 0)
main_layout.addSpacing(5)
main_layout.addWidget(folders_widget, 2)
main_layout.addWidget(tasks_widget, 1)
folders_widget.selection_changed.connect(self._on_folder_change)
tasks_widget.selection_changed.connect(self._on_task_change)
current_context_btn.clicked.connect(self._on_current_context_click)
folder_filter_input.textChanged.connect(self._on_folder_filter_change)
filters_widget.text_changed.connect(self._on_folder_filter_change)
filters_widget.my_tasks_changed.connect(self._on_my_tasks_change)
self._folder_filter_input = folder_filter_input
self._current_context_btn = current_context_btn
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
@ -303,5 +308,17 @@ class CreateContextWidget(QtWidgets.QWidget):
self._last_project_name, folder_id, task_name
)
def _on_folder_filter_change(self, text):
def _on_folder_filter_change(self, text: str) -> None:
self._folders_widget.set_name_filter(text)
def _on_my_tasks_change(self, enabled: bool) -> None:
folder_ids = None
task_ids = None
if enabled:
entity_ids = self._controller.get_my_tasks_entity_ids(
self._last_project_name
)
folder_ids = entity_ids["folder_ids"]
task_ids = entity_ids["task_ids"]
self._folders_widget.set_folder_ids_filter(folder_ids)
self._tasks_widget.set_task_ids_filter(task_ids)

View file

@ -710,11 +710,13 @@ class CreateWidget(QtWidgets.QWidget):
def _on_first_show(self):
width = self.width()
part = int(width / 4)
rem_width = width - part
self._main_splitter_widget.setSizes([part, rem_width])
rem_width = rem_width - part
self._creators_splitter.setSizes([part, rem_width])
part = int(width / 9)
context_width = part * 3
create_sel_width = part * 2
rem_width = width - context_width
self._main_splitter_widget.setSizes([context_width, rem_width])
rem_width -= create_sel_width
self._creators_splitter.setSizes([create_sel_width, rem_width])
def showEvent(self, event):
super().showEvent(event)

View file

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

View file

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

View file

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

View file

@ -89,7 +89,7 @@ class ProjectPushItem:
variant,
comment,
new_folder_name,
dst_version,
version_up,
item_id=None,
use_original_name=False
):
@ -100,7 +100,7 @@ class ProjectPushItem:
self.dst_project_name = dst_project_name
self.dst_folder_id = dst_folder_id
self.dst_task_name = dst_task_name
self.dst_version = dst_version
self.version_up = version_up
self.variant = variant
self.new_folder_name = new_folder_name
self.comment = comment or ""
@ -118,7 +118,7 @@ class ProjectPushItem:
str(self.dst_folder_id),
str(self.new_folder_name),
str(self.dst_task_name),
str(self.dst_version),
str(self.version_up),
self.use_original_name
])
return self._repr_value
@ -133,7 +133,7 @@ class ProjectPushItem:
"dst_project_name": self.dst_project_name,
"dst_folder_id": self.dst_folder_id,
"dst_task_name": self.dst_task_name,
"dst_version": self.dst_version,
"version_up": self.version_up,
"variant": self.variant,
"comment": self.comment,
"new_folder_name": self.new_folder_name,
@ -948,10 +948,22 @@ class ProjectPushItemProcess:
self._product_entity = 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_name,
product_type,
folder_id,
attribs=dst_attrib
)
self._operations.create_entity(
project_name, "product", product_entity
@ -962,7 +974,7 @@ class ProjectPushItemProcess:
"""Make sure version document exits in database."""
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
product_entity = self._product_entity
product_id = product_entity["id"]
@ -990,27 +1002,29 @@ class ProjectPushItemProcess:
"description",
"intent",
}:
if key in src_attrib:
dst_attrib[key] = src_attrib[key]
value = src_attrib.get(key)
if value:
dst_attrib[key] = value
if version is None:
last_version_entity = ayon_api.get_last_version_by_product_id(
project_name, product_id
last_version_entity = ayon_api.get_last_version_by_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:
version = int(last_version_entity["version"]) + 1
else:
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"],
)
else:
dst_version = int(last_version_entity["version"])
if version_up:
dst_version += 1
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()
@ -1032,7 +1046,7 @@ class ProjectPushItemProcess:
copied_status = self._get_transferable_status(src_version_entity)
version_entity = new_version_entity(
version,
dst_version,
product_id,
author=src_version_entity["author"],
status=copied_status,
@ -1380,7 +1394,7 @@ class IntegrateModel:
variant,
comment,
new_folder_name,
dst_version,
version_up,
use_original_name
):
"""Create new item for integration.
@ -1394,7 +1408,7 @@ class IntegrateModel:
variant (str): Variant name.
comment (Union[str, None]): Comment.
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
Returns:
@ -1411,7 +1425,7 @@ class IntegrateModel:
variant,
comment=comment,
new_folder_name=new_folder_name,
dst_version=dst_version,
version_up=version_up,
use_original_name=use_original_name
)
process_item = ProjectPushItemProcess(self, item)

View file

@ -144,6 +144,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
variant_input.setPlaceholderText("< Variant >")
variant_input.setObjectName("ValidatedLineEdit")
version_up_checkbox = NiceCheckbox(True, parent=inputs_widget)
comment_input = PlaceholderLineEdit(inputs_widget)
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("Variant", variant_input)
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)
main_splitter.addWidget(context_widget)
@ -209,8 +215,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
"Show error detail dialog to copy full error."
)
original_names_checkbox.setToolTip(
"Required for multi copy, doesn't allow changes "
"variant values."
"Required for multi copy, doesn't allow changes variant values."
)
version_up_checkbox.setToolTip(
"Version up existing product. If not selected version will be "
"updated."
)
overlay_close_btn = QtWidgets.QPushButton(
@ -259,6 +268,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
library_only_checkbox.stateChanged.connect(self._on_library_only_change)
original_names_checkbox.stateChanged.connect(
self._on_original_names_change)
version_up_checkbox.stateChanged.connect(
self._on_version_up_checkbox_change)
publish_btn.clicked.connect(self._on_select_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._comment_input = comment_input
self._use_original_names_checkbox = original_names_checkbox
self._library_only_checkbox = library_only_checkbox
self._publish_btn = publish_btn
@ -328,6 +340,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._new_folder_name_input_text = None
self._variant_input_text = None
self._comment_input_text = None
self._version_up_checkbox = version_up_checkbox
self._first_show = True
self._show_timer = show_timer
@ -344,6 +357,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
show_detail_btn.setVisible(False)
overlay_close_btn.setVisible(False)
overlay_try_btn.setVisible(False)
version_up_checkbox.setChecked(False)
# Support of public api function of controller
def set_source(self, project_name, version_ids):
@ -376,7 +390,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._invalidate_new_folder_name(
new_folder_name, user_values["is_new_folder_name_valid"]
)
self._controller._invalidate()
self._projects_combobox.refresh()
def _on_first_show(self):
@ -415,14 +428,18 @@ class PushToContextSelectWindow(QtWidgets.QWidget):
self._comment_input_text = text
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"""
state = bool(state)
self._projects_combobox.set_standard_filter_enabled(state)
is_checked = self._library_only_checkbox.isChecked()
self._projects_combobox.set_standard_filter_enabled(is_checked)
def _on_original_names_change(self, state: int) -> None:
use_original_name = bool(state)
self._invalidate_use_original_names(use_original_name)
def _on_original_names_change(self) -> None:
is_checked = self._use_original_names_checkbox.isChecked()
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):
folder_name_enabled = self._new_folder_name_enabled

View file

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

View file

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

View file

@ -15,6 +15,8 @@ from ayon_core.tools.common_models import (
from .models import RecursiveSortFilterProxyModel
from .views import TreeView
from .lib import RefreshThread, get_qt_icon
from .widgets import PlaceholderLineEdit
from .nice_checkbox import NiceCheckbox
FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
@ -343,6 +345,8 @@ class FoldersProxyModel(RecursiveSortFilterProxyModel):
def __init__(self):
super().__init__()
self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
self._folder_ids_filter = None
def set_folder_ids_filter(self, folder_ids: Optional[list[str]]):
@ -794,3 +798,47 @@ class SimpleFoldersWidget(FoldersWidget):
event (Event): Triggered event.
"""
pass
class FoldersFiltersWidget(QtWidgets.QWidget):
"""Helper widget for most commonly used filters in context selection."""
text_changed = QtCore.Signal(str)
my_tasks_changed = QtCore.Signal(bool)
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
folders_filter_input = PlaceholderLineEdit(self)
folders_filter_input.setPlaceholderText("Folder name filter...")
my_tasks_tooltip = (
"Filter folders and task to only those you are assigned to."
)
my_tasks_label = QtWidgets.QLabel("My tasks", self)
my_tasks_label.setToolTip(my_tasks_tooltip)
my_tasks_checkbox = NiceCheckbox(self)
my_tasks_checkbox.setChecked(False)
my_tasks_checkbox.setToolTip(my_tasks_tooltip)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.addWidget(folders_filter_input, 1)
layout.addWidget(my_tasks_label, 0)
layout.addWidget(my_tasks_checkbox, 0)
folders_filter_input.textChanged.connect(self.text_changed)
my_tasks_checkbox.stateChanged.connect(self._on_my_tasks_change)
self._folders_filter_input = folders_filter_input
self._my_tasks_checkbox = my_tasks_checkbox
def set_text(self, text: str) -> None:
self._folders_filter_input.setText(text)
def set_my_tasks_checked(self, checked: bool) -> None:
self._my_tasks_checkbox.setChecked(checked)
def _on_my_tasks_change(self, _state: int) -> None:
self.my_tasks_changed.emit(self._my_tasks_checkbox.isChecked())

View file

@ -53,14 +53,8 @@ def checkstate_enum_to_int(state):
def center_window(window):
"""Move window to center of it's screen."""
if hasattr(QtWidgets.QApplication, "desktop"):
desktop = QtWidgets.QApplication.desktop()
screen_idx = desktop.screenNumber(window)
screen_geo = desktop.screenGeometry(screen_idx)
else:
screen = window.screen()
screen_geo = screen.geometry()
screen = window.screen()
screen_geo = screen.geometry()
geo = window.frameGeometry()
geo.moveCenter(screen_geo.center())

View file

@ -1,8 +1,15 @@
from __future__ import annotations
import os
from abc import ABC, abstractmethod
import typing
from typing import Optional
from ayon_core.style import get_default_entity_icon_color
if typing.TYPE_CHECKING:
from ayon_core.host import PublishedWorkfileInfo
class FolderItem:
"""Item representing folder entity on a server.
@ -159,6 +166,17 @@ class WorkareaFilepathResult:
self.filepath = filepath
class PublishedWorkfileWrap:
"""Wrapper for workfile info that also contains version comment."""
def __init__(
self,
info: Optional[PublishedWorkfileInfo] = None,
comment: Optional[str] = None,
) -> None:
self.info = info
self.comment = comment
class AbstractWorkfilesCommon(ABC):
@abstractmethod
def is_host_valid(self):
@ -787,6 +805,25 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
"""
pass
@abstractmethod
def get_published_workfile_info(
self,
folder_id: Optional[str],
representation_id: Optional[str],
) -> PublishedWorkfileWrap:
"""Get published workfile info by representation ID.
Args:
folder_id (Optional[str]): Folder id.
representation_id (Optional[str]): Representation id.
Returns:
PublishedWorkfileWrap: Published workfile info or None
if not found.
"""
pass
@abstractmethod
def get_workfile_info(self, folder_id, task_id, rootless_path):
"""Workfile info from database.

View file

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

View file

@ -17,6 +17,8 @@ class SelectionModel(object):
self._task_name = None
self._task_id = None
self._workfile_path = None
self._rootless_workfile_path = None
self._workfile_entity_id = None
self._representation_id = None
def get_selected_folder_id(self):
@ -62,39 +64,49 @@ class SelectionModel(object):
def get_selected_workfile_path(self):
return self._workfile_path
def get_selected_workfile_data(self):
return {
"project_name": self._controller.get_current_project_name(),
"path": self._workfile_path,
"rootless_path": self._rootless_workfile_path,
"folder_id": self._folder_id,
"task_name": self._task_name,
"task_id": self._task_id,
"workfile_entity_id": self._workfile_entity_id,
}
def set_selected_workfile_path(
self, rootless_path, path, workfile_entity_id
):
if path == self._workfile_path:
return
self._rootless_workfile_path = rootless_path
self._workfile_path = path
self._workfile_entity_id = workfile_entity_id
self._controller.emit_event(
"selection.workarea.changed",
{
"project_name": self._controller.get_current_project_name(),
"path": path,
"rootless_path": rootless_path,
"folder_id": self._folder_id,
"task_name": self._task_name,
"task_id": self._task_id,
"workfile_entity_id": workfile_entity_id,
},
self.get_selected_workfile_data(),
self.event_source
)
def get_selected_representation_id(self):
return self._representation_id
def get_selected_representation_data(self):
return {
"project_name": self._controller.get_current_project_name(),
"folder_id": self._folder_id,
"task_id": self._task_id,
"representation_id": self._representation_id,
}
def set_selected_representation_id(self, representation_id):
if representation_id == self._representation_id:
return
self._representation_id = representation_id
self._controller.emit_event(
"selection.representation.changed",
{
"project_name": self._controller.get_current_project_name(),
"representation_id": representation_id,
},
self.get_selected_representation_data(),
self.event_source
)

View file

@ -39,6 +39,7 @@ from ayon_core.pipeline.workfile import (
from ayon_core.pipeline.version_start import get_versioning_start
from ayon_core.tools.workfiles.abstract import (
WorkareaFilepathResult,
PublishedWorkfileWrap,
AbstractWorkfilesBackend,
)
@ -79,6 +80,7 @@ class WorkfilesModel:
# Published workfiles
self._repre_by_id = {}
self._version_comment_by_id = {}
self._published_workfile_items_cache = NestedCacheItem(
levels=1, default_factory=list
)
@ -95,6 +97,7 @@ class WorkfilesModel:
self._workarea_file_items_cache.reset()
self._repre_by_id = {}
self._version_comment_by_id = {}
self._published_workfile_items_cache.reset()
self._workfile_entities_by_task_id = {}
@ -552,13 +555,13 @@ class WorkfilesModel:
)
def get_published_file_items(
self, folder_id: str, task_id: str
self, folder_id: Optional[str], task_id: Optional[str]
) -> list[PublishedWorkfileInfo]:
"""Published workfiles for passed context.
Args:
folder_id (str): Folder id.
task_id (str): Task id.
folder_id (Optional[str]): Folder id.
task_id (Optional[str]): Task id.
Returns:
list[PublishedWorkfileInfo]: List of files for published workfiles.
@ -586,7 +589,7 @@ class WorkfilesModel:
version_entities = list(ayon_api.get_versions(
project_name,
product_ids=product_ids,
fields={"id", "author", "taskId"},
fields={"id", "author", "taskId", "attrib.comment"},
))
repre_entities = []
@ -600,6 +603,13 @@ class WorkfilesModel:
repre_entity["id"]: repre_entity
for repre_entity in repre_entities
})
# Map versions by representation ID for easy lookup
self._version_comment_by_id.update({
version_entity["id"]: version_entity["attrib"].get("comment")
for version_entity in version_entities
})
project_entity = self._controller.get_project_entity(project_name)
prepared_data = ListPublishedWorkfilesOptionalData(
@ -626,6 +636,34 @@ class WorkfilesModel:
]
return items
def get_published_workfile_info(
self,
folder_id: Optional[str],
representation_id: Optional[str],
) -> PublishedWorkfileWrap:
"""Get published workfile info by representation ID.
Args:
folder_id (Optional[str]): Folder id.
representation_id (Optional[str]): Representation id.
Returns:
PublishedWorkfileWrap: Published workfile info or None
if not found.
"""
if not representation_id:
return PublishedWorkfileWrap()
# Search through all cached published workfile items
for item in self.get_published_file_items(folder_id, None):
if item.representation_id == representation_id:
comment = self._get_published_workfile_version_comment(
representation_id
)
return PublishedWorkfileWrap(item, comment)
return PublishedWorkfileWrap()
@property
def _project_name(self) -> str:
return self._controller.get_current_project_name()
@ -642,6 +680,25 @@ class WorkfilesModel:
self._current_username = get_ayon_username()
return self._current_username
def _get_published_workfile_version_comment(
self, representation_id: str
) -> Optional[str]:
"""Get version comment for published workfile.
Args:
representation_id (str): Representation id.
Returns:
Optional[str]: Version comment or None.
"""
if not representation_id:
return None
repre = self._repre_by_id.get(representation_id)
if not repre:
return None
return self._version_comment_by_id.get(repre["versionId"])
# --- Host ---
def _open_workfile(self, folder_id: str, task_id: str, filepath: str):
# TODO move to workfiles pipeline

View file

@ -1,6 +1,7 @@
import datetime
from typing import Optional
from qtpy import QtWidgets, QtCore
from qtpy import QtCore, QtWidgets
def file_size_to_string(file_size):
@ -8,9 +9,9 @@ def file_size_to_string(file_size):
return "N/A"
size = 0
size_ending_mapping = {
"KB": 1024 ** 1,
"MB": 1024 ** 2,
"GB": 1024 ** 3
"KB": 1024**1,
"MB": 1024**2,
"GB": 1024**3,
}
ending = "B"
for _ending, _size in size_ending_mapping.items():
@ -70,7 +71,12 @@ class SidePanelWidget(QtWidgets.QWidget):
btn_description_save.clicked.connect(self._on_save_click)
controller.register_event_callback(
"selection.workarea.changed", self._on_selection_change
"selection.workarea.changed",
self._on_workarea_selection_change
)
controller.register_event_callback(
"selection.representation.changed",
self._on_representation_selection_change,
)
self._details_input = details_input
@ -82,12 +88,13 @@ class SidePanelWidget(QtWidgets.QWidget):
self._task_id = None
self._filepath = None
self._rootless_path = None
self._representation_id = None
self._orig_description = ""
self._controller = controller
self._set_context(None, None, None, None)
self._set_context(False, None, None)
def set_published_mode(self, published_mode):
def set_published_mode(self, published_mode: bool) -> None:
"""Change published mode.
Args:
@ -95,14 +102,37 @@ class SidePanelWidget(QtWidgets.QWidget):
"""
self._description_widget.setVisible(not published_mode)
# Clear the context when switching modes to avoid showing stale data
if published_mode:
self._set_publish_context(
self._folder_id,
self._task_id,
self._representation_id,
)
else:
self._set_workarea_context(
self._folder_id,
self._task_id,
self._rootless_path,
self._filepath,
)
def _on_selection_change(self, event):
def _on_workarea_selection_change(self, event):
folder_id = event["folder_id"]
task_id = event["task_id"]
filepath = event["path"]
rootless_path = event["rootless_path"]
self._set_context(folder_id, task_id, rootless_path, filepath)
self._set_workarea_context(
folder_id, task_id, rootless_path, filepath
)
def _on_representation_selection_change(self, event):
folder_id = event["folder_id"]
task_id = event["task_id"]
representation_id = event["representation_id"]
self._set_publish_context(folder_id, task_id, representation_id)
def _on_description_change(self):
text = self._description_input.toPlainText()
@ -118,85 +148,134 @@ class SidePanelWidget(QtWidgets.QWidget):
self._orig_description = description
self._btn_description_save.setEnabled(False)
def _set_context(self, folder_id, task_id, rootless_path, filepath):
def _set_workarea_context(
self,
folder_id: Optional[str],
task_id: Optional[str],
rootless_path: Optional[str],
filepath: Optional[str],
) -> None:
self._rootless_path = rootless_path
self._filepath = filepath
workfile_info = None
# Check if folder, task and file are selected
if folder_id and task_id and rootless_path:
workfile_info = self._controller.get_workfile_info(
folder_id, task_id, rootless_path
)
enabled = workfile_info is not None
self._details_input.setEnabled(enabled)
self._description_input.setEnabled(enabled)
self._btn_description_save.setEnabled(enabled)
self._folder_id = folder_id
self._task_id = task_id
self._filepath = filepath
self._rootless_path = rootless_path
# Disable inputs and remove texts if any required arguments are
# missing
if not enabled:
if workfile_info is None:
self._orig_description = ""
self._details_input.setPlainText("")
self._description_input.setPlainText("")
self._set_context(False, folder_id, task_id)
return
description = workfile_info.description
size_value = file_size_to_string(workfile_info.file_size)
self._set_context(
True,
folder_id,
task_id,
file_created=workfile_info.file_created,
file_modified=workfile_info.file_modified,
size_value=workfile_info.file_size,
created_by=workfile_info.created_by,
updated_by=workfile_info.updated_by,
)
description = workfile_info.description
self._orig_description = description
self._description_input.setPlainText(description)
def _set_publish_context(
self,
folder_id: Optional[str],
task_id: Optional[str],
representation_id: Optional[str],
) -> None:
self._representation_id = representation_id
published_workfile_wrap = self._controller.get_published_workfile_info(
folder_id,
representation_id,
)
info = published_workfile_wrap.info
comment = published_workfile_wrap.comment
if info is None:
self._set_context(False, folder_id, task_id)
return
self._set_context(
True,
folder_id,
task_id,
file_created=info.file_created,
file_modified=info.file_modified,
size_value=info.file_size,
created_by=info.author,
comment=comment,
)
def _set_context(
self,
is_valid: bool,
folder_id: Optional[str],
task_id: Optional[str],
*,
file_created: Optional[int] = None,
file_modified: Optional[int] = None,
size_value: Optional[int] = None,
created_by: Optional[str] = None,
updated_by: Optional[str] = None,
comment: Optional[str] = None,
) -> None:
self._folder_id = folder_id
self._task_id = task_id
self._details_input.setEnabled(is_valid)
self._description_input.setEnabled(is_valid)
self._btn_description_save.setEnabled(is_valid)
if not is_valid:
self._details_input.setPlainText("")
return
# Append html string
datetime_format = "%b %d %Y %H:%M:%S"
file_created = workfile_info.file_created
modification_time = workfile_info.file_modified
if file_created:
file_created = datetime.datetime.fromtimestamp(file_created)
if modification_time:
modification_time = datetime.datetime.fromtimestamp(
modification_time)
if file_modified:
file_modified = datetime.datetime.fromtimestamp(
file_modified
)
user_items_by_name = self._controller.get_user_items_by_name()
def convert_username(username):
user_item = user_items_by_name.get(username)
def convert_username(username_v):
user_item = user_items_by_name.get(username_v)
if user_item is not None and user_item.full_name:
return user_item.full_name
return username
return username_v
created_lines = []
if workfile_info.created_by:
created_lines.append(
convert_username(workfile_info.created_by)
)
if file_created:
created_lines.append(file_created.strftime(datetime_format))
lines = []
if size_value is not None:
size_value = file_size_to_string(size_value)
lines.append(f"<b>Size:</b><br/>{size_value}")
if created_lines:
created_lines.insert(0, "<b>Created:</b>")
# Add version comment for published workfiles
if comment:
lines.append(f"<b>Comment:</b><br/>{comment}")
modified_lines = []
if workfile_info.updated_by:
modified_lines.append(
convert_username(workfile_info.updated_by)
)
if modification_time:
modified_lines.append(
modification_time.strftime(datetime_format)
)
if modified_lines:
modified_lines.insert(0, "<b>Modified:</b>")
if created_by or file_created:
lines.append("<b>Created:</b>")
if created_by:
lines.append(convert_username(created_by))
if file_created:
lines.append(file_created.strftime(datetime_format))
lines = (
"<b>Size:</b>",
size_value,
"<br/>".join(created_lines),
"<br/>".join(modified_lines),
)
self._orig_description = description
self._description_input.setPlainText(description)
if updated_by or file_modified:
lines.append("<b>Modified:</b>")
if updated_by:
lines.append(convert_username(updated_by))
if file_modified:
lines.append(file_modified.strftime(datetime_format))
# Set as empty string
self._details_input.setPlainText("")

View file

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

View file

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

View file

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

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.6.7+dev"
version = "1.6.9+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
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",
},
},
])