From 148ce21a9ac25a446a3ce2233501d288b396d34b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Nov 2024 09:09:12 +0100 Subject: [PATCH 001/279] Allow ExtractOIIOTranscode to pass with a warning if it can't find the RGBA channels in source media instead of raising error --- client/ayon_core/lib/transcoding.py | 7 ++++- .../publish/extract_color_transcode.py | 30 ++++++++++++------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index e9750864ac..7fe2c84789 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -67,6 +67,11 @@ VIDEO_EXTENSIONS = { } +class UnknownRGBAChannelsError(ValueError): + """Raised when we can't find RGB channels for conversion in input media.""" + pass + + def get_transcode_temp_directory(): """Creates temporary folder for transcoding. @@ -1427,7 +1432,7 @@ 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( + raise UnknownRGBAChannelsError( "Couldn't find channels that can be used for conversion." ) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 3e54d324e3..b259a3230a 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -10,6 +10,7 @@ from ayon_core.lib import ( ) from ayon_core.lib.transcoding import ( + UnknownRGBAChannelsError, convert_colorspace, get_transcode_temp_directory, ) @@ -160,17 +161,24 @@ class ExtractOIIOTranscode(publish.Extractor): output_path = self._get_output_file_path(input_path, new_staging_dir, output_extension) - convert_colorspace( - input_path, - output_path, - config_path, - source_colorspace, - target_colorspace, - view, - display, - additional_command_args, - self.log - ) + try: + convert_colorspace( + input_path, + output_path, + config_path, + source_colorspace, + target_colorspace, + view, + display, + additional_command_args, + self.log + ) + except UnknownRGBAChannelsError: + self.log.error( + "Skipping OIIO Transcode. Unknown RGBA channels" + f" for colorspace conversion in file: {input_path}" + ) + continue # cleanup temporary transcoded files for file_name in new_repre["files"]: From d072da86d1abada39af3e024c6010efc5554cd52 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Nov 2024 09:11:22 +0100 Subject: [PATCH 002/279] Stop processing directly on unknown RGBA channel for a representation --- .../plugins/publish/extract_color_transcode.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index b259a3230a..0287eca300 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -153,8 +153,7 @@ class ExtractOIIOTranscode(publish.Extractor): additional_command_args = (output_def["oiiotool_args"] ["additional_command_args"]) - files_to_convert = self._translate_to_sequence( - files_to_convert) + unknown_rgba_channels = False for file_name in files_to_convert: input_path = os.path.join(original_staging_dir, file_name) @@ -174,11 +173,16 @@ class ExtractOIIOTranscode(publish.Extractor): self.log ) except UnknownRGBAChannelsError: + unknown_rgba_channels = True self.log.error( "Skipping OIIO Transcode. Unknown RGBA channels" f" for colorspace conversion in file: {input_path}" ) - continue + break + + if unknown_rgba_channels: + # Stop processing this representation + break # cleanup temporary transcoded files for file_name in new_repre["files"]: From 363824d58946796d148b0216a5464928a91b3b1d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Nov 2024 09:12:19 +0100 Subject: [PATCH 003/279] Move logic to make it clearer that we always process the same input files of the source representation but used for multiple output profiles --- .../plugins/publish/extract_color_transcode.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 0287eca300..0d88f3073f 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -100,7 +100,19 @@ 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"]] + repre_files_to_convert = self._translate_to_sequence( + repre_files_to_convert) + + # 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) @@ -108,11 +120,6 @@ class ExtractOIIOTranscode(publish.Extractor): new_staging_dir = get_transcode_temp_directory() 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, @@ -121,7 +128,6 @@ class ExtractOIIOTranscode(publish.Extractor): output_extension) transcoding_type = output_def["transcoding_type"] - target_colorspace = view = display = None # NOTE: we use colorspace_data as the fallback values for # the target colorspace. From 445dd4ec5bd79732736eaae89d4c6d337ef9c339 Mon Sep 17 00:00:00 2001 From: jm22dogs Date: Fri, 4 Apr 2025 12:32:15 +0100 Subject: [PATCH 004/279] add card sublabel with folder and task name --- .../tools/publisher/widgets/card_view_widgets.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 2f633b3149..65c5d1d4ef 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -492,10 +492,22 @@ class InstanceCardWidget(CardWidget): self._icon_widget.setVisible(valid) self._context_warning.setVisible(not valid) + @staticmethod + def get_card_widget_sub_label(folder_name, task_name=None): + sublabel = "
" + sublabel += "{}".format(folder_name) + if task_name: + sublabel += " - {}".format(task_name) + sublabel += "" + return sublabel + def _update_product_name(self): variant = self.instance.variant product_name = self.instance.product_name label = self.instance.label + folder_name = self.instance.get_folder_path().split("/")[-1] + task_name = self.instance.get_task_name() + if ( variant == self._last_variant and product_name == self._last_product_name @@ -513,6 +525,7 @@ class InstanceCardWidget(CardWidget): for part in found_parts: replacement = "{}".format(part) label = label.replace(part, replacement) + label += self.get_card_widget_sub_label(folder_name, task_name) self._label_widget.setText(label) # HTML text will cause that label start catch mouse clicks From 1e3aaa887db0a9e7d6e823404911782530c308e2 Mon Sep 17 00:00:00 2001 From: Juan M <166030421+jm22dogs@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:41:39 +0100 Subject: [PATCH 005/279] Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../publisher/widgets/card_view_widgets.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 65c5d1d4ef..8a8b81d615 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -493,13 +493,17 @@ class InstanceCardWidget(CardWidget): self._context_warning.setVisible(not valid) @staticmethod - def get_card_widget_sub_label(folder_name, task_name=None): - sublabel = "
" - sublabel += "{}".format(folder_name) - if task_name: - sublabel += " - {}".format(task_name) - sublabel += "" - return sublabel + def _get_card_widget_sub_label(folder_path, task_name): + parts = [] + if folder_path: + folder_name = folder_path.split("/")[-1] + parts.append(f"{folder_name}") + if task_name: + parts.append(folder_name) + if not parts: + return None + sublabel = " - ".join(parts) + return f"{sublabel}" def _update_product_name(self): variant = self.instance.variant From 66ecc40a80f084bfccfc28ffe424ef0f3fa8cb1a Mon Sep 17 00:00:00 2001 From: Juan M <166030421+jm22dogs@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:42:45 +0100 Subject: [PATCH 006/279] Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 8a8b81d615..e847b2e970 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -529,7 +529,9 @@ class InstanceCardWidget(CardWidget): for part in found_parts: replacement = "{}".format(part) label = label.replace(part, replacement) - label += self.get_card_widget_sub_label(folder_name, task_name) + sublabel = self._get_card_widget_sub_label(folder_path, task_name) + if sublabel: + label += f"
{sublabel}" self._label_widget.setText(label) # HTML text will cause that label start catch mouse clicks From b6296423f5c7103da44e8e103f106d26b9e34030 Mon Sep 17 00:00:00 2001 From: Juan M <166030421+jm22dogs@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:42:55 +0100 Subject: [PATCH 007/279] Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index e847b2e970..cbafa6fe81 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -509,7 +509,7 @@ class InstanceCardWidget(CardWidget): variant = self.instance.variant product_name = self.instance.product_name label = self.instance.label - folder_name = self.instance.get_folder_path().split("/")[-1] + folder_path = self.instance.get_folder_path() task_name = self.instance.get_task_name() if ( From 08aee24a484986a4e8d3943ff762fa926d3cbc55 Mon Sep 17 00:00:00 2001 From: Juan M <166030421+jm22dogs@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:47:51 +0100 Subject: [PATCH 008/279] Update client/ayon_core/tools/publisher/widgets/card_view_widgets.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../ayon_core/tools/publisher/widgets/card_view_widgets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index cbafa6fe81..958542d264 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -529,9 +529,9 @@ class InstanceCardWidget(CardWidget): for part in found_parts: replacement = "{}".format(part) label = label.replace(part, replacement) - sublabel = self._get_card_widget_sub_label(folder_path, task_name) - if sublabel: - label += f"
{sublabel}" + sublabel = self._get_card_widget_sub_label(folder_path, task_name) + if sublabel: + label += f"
{sublabel}" self._label_widget.setText(label) # HTML text will cause that label start catch mouse clicks From 2d7bd487bac24b28c78e5bf67a19c4f69c6e3db7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 13 May 2025 15:40:40 +0200 Subject: [PATCH 009/279] Allow review/transcoding of more channels, like "Y", "XYZ", "AR", "AG", "AB" --- client/ayon_core/lib/transcoding.py | 73 ++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..b236cd101b 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -345,6 +345,9 @@ 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 'red', 'green', 'blue' or 'R', 'G', 'B', + Args: channel_names (list[str]): List of channel names. @@ -353,7 +356,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: @@ -362,42 +364,76 @@ 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" + # Detect RGBA channels + "r", "g", "b", "a", + # 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 + + # R, G, B, A or X, Y, Z, N, AR, AG, AB 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 to the beginning of the list # - if input has R, G, B, A channels they should be used for review - if "" in layer_names_order: - layer_names_order.remove("") - layer_names_order.insert(0, "") + def _sort(_layer_name: str) -> int: + # Prioritize "" layer name + # Prioritize layers with RGB channels + order = 0 + if _layer_name == "": + order -= 10 + + channels = channels_by_layer_name[_layer_name] + if all(channel in channels for channel in "RGB"): + order -= 1 + return order + 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] + + # RGB channels + if all(channel in channel_info for channel in "RGB"): + rgb = "R", "G", "B" + + # 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" + + 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": channel_info.get("A"), } }) return output @@ -1428,7 +1464,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): if review_channels is None: raise ValueError( - "Couldn't find channels that can be used for conversion." + "Couldn't find channels that can be used for conversion " + f"among channels: {channel_names}." ) red, green, blue, alpha = review_channels From afbf2c8848b6045dd7d712323237a7d823922d5f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 17 May 2025 15:40:54 +0200 Subject: [PATCH 010/279] Refactor `UnknownRGBAChannelsError` -> `MissingRGBAChannelsError` --- client/ayon_core/lib/transcoding.py | 4 ++-- client/ayon_core/plugins/publish/extract_color_transcode.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 8c96bde07e..07b6d8a039 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -67,7 +67,7 @@ VIDEO_EXTENSIONS = { } -class UnknownRGBAChannelsError(ValueError): +class MissingRGBAChannelsError(ValueError): """Raised when we can't find RGB channels for conversion in input media.""" pass @@ -1337,7 +1337,7 @@ 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 UnknownRGBAChannelsError( + raise MissingRGBAChannelsError( "Couldn't find channels that can be used for conversion " f"among channels: {channel_names}." ) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 0475b63f82..a4b6fe7fc2 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -11,7 +11,7 @@ from ayon_core.lib import ( is_oiio_supported, ) from ayon_core.lib.transcoding import ( - UnknownRGBAChannelsError, + MissingRGBAChannelsError, convert_colorspace, ) @@ -186,7 +186,7 @@ class ExtractOIIOTranscode(publish.Extractor): additional_command_args, self.log ) - except UnknownRGBAChannelsError: + except MissingRGBAChannelsError: unknown_rgba_channels = True self.log.error( "Skipping OIIO Transcode. Unknown RGBA channels" From 44dc1ea99e46c108bfbc9f4dd63c9eff7a945731 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 17 May 2025 15:43:41 +0200 Subject: [PATCH 011/279] Include message of the original raised error --- client/ayon_core/plugins/publish/extract_color_transcode.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index a4b6fe7fc2..09f7e6f201 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -186,8 +186,9 @@ class ExtractOIIOTranscode(publish.Extractor): additional_command_args, self.log ) - except MissingRGBAChannelsError: + except MissingRGBAChannelsError as exc: unknown_rgba_channels = True + self.log.error(exc) self.log.error( "Skipping OIIO Transcode. Unknown RGBA channels" f" for colorspace conversion in file: {input_path}" From 7fa192229c207483c3bf30bf55d479a1d2aae8b3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 17 May 2025 15:47:50 +0200 Subject: [PATCH 012/279] Improve docstring --- client/ayon_core/lib/transcoding.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 07b6d8a039..e665486d7b 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -351,7 +351,8 @@ 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 'red', 'green', 'blue' or 'R', 'G', 'B', + searching for channel names like RGBA, but also XYZ, Z, N, AR, AG, AB + channels. Args: channel_names (list[str]): List of channel names. From 72895df6ae121b013c63071e080e2609cc22b7a0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 19 May 2025 11:16:52 +0200 Subject: [PATCH 013/279] Match variable name more with captured exception --- client/ayon_core/plugins/publish/extract_color_transcode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 09f7e6f201..45832bd1e5 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -166,7 +166,7 @@ class ExtractOIIOTranscode(publish.Extractor): files_to_convert = self._translate_to_sequence( files_to_convert) self.log.debug("Files to convert: {}".format(files_to_convert)) - unknown_rgba_channels = False + missing_rgba_review_channels = False for file_name in files_to_convert: self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, @@ -187,7 +187,7 @@ class ExtractOIIOTranscode(publish.Extractor): self.log ) except MissingRGBAChannelsError as exc: - unknown_rgba_channels = True + missing_rgba_review_channels = True self.log.error(exc) self.log.error( "Skipping OIIO Transcode. Unknown RGBA channels" @@ -195,7 +195,7 @@ class ExtractOIIOTranscode(publish.Extractor): ) break - if unknown_rgba_channels: + if missing_rgba_review_channels: # Stop processing this representation break From b8ea018b43b7256676362f699d6f66df848cb710 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 19 May 2025 11:18:37 +0200 Subject: [PATCH 014/279] Clarify exception --- client/ayon_core/lib/transcoding.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index e665486d7b..57ae6cb0bc 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -68,7 +68,12 @@ VIDEO_EXTENSIONS = { class MissingRGBAChannelsError(ValueError): - """Raised when we can't find RGB channels for conversion in input media.""" + """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. + """ pass From 526e5bfabb2af995863ef652fb34635002f37641 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 19 May 2025 12:01:14 +0200 Subject: [PATCH 015/279] Add unittest --- .../client/ayon_core/lib/test_transcoding.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/client/ayon_core/lib/test_transcoding.py diff --git a/tests/client/ayon_core/lib/test_transcoding.py b/tests/client/ayon_core/lib/test_transcoding.py new file mode 100644 index 0000000000..f637e8bd9e --- /dev/null +++ b/tests/client/ayon_core/lib/test_transcoding.py @@ -0,0 +1,135 @@ +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_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", + }, + }, + ]) From 591767152131c6be76db170f10a745a65e79fd5d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 19 May 2025 12:04:01 +0200 Subject: [PATCH 016/279] Add AR, AG, AB test case and fix behavior --- client/ayon_core/lib/transcoding.py | 2 +- .../client/ayon_core/lib/test_transcoding.py | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 57ae6cb0bc..895e220729 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -389,7 +389,7 @@ def get_review_info_by_layer_name(channel_names): layer_names_order.append(layer_name) # R, G, B, A or X, Y, Z, N, AR, AG, AB - channel = last_part[0].upper() + channel = last_part.upper() channels_by_layer_name[layer_name][channel] = channel_name # Put empty layer to the beginning of the list diff --git a/tests/client/ayon_core/lib/test_transcoding.py b/tests/client/ayon_core/lib/test_transcoding.py index f637e8bd9e..b9959e2958 100644 --- a/tests/client/ayon_core/lib/test_transcoding.py +++ b/tests/client/ayon_core/lib/test_transcoding.py @@ -82,6 +82,29 @@ class GetReviewInfoByLayerName(unittest.TestCase): } }]) + 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"]) From 00921e7806ab345037030fc8bb5ebd426840ce53 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 19 May 2025 12:05:28 +0200 Subject: [PATCH 017/279] Update client/ayon_core/lib/transcoding.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 895e220729..a22b8aaf64 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -74,7 +74,6 @@ class MissingRGBAChannelsError(ValueError): This may be other channels than solely RGBA, like Z-channel. The error is raised when no matching 'reviewable' channel was found. """ - pass def get_transcode_temp_directory(): From 0c23ecc70d1dd2c2ea8e39c7f752b2cc406f379a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 20:52:08 +0200 Subject: [PATCH 018/279] Add support for red, green, blue and alpha --- client/ayon_core/lib/transcoding.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index a22b8aaf64..fc0d6bf8cd 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -377,6 +377,8 @@ def get_review_info_by_layer_name(channel_names): if last_part.lower() 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 @@ -410,10 +412,20 @@ def get_review_info_by_layer_name(channel_names): for layer_name in layer_names_order: 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" @@ -443,7 +455,7 @@ def get_review_info_by_layer_name(channel_names): "R": red, "G": green, "B": blue, - "A": channel_info.get("A"), + "A": alpha, } }) return output From 55bfd79cf3cdfd4225b5e7ffdb9b660e3dd23592 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 11 Jun 2025 20:55:21 +0200 Subject: [PATCH 019/279] Check against `.upper()` instead of `.lower()` to match strings more with how they are compared later in the code (improve style consistency) --- client/ayon_core/lib/transcoding.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index fc0d6bf8cd..643a056563 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -374,23 +374,23 @@ def get_review_info_by_layer_name(channel_names): if "." in channel_name: layer_name, last_part = channel_name.rsplit(".", 1) - if last_part.lower() not in { + # 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", + "R", "G", "B", "A", # Support fully written out rgba channel names - "red", "green", "blue", "alpha", + "RED", "GREEN", "BLUE", "ALPHA", # Allow detecting of x, y and z channels, and normal channels - "x", "y", "z", "n", + "X", "Y", "Z", "N", # red, green and blue alpha/opacity, for colored mattes - "ar", "ag", "ab" + "AR", "AG", "AB" }: continue if layer_name not in layer_names_order: layer_names_order.append(layer_name) - # R, G, B, A or X, Y, Z, N, AR, AG, AB - channel = last_part.upper() channels_by_layer_name[layer_name][channel] = channel_name # Put empty layer to the beginning of the list From 3d612016089436d0742262e02862586b8410c5b4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 12:04:17 +0200 Subject: [PATCH 020/279] Collect Loaded Scene Versions: Enable for more hosts --- .../plugins/publish/collect_scene_loaded_versions.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1abb8e29d2..1c28c28f5b 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -13,15 +13,23 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): "aftereffects", "blender", "celaction", + "cinema4d", + "flame", "fusion", "harmony", "hiero", "houdini", + "max", "maya", + "motionbuilder", "nuke", "photoshop", + "silhouette", + "substancepainter", + "substancedesigner", "resolve", - "tvpaint" + "tvpaint", + "zbrush", ] def process(self, context): From 1e1828bbdc8cc9fb8c5f81d7a2f34a4e745d3285 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:21:40 +0200 Subject: [PATCH 021/279] moved current actions to subdir --- client/ayon_core/pipeline/actions/__init__.py | 33 ++++++ .../ayon_core/pipeline/actions/inventory.py | 108 ++++++++++++++++++ .../{actions.py => actions/launcher.py} | 104 ----------------- 3 files changed, 141 insertions(+), 104 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/__init__.py create mode 100644 client/ayon_core/pipeline/actions/inventory.py rename client/ayon_core/pipeline/{actions.py => actions/launcher.py} (76%) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py new file mode 100644 index 0000000000..bda9b50ede --- /dev/null +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -0,0 +1,33 @@ +from .launcher import ( + LauncherAction, + LauncherActionSelection, + discover_launcher_actions, + register_launcher_action, + register_launcher_action_path, +) + +from .inventory import ( + InventoryAction, + discover_inventory_actions, + register_inventory_action, + register_inventory_action_path, + + deregister_inventory_action, + deregister_inventory_action_path, +) + + +__all__= ( + "LauncherAction", + "LauncherActionSelection", + "discover_launcher_actions", + "register_launcher_action", + "register_launcher_action_path", + + "InventoryAction", + "discover_inventory_actions", + "register_inventory_action", + "register_inventory_action_path", + "deregister_inventory_action", + "deregister_inventory_action_path", +) diff --git a/client/ayon_core/pipeline/actions/inventory.py b/client/ayon_core/pipeline/actions/inventory.py new file mode 100644 index 0000000000..2300119336 --- /dev/null +++ b/client/ayon_core/pipeline/actions/inventory.py @@ -0,0 +1,108 @@ +import logging + +from ayon_core.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) +from ayon_core.pipeline.load.utils import get_representation_path_from_context + + +class InventoryAction: + """A custom action for the scene inventory tool + + If registered the action will be visible in the Right Mouse Button menu + under the submenu "Actions". + + """ + + label = None + icon = None + color = None + order = 0 + + log = logging.getLogger("InventoryAction") + log.propagate = True + + @staticmethod + def is_compatible(container): + """Override function in a custom class + + This method is specifically used to ensure the action can operate on + the container. + + Args: + container(dict): the data of a loaded asset, see host.ls() + + Returns: + bool + """ + return bool(container.get("objectName")) + + def process(self, containers): + """Override function in a custom class + + This method will receive all containers even those which are + incompatible. It is advised to create a small filter along the lines + of this example: + + valid_containers = filter(self.is_compatible(c) for c in containers) + + The return value will need to be a True-ish value to trigger + the data_changed signal in order to refresh the view. + + You can return a list of container names to trigger GUI to select + treeview items. + + You can return a dict to carry extra GUI options. For example: + { + "objectNames": [container names...], + "options": {"mode": "toggle", + "clear": False} + } + Currently workable GUI options are: + - clear (bool): Clear current selection before selecting by action. + Default `True`. + - mode (str): selection mode, use one of these: + "select", "deselect", "toggle". Default is "select". + + Args: + containers (list): list of dictionaries + + Return: + bool, list or dict + + """ + return True + + @classmethod + def filepath_from_context(cls, context): + return get_representation_path_from_context(context) + + +def discover_inventory_actions(): + actions = discover(InventoryAction) + filtered_actions = [] + for action in actions: + if action is not InventoryAction: + filtered_actions.append(action) + + return filtered_actions + + +def register_inventory_action(plugin): + return register_plugin(InventoryAction, plugin) + + +def deregister_inventory_action(plugin): + deregister_plugin(InventoryAction, plugin) + + +def register_inventory_action_path(path): + return register_plugin_path(InventoryAction, path) + + +def deregister_inventory_action_path(path): + return deregister_plugin_path(InventoryAction, path) diff --git a/client/ayon_core/pipeline/actions.py b/client/ayon_core/pipeline/actions/launcher.py similarity index 76% rename from client/ayon_core/pipeline/actions.py rename to client/ayon_core/pipeline/actions/launcher.py index 860fed5e8b..d47123cf20 100644 --- a/client/ayon_core/pipeline/actions.py +++ b/client/ayon_core/pipeline/actions/launcher.py @@ -8,12 +8,8 @@ from ayon_core.pipeline.plugin_discover import ( discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) -from .load.utils import get_representation_path_from_context - class LauncherActionSelection: """Object helper to pass selection to actions. @@ -347,79 +343,6 @@ class LauncherAction(object): pass -class InventoryAction(object): - """A custom action for the scene inventory tool - - If registered the action will be visible in the Right Mouse Button menu - under the submenu "Actions". - - """ - - label = None - icon = None - color = None - order = 0 - - log = logging.getLogger("InventoryAction") - log.propagate = True - - @staticmethod - def is_compatible(container): - """Override function in a custom class - - This method is specifically used to ensure the action can operate on - the container. - - Args: - container(dict): the data of a loaded asset, see host.ls() - - Returns: - bool - """ - return bool(container.get("objectName")) - - def process(self, containers): - """Override function in a custom class - - This method will receive all containers even those which are - incompatible. It is advised to create a small filter along the lines - of this example: - - valid_containers = filter(self.is_compatible(c) for c in containers) - - The return value will need to be a True-ish value to trigger - the data_changed signal in order to refresh the view. - - You can return a list of container names to trigger GUI to select - treeview items. - - You can return a dict to carry extra GUI options. For example: - { - "objectNames": [container names...], - "options": {"mode": "toggle", - "clear": False} - } - Currently workable GUI options are: - - clear (bool): Clear current selection before selecting by action. - Default `True`. - - mode (str): selection mode, use one of these: - "select", "deselect", "toggle". Default is "select". - - Args: - containers (list): list of dictionaries - - Return: - bool, list or dict - - """ - return True - - @classmethod - def filepath_from_context(cls, context): - return get_representation_path_from_context(context) - - -# Launcher action def discover_launcher_actions(): return discover(LauncherAction) @@ -430,30 +353,3 @@ def register_launcher_action(plugin): def register_launcher_action_path(path): return register_plugin_path(LauncherAction, path) - - -# Inventory action -def discover_inventory_actions(): - actions = discover(InventoryAction) - filtered_actions = [] - for action in actions: - if action is not InventoryAction: - filtered_actions.append(action) - - return filtered_actions - - -def register_inventory_action(plugin): - return register_plugin(InventoryAction, plugin) - - -def deregister_inventory_action(plugin): - deregister_plugin(InventoryAction, plugin) - - -def register_inventory_action_path(path): - return register_plugin_path(InventoryAction, path) - - -def deregister_inventory_action_path(path): - return deregister_plugin_path(InventoryAction, path) From bd94d7ede6d2cf4806e817aa2b93d7d6d2160408 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:36:39 +0200 Subject: [PATCH 022/279] move 'StrEnum' to lib --- client/ayon_core/host/constants.py | 9 +-------- client/ayon_core/lib/__init__.py | 3 +++ client/ayon_core/lib/_compatibility.py | 8 ++++++++ 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 client/ayon_core/lib/_compatibility.py diff --git a/client/ayon_core/host/constants.py b/client/ayon_core/host/constants.py index 2564c5d54d..1ca33728d8 100644 --- a/client/ayon_core/host/constants.py +++ b/client/ayon_core/host/constants.py @@ -1,11 +1,4 @@ -from enum import Enum - - -class StrEnum(str, Enum): - """A string-based Enum class that allows for string comparison.""" - - def __str__(self) -> str: - return self.value +from ayon_core.lib import StrEnum class ContextChangeReason(StrEnum): diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 5ccc8d03e5..1097cf701a 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -2,6 +2,7 @@ # flake8: noqa E402 """AYON lib functions.""" +from ._compatibility import StrEnum from .local_settings import ( IniSettingRegistry, JSONSettingRegistry, @@ -140,6 +141,8 @@ from .ayon_info import ( terminal = Terminal __all__ = [ + "StrEnum", + "IniSettingRegistry", "JSONSettingRegistry", "AYONSecureRegistry", diff --git a/client/ayon_core/lib/_compatibility.py b/client/ayon_core/lib/_compatibility.py new file mode 100644 index 0000000000..299ed5e233 --- /dev/null +++ b/client/ayon_core/lib/_compatibility.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class StrEnum(str, Enum): + """A string-based Enum class that allows for string comparison.""" + + def __str__(self) -> str: + return self.value From 5e3b38376c6e5ed5f4bc0450a08632fb19e45f9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:37:16 +0200 Subject: [PATCH 023/279] separated discover logic from 'PluginDiscoverContext' --- client/ayon_core/pipeline/plugin_discover.py | 124 +++++++++++-------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index 03da7fce79..dddd6847ec 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import os import inspect import traceback +from typing import Optional from ayon_core.lib import Logger from ayon_core.lib.python_module_tools import ( @@ -96,6 +99,70 @@ class DiscoverResult: log.info(report) +def discover_plugins( + base_class: type, + paths: Optional[list[str]] = None, + classes: Optional[list[type]] = None, + ignored_classes: Optional[list[type]] = None, + allow_duplicates: bool = True, +): + """Find and return subclasses of `superclass` + + Args: + base_class (type): Class which determines discovered subclasses. + paths (Optional[list[str]]): List of paths to look for plug-ins. + classes (Optional[list[str]]): List of classes to filter. + ignored_classes (list[type]): List of classes that won't be added to + the output plugins. + allow_duplicates (bool): Validate class name duplications. + + Returns: + DiscoverResult: Object holding successfully + discovered plugins, ignored plugins, plugins with missing + abstract implementation and duplicated plugin. + + """ + ignored_classes = ignored_classes or [] + paths = paths or [] + classes = classes or [] + + result = DiscoverResult(base_class) + + all_plugins = list(classes) + + for path in paths: + modules, crashed = modules_from_path(path) + for (filepath, exc_info) in crashed: + result.crashed_file_paths[filepath] = exc_info + + for item in modules: + filepath, module = item + result.add_module(module) + all_plugins.extend(classes_from_module(base_class, module)) + + if base_class not in ignored_classes: + ignored_classes.append(base_class) + + plugin_names = set() + for cls in all_plugins: + if cls in ignored_classes: + result.ignored_plugins.add(cls) + continue + + if inspect.isabstract(cls): + result.abstract_plugins.append(cls) + continue + + if not allow_duplicates: + class_name = cls.__name__ + if class_name in plugin_names: + result.duplicated_plugins.append(cls) + continue + plugin_names.add(class_name) + result.plugins.append(cls) + return result + + class PluginDiscoverContext(object): """Store and discover registered types nad registered paths to types. @@ -141,58 +208,17 @@ class PluginDiscoverContext(object): Union[DiscoverResult, list[Any]]: Object holding successfully discovered plugins, ignored plugins, plugins with missing abstract implementation and duplicated plugin. + """ - - if not ignore_classes: - ignore_classes = [] - - result = DiscoverResult(superclass) - plugin_names = set() registered_classes = self._registered_plugins.get(superclass) or [] registered_paths = self._registered_plugin_paths.get(superclass) or [] - for cls in registered_classes: - if cls is superclass or cls in ignore_classes: - result.ignored_plugins.add(cls) - continue - - if inspect.isabstract(cls): - result.abstract_plugins.append(cls) - continue - - class_name = cls.__name__ - if class_name in plugin_names: - result.duplicated_plugins.append(cls) - continue - plugin_names.add(class_name) - result.plugins.append(cls) - - # Include plug-ins from registered paths - for path in registered_paths: - modules, crashed = modules_from_path(path) - for item in crashed: - filepath, exc_info = item - result.crashed_file_paths[filepath] = exc_info - - for item in modules: - filepath, module = item - result.add_module(module) - for cls in classes_from_module(superclass, module): - if cls is superclass or cls in ignore_classes: - result.ignored_plugins.add(cls) - continue - - if inspect.isabstract(cls): - result.abstract_plugins.append(cls) - continue - - if not allow_duplicates: - class_name = cls.__name__ - if class_name in plugin_names: - result.duplicated_plugins.append(cls) - continue - plugin_names.add(class_name) - - result.plugins.append(cls) + result = discover_plugins( + superclass, + paths=registered_paths, + classes=registered_classes, + ignored_classes=ignore_classes, + allow_duplicates=allow_duplicates, + ) # Store in memory last result to keep in memory loaded modules self._last_discovered_results[superclass] = result From 723932cfac04cfb114b949898fe0a07a851f9f9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:38:56 +0200 Subject: [PATCH 024/279] reduced information that is used in loader for action item --- client/ayon_core/tools/loader/abstract.py | 36 ++++---------- client/ayon_core/tools/loader/control.py | 10 ++-- .../ayon_core/tools/loader/models/actions.py | 47 ++++++------------- .../tools/loader/ui/products_widget.py | 6 +-- .../tools/loader/ui/repres_widget.py | 6 +-- 5 files changed, 33 insertions(+), 72 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5ab7e78212..04cf0c6037 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -324,11 +324,6 @@ class ActionItem: options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. order (int): Action order. - project_name (str): Project name. - folder_ids (list[str]): Folder ids. - product_ids (list[str]): Product ids. - version_ids (list[str]): Version ids. - representation_ids (list[str]): Representation ids. """ def __init__( @@ -339,11 +334,6 @@ class ActionItem: tooltip, options, order, - project_name, - folder_ids, - product_ids, - version_ids, - representation_ids, ): self.identifier = identifier self.label = label @@ -351,11 +341,6 @@ class ActionItem: self.tooltip = tooltip self.options = options self.order = order - self.project_name = project_name - self.folder_ids = folder_ids - self.product_ids = product_ids - self.version_ids = version_ids - self.representation_ids = representation_ids def _options_to_data(self): options = self.options @@ -382,11 +367,6 @@ class ActionItem: "tooltip": self.tooltip, "options": options, "order": self.order, - "project_name": self.project_name, - "folder_ids": self.folder_ids, - "product_ids": self.product_ids, - "version_ids": self.version_ids, - "representation_ids": self.representation_ids, } @classmethod @@ -1013,11 +993,11 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, - identifier, - options, - project_name, - version_ids, - representation_ids + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, ): """Trigger action item. @@ -1038,10 +1018,10 @@ class FrontendLoaderController(_BaseLoaderController): identifier (str): Action identifier. options (dict[str, Any]): Action option values from UI. project_name (str): Project name. - version_ids (Iterable[str]): Version ids. - representation_ids (Iterable[str]): Representation ids. - """ + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. + """ pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7ba42a0981..a48fa7b853 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -309,14 +309,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): identifier, options, project_name, - version_ids, - representation_ids + entity_ids, + entity_type, ): if self._sitesync_model.is_sitesync_action(identifier): self._sitesync_model.trigger_action_item( identifier, project_name, - representation_ids + entity_ids, ) return @@ -324,8 +324,8 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): identifier, options, project_name, - version_ids, - representation_ids + entity_ids, + entity_type, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index b792f92dfd..ec0997685f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -113,11 +113,11 @@ class LoaderActionsModel: def trigger_action_item( self, - identifier, - options, - project_name, - version_ids, - representation_ids + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, ): """Trigger action by identifier. @@ -131,10 +131,10 @@ class LoaderActionsModel: identifier (str): Loader identifier. options (dict[str, Any]): Loader option values. project_name (str): Project name. - version_ids (Iterable[str]): Version ids. - representation_ids (Iterable[str]): Representation ids. - """ + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. + """ event_data = { "identifier": identifier, "id": uuid.uuid4().hex, @@ -145,23 +145,24 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - if representation_ids is not None: + if entity_type == "representation": error_info = self._trigger_representation_loader( loader, options, project_name, - representation_ids, + entity_ids, ) - elif version_ids is not None: + elif entity_type == "version": error_info = self._trigger_version_loader( loader, options, project_name, - version_ids, + entity_ids, ) else: raise NotImplementedError( - "Invalid arguments to trigger action item") + f"Invalid entity type '{entity_type}' to trigger action item" + ) event_data["error_info"] = error_info self._controller.emit_event( @@ -276,11 +277,6 @@ class LoaderActionsModel: self, loader, contexts, - project_name, - folder_ids=None, - product_ids=None, - version_ids=None, - representation_ids=None, repre_name=None, ): label = self._get_action_label(loader) @@ -293,11 +289,6 @@ class LoaderActionsModel: tooltip=self._get_action_tooltip(loader), options=loader.get_options(contexts), order=loader.order, - project_name=project_name, - folder_ids=folder_ids, - product_ids=product_ids, - version_ids=version_ids, - representation_ids=representation_ids, ) def _get_loaders(self, project_name): @@ -570,17 +561,11 @@ class LoaderActionsModel: item = self._create_loader_action_item( loader, repre_contexts, - project_name=project_name, - folder_ids=repre_folder_ids, - product_ids=repre_product_ids, - version_ids=repre_version_ids, - representation_ids=repre_ids, repre_name=repre_name, ) action_items.append(item) # Product Loaders. - version_ids = set(version_context_by_id.keys()) product_folder_ids = set() product_ids = set() for product_context in version_context_by_id.values(): @@ -592,10 +577,6 @@ class LoaderActionsModel: item = self._create_loader_action_item( loader, version_contexts, - project_name=project_name, - folder_ids=product_folder_ids, - product_ids=product_ids, - version_ids=version_ids, ) action_items.append(item) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index e5bb75a208..caa2ee82d0 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -439,9 +439,9 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.identifier, options, - action_item.project_name, - version_ids=action_item.version_ids, - representation_ids=action_item.representation_ids, + project_name, + version_ids, + "version", ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index d19ad306a3..17c429cb53 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -401,7 +401,7 @@ class RepresentationsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.identifier, options, - action_item.project_name, - version_ids=action_item.version_ids, - representation_ids=action_item.representation_ids, + self._selected_project_name, + repre_ids, + "representation", ) From 53848ad366ca2451091223ca7871482ecaa75d2d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:53:03 +0200 Subject: [PATCH 025/279] keep entity ids and entity type on action item --- client/ayon_core/tools/loader/abstract.py | 11 ++++++- .../ayon_core/tools/loader/models/actions.py | 31 +++++++++++-------- .../tools/loader/ui/products_widget.py | 9 +++--- .../tools/loader/ui/repres_widget.py | 8 ++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 04cf0c6037..55898e460f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -318,17 +318,21 @@ class ActionItem: Args: identifier (str): Action identifier. + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. label (str): Action label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. order (int): Action order. - """ + """ def __init__( self, identifier, + entity_ids, + entity_type, label, icon, tooltip, @@ -336,6 +340,8 @@ class ActionItem: order, ): self.identifier = identifier + self.entity_ids = entity_ids + self.entity_type = entity_type self.label = label self.icon = icon self.tooltip = tooltip @@ -362,6 +368,8 @@ class ActionItem: options = self._options_to_data() return { "identifier": self.identifier, + "entity_ids": list(self.entity_ids), + "entity_type": self.entity_type, "label": self.label, "icon": self.icon, "tooltip": self.tooltip, @@ -375,6 +383,7 @@ class ActionItem: if options: options = deserialize_attr_defs(options) data["options"] = options + data["entity_ids"] = set(data["entity_ids"]) return cls(**data) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index ec0997685f..d8fd67234c 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -145,15 +145,16 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - if entity_type == "representation": - error_info = self._trigger_representation_loader( + + if entity_type == "version": + error_info = self._trigger_version_loader( loader, options, project_name, entity_ids, ) - elif entity_type == "version": - error_info = self._trigger_version_loader( + elif entity_type == "representation": + error_info = self._trigger_representation_loader( loader, options, project_name, @@ -277,6 +278,8 @@ class LoaderActionsModel: self, loader, contexts, + entity_ids, + entity_type, repre_name=None, ): label = self._get_action_label(loader) @@ -284,6 +287,8 @@ class LoaderActionsModel: label = "{} ({})".format(label, repre_name) return ActionItem( get_loader_identifier(loader), + entity_ids=entity_ids, + entity_type=entity_type, label=label, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), @@ -548,19 +553,16 @@ class LoaderActionsModel: if not filtered_repre_contexts: continue - repre_ids = set() - repre_version_ids = set() - repre_product_ids = set() - repre_folder_ids = set() - for repre_context in filtered_repre_contexts: - repre_ids.add(repre_context["representation"]["id"]) - repre_product_ids.add(repre_context["product"]["id"]) - repre_version_ids.add(repre_context["version"]["id"]) - repre_folder_ids.add(repre_context["folder"]["id"]) + repre_ids = { + repre_context["representation"]["id"] + for repre_context in filtered_repre_contexts + } item = self._create_loader_action_item( loader, repre_contexts, + repre_ids, + "representation", repre_name=repre_name, ) action_items.append(item) @@ -572,11 +574,14 @@ class LoaderActionsModel: product_ids.add(product_context["product"]["id"]) product_folder_ids.add(product_context["folder"]["id"]) + version_ids = set(version_context_by_id.keys()) version_contexts = list(version_context_by_id.values()) for loader in product_loaders: item = self._create_loader_action_item( loader, version_contexts, + version_ids, + "version", ) action_items.append(item) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index caa2ee82d0..4ed4368ab4 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -420,8 +420,9 @@ class ProductsWidget(QtWidgets.QWidget): if version_id is not None: version_ids.add(version_id) - action_items = self._controller.get_versions_action_items( - project_name, version_ids) + action_items = self._controller.get_action_items( + project_name, version_ids, "version" + ) # Prepare global point where to show the menu global_point = self._products_view.mapToGlobal(point) @@ -440,8 +441,8 @@ class ProductsWidget(QtWidgets.QWidget): action_item.identifier, options, project_name, - version_ids, - "version", + action_item.entity_ids, + action_item.entity_type, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index 17c429cb53..c0957d186c 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -384,8 +384,8 @@ class RepresentationsWidget(QtWidgets.QWidget): def _on_context_menu(self, point): repre_ids = self._get_selected_repre_ids() - action_items = self._controller.get_representations_action_items( - self._selected_project_name, repre_ids + action_items = self._controller.get_action_items( + self._selected_project_name, repre_ids, "representation" ) global_point = self._repre_view.mapToGlobal(point) result = show_actions_menu( @@ -402,6 +402,6 @@ class RepresentationsWidget(QtWidgets.QWidget): action_item.identifier, options, self._selected_project_name, - repre_ids, - "representation", + action_item.entity_ids, + action_item.entity_type, ) From 29b3794dd8625d547ee52fe51e632f9726f1717f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:59:56 +0200 Subject: [PATCH 026/279] only one method to get actions --- client/ayon_core/tools/loader/abstract.py | 28 +++------ client/ayon_core/tools/loader/control.py | 30 +++++----- .../ayon_core/tools/loader/models/actions.py | 59 ++++++------------- 3 files changed, 42 insertions(+), 75 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 55898e460f..baf6aabb69 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -970,33 +970,23 @@ class FrontendLoaderController(_BaseLoaderController): # Load action items @abstractmethod - def get_versions_action_items(self, project_name, version_ids): + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: """Action items for versions selection. Args: project_name (str): Project name. - version_ids (Iterable[str]): Version ids. + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. Returns: list[ActionItem]: List of action items. + """ - - pass - - @abstractmethod - def get_representations_action_items( - self, project_name, representation_ids - ): - """Action items for representations selection. - - Args: - project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. - - Returns: - list[ActionItem]: List of action items. - """ - pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index a48fa7b853..f05914da17 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -21,7 +21,8 @@ from ayon_core.tools.common_models import ( from .abstract import ( BackendLoaderController, FrontendLoaderController, - ProductTypesFilter + ProductTypesFilter, + ActionItem, ) from .models import ( SelectionModel, @@ -287,21 +288,20 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): project_name, product_ids, group_name ) - def get_versions_action_items(self, project_name, version_ids): - return self._loader_actions_model.get_versions_action_items( - project_name, version_ids) - - def get_representations_action_items( - self, project_name, representation_ids): - action_items = ( - self._loader_actions_model.get_representations_action_items( - project_name, representation_ids) + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + action_items = self._loader_actions_model.get_action_items( + project_name, entity_ids, entity_type ) - - action_items.extend(self._sitesync_model.get_sitesync_action_items( - project_name, representation_ids) - ) - + if entity_type == "representation": + site_sync_items = self._sitesync_model.get_sitesync_action_items( + project_name, entity_ids + ) + action_items.extend(site_sync_items) return action_items def trigger_action_item( diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index d8fd67234c..2ef20a7921 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -61,56 +61,33 @@ class LoaderActionsModel: self._product_loaders.reset() self._repre_loaders.reset() - def get_versions_action_items(self, project_name, version_ids): - """Get action items for given version ids. - Args: - project_name (str): Project name. - version_ids (Iterable[str]): Version ids. + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + version_context_by_id = {} + repre_context_by_id = {} + if entity_type == "representation": + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations(project_name, entity_ids) - Returns: - list[ActionItem]: List of action items. - """ + if entity_type == "version": + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions(project_name, entity_ids) - ( - version_context_by_id, - repre_context_by_id - ) = self._contexts_for_versions( - project_name, - version_ids - ) return self._get_action_items_for_contexts( project_name, version_context_by_id, repre_context_by_id ) - def get_representations_action_items( - self, project_name, representation_ids - ): - """Get action items for given representation ids. - - Args: - project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. - - Returns: - list[ActionItem]: List of action items. - """ - - ( - product_context_by_id, - repre_context_by_id - ) = self._contexts_for_representations( - project_name, - representation_ids - ) - return self._get_action_items_for_contexts( - project_name, - product_context_by_id, - repre_context_by_id - ) - def trigger_action_item( self, identifier: str, From b3c5933042a6a50372810df15b78a61ca55a5ebf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:12:27 +0200 Subject: [PATCH 027/279] use version contexts instead of product contexts --- client/ayon_core/tools/loader/models/actions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 2ef20a7921..c41119ac45 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -434,10 +434,10 @@ class LoaderActionsModel: representation contexts. """ - product_context_by_id = {} + version_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id repre_entities = list(ayon_api.get_representations( project_name, representation_ids=repre_ids @@ -468,13 +468,17 @@ class LoaderActionsModel: project_entity = ayon_api.get_project(project_name) - for product_id, product_entity in product_entities_by_id.items(): + version_context_by_id = {} + for version_id, version_entity in version_entities_by_id.items(): + product_id = version_entity["productId"] + product_entity = product_entities_by_id[product_id] folder_id = product_entity["folderId"] folder_entity = folder_entities_by_id[folder_id] - product_context_by_id[product_id] = { + version_context_by_id[version_id] = { "project": project_entity, "folder": folder_entity, "product": product_entity, + "version": version_entity, } for repre_entity in repre_entities: @@ -492,7 +496,7 @@ class LoaderActionsModel: "version": version_entity, "representation": repre_entity, } - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id def _get_action_items_for_contexts( self, From dee1d51640decb7e14b84a041a2e38389316499c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:13:30 +0200 Subject: [PATCH 028/279] cache entities --- .../ayon_core/tools/loader/models/actions.py | 185 +++++++++++++++--- 1 file changed, 163 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index c41119ac45..1e8bfe7ae1 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -5,6 +5,7 @@ import traceback import inspect import collections import uuid +from typing import Callable, Any import ayon_api @@ -53,6 +54,14 @@ class LoaderActionsModel: self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._projects_cache = NestedCacheItem(levels=1, lifetime=60) + self._folders_cache = NestedCacheItem(levels=2, lifetime=300) + self._tasks_cache = NestedCacheItem(levels=2, lifetime=300) + self._products_cache = NestedCacheItem(levels=2, lifetime=300) + self._versions_cache = NestedCacheItem(levels=2, lifetime=1200) + self._representations_cache = NestedCacheItem(levels=2, lifetime=1200) + self._repre_parents_cache = NestedCacheItem(levels=2, lifetime=1200) + def reset(self): """Reset the model with all cached items.""" @@ -61,6 +70,12 @@ class LoaderActionsModel: self._product_loaders.reset() self._repre_loaders.reset() + self._folders_cache.reset() + self._tasks_cache.reset() + self._products_cache.reset() + self._versions_cache.reset() + self._representations_cache.reset() + self._repre_parents_cache.reset() def get_action_items( self, @@ -358,8 +373,8 @@ class LoaderActionsModel: if not project_name and not version_ids: return version_context_by_id, repre_context_by_id - version_entities = ayon_api.get_versions( - project_name, version_ids=version_ids + version_entities = self._get_versions( + project_name, version_ids ) version_entities_by_id = {} version_entities_by_product_id = collections.defaultdict(list) @@ -370,18 +385,18 @@ class LoaderActionsModel: version_entities_by_product_id[product_id].append(version_entity) _product_ids = set(version_entities_by_product_id.keys()) - _product_entities = ayon_api.get_products( - project_name, product_ids=_product_ids + _product_entities = self._get_products( + project_name, _product_ids ) product_entities_by_id = {p["id"]: p for p in _product_entities} _folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - _folder_entities = ayon_api.get_folders( - project_name, folder_ids=_folder_ids + _folder_entities = self._get_folders( + project_name, _folder_ids ) folder_entities_by_id = {f["id"]: f for f in _folder_entities} - project_entity = ayon_api.get_project(project_name) + project_entity = self._get_project(project_name) for version_id, version_entity in version_entities_by_id.items(): product_id = version_entity["productId"] @@ -395,8 +410,15 @@ class LoaderActionsModel: "version": version_entity, } - repre_entities = ayon_api.get_representations( - project_name, version_ids=version_ids) + all_repre_ids = set() + for repre_ids in self._get_repre_ids_by_version_ids( + project_name, version_ids + ).values(): + all_repre_ids |= repre_ids + + repre_entities = self._get_representations( + project_name, all_repre_ids + ) for repre_entity in repre_entities: version_id = repre_entity["versionId"] version_entity = version_entities_by_id[version_id] @@ -439,34 +461,35 @@ class LoaderActionsModel: if not project_name and not repre_ids: return version_context_by_id, repre_context_by_id - repre_entities = list(ayon_api.get_representations( - project_name, representation_ids=repre_ids - )) + repre_entities = self._get_representations( + project_name, repre_ids + ) version_ids = {r["versionId"] for r in repre_entities} - version_entities = ayon_api.get_versions( - project_name, version_ids=version_ids + version_entities = self._get_versions( + project_name, version_ids ) version_entities_by_id = { v["id"]: v for v in version_entities } product_ids = {v["productId"] for v in version_entities_by_id.values()} - product_entities = ayon_api.get_products( - project_name, product_ids=product_ids + product_entities = self._get_products( + project_name, product_ids + ) product_entities_by_id = { p["id"]: p for p in product_entities } folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - folder_entities = ayon_api.get_folders( - project_name, folder_ids=folder_ids + folder_entities = self._get_folders( + project_name, folder_ids ) folder_entities_by_id = { f["id"]: f for f in folder_entities } - project_entity = ayon_api.get_project(project_name) + project_entity = self._get_project(project_name) version_context_by_id = {} for version_id, version_entity in version_entities_by_id.items(): @@ -498,6 +521,124 @@ class LoaderActionsModel: } return version_context_by_id, repre_context_by_id + def _get_project(self, project_name: str) -> dict[str, Any]: + cache = self._projects_cache[project_name] + if not cache.is_valid: + cache.update_data(ayon_api.get_project(project_name)) + return cache.get_data() + + def _get_folders( + self, project_name: str, folder_ids: set[str] + ) -> list[dict[str, Any]]: + """Get folders by ids.""" + return self._get_entities( + project_name, + folder_ids, + self._folders_cache, + ayon_api.get_folders, + "folder_ids", + ) + + def _get_products( + self, project_name: str, product_ids: set[str] + ) -> list[dict[str, Any]]: + """Get products by ids.""" + return self._get_entities( + project_name, + product_ids, + self._products_cache, + ayon_api.get_products, + "product_ids", + ) + + def _get_versions( + self, project_name: str, version_ids: set[str] + ) -> list[dict[str, Any]]: + """Get versions by ids.""" + return self._get_entities( + project_name, + version_ids, + self._versions_cache, + ayon_api.get_versions, + "version_ids", + ) + + def _get_representations( + self, project_name: str, representation_ids: set[str] + ) -> list[dict[str, Any]]: + """Get representations by ids.""" + return self._get_entities( + project_name, + representation_ids, + self._representations_cache, + ayon_api.get_representations, + "representation_ids", + ) + + def _get_repre_ids_by_version_ids( + self, project_name: str, version_ids: set[str] + ) -> dict[str, set[str]]: + output = {} + if not version_ids: + return output + + project_cache = self._repre_parents_cache[project_name] + missing_ids = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_ids.add(version_id) + + if missing_ids: + repre_cache = self._representations_cache[project_name] + repres_by_parent_id = collections.defaultdict(list) + for repre in ayon_api.get_representations( + project_name, version_ids=missing_ids + ): + version_id = repre["versionId"] + repre_cache[repre["id"]].update_data(repre) + repres_by_parent_id[version_id].append(repre) + + for version_id, repres in repres_by_parent_id.items(): + repre_ids = { + repre["id"] + for repre in repres + } + output[version_id] = set(repre_ids) + project_cache[version_id].update_data(repre_ids) + + return output + + def _get_entities( + self, + project_name: str, + entity_ids: set[str], + cache: NestedCacheItem, + getter: Callable, + filter_arg: str, + ) -> list[dict[str, Any]]: + entities = [] + if not entity_ids: + return entities + + missing_ids = set() + project_cache = cache[project_name] + for entity_id in entity_ids: + entity_cache = project_cache[entity_id] + if entity_cache.is_valid: + entities.append(entity_cache.get_data()) + else: + missing_ids.add(entity_id) + + if missing_ids: + for entity in getter(project_name, **{filter_arg: missing_ids}): + entities.append(entity) + entity_id = entity["id"] + project_cache[entity_id].update_data(entity) + return entities + def _get_action_items_for_contexts( self, project_name, @@ -601,12 +742,12 @@ class LoaderActionsModel: project_name, version_ids=version_ids )) product_ids = {v["productId"] for v in version_entities} - product_entities = ayon_api.get_products( - project_name, product_ids=product_ids + product_entities = self._get_products( + project_name, product_ids ) product_entities_by_id = {p["id"]: p for p in product_entities} folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - folder_entities = ayon_api.get_folders( + folder_entities = self._get_folders( project_name, folder_ids=folder_ids ) folder_entities_by_id = {f["id"]: f for f in folder_entities} From 599716fe942952649d4bd66f99de712690199f59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:23:10 +0200 Subject: [PATCH 029/279] base of loader action --- client/ayon_core/pipeline/actions/__init__.py | 18 + client/ayon_core/pipeline/actions/loader.py | 546 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 client/ayon_core/pipeline/actions/loader.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index bda9b50ede..188414bdbe 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,3 +1,13 @@ +from .loader import ( + LoaderActionForm, + LoaderActionResult, + LoaderActionItem, + LoaderActionPlugin, + LoaderActionSelection, + LoaderActionsContext, + SelectionEntitiesCache, +) + from .launcher import ( LauncherAction, LauncherActionSelection, @@ -18,6 +28,14 @@ from .inventory import ( __all__= ( + "LoaderActionForm", + "LoaderActionResult", + "LoaderActionItem", + "LoaderActionPlugin", + "LoaderActionSelection", + "LoaderActionsContext", + "SelectionEntitiesCache", + "LauncherAction", "LauncherActionSelection", "discover_launcher_actions", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py new file mode 100644 index 0000000000..33f48b195c --- /dev/null +++ b/client/ayon_core/pipeline/actions/loader.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +import os +import collections +import copy +from abc import ABC, abstractmethod +from typing import Optional, Any, Callable +from dataclasses import dataclass + +import ayon_api + +from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import StrEnum, Logger, AbstractAttrDef +from ayon_core.addon import AddonsManager, IPluginPaths +from ayon_core.settings import get_studio_settings, get_project_settings +from ayon_core.pipeline.plugin_discover import discover_plugins + + +class EntityType(StrEnum): + """Selected entity type.""" + # folder = "folder" + # task = "task" + version = "version" + representation = "representation" + + +class SelectionEntitiesCache: + def __init__( + self, + project_name: str, + project_entity: Optional[dict[str, Any]] = None, + folders_by_id: Optional[dict[str, dict[str, Any]]] = None, + tasks_by_id: Optional[dict[str, dict[str, Any]]] = None, + products_by_id: Optional[dict[str, dict[str, Any]]] = None, + versions_by_id: Optional[dict[str, dict[str, Any]]] = None, + representations_by_id: Optional[dict[str, dict[str, Any]]] = None, + task_ids_by_folder_id: Optional[dict[str, str]] = None, + product_ids_by_folder_id: Optional[dict[str, str]] = None, + version_ids_by_product_id: Optional[dict[str, str]] = None, + version_id_by_task_id: Optional[dict[str, str]] = None, + representation_id_by_version_id: Optional[dict[str, str]] = None, + ): + self._project_name = project_name + self._project_entity = project_entity + self._folders_by_id = folders_by_id or {} + self._tasks_by_id = tasks_by_id or {} + self._products_by_id = products_by_id or {} + self._versions_by_id = versions_by_id or {} + self._representations_by_id = representations_by_id or {} + + self._task_ids_by_folder_id = task_ids_by_folder_id or {} + self._product_ids_by_folder_id = product_ids_by_folder_id or {} + self._version_ids_by_product_id = version_ids_by_product_id or {} + self._version_id_by_task_id = version_id_by_task_id or {} + self._representation_id_by_version_id = ( + representation_id_by_version_id or {} + ) + + def get_project(self) -> dict[str, Any]: + if self._project_entity is None: + self._project_entity = ayon_api.get_project(self._project_name) + return copy.deepcopy(self._project_entity) + + def get_folders( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + folder_ids, + self._folders_by_id, + "folder_ids", + ayon_api.get_folders, + ) + + def get_tasks( + self, task_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + task_ids, + self._tasks_by_id, + "task_ids", + ayon_api.get_tasks, + ) + + def get_products( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + product_ids, + self._products_by_id, + "product_ids", + ayon_api.get_products, + ) + + def get_versions( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + version_ids, + self._versions_by_id, + "version_ids", + ayon_api.get_versions, + ) + + def get_representations( + self, representation_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + representation_ids, + self._representations_by_id, + "representation_ids", + ayon_api.get_representations, + ) + + def get_folders_tasks( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + task_ids = self._fill_parent_children_ids( + folder_ids, + "folderId", + "folder_ids", + self._task_ids_by_folder_id, + ayon_api.get_tasks, + ) + return self.get_tasks(task_ids) + + def get_folders_products( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + product_ids = self._get_folders_products_ids(folder_ids) + return self.get_products(product_ids) + + def get_tasks_versions( + self, task_ids: set[str] + ) -> list[dict[str, Any]]: + folder_ids = { + task["folderId"] + for task in self.get_tasks(task_ids) + } + product_ids = self._get_folders_products_ids(folder_ids) + output = [] + for version in self.get_products_versions(product_ids): + task_id = version["taskId"] + if task_id in task_ids: + output.append(version) + return output + + def get_products_versions( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + version_ids = self._fill_parent_children_ids( + product_ids, + "productId", + "product_ids", + self._version_ids_by_product_id, + ayon_api.get_versions, + ) + return self.get_versions(version_ids) + + def get_versions_representations( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + repre_ids = self._fill_parent_children_ids( + version_ids, + "versionId", + "version_ids", + self._representation_id_by_version_id, + ayon_api.get_representations, + ) + return self.get_representations(repre_ids) + + def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]: + folder_ids = { + task["folderId"] + for task in self.get_tasks(task_ids) + } + return self.get_folders(folder_ids) + + def get_products_folders( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + folder_ids = { + product["folderId"] + for product in self.get_products(product_ids) + } + return self.get_folders(folder_ids) + + def get_versions_products( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + product_ids = { + version["productId"] + for version in self.get_versions(version_ids) + } + return self.get_products(product_ids) + + def get_versions_tasks( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + task_ids = { + version["taskId"] + for version in self.get_versions(version_ids) + if version["taskId"] + } + return self.get_tasks(task_ids) + + def get_representations_versions( + self, representation_ids: set[str] + ) -> list[dict[str, Any]]: + version_ids = { + repre["versionId"] + for repre in self.get_representations(representation_ids) + } + return self.get_versions(version_ids) + + def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]: + return self._fill_parent_children_ids( + folder_ids, + "folderId", + "folder_ids", + self._product_ids_by_folder_id, + ayon_api.get_products, + ) + + def _fill_parent_children_ids( + self, + entity_ids: set[str], + parent_key: str, + filter_attr: str, + parent_mapping: dict[str, set[str]], + getter: Callable, + ) -> set[str]: + if not entity_ids: + return set() + children_ids = set() + missing_ids = set() + for entity_id in entity_ids: + _children_ids = parent_mapping.get(entity_id) + if _children_ids is None: + missing_ids.add(entity_id) + else: + children_ids.update(_children_ids) + if missing_ids: + entities_by_parent_id = collections.defaultdict(set) + for entity in getter( + self._project_name, + fields={"id", parent_key}, + **{filter_attr: missing_ids}, + ): + child_id = entity["id"] + children_ids.add(child_id) + entities_by_parent_id[entity[parent_key]].add(child_id) + + for entity_id in missing_ids: + parent_mapping[entity_id] = entities_by_parent_id[entity_id] + + return children_ids + + def _get_entities( + self, + entity_ids: set[str], + cache_var: dict[str, Any], + filter_arg: str, + getter: Callable, + ) -> list[dict[str, Any]]: + if not entity_ids: + return [] + + output = [] + missing_ids: set[str] = set() + for entity_id in entity_ids: + entity = cache_var.get(entity_id) + if entity_id not in cache_var: + missing_ids.add(entity_id) + cache_var[entity_id] = None + elif entity: + output.append(entity) + + if missing_ids: + for entity in getter( + self._project_name, + **{filter_arg: missing_ids} + ): + output.append(entity) + cache_var[entity["id"]] = entity + return output + + +class LoaderActionSelection: + def __init__( + self, + project_name: str, + selected_ids: set[str], + selected_type: EntityType, + *, + project_anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + entities_cache: Optional[SelectionEntitiesCache] = None, + ): + self._project_name = project_name + self._selected_ids = selected_ids + self._selected_type = selected_type + + self._project_anatomy = project_anatomy + self._project_settings = project_settings + + if entities_cache is None: + entities_cache = SelectionEntitiesCache(project_name) + self._entities_cache = entities_cache + + def get_entities_cache(self) -> SelectionEntitiesCache: + return self._entities_cache + + def get_project_name(self) -> str: + return self._project_name + + def get_selected_ids(self) -> set[str]: + return set(self._selected_ids) + + def get_selected_type(self) -> str: + return self._selected_type + + def get_project_settings(self) -> dict[str, Any]: + if self._project_settings is None: + self._project_settings = get_project_settings(self._project_name) + return copy.deepcopy(self._project_settings) + + def get_project_anatomy(self) -> dict[str, Any]: + if self._project_anatomy is None: + from ayon_core.pipeline import Anatomy + + self._project_anatomy = Anatomy( + self._project_name, + project_entity=self.get_entities_cache().get_project(), + ) + return self._project_anatomy + + project_name = property(get_project_name) + selected_ids = property(get_selected_ids) + selected_type = property(get_selected_type) + project_settings = property(get_project_settings) + project_anatomy = property(get_project_anatomy) + entities = property(get_entities_cache) + + +@dataclass +class LoaderActionItem: + identifier: str + entity_ids: set[str] + entity_type: EntityType + label: str + group_label: Optional[str] = None + # Is filled automatically + plugin_identifier: str = None + + +@dataclass +class LoaderActionForm: + title: str + fields: list[AbstractAttrDef] + submit_label: Optional[str] = "Submit" + submit_icon: Optional[str] = None + cancel_label: Optional[str] = "Cancel" + cancel_icon: Optional[str] = None + + +@dataclass +class LoaderActionResult: + message: Optional[str] = None + success: bool = True + form: Optional[LoaderActionForm] = None + + +class LoaderActionPlugin(ABC): + """Plugin for loader actions. + + Plugin is responsible for getting action items and executing actions. + + + """ + def __init__(self, studio_settings: dict[str, Any]): + self.apply_settings(studio_settings) + + def apply_settings(self, studio_settings: dict[str, Any]) -> None: + """Apply studio settings to the plugin. + + Args: + studio_settings (dict[str, Any]): Studio settings. + + """ + pass + + @property + def identifier(self) -> str: + """Identifier of the plugin. + + Returns: + str: Plugin identifier. + + """ + return self.__class__.__name__ + + @abstractmethod + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + """Action items for the selection. + + Args: + selection (LoaderActionSelection): Selection. + + Returns: + list[LoaderActionItem]: Action items. + + """ + pass + + @abstractmethod + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + """Execute an action. + + Args: + identifier (str): Action identifier. + entity_ids: (set[str]): Entity ids stored on action item. + entity_type: (str): Entity type stored on action item. + selection (LoaderActionSelection): Selection wrapper. Can be used + to get entities or get context of original selection. + form_values (dict[str, Any]): Attribute values. + + Returns: + Optional[LoaderActionResult]: Result of the action execution. + + """ + pass + + +class LoaderActionsContext: + def __init__( + self, + studio_settings: Optional[dict[str, Any]] = None, + addons_manager: Optional[AddonsManager] = None, + ) -> None: + self._log = Logger.get_logger(self.__class__.__name__) + + self._addons_manager = addons_manager + + self._studio_settings = studio_settings + self._plugins = None + + def reset( + self, studio_settings: Optional[dict[str, Any]] = None + ) -> None: + self._studio_settings = studio_settings + self._plugins = None + + def get_addons_manager(self) -> AddonsManager: + if self._addons_manager is None: + self._addons_manager = AddonsManager( + settings=self._get_studio_settings() + ) + return self._addons_manager + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + output = [] + for plugin in self._get_plugins().values(): + try: + for action_item in plugin.get_action_items(selection): + action_item.identifier = plugin.identifier + output.append(action_item) + + except Exception: + self._log.warning( + "Failed to get action items for" + f" plugin '{plugin.identifier}'", + exc_info=True, + ) + return output + + def execute_action( + self, + plugin_identifier: str, + action_identifier: str, + entity_ids: set[str], + entity_type: EntityType, + selection: LoaderActionSelection, + attribute_values: dict[str, Any], + ) -> None: + plugins_by_id = self._get_plugins() + plugin = plugins_by_id[plugin_identifier] + plugin.execute_action( + action_identifier, + entity_ids, + entity_type, + selection, + attribute_values, + ) + + def _get_studio_settings(self) -> dict[str, Any]: + if self._studio_settings is None: + self._studio_settings = get_studio_settings() + return copy.deepcopy(self._studio_settings) + + def _get_plugins(self) -> dict[str, LoaderActionPlugin]: + if self._plugins is None: + addons_manager = self.get_addons_manager() + all_paths = [ + os.path.join(AYON_CORE_ROOT, "plugins", "loader") + ] + for addon in addons_manager.addons: + if not isinstance(addon, IPluginPaths): + continue + paths = addon.get_loader_action_plugin_paths() + if paths: + all_paths.extend(paths) + + studio_settings = self._get_studio_settings() + result = discover_plugins(LoaderActionPlugin, all_paths) + result.log_report() + plugins = {} + for cls in result.plugins: + try: + plugin = cls(studio_settings) + plugin_id = plugin.identifier + if plugin_id not in plugins: + plugins[plugin_id] = plugin + continue + + self._log.warning( + f"Duplicated plugins identifier found '{plugin_id}'." + ) + + except Exception: + self._log.warning( + f"Failed to initialize plugin '{cls.__name__}'", + exc_info=True, + ) + self._plugins = plugins + return self._plugins From 7b81cb1215e900637593c26aa4213db11e6dc038 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:54:38 +0200 Subject: [PATCH 030/279] added logger to action plugin --- client/ayon_core/pipeline/actions/loader.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 33f48b195c..b81b89a56a 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import collections import copy +import logging from abc import ABC, abstractmethod from typing import Optional, Any, Callable from dataclasses import dataclass @@ -377,6 +378,8 @@ class LoaderActionPlugin(ABC): """ + _log: Optional[logging.Logger] = None + def __init__(self, studio_settings: dict[str, Any]): self.apply_settings(studio_settings) @@ -389,6 +392,12 @@ class LoaderActionPlugin(ABC): """ pass + @property + def log(self) -> logging.Logger: + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + @property def identifier(self) -> str: """Identifier of the plugin. From 0f65fe34a78a9f1e6c51677d03bdcf4c4f401b73 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:02:19 +0200 Subject: [PATCH 031/279] change entity type to str --- client/ayon_core/pipeline/actions/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index b81b89a56a..a6cceabd76 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -347,7 +347,7 @@ class LoaderActionSelection: class LoaderActionItem: identifier: str entity_ids: set[str] - entity_type: EntityType + entity_type: str label: str group_label: Optional[str] = None # Is filled automatically From 700006692a27793e8295b212bdea06e650d3078f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:15:02 +0200 Subject: [PATCH 032/279] added order and icon to --- client/ayon_core/pipeline/actions/loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index a6cceabd76..ccc81a2d73 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -349,7 +349,9 @@ class LoaderActionItem: entity_ids: set[str] entity_type: str label: str + order: int = 0 group_label: Optional[str] = None + icon: Optional[dict[str, Any]] = None # Is filled automatically plugin_identifier: str = None From e05ffe0263848aee9db112901202b99c45530bfc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:08:04 +0200 Subject: [PATCH 033/279] converted copy file action --- client/ayon_core/plugins/load/copy_file.py | 34 ------ .../ayon_core/plugins/load/copy_file_path.py | 29 ----- client/ayon_core/plugins/loader/copy_file.py | 115 ++++++++++++++++++ 3 files changed, 115 insertions(+), 63 deletions(-) delete mode 100644 client/ayon_core/plugins/load/copy_file.py delete mode 100644 client/ayon_core/plugins/load/copy_file_path.py create mode 100644 client/ayon_core/plugins/loader/copy_file.py diff --git a/client/ayon_core/plugins/load/copy_file.py b/client/ayon_core/plugins/load/copy_file.py deleted file mode 100644 index 08dad03be3..0000000000 --- a/client/ayon_core/plugins/load/copy_file.py +++ /dev/null @@ -1,34 +0,0 @@ -from ayon_core.style import get_default_entity_icon_color -from ayon_core.pipeline import load - - -class CopyFile(load.LoaderPlugin): - """Copy the published file to be pasted at the desired location""" - - representations = {"*"} - product_types = {"*"} - - label = "Copy File" - order = 10 - icon = "copy" - color = get_default_entity_icon_color() - - def load(self, context, name=None, namespace=None, data=None): - path = self.filepath_from_context(context) - self.log.info("Added copy to clipboard: {0}".format(path)) - self.copy_file_to_clipboard(path) - - @staticmethod - def copy_file_to_clipboard(path): - from qtpy import QtCore, QtWidgets - - clipboard = QtWidgets.QApplication.clipboard() - assert clipboard, "Must have running QApplication instance" - - # Build mime data for clipboard - data = QtCore.QMimeData() - url = QtCore.QUrl.fromLocalFile(path) - data.setUrls([url]) - - # Set to Clipboard - clipboard.setMimeData(data) diff --git a/client/ayon_core/plugins/load/copy_file_path.py b/client/ayon_core/plugins/load/copy_file_path.py deleted file mode 100644 index fdf31b5e02..0000000000 --- a/client/ayon_core/plugins/load/copy_file_path.py +++ /dev/null @@ -1,29 +0,0 @@ -import os - -from ayon_core.pipeline import load - - -class CopyFilePath(load.LoaderPlugin): - """Copy published file path to clipboard""" - representations = {"*"} - product_types = {"*"} - - label = "Copy File Path" - order = 20 - icon = "clipboard" - color = "#999999" - - def load(self, context, name=None, namespace=None, data=None): - path = self.filepath_from_context(context) - self.log.info("Added file path to clipboard: {0}".format(path)) - self.copy_path_to_clipboard(path) - - @staticmethod - def copy_path_to_clipboard(path): - from qtpy import QtWidgets - - clipboard = QtWidgets.QApplication.clipboard() - assert clipboard, "Must have running QApplication instance" - - # Set to Clipboard - clipboard.setText(os.path.normpath(path)) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py new file mode 100644 index 0000000000..54e92b0ab9 --- /dev/null +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -0,0 +1,115 @@ +import os +import collections + +from typing import Optional, Any + +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +class CopyFileActionPlugin(LoaderActionPlugin): + """Copy published file path to clipboard""" + identifier = "core.copy-action" + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + repres = [] + if selection.selected_type in "representations": + repres = selection.entities.get_representations( + selection.selected_ids + ) + + if selection.selected_type in "version": + repres = selection.entities.get_versions_representations( + selection.selected_ids + ) + + output = [] + if not repres: + return output + + repre_ids_by_name = collections.defaultdict(set) + for repre in repres: + repre_ids_by_name[repre["name"]].add(repre["id"]) + + for repre_name, repre_ids in repre_ids_by_name.items(): + output.append( + LoaderActionItem( + identifier="copy-path", + label=repre_name, + group_label="Copy file path", + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "content_copy", + "color": "#999999", + } + ) + ) + output.append( + LoaderActionItem( + identifier="copy-file", + label=repre_name, + group_label="Copy file", + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "file_copy", + "color": "#999999", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + from qtpy import QtWidgets, QtCore + + repre = next(iter(selection.entities.get_representations(entity_ids))) + path = get_representation_path_with_anatomy( + repre, selection.get_project_anatomy() + ) + self.log.info(f"Added file path to clipboard: {path}") + + clipboard = QtWidgets.QApplication.clipboard() + if not clipboard: + return LoaderActionResult( + "Failed to copy file path to clipboard", + success=False, + ) + + if identifier == "copy-path": + # Set to Clipboard + clipboard.setText(os.path.normpath(path)) + + return LoaderActionResult( + "Path stored to clipboard", + success=True, + ) + + # Build mime data for clipboard + data = QtCore.QMimeData() + url = QtCore.QUrl.fromLocalFile(path) + data.setUrls([url]) + + # Set to Clipboard + clipboard.setMimeData(data) + + return LoaderActionResult( + "File added to clipboard", + success=True, + ) From e7439a2d7fe093c7181fccfecd5ef170f62e945f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:08:33 +0200 Subject: [PATCH 034/279] fix fill of plugin identifier --- client/ayon_core/pipeline/actions/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ccc81a2d73..96806809bd 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -484,7 +484,7 @@ class LoaderActionsContext: for plugin in self._get_plugins().values(): try: for action_item in plugin.get_action_items(selection): - action_item.identifier = plugin.identifier + action_item.plugin_identifier = plugin.identifier output.append(action_item) except Exception: From 422968315ebe7c0509612c6760138bce445e587b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:01 +0200 Subject: [PATCH 035/279] do not hard force plugin identifier --- client/ayon_core/pipeline/actions/loader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 96806809bd..7822522496 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -484,7 +484,8 @@ class LoaderActionsContext: for plugin in self._get_plugins().values(): try: for action_item in plugin.get_action_items(selection): - action_item.plugin_identifier = plugin.identifier + if action_item.plugin_identifier is None: + action_item.plugin_identifier = plugin.identifier output.append(action_item) except Exception: From 3a65c56123936041f4fb8bfba2994f7ce6f1311e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:11 +0200 Subject: [PATCH 036/279] import Anatomy directly --- client/ayon_core/pipeline/actions/loader.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 7822522496..e2628da43c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -14,6 +14,7 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins @@ -293,7 +294,7 @@ class LoaderActionSelection: selected_ids: set[str], selected_type: EntityType, *, - project_anatomy: Optional["Anatomy"] = None, + project_anatomy: Optional[Anatomy] = None, project_settings: Optional[dict[str, Any]] = None, entities_cache: Optional[SelectionEntitiesCache] = None, ): @@ -325,10 +326,8 @@ class LoaderActionSelection: self._project_settings = get_project_settings(self._project_name) return copy.deepcopy(self._project_settings) - def get_project_anatomy(self) -> dict[str, Any]: + def get_project_anatomy(self) -> Anatomy: if self._project_anatomy is None: - from ayon_core.pipeline import Anatomy - self._project_anatomy = Anatomy( self._project_name, project_entity=self.get_entities_cache().get_project(), From a22f378ed51dcd019531780524e58f531822c7dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:10:03 +0200 Subject: [PATCH 037/279] added 'get_loader_action_plugin_paths' to 'IPluginPaths' --- client/ayon_core/addon/interfaces.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index bf08ccd48c..cc7e39218e 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -185,6 +185,10 @@ class IPluginPaths(AYONInterface): """ return self._get_plugin_paths_by_type("inventory") + def get_loader_action_plugin_paths(self) -> list[str]: + """Receive loader action plugin paths.""" + return [] + class ITrayAddon(AYONInterface): """Addon has special procedures when used in Tray tool. From db764619fc55fb7f0d4fd1c62c350d62ff4918e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:06:39 +0200 Subject: [PATCH 038/279] sort actions at different place --- client/ayon_core/tools/loader/models/actions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 1e8bfe7ae1..5dda2ef51f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -97,11 +97,13 @@ class LoaderActionsModel: repre_context_by_id ) = self._contexts_for_versions(project_name, entity_ids) - return self._get_action_items_for_contexts( + action_items = self._get_action_items_for_contexts( project_name, version_context_by_id, repre_context_by_id ) + action_items.sort(key=self._actions_sorter) + return action_items def trigger_action_item( self, @@ -706,8 +708,6 @@ class LoaderActionsModel: "version", ) action_items.append(item) - - action_items.sort(key=self._actions_sorter) return action_items def _trigger_version_loader( From b5ab3d3380e68c1f968189e65dac0332cbae701e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:01 +0200 Subject: [PATCH 039/279] different way how to set plugin id --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e2628da43c..c14c4bd0cb 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -480,11 +480,11 @@ class LoaderActionsContext: self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: output = [] - for plugin in self._get_plugins().values(): + for plugin_id, plugin in self._get_plugins().items(): try: for action_item in plugin.get_action_items(selection): if action_item.plugin_identifier is None: - action_item.plugin_identifier = plugin.identifier + action_item.plugin_identifier = plugin_id output.append(action_item) except Exception: From 39dc54b09e0aacd2f117fb1a996d407439de923a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:18 +0200 Subject: [PATCH 040/279] return output of execute action --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c14c4bd0cb..ed6a47502c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -503,10 +503,10 @@ class LoaderActionsContext: entity_type: EntityType, selection: LoaderActionSelection, attribute_values: dict[str, Any], - ) -> None: + ) -> Optional[LoaderActionResult]: plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] - plugin.execute_action( + return plugin.execute_action( action_identifier, entity_ids, entity_type, From 234ac09f42dc08715bc43a5228dfb3b1a4a84a80 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:29 +0200 Subject: [PATCH 041/279] added enabled option to plugin --- client/ayon_core/pipeline/actions/loader.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ed6a47502c..be311dbdff 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -380,6 +380,7 @@ class LoaderActionPlugin(ABC): """ _log: Optional[logging.Logger] = None + enabled: bool = True def __init__(self, studio_settings: dict[str, Any]): self.apply_settings(studio_settings) @@ -539,6 +540,9 @@ class LoaderActionsContext: for cls in result.plugins: try: plugin = cls(studio_settings) + if not plugin.enabled: + continue + plugin_id = plugin.identifier if plugin_id not in plugins: plugins[plugin_id] = plugin From 12d4905b39e23e9a0eb11f78410e468111ea4201 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:41:30 +0200 Subject: [PATCH 042/279] base implementation in loader tool --- client/ayon_core/tools/loader/abstract.py | 16 ++- client/ayon_core/tools/loader/control.py | 29 ++-- .../ayon_core/tools/loader/models/actions.py | 127 ++++++++++++++---- .../ayon_core/tools/loader/models/sitesync.py | 42 +++--- .../tools/loader/ui/products_widget.py | 3 + .../tools/loader/ui/repres_widget.py | 3 + 6 files changed, 162 insertions(+), 58 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index baf6aabb69..9bff8dbb2d 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -317,6 +317,7 @@ class ActionItem: use 'identifier' and context, it necessary also use 'options'. Args: + plugin_identifier (str): Action identifier. identifier (str): Action identifier. entity_ids (set[str]): Entity ids. entity_type (str): Entity type. @@ -330,6 +331,7 @@ class ActionItem: """ def __init__( self, + plugin_identifier, identifier, entity_ids, entity_type, @@ -339,6 +341,7 @@ class ActionItem: options, order, ): + self.plugin_identifier = plugin_identifier self.identifier = identifier self.entity_ids = entity_ids self.entity_type = entity_type @@ -367,6 +370,7 @@ class ActionItem: def to_data(self): options = self._options_to_data() return { + "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, "entity_ids": list(self.entity_ids), "entity_type": self.entity_type, @@ -992,11 +996,14 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, + plugin_identifier: str, identifier: str, options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): """Trigger action item. @@ -1014,11 +1021,14 @@ class FrontendLoaderController(_BaseLoaderController): } Args: - identifier (str): Action identifier. + plugin_identifier (sttr): Plugin identifier. + identifier (sttr): Action identifier. options (dict[str, Any]): Action option values from UI. project_name (str): Project name. - entity_ids (set[str]): Selected entity ids. - entity_type (str): Selected entity type. + entity_ids (set[str]): Entity ids stored on action item. + entity_type (str): Entity type stored on action item. + selected_ids (set[str]): Selected entity ids. + selected_entity_type (str): Selected entity type. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index f05914da17..900eaf7656 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import uuid +from typing import Any import ayon_api @@ -297,22 +298,25 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): action_items = self._loader_actions_model.get_action_items( project_name, entity_ids, entity_type ) - if entity_type == "representation": - site_sync_items = self._sitesync_model.get_sitesync_action_items( - project_name, entity_ids - ) - action_items.extend(site_sync_items) + + site_sync_items = self._sitesync_model.get_sitesync_action_items( + project_name, entity_ids, entity_type + ) + action_items.extend(site_sync_items) return action_items def trigger_action_item( self, - identifier, - options, - project_name, - entity_ids, - entity_type, + plugin_identifier: str, + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): - if self._sitesync_model.is_sitesync_action(identifier): + if self._sitesync_model.is_sitesync_action(plugin_identifier): self._sitesync_model.trigger_action_item( identifier, project_name, @@ -321,11 +325,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): return self._loader_actions_model.trigger_action_item( + plugin_identifier, identifier, options, project_name, entity_ids, entity_type, + selected_ids, + selected_entity_type, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 5dda2ef51f..e6ac328f92 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -9,7 +9,12 @@ from typing import Callable, Any import ayon_api -from ayon_core.lib import NestedCacheItem +from ayon_core.lib import NestedCacheItem, Logger +from ayon_core.pipeline.actions import ( + LoaderActionsContext, + LoaderActionSelection, + SelectionEntitiesCache, +) from ayon_core.pipeline.load import ( discover_loader_plugins, ProductLoaderPlugin, @@ -24,6 +29,7 @@ from ayon_core.pipeline.load import ( from ayon_core.tools.loader.abstract import ActionItem ACTIONS_MODEL_SENDER = "actions.model" +LOADER_PLUGIN_ID = "__loader_plugin__" NOT_SET = object() @@ -45,6 +51,7 @@ class LoaderActionsModel: loaders_cache_lifetime = 30 def __init__(self, controller): + self._log = Logger.get_logger(self.__class__.__name__) self._controller = controller self._current_context_project = NOT_SET self._loaders_by_identifier = NestedCacheItem( @@ -53,6 +60,7 @@ class LoaderActionsModel: levels=1, lifetime=self.loaders_cache_lifetime) self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._loader_actions = LoaderActionsContext() self._projects_cache = NestedCacheItem(levels=1, lifetime=60) self._folders_cache = NestedCacheItem(levels=2, lifetime=300) @@ -69,6 +77,7 @@ class LoaderActionsModel: self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() + self._loader_actions.reset() self._folders_cache.reset() self._tasks_cache.reset() @@ -102,16 +111,25 @@ class LoaderActionsModel: version_context_by_id, repre_context_by_id ) + action_items.extend(self._get_loader_action_items( + project_name, + entity_ids, + entity_type, + )) + action_items.sort(key=self._actions_sorter) return action_items def trigger_action_item( self, + plugin_identifier: str, identifier: str, options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): """Trigger action by identifier. @@ -122,14 +140,18 @@ class LoaderActionsModel: happened. Args: - identifier (str): Loader identifier. + plugin_identifier (str): Plugin identifier. + identifier (str): Action identifier. options (dict[str, Any]): Loader option values. project_name (str): Project name. - entity_ids (set[str]): Entity ids. - entity_type (str): Entity type. + entity_ids (set[str]): Entity ids on action item. + entity_type (str): Entity type on action item. + selected_ids (set[str]): Selected entity ids. + selected_entity_type (str): Selected entity type. """ event_data = { + "plugin_identifier": plugin_identifier, "identifier": identifier, "id": uuid.uuid4().hex, } @@ -138,27 +160,52 @@ class LoaderActionsModel: event_data, ACTIONS_MODEL_SENDER, ) - loader = self._get_loader_by_identifier(project_name, identifier) + if plugin_identifier != LOADER_PLUGIN_ID: + # TODO fill error infor if any happens + error_info = [] + try: + self._loader_actions.execute_action( + plugin_identifier, + identifier, + entity_ids, + entity_type, + LoaderActionSelection( + project_name, + selected_ids, + selected_entity_type, + ), + {}, + ) - if entity_type == "version": - error_info = self._trigger_version_loader( - loader, - options, - project_name, - entity_ids, - ) - elif entity_type == "representation": - error_info = self._trigger_representation_loader( - loader, - options, - project_name, - entity_ids, - ) + except Exception: + self._log.warning( + f"Failed to execute action '{identifier}'", + exc_info=True, + ) else: - raise NotImplementedError( - f"Invalid entity type '{entity_type}' to trigger action item" + loader = self._get_loader_by_identifier( + project_name, identifier ) + if entity_type == "version": + error_info = self._trigger_version_loader( + loader, + options, + project_name, + entity_ids, + ) + elif entity_type == "representation": + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + entity_ids, + ) + else: + raise NotImplementedError( + f"Invalid entity type '{entity_type}' to trigger action item" + ) + event_data["error_info"] = error_info self._controller.emit_event( "load.finished", @@ -278,8 +325,9 @@ class LoaderActionsModel: ): label = self._get_action_label(loader) if repre_name: - label = "{} ({})".format(label, repre_name) + label = f"{label} ({repre_name})" return ActionItem( + LOADER_PLUGIN_ID, get_loader_identifier(loader), entity_ids=entity_ids, entity_type=entity_type, @@ -456,8 +504,8 @@ class LoaderActionsModel: Returns: tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and representation contexts. - """ + """ version_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: @@ -710,6 +758,39 @@ class LoaderActionsModel: action_items.append(item) return action_items + + def _get_loader_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + # TODO prepare cached entities + # entities_cache = SelectionEntitiesCache(project_name) + selection = LoaderActionSelection( + project_name, + entity_ids, + entity_type, + # entities_cache=entities_cache + ) + items = [] + for action in self._loader_actions.get_action_items(selection): + label = action.label + if action.group_label: + label = f"{action.group_label} ({label})" + items.append(ActionItem( + action.plugin_identifier, + action.identifier, + action.entity_ids, + action.entity_type, + label, + action.icon, + None, # action.tooltip, + None, # action.options, + action.order, + )) + return items + def _trigger_version_loader( self, loader, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 3a54a1b5f8..bab8a68132 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -246,26 +246,32 @@ class SiteSyncModel: output[repre_id] = repre_cache.get_data() return output - def get_sitesync_action_items(self, project_name, representation_ids): + def get_sitesync_action_items( + self, project_name, entity_ids, entity_type + ): """ Args: project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. Returns: list[ActionItem]: Actions that can be shown in loader. + """ + if entity_type != "representation": + return [] if not self.is_sitesync_enabled(project_name): return [] repres_status = self.get_representations_sync_status( - project_name, representation_ids + project_name, entity_ids ) repre_ids_per_identifier = collections.defaultdict(set) - for repre_id in representation_ids: + for repre_id in entity_ids: repre_status = repres_status[repre_id] local_status, remote_status = repre_status @@ -293,27 +299,23 @@ class SiteSyncModel: return action_items - def is_sitesync_action(self, identifier): + def is_sitesync_action(self, plugin_identifier: str) -> bool: """Should be `identifier` handled by SiteSync. Args: - identifier (str): Action identifier. + plugin_identifier (str): Plugin identifier. Returns: bool: Should action be handled by SiteSync. - """ - return identifier in { - UPLOAD_IDENTIFIER, - DOWNLOAD_IDENTIFIER, - REMOVE_IDENTIFIER, - } + """ + return plugin_identifier == "sitesync.loader.action" def trigger_action_item( self, - identifier, - project_name, - representation_ids + identifier: str, + project_name: str, + representation_ids: set[str], ): """Resets status for site_name or remove local files. @@ -321,8 +323,8 @@ class SiteSyncModel: identifier (str): Action identifier. project_name (str): Project name. representation_ids (Iterable[str]): Representation ids. - """ + """ active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -482,6 +484,7 @@ class SiteSyncModel: icon_name ): return ActionItem( + "sitesync.loader.action", identifier, label, icon={ @@ -492,11 +495,8 @@ class SiteSyncModel: tooltip=tooltip, options={}, order=1, - project_name=project_name, - folder_ids=[], - product_ids=[], - version_ids=[], - representation_ids=representation_ids, + entity_ids=representation_ids, + entity_type="representation", ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 4ed4368ab4..29bab7d0c5 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,11 +438,14 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( + action_item.plugin_identifier, action_item.identifier, options, project_name, action_item.entity_ids, action_item.entity_type, + version_ids, + "version", ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index c0957d186c..d1d9c73a2b 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,9 +399,12 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( + action_item.plugin_identifier, action_item.identifier, options, self._selected_project_name, action_item.entity_ids, action_item.entity_type, + repre_ids, + "representation", ) From afc1af7e9579e7304a2b8e47da2db72bde5c7499 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:42:32 +0200 Subject: [PATCH 043/279] use kwargs --- client/ayon_core/tools/loader/models/sitesync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index bab8a68132..67da36cd53 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -485,8 +485,8 @@ class SiteSyncModel: ): return ActionItem( "sitesync.loader.action", - identifier, - label, + identifier=identifier, + label=label, icon={ "type": "awesome-font", "name": icon_name, From d0cb16a1558a8bfeffe525b47aba4945b45d6a20 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:44:55 +0200 Subject: [PATCH 044/279] pass context to loader action plugins --- client/ayon_core/pipeline/actions/loader.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index be311dbdff..e62f10e7f2 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -382,8 +382,9 @@ class LoaderActionPlugin(ABC): _log: Optional[logging.Logger] = None enabled: bool = True - def __init__(self, studio_settings: dict[str, Any]): - self.apply_settings(studio_settings) + def __init__(self, context: "LoaderActionsContext") -> None: + self._context = context + self.apply_settings(context.get_studio_settings()) def apply_settings(self, studio_settings: dict[str, Any]) -> None: """Apply studio settings to the plugin. @@ -473,10 +474,15 @@ class LoaderActionsContext: def get_addons_manager(self) -> AddonsManager: if self._addons_manager is None: self._addons_manager = AddonsManager( - settings=self._get_studio_settings() + settings=self.get_studio_settings() ) return self._addons_manager + def get_studio_settings(self) -> dict[str, Any]: + if self._studio_settings is None: + self._studio_settings = get_studio_settings() + return copy.deepcopy(self._studio_settings) + def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -515,11 +521,6 @@ class LoaderActionsContext: attribute_values, ) - def _get_studio_settings(self) -> dict[str, Any]: - if self._studio_settings is None: - self._studio_settings = get_studio_settings() - return copy.deepcopy(self._studio_settings) - def _get_plugins(self) -> dict[str, LoaderActionPlugin]: if self._plugins is None: addons_manager = self.get_addons_manager() @@ -533,13 +534,12 @@ class LoaderActionsContext: if paths: all_paths.extend(paths) - studio_settings = self._get_studio_settings() result = discover_plugins(LoaderActionPlugin, all_paths) result.log_report() plugins = {} for cls in result.plugins: try: - plugin = cls(studio_settings) + plugin = cls(self) if not plugin.enabled: continue From 8da213c5660b19a0b72536df37703ce06a21ffb7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:26 +0200 Subject: [PATCH 045/279] added host to the context --- client/ayon_core/pipeline/actions/loader.py | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e62f10e7f2..c3216064e3 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -11,12 +11,14 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT +from ayon_core.host import AbstractHost from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings -from ayon_core.pipeline import Anatomy +from ayon_core.pipeline import Anatomy, registered_host from ayon_core.pipeline.plugin_discover import discover_plugins +_PLACEHOLDER = object() class EntityType(StrEnum): """Selected entity type.""" @@ -411,6 +413,11 @@ class LoaderActionPlugin(ABC): """ return self.__class__.__name__ + @property + def host_name(self) -> Optional[str]: + """Name of the current host.""" + return self._context.get_host_name() + @abstractmethod def get_action_items( self, selection: LoaderActionSelection @@ -457,11 +464,14 @@ class LoaderActionsContext: self, studio_settings: Optional[dict[str, Any]] = None, addons_manager: Optional[AddonsManager] = None, + host: Optional[AbstractHost] = _PLACEHOLDER, ) -> None: self._log = Logger.get_logger(self.__class__.__name__) self._addons_manager = addons_manager + self._host = host + # Attributes that are re-cached on reset self._studio_settings = studio_settings self._plugins = None @@ -478,6 +488,17 @@ class LoaderActionsContext: ) return self._addons_manager + def get_host(self) -> Optional[AbstractHost]: + if self._host is _PLACEHOLDER: + self._host = registered_host() + return self._host + + def get_host_name(self) -> Optional[str]: + host = self.get_host() + if host is None: + return None + return host.name + def get_studio_settings(self) -> dict[str, Any]: if self._studio_settings is None: self._studio_settings = get_studio_settings() From e30738d79b7b9fec3cf9b75f440b6f41fae9fe3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:48 +0200 Subject: [PATCH 046/279] LoaderSelectedType is public --- client/ayon_core/pipeline/actions/__init__.py | 4 +++- client/ayon_core/pipeline/actions/loader.py | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 188414bdbe..247f64e890 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,4 +1,5 @@ from .loader import ( + LoaderSelectedType, LoaderActionForm, LoaderActionResult, LoaderActionItem, @@ -27,7 +28,8 @@ from .inventory import ( ) -__all__= ( +__all__ = ( + "LoaderSelectedType", "LoaderActionForm", "LoaderActionResult", "LoaderActionItem", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c3216064e3..726ee6dcff 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -20,7 +20,8 @@ from ayon_core.pipeline.plugin_discover import discover_plugins _PLACEHOLDER = object() -class EntityType(StrEnum): + +class LoaderSelectedType(StrEnum): """Selected entity type.""" # folder = "folder" # task = "task" @@ -294,7 +295,7 @@ class LoaderActionSelection: self, project_name: str, selected_ids: set[str], - selected_type: EntityType, + selected_type: LoaderSelectedType, *, project_anatomy: Optional[Anatomy] = None, project_settings: Optional[dict[str, Any]] = None, @@ -528,7 +529,7 @@ class LoaderActionsContext: plugin_identifier: str, action_identifier: str, entity_ids: set[str], - entity_type: EntityType, + entity_type: LoaderSelectedType, selection: LoaderActionSelection, attribute_values: dict[str, Any], ) -> Optional[LoaderActionResult]: From 8bdfe806e0a896785b5f7f404aaaffc9b376274e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:46:27 +0200 Subject: [PATCH 047/279] result can contain form values This allows to re-open the same dialog having the same default values but with values already filled from user --- client/ayon_core/pipeline/actions/loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 726ee6dcff..9f0653a7f1 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -373,6 +373,7 @@ class LoaderActionResult: message: Optional[str] = None success: bool = True form: Optional[LoaderActionForm] = None + form_values: Optional[dict[str, Any]] = None class LoaderActionPlugin(ABC): From 2be5d3b72b9b0fac9e97cf581a3bd299fb0f1100 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:46:49 +0200 Subject: [PATCH 048/279] fix type comparison --- client/ayon_core/plugins/loader/copy_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 54e92b0ab9..d8424761e9 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -20,12 +20,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type in "representations": + if selection.selected_type == "representations": repres = selection.entities.get_representations( selection.selected_ids ) - if selection.selected_type in "version": + if selection.selected_type == "version": repres = selection.entities.get_versions_representations( selection.selected_ids ) From c6c642f37af190ffdbf5da8f88393fd3fff2c3fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:47:20 +0200 Subject: [PATCH 049/279] added json conversions --- client/ayon_core/pipeline/actions/loader.py | 57 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 9f0653a7f1..b537655ada 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -11,11 +11,16 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import StrEnum, Logger +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) from ayon_core.host import AbstractHost -from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings -from ayon_core.pipeline import Anatomy, registered_host +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins _PLACEHOLDER = object() @@ -363,9 +368,29 @@ class LoaderActionForm: title: str fields: list[AbstractAttrDef] submit_label: Optional[str] = "Submit" - submit_icon: Optional[str] = None + submit_icon: Optional[dict[str, Any]] = None cancel_label: Optional[str] = "Cancel" - cancel_icon: Optional[str] = None + cancel_icon: Optional[dict[str, Any]] = None + + def to_json_data(self) -> dict[str, Any]: + fields = self.fields + if fields is not None: + fields = serialize_attr_defs(fields) + return { + "title": self.title, + "fields": fields, + "submit_label": self.submit_label, + "submit_icon": self.submit_icon, + "cancel_label": self.cancel_label, + "cancel_icon": self.cancel_icon, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionForm": + fields = data["fields"] + if fields is not None: + data["fields"] = deserialize_attr_defs(fields) + return cls(**data) @dataclass @@ -375,6 +400,24 @@ class LoaderActionResult: form: Optional[LoaderActionForm] = None form_values: Optional[dict[str, Any]] = None + def to_json_data(self) -> dict[str, Any]: + form = self.form + if form is not None: + form = form.to_json_data() + return { + "message": self.message, + "success": self.success, + "form": form, + "form_values": self.form_values, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult": + form = data["form"] + if form is not None: + data["form"] = LoaderActionForm.from_json_data(form) + return LoaderActionResult(**data) + class LoaderActionPlugin(ABC): """Plugin for loader actions. @@ -492,6 +535,8 @@ class LoaderActionsContext: def get_host(self) -> Optional[AbstractHost]: if self._host is _PLACEHOLDER: + from ayon_core.pipeline import registered_host + self._host = registered_host() return self._host @@ -532,7 +577,7 @@ class LoaderActionsContext: entity_ids: set[str], entity_type: LoaderSelectedType, selection: LoaderActionSelection, - attribute_values: dict[str, Any], + form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] @@ -541,7 +586,7 @@ class LoaderActionsContext: entity_ids, entity_type, selection, - attribute_values, + form_values, ) def _get_plugins(self) -> dict[str, LoaderActionPlugin]: From 856aa31231c364d1957e6e2e07e53cbc45532faf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:57:40 +0200 Subject: [PATCH 050/279] change order of arguments --- client/ayon_core/tools/loader/abstract.py | 14 ++++++++------ client/ayon_core/tools/loader/control.py | 6 ++++-- .../ayon_core/tools/loader/ui/products_widget.py | 3 ++- client/ayon_core/tools/loader/ui/repres_widget.py | 3 ++- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 9bff8dbb2d..a58ddf11d7 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -324,9 +324,9 @@ class ActionItem: label (str): Action label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. + order (int): Action order. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. - order (int): Action order. """ def __init__( @@ -338,8 +338,8 @@ class ActionItem: label, icon, tooltip, - options, order, + options, ): self.plugin_identifier = plugin_identifier self.identifier = identifier @@ -348,8 +348,8 @@ class ActionItem: self.label = label self.icon = icon self.tooltip = tooltip - self.options = options self.order = order + self.options = options def _options_to_data(self): options = self.options @@ -377,8 +377,8 @@ class ActionItem: "label": self.label, "icon": self.icon, "tooltip": self.tooltip, - "options": options, "order": self.order, + "options": options, } @classmethod @@ -998,12 +998,13 @@ class FrontendLoaderController(_BaseLoaderController): self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): """Trigger action item. @@ -1023,12 +1024,13 @@ class FrontendLoaderController(_BaseLoaderController): Args: plugin_identifier (sttr): Plugin identifier. identifier (sttr): Action identifier. - options (dict[str, Any]): Action option values from UI. project_name (str): Project name. entity_ids (set[str]): Entity ids stored on action item. entity_type (str): Entity type stored on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + options (dict[str, Any]): Action option values from UI. + form_values (dict[str, Any]): Action form values from UI. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 900eaf7656..7ca25976b9 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -309,12 +309,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): if self._sitesync_model.is_sitesync_action(plugin_identifier): self._sitesync_model.trigger_action_item( @@ -327,12 +328,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loader_actions_model.trigger_action_item( plugin_identifier, identifier, - options, project_name, entity_ids, entity_type, selected_ids, selected_entity_type, + options, + form_values, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 29bab7d0c5..319108e8ea 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -440,12 +440,13 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.plugin_identifier, action_item.identifier, - options, project_name, action_item.entity_ids, action_item.entity_type, version_ids, "version", + options, + {}, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index d1d9c73a2b..bfbcc73503 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -401,10 +401,11 @@ class RepresentationsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.plugin_identifier, action_item.identifier, - options, self._selected_project_name, action_item.entity_ids, action_item.entity_type, repre_ids, "representation", + options, + {}, ) From 51beef8192a435edcd2e0c4b29802f161ab755f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:22:22 +0200 Subject: [PATCH 051/279] handle the actions --- .../ayon_core/tools/loader/models/actions.py | 84 ++++++++------ .../ayon_core/tools/loader/models/sitesync.py | 2 +- client/ayon_core/tools/loader/ui/window.py | 103 +++++++++++++++++- 3 files changed, 154 insertions(+), 35 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index e6ac328f92..d3d053ae85 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -124,12 +124,13 @@ class LoaderActionsModel: self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): """Trigger action by identifier. @@ -142,17 +143,23 @@ class LoaderActionsModel: Args: plugin_identifier (str): Plugin identifier. identifier (str): Action identifier. - options (dict[str, Any]): Loader option values. project_name (str): Project name. entity_ids (set[str]): Entity ids on action item. entity_type (str): Entity type on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + options (dict[str, Any]): Loader option values. + form_values (dict[str, Any]): Form values. """ event_data = { "plugin_identifier": plugin_identifier, "identifier": identifier, + "project_name": project_name, + "entity_ids": list(entity_ids), + "entity_type": entity_type, + "selected_ids": list(selected_ids), + "selected_entity_type": selected_entity_type, "id": uuid.uuid4().hex, } self._controller.emit_event( @@ -162,9 +169,10 @@ class LoaderActionsModel: ) if plugin_identifier != LOADER_PLUGIN_ID: # TODO fill error infor if any happens - error_info = [] + result = None + crashed = False try: - self._loader_actions.execute_action( + result = self._loader_actions.execute_action( plugin_identifier, identifier, entity_ids, @@ -174,37 +182,47 @@ class LoaderActionsModel: selected_ids, selected_entity_type, ), - {}, + form_values, ) except Exception: + crashed = True self._log.warning( f"Failed to execute action '{identifier}'", exc_info=True, ) - else: - loader = self._get_loader_by_identifier( - project_name, identifier - ) - if entity_type == "version": - error_info = self._trigger_version_loader( - loader, - options, - project_name, - entity_ids, - ) - elif entity_type == "representation": - error_info = self._trigger_representation_loader( - loader, - options, - project_name, - entity_ids, - ) - else: - raise NotImplementedError( - f"Invalid entity type '{entity_type}' to trigger action item" - ) + event_data["result"] = result + event_data["crashed"] = crashed + self._controller.emit_event( + "loader.action.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + return + + loader = self._get_loader_by_identifier( + project_name, identifier + ) + + if entity_type == "version": + error_info = self._trigger_version_loader( + loader, + options, + project_name, + entity_ids, + ) + elif entity_type == "representation": + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + entity_ids, + ) + else: + raise NotImplementedError( + f"Invalid entity type '{entity_type}' to trigger action item" + ) event_data["error_info"] = error_info self._controller.emit_event( @@ -334,8 +352,8 @@ class LoaderActionsModel: label=label, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), - options=loader.get_options(contexts), order=loader.order, + options=loader.get_options(contexts), ) def _get_loaders(self, project_name): @@ -783,11 +801,11 @@ class LoaderActionsModel: action.identifier, action.entity_ids, action.entity_type, - label, - action.icon, - None, # action.tooltip, - None, # action.options, - action.order, + label=label, + icon=action.icon, + tooltip=None, # action.tooltip, + order=action.order, + options=None, # action.options, )) return items diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 67da36cd53..ced4ac5d05 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -493,10 +493,10 @@ class SiteSyncModel: "color": "#999999" }, tooltip=tooltip, - options={}, order=1, entity_ids=representation_ids, entity_type="representation", + options={}, ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index df5beb708f..71679213e5 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -1,18 +1,24 @@ from __future__ import annotations +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath from ayon_core.style import load_stylesheet +from ayon_core.pipeline.actions import LoaderActionResult from ayon_core.tools.utils import ( PlaceholderLineEdit, + MessageOverlayObject, ErrorMessageBox, ThumbnailPainterWidget, RefreshButton, GoToCurrentButton, + ProjectsCombobox, + get_qt_icon, ) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController @@ -141,6 +147,8 @@ class LoaderWindow(QtWidgets.QWidget): if controller is None: controller = LoaderController() + overlay_object = MessageOverlayObject(self) + main_splitter = QtWidgets.QSplitter(self) context_splitter = QtWidgets.QSplitter(main_splitter) @@ -294,6 +302,12 @@ class LoaderWindow(QtWidgets.QWidget): "controller.reset.finished", self._on_controller_reset_finish, ) + controller.register_event_callback( + "loader.action.finished", + self._on_loader_action_finished, + ) + + self._overlay_object = overlay_object self._group_dialog = ProductGroupDialog(controller, self) @@ -406,6 +420,20 @@ class LoaderWindow(QtWidgets.QWidget): if self._reset_on_show: self.refresh() + def _show_toast_message( + self, + message: str, + success: bool = True, + message_id: Optional[str] = None, + ): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _show_group_dialog(self): project_name = self._projects_combobox.get_selected_project_name() if not project_name: @@ -494,6 +522,79 @@ class LoaderWindow(QtWidgets.QWidget): box = LoadErrorMessageBox(error_info, self) box.show() + def _on_loader_action_finished(self, event): + crashed = event["crashed"] + if crashed: + self._show_toast_message( + "Action failed", + success=False, + ) + return + + result: Optional[LoaderActionResult] = event["result"] + if result is None: + return + + if result.message: + self._show_toast_message( + result.message, result.success + ) + + if result.form is None: + return + + form = result.form + dialog = AttributeDefinitionsDialog( + form.fields, + title=form.title, + parent=self, + ) + if result.form_values: + dialog.set_values(result.form_values) + submit_label = form.submit_label + submit_icon = form.submit_icon + cancel_label = form.cancel_label + cancel_icon = form.cancel_icon + + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + + dialog.setMinimumSize(300, 140) + result = dialog.exec_() + if result != QtWidgets.QDialog.Accepted: + return + + form_data = dialog.get_values() + self._controller.trigger_action_item( + event["plugin_identifier"], + event["identifier"], + event["project_name"], + event["entity_ids"], + event["entity_type"], + event["selected_ids"], + event["selected_entity_type"], + {}, + form_data, + ) + def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] self._update_filters() From c2cdd4130edaaa78c3ef9eaf9cd99ea510f78c34 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:25:09 +0200 Subject: [PATCH 052/279] better stretch, margins and spacing --- client/ayon_core/tools/attribute_defs/dialog.py | 1 + client/ayon_core/tools/attribute_defs/widgets.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index 7423d58475..4d8e41199e 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addWidget(attrs_widget, 0) main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 1e948b2d28..f7766f50ac 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -182,6 +182,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): layout.deleteLater() new_layout = QtWidgets.QGridLayout() + new_layout.setContentsMargins(0, 0, 0, 0) new_layout.setColumnStretch(0, 0) new_layout.setColumnStretch(1, 1) self.setLayout(new_layout) @@ -210,12 +211,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): if not attr_def.visible: continue + col_num = 0 expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - if attr_def.is_value_def and attr_def.label: label_widget = AttributeDefinitionsLabel( attr_def.id, attr_def.label, self @@ -233,9 +230,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): | QtCore.Qt.AlignVCenter ) layout.addWidget( - label_widget, row, 0, 1, expand_cols + label_widget, row, col_num, 1, 1 ) - if not attr_def.is_label_horizontal: + if attr_def.is_label_horizontal: + col_num += 1 + expand_cols = 1 + else: row += 1 if attr_def.is_value_def: From 270d7cbff9679bb434d586728191d5cae8613447 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:44:16 +0200 Subject: [PATCH 053/279] convert delete old versions actions --- .../plugins/load/delete_old_versions.py | 477 ------------------ .../plugins/loader/delete_old_versions.py | 393 +++++++++++++++ 2 files changed, 393 insertions(+), 477 deletions(-) delete mode 100644 client/ayon_core/plugins/load/delete_old_versions.py create mode 100644 client/ayon_core/plugins/loader/delete_old_versions.py diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py deleted file mode 100644 index 3a42ccba7e..0000000000 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ /dev/null @@ -1,477 +0,0 @@ -import collections -import os -import uuid -from typing import List, Dict, Any - -import clique -import ayon_api -from ayon_api.operations import OperationsSession -import qargparse -from qtpy import QtWidgets, QtCore - -from ayon_core import style -from ayon_core.lib import format_file_size -from ayon_core.pipeline import load, Anatomy -from ayon_core.pipeline.load import ( - get_representation_path_with_anatomy, - InvalidRepresentationContext, -) - - -class DeleteOldVersions(load.ProductLoaderPlugin): - """Deletes specific number of old version""" - - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" - - representations = ["*"] - product_types = {"*"} - tool_names = ["library_loader"] - - label = "Delete Old Versions" - order = 35 - icon = "trash" - color = "#d8d8d8" - - options = [ - qargparse.Integer( - "versions_to_keep", default=2, min=0, help="Versions to keep:" - ), - qargparse.Boolean( - "remove_publish_folder", help="Remove publish folder:" - ) - ] - - requires_confirmation = True - - def delete_whole_dir_paths(self, dir_paths, delete=True): - size = 0 - - for dir_path in dir_paths: - # Delete all files and folders in dir path - for root, dirs, files in os.walk(dir_path, topdown=False): - for name in files: - file_path = os.path.join(root, name) - size += os.path.getsize(file_path) - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - - for name in dirs: - if delete: - os.rmdir(os.path.join(root, name)) - - if not delete: - continue - - # Delete even the folder and it's parents folders if they are empty - while True: - if not os.path.exists(dir_path): - dir_path = os.path.dirname(dir_path) - continue - - if len(os.listdir(dir_path)) != 0: - break - - os.rmdir(os.path.join(dir_path)) - - return size - - def path_from_representation(self, representation, anatomy): - try: - context = representation["context"] - except KeyError: - return (None, None) - - try: - path = get_representation_path_with_anatomy( - representation, anatomy - ) - except InvalidRepresentationContext: - return (None, None) - - sequence_path = None - if "frame" in context: - context["frame"] = self.sequence_splitter - sequence_path = get_representation_path_with_anatomy( - representation, anatomy - ) - - if sequence_path: - sequence_path = sequence_path.normalized() - - return (path.normalized(), sequence_path) - - def delete_only_repre_files(self, dir_paths, file_paths, delete=True): - size = 0 - - for dir_id, dir_path in dir_paths.items(): - dir_files = os.listdir(dir_path) - collections, remainders = clique.assemble(dir_files) - for file_path, seq_path in file_paths[dir_id]: - file_path_base = os.path.split(file_path)[1] - # Just remove file if `frame` key was not in context or - # filled path is in remainders (single file sequence) - if not seq_path or file_path_base in remainders: - if not os.path.exists(file_path): - self.log.debug( - "File was not found: {}".format(file_path) - ) - continue - - size += os.path.getsize(file_path) - - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - - if file_path_base in remainders: - remainders.remove(file_path_base) - continue - - seq_path_base = os.path.split(seq_path)[1] - head, tail = seq_path_base.split(self.sequence_splitter) - - final_col = None - for collection in collections: - if head != collection.head or tail != collection.tail: - continue - final_col = collection - break - - if final_col is not None: - # Fill full path to head - final_col.head = os.path.join(dir_path, final_col.head) - for _file_path in final_col: - if os.path.exists(_file_path): - - size += os.path.getsize(_file_path) - - if delete: - os.remove(_file_path) - self.log.debug( - "Removed file: {}".format(_file_path) - ) - - _seq_path = final_col.format("{head}{padding}{tail}") - self.log.debug("Removed files: {}".format(_seq_path)) - collections.remove(final_col) - - elif os.path.exists(file_path): - size += os.path.getsize(file_path) - - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - else: - self.log.debug( - "File was not found: {}".format(file_path) - ) - - # Delete as much as possible parent folders - if not delete: - return size - - for dir_path in dir_paths.values(): - while True: - if not os.path.exists(dir_path): - dir_path = os.path.dirname(dir_path) - continue - - if len(os.listdir(dir_path)) != 0: - break - - self.log.debug("Removed folder: {}".format(dir_path)) - os.rmdir(dir_path) - - return size - - def message(self, text): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(text) - msgBox.setStyleSheet(style.load_stylesheet()) - msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) - msgBox.exec_() - - def _confirm_delete(self, - contexts: List[Dict[str, Any]], - versions_to_keep: int) -> bool: - """Prompt user for a deletion confirmation""" - - contexts_list = "\n".join(sorted( - "- {folder[name]} > {product[name]}".format_map(context) - for context in contexts - )) - num_contexts = len(contexts) - s = "s" if num_contexts > 1 else "" - text = ( - "Are you sure you want to delete versions?\n\n" - f"This will keep only the last {versions_to_keep} " - f"versions for the {num_contexts} selected product{s}." - ) - informative_text = "Warning: This will delete files from disk" - detailed_text = ( - f"Keep only {versions_to_keep} versions for:\n{contexts_list}" - ) - - messagebox = QtWidgets.QMessageBox() - messagebox.setIcon(QtWidgets.QMessageBox.Warning) - messagebox.setWindowTitle("Delete Old Versions") - messagebox.setText(text) - messagebox.setInformativeText(informative_text) - messagebox.setDetailedText(detailed_text) - messagebox.setStandardButtons( - QtWidgets.QMessageBox.Yes - | QtWidgets.QMessageBox.Cancel - ) - messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel) - messagebox.setStyleSheet(style.load_stylesheet()) - messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) - return messagebox.exec_() == QtWidgets.QMessageBox.Yes - - def get_data(self, context, versions_count): - product_entity = context["product"] - folder_entity = context["folder"] - project_name = context["project"]["name"] - anatomy = Anatomy(project_name, project_entity=context["project"]) - - version_fields = ayon_api.get_default_fields_for_type("version") - version_fields.add("tags") - versions = list(ayon_api.get_versions( - project_name, - product_ids=[product_entity["id"]], - active=None, - hero=False, - fields=version_fields - )) - self.log.debug( - "Version Number ({})".format(len(versions)) - ) - versions_by_parent = collections.defaultdict(list) - for ent in versions: - versions_by_parent[ent["productId"]].append(ent) - - def sort_func(ent): - return int(ent["version"]) - - all_last_versions = [] - for _parent_id, _versions in versions_by_parent.items(): - for idx, version in enumerate( - sorted(_versions, key=sort_func, reverse=True) - ): - if idx >= versions_count: - break - all_last_versions.append(version) - - self.log.debug("Collected versions ({})".format(len(versions))) - - # Filter latest versions - for version in all_last_versions: - versions.remove(version) - - # Update versions_by_parent without filtered versions - versions_by_parent = collections.defaultdict(list) - for ent in versions: - versions_by_parent[ent["productId"]].append(ent) - - # Filter already deleted versions - versions_to_pop = [] - for version in versions: - if "deleted" in version["tags"]: - versions_to_pop.append(version) - - for version in versions_to_pop: - msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format( - folder_entity["path"], - product_entity["name"], - version["version"] - ) - self.log.debug(( - "Skipping version. Already tagged as inactive. < {} >" - ).format(msg)) - versions.remove(version) - - version_ids = [ent["id"] for ent in versions] - - self.log.debug( - "Filtered versions to delete ({})".format(len(version_ids)) - ) - - if not version_ids: - msg = "Skipping processing. Nothing to delete on {}/{}".format( - folder_entity["path"], product_entity["name"] - ) - self.log.info(msg) - print(msg) - return - - repres = list(ayon_api.get_representations( - project_name, version_ids=version_ids - )) - - self.log.debug( - "Collected representations to remove ({})".format(len(repres)) - ) - - dir_paths = {} - file_paths_by_dir = collections.defaultdict(list) - for repre in repres: - file_path, seq_path = self.path_from_representation( - repre, anatomy - ) - if file_path is None: - self.log.debug(( - "Could not format path for represenation \"{}\"" - ).format(str(repre))) - continue - - dir_path = os.path.dirname(file_path) - dir_id = None - for _dir_id, _dir_path in dir_paths.items(): - if _dir_path == dir_path: - dir_id = _dir_id - break - - if dir_id is None: - dir_id = uuid.uuid4() - dir_paths[dir_id] = dir_path - - file_paths_by_dir[dir_id].append([file_path, seq_path]) - - dir_ids_to_pop = [] - for dir_id, dir_path in dir_paths.items(): - if os.path.exists(dir_path): - continue - - dir_ids_to_pop.append(dir_id) - - # Pop dirs from both dictionaries - for dir_id in dir_ids_to_pop: - dir_paths.pop(dir_id) - paths = file_paths_by_dir.pop(dir_id) - # TODO report of missing directories? - paths_msg = ", ".join([ - "'{}'".format(path[0].replace("\\", "/")) for path in paths - ]) - self.log.debug(( - "Folder does not exist. Deleting its files skipped: {}" - ).format(paths_msg)) - - return { - "dir_paths": dir_paths, - "file_paths_by_dir": file_paths_by_dir, - "versions": versions, - "folder": folder_entity, - "product": product_entity, - "archive_product": versions_count == 0 - } - - def main(self, project_name, data, remove_publish_folder): - # Size of files. - size = 0 - if not data: - return size - - if remove_publish_folder: - size = self.delete_whole_dir_paths(data["dir_paths"].values()) - else: - size = self.delete_only_repre_files( - data["dir_paths"], data["file_paths_by_dir"] - ) - - op_session = OperationsSession() - for version in data["versions"]: - orig_version_tags = version["tags"] - version_tags = list(orig_version_tags) - changes = {} - if "deleted" not in version_tags: - version_tags.append("deleted") - changes["tags"] = version_tags - - if version["active"]: - changes["active"] = False - - if not changes: - continue - op_session.update_entity( - project_name, "version", version["id"], changes - ) - - op_session.commit() - - return size - - def load(self, contexts, name=None, namespace=None, options=None): - - # Get user options - versions_to_keep = 2 - remove_publish_folder = False - if options: - versions_to_keep = options.get( - "versions_to_keep", versions_to_keep - ) - remove_publish_folder = options.get( - "remove_publish_folder", remove_publish_folder - ) - - # Because we do not want this run by accident we will add an extra - # user confirmation - if ( - self.requires_confirmation - and not self._confirm_delete(contexts, versions_to_keep) - ): - return - - try: - size = 0 - for count, context in enumerate(contexts): - data = self.get_data(context, versions_to_keep) - if not data: - continue - project_name = context["project"]["name"] - size += self.main(project_name, data, remove_publish_folder) - print("Progressing {}/{}".format(count + 1, len(contexts))) - - msg = "Total size of files: {}".format(format_file_size(size)) - self.log.info(msg) - self.message(msg) - - except Exception: - self.log.error("Failed to delete versions.", exc_info=True) - - -class CalculateOldVersions(DeleteOldVersions): - """Calculate file size of old versions""" - label = "Calculate Old Versions" - order = 30 - tool_names = ["library_loader"] - - options = [ - qargparse.Integer( - "versions_to_keep", default=2, min=0, help="Versions to keep:" - ), - qargparse.Boolean( - "remove_publish_folder", help="Remove publish folder:" - ) - ] - - requires_confirmation = False - - def main(self, project_name, data, remove_publish_folder): - size = 0 - - if not data: - return size - - if remove_publish_folder: - size = self.delete_whole_dir_paths( - data["dir_paths"].values(), delete=False - ) - else: - size = self.delete_only_repre_files( - data["dir_paths"], data["file_paths_by_dir"], delete=False - ) - - return size diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py new file mode 100644 index 0000000000..31b0ff4bdf --- /dev/null +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import os +import collections +import json +import shutil +from typing import Optional, Any + +import clique +from ayon_api.operations import OperationsSession +from qtpy import QtWidgets, QtCore + +from ayon_core import style +from ayon_core.lib import ( + format_file_size, + AbstractAttrDef, + NumberDef, + BoolDef, + TextDef, + UILabelDef, +) +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.actions import ( + LoaderSelectedType, + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, + LoaderActionForm, +) + + +class DeleteOldVersions(LoaderActionPlugin): + """Deletes specific number of old version""" + + is_multiple_contexts_compatible = True + sequence_splitter = "__sequence_splitter__" + + requires_confirmation = True + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + # Do not show in hosts + if self.host_name is not None: + return [] + + versions = None + if selection.selected_type == LoaderSelectedType.version: + versions = selection.entities.get_versions( + selection.selected_ids + ) + + if not versions: + return [] + + product_ids = { + version["productId"] + for version in versions + } + + return [ + LoaderActionItem( + identifier="delete-versions", + label="Delete Versions", + order=35, + entity_ids=product_ids, + entity_type="product", + icon={ + "type": "material-symbols", + "name": "delete", + "color": "#d8d8d8", + } + ), + LoaderActionItem( + identifier="calculate-versions-size", + label="Calculate Versions size", + order=30, + entity_ids=product_ids, + entity_type="product", + icon={ + "type": "material-symbols", + "name": "auto_delete", + "color": "#d8d8d8", + } + ) + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + step = form_values.get("step") + versions_to_keep = form_values.get("versions_to_keep") + remove_publish_folder = form_values.get("remove_publish_folder") + if step is None: + return self._first_step( + identifier, + versions_to_keep, + remove_publish_folder, + ) + + if versions_to_keep is None: + versions_to_keep = 2 + if remove_publish_folder is None: + remove_publish_folder = False + + if step == "prepare-data": + return self._prepare_data_step( + identifier, + versions_to_keep, + remove_publish_folder, + entity_ids, + selection, + ) + + if step == "delete-versions": + return self._delete_versions_step( + selection.project_name, form_values + ) + return None + + def _first_step( + self, + identifier: str, + versions_to_keep: Optional[int], + remove_publish_folder: Optional[bool], + ) -> LoaderActionResult: + fields: list[AbstractAttrDef] = [ + TextDef( + "step", + visible=False, + ), + NumberDef( + "versions_to_keep", + label="Versions to keep", + minimum=0, + default=2, + ), + ] + if identifier == "delete-versions": + fields.append( + BoolDef( + "remove_publish_folder", + label="Remove publish folder", + default=False, + ) + ) + + form_values = { + key: value + for key, value in ( + ("remove_publish_folder", remove_publish_folder), + ("versions_to_keep", versions_to_keep), + ) + if value is not None + } + form_values["step"] = "prepare-data" + return LoaderActionResult( + form=LoaderActionForm( + title="Delete Old Versions", + fields=fields, + ), + form_values=form_values + ) + + def _prepare_data_step( + self, + identifier: str, + versions_to_keep: int, + remove_publish_folder: bool, + entity_ids: set[str], + selection: LoaderActionSelection, + ): + versions_by_product_id = collections.defaultdict(list) + for version in selection.entities.get_products_versions(entity_ids): + # Keep hero version + if versions_to_keep != 0 and version["version"] < 0: + continue + versions_by_product_id[version["productId"]].append(version) + + versions_to_delete = [] + for product_id, versions in versions_by_product_id.items(): + if versions_to_keep == 0: + versions_to_delete.extend(versions) + continue + + if len(versions) <= versions_to_keep: + continue + + versions.sort(key=lambda v: v["version"]) + for _ in range(versions_to_keep): + if not versions: + break + versions.pop(-1) + versions_to_delete.extend(versions) + + self.log.debug( + f"Collected versions to delete ({len(versions_to_delete)})" + ) + + version_ids = { + version["id"] + for version in versions_to_delete + } + if not version_ids: + return LoaderActionResult( + message="Skipping. Nothing to delete.", + success=False, + ) + + project = selection.entities.get_project() + anatomy = Anatomy(project["name"], project_entity=project) + + repres = selection.entities.get_versions_representations(version_ids) + + self.log.debug( + f"Collected representations to remove ({len(repres)})" + ) + + filepaths_by_repre_id = {} + repre_ids_by_version_id = { + version_id: [] + for version_id in version_ids + } + for repre in repres: + repre_ids_by_version_id[repre["versionId"]].append(repre["id"]) + filepaths_by_repre_id[repre["id"]] = [ + anatomy.fill_root(repre_file["path"]) + for repre_file in repre["files"] + ] + + size = 0 + for filepaths in filepaths_by_repre_id.values(): + for filepath in filepaths: + if os.path.exists(filepath): + size += os.path.getsize(filepath) + + if identifier == "calculate-versions-size": + return LoaderActionResult( + message="Calculated size", + success=True, + form=LoaderActionForm( + title="Calculated versions size", + fields=[ + UILabelDef( + f"Total size of files: {format_file_size(size)}" + ), + ], + submit_label=None, + cancel_label="Close", + ), + ) + + form, form_values = self._get_delete_form( + size, + remove_publish_folder, + list(version_ids), + repre_ids_by_version_id, + filepaths_by_repre_id, + ) + return LoaderActionResult( + form=form, + form_values=form_values + ) + + def _delete_versions_step( + self, project_name: str, form_values: dict[str, Any] + ) -> LoaderActionResult: + delete_data = json.loads(form_values["delete_data"]) + remove_publish_folder = form_values["remove_publish_folder"] + if form_values["delete_value"].lower() != "delete": + size = delete_data["size"] + form, form_values = self._get_delete_form( + size, + remove_publish_folder, + delete_data["version_ids"], + delete_data["repre_ids_by_version_id"], + delete_data["filepaths_by_repre_id"], + True, + ) + return LoaderActionResult( + form=form, + form_values=form_values, + ) + + version_ids = delete_data["version_ids"] + repre_ids_by_version_id = delete_data["repre_ids_by_version_id"] + filepaths_by_repre_id = delete_data["filepaths_by_repre_id"] + op_session = OperationsSession() + total_versions = len(version_ids) + try: + for version_idx, version_id in enumerate(version_ids): + self.log.info( + f"Progressing version {version_idx + 1}/{total_versions}" + ) + for repre_id in repre_ids_by_version_id[version_id]: + for filepath in filepaths_by_repre_id[repre_id]: + publish_folder = os.path.dirname(filepath) + if remove_publish_folder: + if os.path.exists(publish_folder): + shutil.rmtree(publish_folder, ignore_errors=True) + continue + + if os.path.exists(filepath): + os.remove(filepath) + + op_session.delete_entity( + project_name, "representation", repre_id + ) + op_session.delete_entity( + project_name, "version", version_id + ) + self.log.info("All done") + + except Exception: + self.log.error("Failed to delete versions.", exc_info=True) + return LoaderActionResult( + message="Failed to delete versions.", + success=False, + ) + + finally: + op_session.commit() + + return LoaderActionResult( + message="Deleted versions", + success=True, + ) + + def _get_delete_form( + self, + size: int, + remove_publish_folder: bool, + version_ids: list[str], + repre_ids_by_version_id: dict[str, list[str]], + filepaths_by_repre_id: dict[str, list[str]], + repeated: bool = False, + ) -> tuple[LoaderActionForm, dict[str, Any]]: + versions_len = len(repre_ids_by_version_id) + fields = [ + UILabelDef( + f"Going to delete {versions_len} versions
" + f"- total size of files: {format_file_size(size)}
" + ), + UILabelDef("Are you sure you want to continue?"), + TextDef( + "delete_value", + placeholder="Type 'delete' to confirm...", + ), + ] + if repeated: + fields.append(UILabelDef( + "*Please fill in '**delete**' to confirm deletion.*" + )) + fields.extend([ + TextDef( + "delete_data", + visible=False, + ), + TextDef( + "step", + visible=False, + ), + BoolDef( + "remove_publish_folder", + label="Remove publish folder", + default=False, + visible=False, + ) + ]) + + form = LoaderActionForm( + title="Delete versions", + submit_label="Delete", + cancel_label="Close", + fields=fields, + ) + form_values = { + "delete_data": json.dumps({ + "size": size, + "version_ids": version_ids, + "repre_ids_by_version_id": repre_ids_by_version_id, + "filepaths_by_repre_id": filepaths_by_repre_id, + }), + "step": "delete-versions", + "remove_publish_folder": remove_publish_folder, + } + return form, form_values From f06fbe159f4dcdf8b87586706a1a0d0f4810d17b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:00:10 +0200 Subject: [PATCH 054/279] added group label to 'ActionItem' --- client/ayon_core/tools/loader/abstract.py | 4 +++ .../ayon_core/tools/loader/models/actions.py | 18 ++-------- .../ayon_core/tools/loader/models/sitesync.py | 1 + .../tools/loader/ui/actions_utils.py | 34 ++++++++++++++++--- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index a58ddf11d7..d3de8fb7c2 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -322,6 +322,7 @@ class ActionItem: entity_ids (set[str]): Entity ids. entity_type (str): Entity type. label (str): Action label. + group_label (str): Group label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. order (int): Action order. @@ -336,6 +337,7 @@ class ActionItem: entity_ids, entity_type, label, + group_label, icon, tooltip, order, @@ -346,6 +348,7 @@ class ActionItem: self.entity_ids = entity_ids self.entity_type = entity_type self.label = label + self.group_label = group_label self.icon = icon self.tooltip = tooltip self.order = order @@ -375,6 +378,7 @@ class ActionItem: "entity_ids": list(self.entity_ids), "entity_type": self.entity_type, "label": self.label, + "group_label": self.group_label, "icon": self.icon, "tooltip": self.tooltip, "order": self.order, diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index d3d053ae85..684adf36a9 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -116,8 +116,6 @@ class LoaderActionsModel: entity_ids, entity_type, )) - - action_items.sort(key=self._actions_sorter) return action_items def trigger_action_item( @@ -350,6 +348,7 @@ class LoaderActionsModel: entity_ids=entity_ids, entity_type=entity_type, label=label, + group_label=None, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), order=loader.order, @@ -407,15 +406,6 @@ class LoaderActionsModel: loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) - def _actions_sorter(self, action_item): - """Sort the Loaders by their order and then their name. - - Returns: - tuple[int, str]: Sort keys. - """ - - return action_item.order, action_item.label - def _contexts_for_versions(self, project_name, version_ids): """Get contexts for given version ids. @@ -793,15 +783,13 @@ class LoaderActionsModel: ) items = [] for action in self._loader_actions.get_action_items(selection): - label = action.label - if action.group_label: - label = f"{action.group_label} ({label})" items.append(ActionItem( action.plugin_identifier, action.identifier, action.entity_ids, action.entity_type, - label=label, + label=action.label, + group_label=action.group_label, icon=action.icon, tooltip=None, # action.tooltip, order=action.order, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index ced4ac5d05..4d6ffcf9d4 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -487,6 +487,7 @@ class SiteSyncModel: "sitesync.loader.action", identifier=identifier, label=label, + group_label=None, icon={ "type": "awesome-font", "name": icon_name, diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index b601cd95bd..3281a170bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -1,6 +1,7 @@ import uuid +from typing import Optional, Any -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore import qtawesome from ayon_core.lib.attribute_definitions import AbstractAttrDef @@ -11,9 +12,26 @@ from ayon_core.tools.utils.widgets import ( OptionDialog, ) from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.loader.abstract import ActionItem -def show_actions_menu(action_items, global_point, one_item_selected, parent): +def _actions_sorter(item: tuple[str, ActionItem]): + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + + """ + label, action_item = item + return action_item.order, label + + +def show_actions_menu( + action_items: list[ActionItem], + global_point: QtCore.QPoint, + one_item_selected: bool, + parent: QtWidgets.QWidget, +) -> tuple[Optional[ActionItem], Optional[dict[str, Any]]]: selected_action_item = None selected_options = None @@ -26,15 +44,23 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent): menu = OptionalMenu(parent) - action_items_by_id = {} + action_items_with_labels = [] for action_item in action_items: + label = action_item.label + if action_item.group_label: + label = f"{action_item.group_label} ({label})" + action_items_with_labels.append((label, action_item)) + + action_items_by_id = {} + for item in sorted(action_items_with_labels, key=_actions_sorter): + label, action_item = item item_id = uuid.uuid4().hex action_items_by_id[item_id] = action_item item_options = action_item.options icon = get_qt_icon(action_item.icon) use_option = bool(item_options) action = OptionalAction( - action_item.label, + label, icon, use_option, menu From 1768543b8bd05327168649036743c43c3c3323d0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:18:18 +0200 Subject: [PATCH 055/279] safe-guards for optional action and menu --- client/ayon_core/tools/utils/widgets.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index de2c42c91f..61a4886741 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -861,24 +861,26 @@ class OptionalMenu(QtWidgets.QMenu): def mouseReleaseEvent(self, event): """Emit option clicked signal if mouse released on it""" active = self.actionAt(event.pos()) - if active and active.use_option: + if isinstance(active, OptionalAction) and active.use_option: option = active.widget.option if option.is_hovered(event.globalPos()): option.clicked.emit() - super(OptionalMenu, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): """Add highlight to active action""" active = self.actionAt(event.pos()) for action in self.actions(): - action.set_highlight(action is active, event.globalPos()) - super(OptionalMenu, self).mouseMoveEvent(event) + if isinstance(action, OptionalAction): + action.set_highlight(action is active, event.globalPos()) + super().mouseMoveEvent(event) def leaveEvent(self, event): """Remove highlight from all actions""" for action in self.actions(): - action.set_highlight(False) - super(OptionalMenu, self).leaveEvent(event) + if isinstance(action, OptionalAction): + action.set_highlight(False) + super().leaveEvent(event) class OptionalAction(QtWidgets.QWidgetAction): @@ -890,7 +892,7 @@ class OptionalAction(QtWidgets.QWidgetAction): """ def __init__(self, label, icon, use_option, parent): - super(OptionalAction, self).__init__(parent) + super().__init__(parent) self.label = label self.icon = icon self.use_option = use_option @@ -951,7 +953,7 @@ class OptionalActionWidget(QtWidgets.QWidget): """Main widget class for `OptionalAction`""" def __init__(self, label, parent=None): - super(OptionalActionWidget, self).__init__(parent) + super().__init__(parent) body_widget = QtWidgets.QWidget(self) body_widget.setObjectName("OptionalActionBody") From f100a6c563b8acea1242c8ca2cc7f102db18de89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:07 +0200 Subject: [PATCH 056/279] show grouped actions as menu --- client/ayon_core/tools/loader/abstract.py | 37 +++++++++---------- .../tools/loader/ui/actions_utils.py | 34 ++++++++++++----- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d3de8fb7c2..2e90a51a5b 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -322,9 +322,9 @@ class ActionItem: entity_ids (set[str]): Entity ids. entity_type (str): Entity type. label (str): Action label. - group_label (str): Group label. - icon (dict[str, Any]): Action icon definition. - tooltip (str): Action tooltip. + group_label (Optional[str]): Group label. + icon (Optional[dict[str, Any]]): Action icon definition. + tooltip (Optional[str]): Action tooltip. order (int): Action order. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. @@ -332,16 +332,16 @@ class ActionItem: """ def __init__( self, - plugin_identifier, - identifier, - entity_ids, - entity_type, - label, - group_label, - icon, - tooltip, - order, - options, + plugin_identifier: str, + identifier: str, + entity_ids: set[str], + entity_type: str, + label: str, + group_label: Optional[str], + icon: Optional[dict[str, Any]], + tooltip: Optional[str], + order: int, + options: Optional[list], ): self.plugin_identifier = plugin_identifier self.identifier = identifier @@ -364,13 +364,12 @@ class ActionItem: # future development of detached UI tools it would be better to be # prepared for it. raise NotImplementedError( - "{}.to_data is not implemented. Use Attribute definitions" - " from 'ayon_core.lib' instead of 'qargparse'.".format( - self.__class__.__name__ - ) + f"{self.__class__.__name__}.to_data is not implemented." + " Use Attribute definitions from 'ayon_core.lib'" + " instead of 'qargparse'." ) - def to_data(self): + def to_data(self) -> dict[str, Any]: options = self._options_to_data() return { "plugin_identifier": self.plugin_identifier, @@ -386,7 +385,7 @@ class ActionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data) -> "ActionItem": options = data["options"] if options: options = deserialize_attr_defs(options) diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 3281a170bd..cf39bc348c 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -15,15 +15,18 @@ from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.loader.abstract import ActionItem -def _actions_sorter(item: tuple[str, ActionItem]): +def _actions_sorter(item: tuple[ActionItem, str, str]): """Sort the Loaders by their order and then their name. Returns: tuple[int, str]: Sort keys. """ - label, action_item = item - return action_item.order, label + action_item, group_label, label = item + if group_label is None: + group_label = label + label = "" + return action_item.order, group_label, label def show_actions_menu( @@ -46,21 +49,21 @@ def show_actions_menu( action_items_with_labels = [] for action_item in action_items: - label = action_item.label - if action_item.group_label: - label = f"{action_item.group_label} ({label})" - action_items_with_labels.append((label, action_item)) + action_items_with_labels.append( + (action_item, action_item.group_label, action_item.label) + ) + group_menu_by_label = {} action_items_by_id = {} for item in sorted(action_items_with_labels, key=_actions_sorter): - label, action_item = item + action_item, _, _ = item item_id = uuid.uuid4().hex action_items_by_id[item_id] = action_item item_options = action_item.options icon = get_qt_icon(action_item.icon) use_option = bool(item_options) action = OptionalAction( - label, + action_item.label, icon, use_option, menu @@ -76,7 +79,18 @@ def show_actions_menu( action.setData(item_id) - menu.addAction(action) + group_label = action_item.group_label + if group_label: + group_menu = group_menu_by_label.get(group_label) + if group_menu is None: + group_menu = OptionalMenu(group_label, menu) + if icon is not None: + group_menu.setIcon(icon) + menu.addMenu(group_menu) + group_menu_by_label[group_label] = group_menu + group_menu.addAction(action) + else: + menu.addAction(action) action = menu.exec_(global_point) if action is not None: From 0ad0b3927ff70b0381974cc605242b5023f7e070 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:23 +0200 Subject: [PATCH 057/279] small enhancements of messages --- client/ayon_core/plugins/loader/copy_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index d8424761e9..716b4ab88f 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -88,7 +88,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard = QtWidgets.QApplication.clipboard() if not clipboard: return LoaderActionResult( - "Failed to copy file path to clipboard", + "Failed to copy file path to clipboard.", success=False, ) @@ -97,7 +97,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard.setText(os.path.normpath(path)) return LoaderActionResult( - "Path stored to clipboard", + "Path stored to clipboard...", success=True, ) @@ -110,6 +110,6 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard.setMimeData(data) return LoaderActionResult( - "File added to clipboard", + "File added to clipboard...", success=True, ) From f784eeb17e4e78967bad1f6475d19e92c4aaba65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:43:10 +0200 Subject: [PATCH 058/279] remove unused imports --- client/ayon_core/plugins/loader/delete_old_versions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 31b0ff4bdf..b0905954f1 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -6,11 +6,8 @@ import json import shutil from typing import Optional, Any -import clique from ayon_api.operations import OperationsSession -from qtpy import QtWidgets, QtCore -from ayon_core import style from ayon_core.lib import ( format_file_size, AbstractAttrDef, From 2a13074e6bbda03b63725b5fc4cd9ef4b9491c3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:04:20 +0200 Subject: [PATCH 059/279] Converted push to project plugin --- .../ayon_core/plugins/load/push_to_project.py | 51 ------------- .../plugins/loader/push_to_project.py | 73 +++++++++++++++++++ 2 files changed, 73 insertions(+), 51 deletions(-) delete mode 100644 client/ayon_core/plugins/load/push_to_project.py create mode 100644 client/ayon_core/plugins/loader/push_to_project.py diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py deleted file mode 100644 index dccac42444..0000000000 --- a/client/ayon_core/plugins/load/push_to_project.py +++ /dev/null @@ -1,51 +0,0 @@ -import os - -from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import get_ayon_launcher_args, run_detached_process -from ayon_core.pipeline import load -from ayon_core.pipeline.load import LoadError - - -class PushToProject(load.ProductLoaderPlugin): - """Export selected versions to different project""" - - is_multiple_contexts_compatible = True - - representations = {"*"} - product_types = {"*"} - - label = "Push to project" - order = 35 - icon = "send" - color = "#d8d8d8" - - def load(self, contexts, name=None, namespace=None, options=None): - filtered_contexts = [ - context - for context in contexts - if context.get("project") and context.get("version") - ] - if not filtered_contexts: - raise LoadError("Nothing to push for your selection") - - if len(filtered_contexts) > 1: - raise LoadError("Please select only one item") - - context = tuple(filtered_contexts)[0] - - push_tool_script_path = os.path.join( - AYON_CORE_ROOT, - "tools", - "push_to_project", - "main.py" - ) - - project_name = context["project"]["name"] - version_id = context["version"]["id"] - - args = get_ayon_launcher_args( - push_tool_script_path, - "--project", project_name, - "--version", version_id - ) - run_detached_process(args) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py new file mode 100644 index 0000000000..ef1908f19c --- /dev/null +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -0,0 +1,73 @@ +import os +from typing import Optional, Any + +from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import get_ayon_launcher_args, run_detached_process + +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +class PushToProject(LoaderActionPlugin): + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + version_ids = set() + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) + + output = [] + if len(version_ids) == 1: + output.append( + LoaderActionItem( + identifier="core.push-to-project", + label="Push to project", + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "send", + "color": "#d8d8d8", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + if len(entity_ids) > 1: + return LoaderActionResult( + message="Please select only one version", + success=False, + ) + + push_tool_script_path = os.path.join( + AYON_CORE_ROOT, + "tools", + "push_to_project", + "main.py" + ) + + version_id = next(iter(entity_ids)) + + args = get_ayon_launcher_args( + push_tool_script_path, + "--project", selection.project_name, + "--version", version_id + ) + run_detached_process(args) + return LoaderActionResult( + message="Push to project tool opened...", + success=True, + ) From ed6247d23194f5cd1936b341418218651c49e9e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:32:00 +0200 Subject: [PATCH 060/279] converted otio export action --- .../plugins/{load => loader}/export_otio.py | 145 +++++++++++++----- 1 file changed, 109 insertions(+), 36 deletions(-) rename client/ayon_core/plugins/{load => loader}/export_otio.py (86%) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py similarity index 86% rename from client/ayon_core/plugins/load/export_otio.py rename to client/ayon_core/plugins/loader/export_otio.py index 8094490246..bbbad3378f 100644 --- a/client/ayon_core/plugins/load/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -2,11 +2,10 @@ import logging import os from pathlib import Path from collections import defaultdict +from typing import Any, Optional from qtpy import QtWidgets, QtCore, QtGui -from ayon_api import get_representations -from ayon_core.pipeline import load, Anatomy from ayon_core import resources, style from ayon_core.lib.transcoding import ( IMAGE_EXTENSIONS, @@ -16,9 +15,17 @@ from ayon_core.lib import ( get_ffprobe_data, is_oiio_supported, ) +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.tools.utils import show_message_dialog +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + OTIO = None FRAME_SPLITTER = "__frame_splitter__" @@ -30,34 +37,116 @@ def _import_otio(): OTIO = opentimelineio -class ExportOTIO(load.ProductLoaderPlugin): - """Export selected versions to OpenTimelineIO.""" - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" +class ExportOTIO(LoaderActionPlugin): + """Copy published file path to clipboard""" + identifier = "core.export-otio" - representations = {"*"} - product_types = {"*"} - tool_names = ["library_loader"] + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + # Don't show in hosts + if self.host_name is None: + return [] - label = "Export OTIO" - order = 35 - icon = "save" - color = "#d8d8d8" + version_ids = set() + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) - def load(self, contexts, name=None, namespace=None, options=None): + output = [] + if version_ids: + output.append( + LoaderActionItem( + identifier="copy-path", + label="Export OTIO", + group_label=None, + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "save", + "color": "#d8d8d8", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: _import_otio() + + versions_by_id = { + version["id"]: version + for version in selection.entities.get_versions(entity_ids) + } + product_ids = { + version["productId"] + for version in versions_by_id.values() + } + products_by_id = { + product["id"]: product + for product in selection.entities.get_products(product_ids) + } + folder_ids = { + product["folderId"] + for product in products_by_id.values() + } + folder_by_id = { + folder["id"]: folder + for folder in selection.entities.get_folders(folder_ids) + } + repre_entities = selection.entities.get_versions_representations( + entity_ids + ) + + version_path_by_id = {} + for version in versions_by_id.values(): + version_id = version["id"] + product_id = version["productId"] + product = products_by_id[product_id] + folder_id = product["folderId"] + folder = folder_by_id[folder_id] + + version_path_by_id[version_id] = "/".join([ + folder["path"], + product["name"], + version["name"] + ]) + try: - dialog = ExportOTIOOptionsDialog(contexts, self.log) + # TODO this should probably trigger a subprocess? + dialog = ExportOTIOOptionsDialog( + selection.project_name, + versions_by_id, + repre_entities, + version_path_by_id, + self.log + ) dialog.exec_() except Exception: self.log.error("Failed to export OTIO.", exc_info=True) + return LoaderActionResult() class ExportOTIOOptionsDialog(QtWidgets.QDialog): """Dialog to select template where to deliver selected representations.""" - def __init__(self, contexts, log=None, parent=None): + def __init__( + self, + project_name, + versions_by_id, + repre_entities, + version_path_by_id, + log=None, + parent=None + ): # Not all hosts have OpenTimelineIO available. self.log = log @@ -73,30 +162,14 @@ class ExportOTIOOptionsDialog(QtWidgets.QDialog): | QtCore.Qt.WindowMinimizeButtonHint ) - project_name = contexts[0]["project"]["name"] - versions_by_id = { - context["version"]["id"]: context["version"] - for context in contexts - } - repre_entities = list(get_representations( - project_name, version_ids=set(versions_by_id) - )) version_by_representation_id = { repre_entity["id"]: versions_by_id[repre_entity["versionId"]] for repre_entity in repre_entities } - version_path_by_id = {} - representations_by_version_id = {} - for context in contexts: - version_id = context["version"]["id"] - if version_id in version_path_by_id: - continue - representations_by_version_id[version_id] = [] - version_path_by_id[version_id] = "/".join([ - context["folder"]["path"], - context["product"]["name"], - context["version"]["name"] - ]) + representations_by_version_id = { + version_id: [] + for version_id in versions_by_id + } for repre_entity in repre_entities: representations_by_version_id[repre_entity["versionId"]].append( From 79ca56f3adde7a1fad81e2ebd7eed6e55747d160 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:33:43 +0200 Subject: [PATCH 061/279] added identifier to push to project plugin --- client/ayon_core/plugins/loader/push_to_project.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index ef1908f19c..4435ecf0c6 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -13,6 +13,8 @@ from ayon_core.pipeline.actions import ( class PushToProject(LoaderActionPlugin): + identifier = "core.push-to-project" + def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: From fc0232b7449f888f54568cae4955e0238ff737dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:57:39 +0200 Subject: [PATCH 062/279] convert open file action --- client/ayon_core/plugins/load/open_file.py | 36 ----- client/ayon_core/plugins/loader/open_file.py | 133 +++++++++++++++++++ 2 files changed, 133 insertions(+), 36 deletions(-) delete mode 100644 client/ayon_core/plugins/load/open_file.py create mode 100644 client/ayon_core/plugins/loader/open_file.py diff --git a/client/ayon_core/plugins/load/open_file.py b/client/ayon_core/plugins/load/open_file.py deleted file mode 100644 index 3b5fbbc0c9..0000000000 --- a/client/ayon_core/plugins/load/open_file.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import os -import subprocess - -from ayon_core.pipeline import load - - -def open(filepath): - """Open file with system default executable""" - if sys.platform.startswith('darwin'): - subprocess.call(('open', filepath)) - elif os.name == 'nt': - os.startfile(filepath) - elif os.name == 'posix': - subprocess.call(('xdg-open', filepath)) - - -class OpenFile(load.LoaderPlugin): - """Open Image Sequence or Video with system default""" - - product_types = {"render2d"} - representations = {"*"} - - label = "Open" - order = -10 - icon = "play-circle" - color = "orange" - - def load(self, context, name, namespace, data): - - path = self.filepath_from_context(context) - if not os.path.exists(path): - raise RuntimeError("File not found: {}".format(path)) - - self.log.info("Opening : {}".format(path)) - open(path) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py new file mode 100644 index 0000000000..a46bb31472 --- /dev/null +++ b/client/ayon_core/plugins/loader/open_file.py @@ -0,0 +1,133 @@ +import os +import sys +import subprocess +import collections +from typing import Optional, Any + +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +def open_file(filepath: str) -> None: + """Open file with system default executable""" + if sys.platform.startswith("darwin"): + subprocess.call(("open", filepath)) + elif os.name == "nt": + os.startfile(filepath) + elif os.name == "posix": + subprocess.call(("xdg-open", filepath)) + + +class OpenFileAction(LoaderActionPlugin): + """Open Image Sequence or Video with system default""" + identifier = "core.open-file" + + product_types = {"render2d"} + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + repres = [] + if selection.selected_type == "representations": + repres = selection.entities.get_representations( + selection.selected_ids + ) + + if selection.selected_type == "version": + repres = selection.entities.get_versions_representations( + selection.selected_ids + ) + + if not repres: + return [] + + repre_ids = {repre["id"] for repre in repres} + versions = selection.entities.get_representations_versions( + repre_ids + ) + product_ids = {version["productId"] for version in versions} + products = selection.entities.get_products(product_ids) + fitlered_product_ids = { + product["id"] + for product in products + if product["productType"] in self.product_types + } + if not fitlered_product_ids: + return [] + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + + repres_by_version_ids = collections.defaultdict(list) + for repre in repres: + repres_by_version_ids[repre["versionId"]].append(repre) + + filtered_repres = [] + for product_id in fitlered_product_ids: + for version in versions_by_product_id[product_id]: + for repre in repres_by_version_ids[version["id"]]: + filtered_repres.append(repre) + + repre_ids_by_name = collections.defaultdict(set) + for repre in filtered_repres: + repre_ids_by_name[repre["name"]].add(repre["id"]) + + return [ + LoaderActionItem( + identifier="open-file", + label=repre_name, + group_label="Open file", + order=-10, + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "play_circle", + "color": "#FFA500", + } + ) + for repre_name, repre_ids in repre_ids_by_name.items() + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + path = None + repre_path = None + for repre in selection.entities.get_representations(entity_ids): + repre_path = get_representation_path_with_anatomy( + repre, selection.get_project_anatomy() + ) + if os.path.exists(repre_path): + path = repre_path + break + + if path is None: + if repre_path is None: + return LoaderActionResult( + "Failed to fill representation path...", + success=False, + ) + return LoaderActionResult( + "File to open was not found...", + success=False, + ) + + self.log.info(f"Opening: {path}") + open_file(path) + + return LoaderActionResult( + "File was opened...", + success=True, + ) From 062069028f4ae5dbe0925f8812b200b10987b81c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:28 +0200 Subject: [PATCH 063/279] convert delivery action --- .../plugins/{load => loader}/delivery.py | 93 +++++++++++++------ 1 file changed, 64 insertions(+), 29 deletions(-) rename client/ayon_core/plugins/{load => loader}/delivery.py (87%) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/loader/delivery.py similarity index 87% rename from client/ayon_core/plugins/load/delivery.py rename to client/ayon_core/plugins/loader/delivery.py index 406040d936..fb668e5b10 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -1,5 +1,6 @@ import platform from collections import defaultdict +from typing import Optional, Any import ayon_api from qtpy import QtWidgets, QtCore, QtGui @@ -10,7 +11,13 @@ from ayon_core.lib import ( collect_frames, get_datetime_data, ) -from ayon_core.pipeline import load, Anatomy +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionSelection, + LoaderActionItem, + LoaderActionResult, +) from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.delivery import ( get_format_dict, @@ -20,43 +27,74 @@ from ayon_core.pipeline.delivery import ( ) -class Delivery(load.ProductLoaderPlugin): - """Export selected versions to folder structure from Template""" +class DeliveryAction(LoaderActionPlugin): + identifier = "core.delivery" - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + if self.host_name is None: + return [] - representations = {"*"} - product_types = {"*"} - tool_names = ["library_loader"] + version_ids = set() + if selection.selected_type == "representations": + versions = selection.entities.get_representations_versions( + selection.selected_ids + ) + version_ids = {version["id"] for version in versions} - label = "Deliver Versions" - order = 35 - icon = "upload" - color = "#d8d8d8" + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) - def message(self, text): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(text) - msgBox.setStyleSheet(style.load_stylesheet()) - msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) - msgBox.exec_() + if not version_ids: + return [] - def load(self, contexts, name=None, namespace=None, options=None): + return [ + LoaderActionItem( + identifier="deliver-versions", + label="Deliver Versions", + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "upload", + "color": "#d8d8d8", + } + ) + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: try: - dialog = DeliveryOptionsDialog(contexts, self.log) + # TODO run the tool in subprocess + dialog = DeliveryOptionsDialog( + selection.project_name, entity_ids, self.log + ) dialog.exec_() except Exception: self.log.error("Failed to deliver versions.", exc_info=True) + return LoaderActionResult() + class DeliveryOptionsDialog(QtWidgets.QDialog): """Dialog to select template where to deliver selected representations.""" - def __init__(self, contexts, log=None, parent=None): - super(DeliveryOptionsDialog, self).__init__(parent=parent) + def __init__( + self, + project_name, + version_ids, + log=None, + parent=None, + ): + super().__init__(parent=parent) self.setWindowTitle("AYON - Deliver versions") icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) @@ -70,13 +108,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) - project_name = contexts[0]["project"]["name"] self.anatomy = Anatomy(project_name) self._representations = None self.log = log self.currently_uploaded = 0 - self._set_representations(project_name, contexts) + self._set_representations(project_name, version_ids) dropdown = QtWidgets.QComboBox() self.templates = self._get_templates(self.anatomy) @@ -316,9 +353,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): return templates - def _set_representations(self, project_name, contexts): - version_ids = {context["version"]["id"] for context in contexts} - + def _set_representations(self, project_name, version_ids): repres = list(ayon_api.get_representations( project_name, version_ids=version_ids )) From b1a4d5dfc54c5141ad7ab878ae5ab16d03481b18 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:34 +0200 Subject: [PATCH 064/279] remove docstring --- client/ayon_core/plugins/loader/export_otio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index bbbad3378f..6a9acc9730 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -39,7 +39,6 @@ def _import_otio(): class ExportOTIO(LoaderActionPlugin): - """Copy published file path to clipboard""" identifier = "core.export-otio" def get_action_items( From c4b47950a8fd944dd4412e2ebb6fd13df2fc21e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:07:12 +0200 Subject: [PATCH 065/279] formatting fixes --- client/ayon_core/plugins/loader/delete_old_versions.py | 4 +++- client/ayon_core/plugins/loader/export_otio.py | 1 - client/ayon_core/tools/loader/models/actions.py | 2 -- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index b0905954f1..69b93cbb32 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -300,7 +300,9 @@ class DeleteOldVersions(LoaderActionPlugin): publish_folder = os.path.dirname(filepath) if remove_publish_folder: if os.path.exists(publish_folder): - shutil.rmtree(publish_folder, ignore_errors=True) + shutil.rmtree( + publish_folder, ignore_errors=True + ) continue if os.path.exists(filepath): diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 6a9acc9730..b23021fc11 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -37,7 +37,6 @@ def _import_otio(): OTIO = opentimelineio - class ExportOTIO(LoaderActionPlugin): identifier = "core.export-otio" diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 684adf36a9..90f3613c24 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -13,7 +13,6 @@ from ayon_core.lib import NestedCacheItem, Logger from ayon_core.pipeline.actions import ( LoaderActionsContext, LoaderActionSelection, - SelectionEntitiesCache, ) from ayon_core.pipeline.load import ( discover_loader_plugins, @@ -766,7 +765,6 @@ class LoaderActionsModel: action_items.append(item) return action_items - def _get_loader_action_items( self, project_name: str, From cf62eede8a2386a4531d979eaffb6d9d14f57ac5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:50:55 +0200 Subject: [PATCH 066/279] use already cached entities --- .../ayon_core/tools/loader/models/actions.py | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 90f3613c24..8aded40919 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -13,6 +13,7 @@ from ayon_core.lib import NestedCacheItem, Logger from ayon_core.pipeline.actions import ( LoaderActionsContext, LoaderActionSelection, + SelectionEntitiesCache, ) from ayon_core.pipeline.load import ( discover_loader_plugins, @@ -114,6 +115,8 @@ class LoaderActionsModel: project_name, entity_ids, entity_type, + version_context_by_id, + repre_context_by_id, )) return action_items @@ -165,7 +168,6 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) if plugin_identifier != LOADER_PLUGIN_ID: - # TODO fill error infor if any happens result = None crashed = False try: @@ -770,14 +772,35 @@ class LoaderActionsModel: project_name: str, entity_ids: set[str], entity_type: str, + version_context_by_id: dict[str, dict[str, Any]], + repre_context_by_id: dict[str, dict[str, Any]], ) -> list[ActionItem]: - # TODO prepare cached entities - # entities_cache = SelectionEntitiesCache(project_name) + """ + + Args: + project_name (str): Project name. + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. + version_context_by_id (dict[str, dict[str, Any]]): Version context + by id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + context by id. + + Returns: + list[ActionItem]: List of action items. + + """ + entities_cache = self._prepare_entities_cache( + project_name, + entity_type, + version_context_by_id, + repre_context_by_id, + ) selection = LoaderActionSelection( project_name, entity_ids, entity_type, - # entities_cache=entities_cache + entities_cache=entities_cache ) items = [] for action in self._loader_actions.get_action_items(selection): @@ -795,6 +818,55 @@ class LoaderActionsModel: )) return items + def _prepare_entities_cache( + self, + project_name: str, + entity_type: str, + version_context_by_id: dict[str, dict[str, Any]], + repre_context_by_id: dict[str, dict[str, Any]], + ): + project_entity = None + folders_by_id = {} + products_by_id = {} + versions_by_id = {} + representations_by_id = {} + for context in version_context_by_id.values(): + if project_entity is None: + project_entity = context["project"] + folder_entity = context["folder"] + product_entity = context["product"] + version_entity = context["version"] + folders_by_id[folder_entity["id"]] = folder_entity + products_by_id[product_entity["id"]] = product_entity + versions_by_id[version_entity["id"]] = version_entity + + for context in repre_context_by_id.values(): + repre_entity = context["representation"] + representations_by_id[repre_entity["id"]] = repre_entity + + # Mapping has to be for all child entities which is available for + # representations only if version is selected + representation_ids_by_version_id = {} + if entity_type == "version": + representation_ids_by_version_id = { + version_id: set() + for version_id in versions_by_id + } + for context in repre_context_by_id.values(): + repre_entity = context["representation"] + v_id = repre_entity["versionId"] + representation_ids_by_version_id[v_id].add(repre_entity["id"]) + + return SelectionEntitiesCache( + project_name, + project_entity=project_entity, + folders_by_id=folders_by_id, + products_by_id=products_by_id, + versions_by_id=versions_by_id, + representations_by_id=representations_by_id, + representation_ids_by_version_id=representation_ids_by_version_id, + ) + def _trigger_version_loader( self, loader, From 751ad94343b8999873f1068c7c2492940c60162f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:51:19 +0200 Subject: [PATCH 067/279] few fixes in entities cache --- client/ayon_core/pipeline/actions/loader.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index b537655ada..e04a64b240 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -44,11 +44,10 @@ class SelectionEntitiesCache: products_by_id: Optional[dict[str, dict[str, Any]]] = None, versions_by_id: Optional[dict[str, dict[str, Any]]] = None, representations_by_id: Optional[dict[str, dict[str, Any]]] = None, - task_ids_by_folder_id: Optional[dict[str, str]] = None, - product_ids_by_folder_id: Optional[dict[str, str]] = None, - version_ids_by_product_id: Optional[dict[str, str]] = None, - version_id_by_task_id: Optional[dict[str, str]] = None, - representation_id_by_version_id: Optional[dict[str, str]] = None, + task_ids_by_folder_id: Optional[dict[str, set[str]]] = None, + product_ids_by_folder_id: Optional[dict[str, set[str]]] = None, + version_ids_by_product_id: Optional[dict[str, set[str]]] = None, + representation_ids_by_version_id: Optional[dict[str, set[str]]] = None, ): self._project_name = project_name self._project_entity = project_entity @@ -61,9 +60,8 @@ class SelectionEntitiesCache: self._task_ids_by_folder_id = task_ids_by_folder_id or {} self._product_ids_by_folder_id = product_ids_by_folder_id or {} self._version_ids_by_product_id = version_ids_by_product_id or {} - self._version_id_by_task_id = version_id_by_task_id or {} - self._representation_id_by_version_id = ( - representation_id_by_version_id or {} + self._representation_ids_by_version_id = ( + representation_ids_by_version_id or {} ) def get_project(self) -> dict[str, Any]: @@ -173,7 +171,7 @@ class SelectionEntitiesCache: version_ids, "versionId", "version_ids", - self._representation_id_by_version_id, + self._representation_ids_by_version_id, ayon_api.get_representations, ) return self.get_representations(repre_ids) From 15a3f9d29aeea40419ece93b51925f3f38fd9066 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:12:32 +0200 Subject: [PATCH 068/279] fix 'representations' -> 'representation' --- client/ayon_core/plugins/loader/copy_file.py | 2 +- client/ayon_core/plugins/loader/delivery.py | 2 +- client/ayon_core/plugins/loader/open_file.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 716b4ab88f..09875698bd 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -20,7 +20,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type == "representations": + if selection.selected_type == "representation": repres = selection.entities.get_representations( selection.selected_ids ) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index fb668e5b10..3b39f2d3f6 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -37,7 +37,7 @@ class DeliveryAction(LoaderActionPlugin): return [] version_ids = set() - if selection.selected_type == "representations": + if selection.selected_type == "representation": versions = selection.entities.get_representations_versions( selection.selected_ids ) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index a46bb31472..f7a7167c9a 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -33,7 +33,7 @@ class OpenFileAction(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type == "representations": + if selection.selected_type == "representation": repres = selection.entities.get_representations( selection.selected_ids ) From b560bb356ed8fb3f687fddbec9628042a48f54f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:19:56 +0200 Subject: [PATCH 069/279] fix host name checks --- client/ayon_core/plugins/loader/delivery.py | 2 +- client/ayon_core/plugins/loader/export_otio.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 3b39f2d3f6..d1fbb20afc 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -33,7 +33,7 @@ class DeliveryAction(LoaderActionPlugin): def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: - if self.host_name is None: + if self.host_name is not None: return [] version_ids = set() diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index b23021fc11..8a142afdb5 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -44,7 +44,7 @@ class ExportOTIO(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: # Don't show in hosts - if self.host_name is None: + if self.host_name is not None: return [] version_ids = set() From 8bbd15c48244ceb9e11c4fae4afb018620026519 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:11:46 +0200 Subject: [PATCH 070/279] added some docstrings --- client/ayon_core/pipeline/actions/loader.py | 124 +++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e04a64b240..2c3ad39c48 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -35,6 +35,17 @@ class LoaderSelectedType(StrEnum): class SelectionEntitiesCache: + """Cache of entities used as helper in the selection wrapper. + + It is possible to get entities based on ids with helper methods to get + entities, their parents or their children's entities. + + The goal is to avoid multiple API calls for the same entity in multiple + action plugins. + + The cache is based on the selected project. Entities are fetched + if are not in cache yet. + """ def __init__( self, project_name: str, @@ -65,6 +76,7 @@ class SelectionEntitiesCache: ) def get_project(self) -> dict[str, Any]: + """Get project entity""" if self._project_entity is None: self._project_entity = ayon_api.get_project(self._project_name) return copy.deepcopy(self._project_entity) @@ -294,6 +306,15 @@ class SelectionEntitiesCache: class LoaderActionSelection: + """Selection of entities for loader actions. + + Selection tells action plugins what exactly is selected in the tool and + which ids. + + Contains entity cache which can be used to get entities by their ids. Or + to get project settings and anatomy. + + """ def __init__( self, project_name: str, @@ -350,9 +371,33 @@ class LoaderActionSelection: @dataclass class LoaderActionItem: + """Item of loader action. + + Action plugins return these items as possible actions to run for a given + context. + + Because the action item can be related to a specific entity + and not the whole selection, they also have to define the entity type + and ids to be executed on. + + Attributes: + identifier (str): Unique action identifier. What is sent to action + plugin when the action is executed. + entity_type (str): Entity type to which the action belongs. + entity_ids (set[str]): Entity ids to which the action belongs. + label (str): Text shown in UI. + order (int): Order of the action in UI. + group_label (Optional[str]): Label of the group to which the action + belongs. + icon (Optional[dict[str, Any]]): Icon definition. + plugin_identifier (Optional[str]): Identifier of the plugin which + created the action item. Is filled automatically. Is not changed + if is filled -> can lead to different plugin. + + """ identifier: str - entity_ids: set[str] entity_type: str + entity_ids: set[str] label: str order: int = 0 group_label: Optional[str] = None @@ -363,6 +408,25 @@ class LoaderActionItem: @dataclass class LoaderActionForm: + """Form for loader action. + + If an action needs to collect information from a user before or during of + the action execution, it can return a response with a form. When the + form is confirmed, a new execution of the action is triggered. + + Attributes: + title (str): Title of the form -> title of the window. + fields (list[AbstractAttrDef]): Fields of the form. + submit_label (Optional[str]): Label of the submit button. Is hidden + if is set to None. + submit_icon (Optional[dict[str, Any]]): Icon definition of the submit + button. + cancel_label (Optional[str]): Label of the cancel button. Is hidden + if is set to None. User can still close the window tho. + cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel + button. + + """ title: str fields: list[AbstractAttrDef] submit_label: Optional[str] = "Submit" @@ -393,6 +457,18 @@ class LoaderActionForm: @dataclass class LoaderActionResult: + """Result of loader action execution. + + Attributes: + message (Optional[str]): Message to show in UI. + success (bool): If the action was successful. Affects color of + the message. + form (Optional[LoaderActionForm]): Form to show in UI. + form_values (Optional[dict[str, Any]]): Values for the form. Can be + used if the same form is re-shown e.g. because a user forgot to + fill a required field. + + """ message: Optional[str] = None success: bool = True form: Optional[LoaderActionForm] = None @@ -422,7 +498,6 @@ class LoaderActionPlugin(ABC): Plugin is responsible for getting action items and executing actions. - """ _log: Optional[logging.Logger] = None enabled: bool = True @@ -503,6 +578,12 @@ class LoaderActionPlugin(ABC): class LoaderActionsContext: + """Wrapper for loader actions and their logic. + + Takes care about the public api of loader actions and internal logic like + discovery and initialization of plugins. + + """ def __init__( self, studio_settings: Optional[dict[str, Any]] = None, @@ -521,6 +602,15 @@ class LoaderActionsContext: def reset( self, studio_settings: Optional[dict[str, Any]] = None ) -> None: + """Reset context cache. + + Reset plugins and studio settings to reload them. + + Notes: + Does not reset the cache of AddonsManger because there should not + be a reason to do so. + + """ self._studio_settings = studio_settings self._plugins = None @@ -532,6 +622,14 @@ class LoaderActionsContext: return self._addons_manager def get_host(self) -> Optional[AbstractHost]: + """Get current host integration. + + Returns: + Optional[AbstractHost]: Host integration. Can be None if host + integration is not registered -> probably not used in the + host integration process. + + """ if self._host is _PLACEHOLDER: from ayon_core.pipeline import registered_host @@ -552,6 +650,12 @@ class LoaderActionsContext: def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: + """Collect action items from all plugins for given selection. + + Args: + selection (LoaderActionSelection): Selection wrapper. + + """ output = [] for plugin_id, plugin in self._get_plugins().items(): try: @@ -572,11 +676,25 @@ class LoaderActionsContext: self, plugin_identifier: str, action_identifier: str, + entity_type: str, entity_ids: set[str], - entity_type: LoaderSelectedType, selection: LoaderActionSelection, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: + """Trigger action execution. + + Args: + plugin_identifier (str): Identifier of the plugin. + action_identifier (str): Identifier of the action. + entity_type (str): Entity type defined on the action item. + entity_ids (set[str]): Entity ids defined on the action item. + selection (LoaderActionSelection): Selection wrapper. Can be used + to get what is selected in UI and to get access to entity + cache. + form_values (dict[str, Any]): Form values related to action. + Usually filled if action returned response with form. + + """ plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] return plugin.execute_action( From a7b379059fdba2282ce1c9ccec50c98078f1bc23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:43:59 +0200 Subject: [PATCH 071/279] allow to pass data into action items --- client/ayon_core/pipeline/actions/loader.py | 30 +++++++++---------- client/ayon_core/plugins/loader/copy_file.py | 12 ++++---- .../plugins/loader/delete_old_versions.py | 12 ++++---- client/ayon_core/plugins/loader/delivery.py | 8 ++--- .../ayon_core/plugins/loader/export_otio.py | 11 ++++--- client/ayon_core/plugins/loader/open_file.py | 9 +++--- .../plugins/loader/push_to_project.py | 11 ++++--- 7 files changed, 42 insertions(+), 51 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 2c3ad39c48..94e30c5114 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -5,6 +5,7 @@ import collections import copy import logging from abc import ABC, abstractmethod +import typing from typing import Optional, Any, Callable from dataclasses import dataclass @@ -23,6 +24,12 @@ from ayon_core.settings import get_studio_settings, get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins +if typing.TYPE_CHECKING: + from typing import Union + + DataBaseType = Union[str, int, float, bool] + DataType = dict[str, Union[DataBaseType, list[DataBaseType]]] + _PLACEHOLDER = object() @@ -383,25 +390,23 @@ class LoaderActionItem: Attributes: identifier (str): Unique action identifier. What is sent to action plugin when the action is executed. - entity_type (str): Entity type to which the action belongs. - entity_ids (set[str]): Entity ids to which the action belongs. label (str): Text shown in UI. order (int): Order of the action in UI. group_label (Optional[str]): Label of the group to which the action belongs. - icon (Optional[dict[str, Any]]): Icon definition. + icon (Optional[dict[str, Any]): Icon definition. + data (Optional[DataType]): Action item data. plugin_identifier (Optional[str]): Identifier of the plugin which created the action item. Is filled automatically. Is not changed if is filled -> can lead to different plugin. """ identifier: str - entity_type: str - entity_ids: set[str] label: str order: int = 0 group_label: Optional[str] = None icon: Optional[dict[str, Any]] = None + data: Optional[DataType] = None # Is filled automatically plugin_identifier: str = None @@ -555,19 +560,17 @@ class LoaderActionPlugin(ABC): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: """Execute an action. Args: identifier (str): Action identifier. - entity_ids: (set[str]): Entity ids stored on action item. - entity_type: (str): Entity type stored on action item. selection (LoaderActionSelection): Selection wrapper. Can be used to get entities or get context of original selection. + data (Optional[DataType]): Additional action item data. form_values (dict[str, Any]): Attribute values. Returns: @@ -676,9 +679,8 @@ class LoaderActionsContext: self, plugin_identifier: str, action_identifier: str, - entity_type: str, - entity_ids: set[str], selection: LoaderActionSelection, + data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: """Trigger action execution. @@ -686,11 +688,10 @@ class LoaderActionsContext: Args: plugin_identifier (str): Identifier of the plugin. action_identifier (str): Identifier of the action. - entity_type (str): Entity type defined on the action item. - entity_ids (set[str]): Entity ids defined on the action item. selection (LoaderActionSelection): Selection wrapper. Can be used to get what is selected in UI and to get access to entity cache. + data (Optional[DataType]): Additional action item data. form_values (dict[str, Any]): Form values related to action. Usually filled if action returned response with form. @@ -699,9 +700,8 @@ class LoaderActionsContext: plugin = plugins_by_id[plugin_identifier] return plugin.execute_action( action_identifier, - entity_ids, - entity_type, selection, + data, form_values, ) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 09875698bd..8253a772eb 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -44,8 +44,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): identifier="copy-path", label=repre_name, group_label="Copy file path", - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "content_copy", @@ -58,8 +57,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): identifier="copy-file", label=repre_name, group_label="Copy file", - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "file_copy", @@ -72,14 +70,14 @@ class CopyFileActionPlugin(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore - repre = next(iter(selection.entities.get_representations(entity_ids))) + repre_ids = data["representation_ids"] + repre = next(iter(selection.entities.get_representations(repre_ids))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 69b93cbb32..cc7d4d3fa6 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -61,8 +61,7 @@ class DeleteOldVersions(LoaderActionPlugin): identifier="delete-versions", label="Delete Versions", order=35, - entity_ids=product_ids, - entity_type="product", + data={"product_ids": list(product_ids)}, icon={ "type": "material-symbols", "name": "delete", @@ -73,8 +72,7 @@ class DeleteOldVersions(LoaderActionPlugin): identifier="calculate-versions-size", label="Calculate Versions size", order=30, - entity_ids=product_ids, - entity_type="product", + data={"product_ids": list(product_ids)}, icon={ "type": "material-symbols", "name": "auto_delete", @@ -86,9 +84,8 @@ class DeleteOldVersions(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: step = form_values.get("step") @@ -106,12 +103,13 @@ class DeleteOldVersions(LoaderActionPlugin): if remove_publish_folder is None: remove_publish_folder = False + product_ids = data["product_ids"] if step == "prepare-data": return self._prepare_data_step( identifier, versions_to_keep, remove_publish_folder, - entity_ids, + product_ids, selection, ) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index d1fbb20afc..538bdec414 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -54,8 +54,7 @@ class DeliveryAction(LoaderActionPlugin): identifier="deliver-versions", label="Deliver Versions", order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "upload", @@ -67,15 +66,14 @@ class DeliveryAction(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: try: # TODO run the tool in subprocess dialog = DeliveryOptionsDialog( - selection.project_name, entity_ids, self.log + selection.project_name, data["version_ids"], self.log ) dialog.exec_() except Exception: diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 8a142afdb5..1ad9038c5e 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -59,8 +59,7 @@ class ExportOTIO(LoaderActionPlugin): label="Export OTIO", group_label=None, order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "save", @@ -73,16 +72,16 @@ class ExportOTIO(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: _import_otio() + version_ids = data["version_ids"] versions_by_id = { version["id"]: version - for version in selection.entities.get_versions(entity_ids) + for version in selection.entities.get_versions(version_ids) } product_ids = { version["productId"] @@ -101,7 +100,7 @@ class ExportOTIO(LoaderActionPlugin): for folder in selection.entities.get_folders(folder_ids) } repre_entities = selection.entities.get_versions_representations( - entity_ids + version_ids ) version_path_by_id = {} diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index f7a7167c9a..1ed470c06e 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -84,8 +84,7 @@ class OpenFileAction(LoaderActionPlugin): label=repre_name, group_label="Open file", order=-10, - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "play_circle", @@ -98,14 +97,14 @@ class OpenFileAction(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: path = None repre_path = None - for repre in selection.entities.get_representations(entity_ids): + repre_ids = data["representation_ids"] + for repre in selection.entities.get_representations(repre_ids): repre_path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index bd0da71c0e..275f5de88d 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -42,8 +42,7 @@ class PushToProject(LoaderActionPlugin): identifier="core.push-to-project", label="Push to project", order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "send", @@ -56,12 +55,12 @@ class PushToProject(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - if len(entity_ids) > 1: + version_ids = data["version_ids"] + if len(version_ids) > 1: return LoaderActionResult( message="Please select only one version", success=False, @@ -77,7 +76,7 @@ class PushToProject(LoaderActionPlugin): args = get_ayon_launcher_args( push_tool_script_path, "--project", selection.project_name, - "--versions", ",".join(entity_ids) + "--versions", ",".join(version_ids) ) run_detached_process(args) return LoaderActionResult( From 8fdbda78ee6b0b3b8e27aa87d6b8907d86d88222 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:37:07 +0200 Subject: [PATCH 072/279] modify loader tool to match changes in backend --- client/ayon_core/tools/loader/abstract.py | 19 ++++------- client/ayon_core/tools/loader/control.py | 22 ++++++------ .../ayon_core/tools/loader/models/actions.py | 34 +++++++++---------- .../ayon_core/tools/loader/models/sitesync.py | 13 ++++--- .../tools/loader/ui/products_widget.py | 17 +++++----- .../tools/loader/ui/repres_widget.py | 17 +++++----- client/ayon_core/tools/loader/ui/window.py | 19 +++++------ 7 files changed, 65 insertions(+), 76 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5de4560d3e..90371204f9 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -316,13 +316,12 @@ class ActionItem: Args: plugin_identifier (str): Action identifier. identifier (str): Action identifier. - entity_ids (set[str]): Entity ids. - entity_type (str): Entity type. label (str): Action label. group_label (Optional[str]): Group label. icon (Optional[dict[str, Any]]): Action icon definition. tooltip (Optional[str]): Action tooltip. order (int): Action order. + data (Optional[dict[str, Any]]): Additional action data. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. @@ -331,23 +330,21 @@ class ActionItem: self, plugin_identifier: str, identifier: str, - entity_ids: set[str], - entity_type: str, label: str, group_label: Optional[str], icon: Optional[dict[str, Any]], tooltip: Optional[str], order: int, + data: Optional[dict[str, Any]], options: Optional[list], ): self.plugin_identifier = plugin_identifier self.identifier = identifier - self.entity_ids = entity_ids - self.entity_type = entity_type self.label = label self.group_label = group_label self.icon = icon self.tooltip = tooltip + self.data = data self.order = order self.options = options @@ -371,13 +368,12 @@ class ActionItem: return { "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, - "entity_ids": list(self.entity_ids), - "entity_type": self.entity_type, "label": self.label, "group_label": self.group_label, "icon": self.icon, "tooltip": self.tooltip, "order": self.order, + "data": self.data, "options": options, } @@ -387,7 +383,6 @@ class ActionItem: if options: options = deserialize_attr_defs(options) data["options"] = options - data["entity_ids"] = set(data["entity_ids"]) return cls(**data) @@ -1011,10 +1006,9 @@ class FrontendLoaderController(_BaseLoaderController): plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -1037,10 +1031,9 @@ class FrontendLoaderController(_BaseLoaderController): plugin_identifier (sttr): Plugin identifier. identifier (sttr): Action identifier. project_name (str): Project name. - entity_ids (set[str]): Entity ids stored on action item. - entity_type (str): Entity type stored on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + data (Optional[dict[str, Any]]): Additional action item data. options (dict[str, Any]): Action option values from UI. form_values (dict[str, Any]): Action form values from UI. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7a406fd2a3..e406b30fe0 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -318,10 +318,9 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -329,20 +328,19 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._sitesync_model.trigger_action_item( identifier, project_name, - entity_ids, + data, ) return self._loader_actions_model.trigger_action_item( - plugin_identifier, - identifier, - project_name, - entity_ids, - entity_type, - selected_ids, - selected_entity_type, - options, - form_values, + plugin_identifier=plugin_identifier, + identifier=identifier, + project_name=project_name, + selected_ids=selected_ids, + selected_entity_type=selected_entity_type, + data=data, + options=options, + form_values=form_values, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 8aded40919..772befc22f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -5,7 +5,7 @@ import traceback import inspect import collections import uuid -from typing import Callable, Any +from typing import Optional, Callable, Any import ayon_api @@ -125,10 +125,9 @@ class LoaderActionsModel: plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -144,10 +143,9 @@ class LoaderActionsModel: plugin_identifier (str): Plugin identifier. identifier (str): Action identifier. project_name (str): Project name. - entity_ids (set[str]): Entity ids on action item. - entity_type (str): Entity type on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + data (Optional[dict[str, Any]]): Additional action item data. options (dict[str, Any]): Loader option values. form_values (dict[str, Any]): Form values. @@ -156,10 +154,9 @@ class LoaderActionsModel: "plugin_identifier": plugin_identifier, "identifier": identifier, "project_name": project_name, - "entity_ids": list(entity_ids), - "entity_type": entity_type, "selected_ids": list(selected_ids), "selected_entity_type": selected_entity_type, + "data": data, "id": uuid.uuid4().hex, } self._controller.emit_event( @@ -172,16 +169,15 @@ class LoaderActionsModel: crashed = False try: result = self._loader_actions.execute_action( - plugin_identifier, - identifier, - entity_ids, - entity_type, - LoaderActionSelection( + plugin_identifier=plugin_identifier, + action_identifier=identifier, + selection=LoaderActionSelection( project_name, selected_ids, selected_entity_type, ), - form_values, + data=data, + form_values=form_values, ) except Exception: @@ -203,7 +199,8 @@ class LoaderActionsModel: loader = self._get_loader_by_identifier( project_name, identifier ) - + entity_type = data["entity_type"] + entity_ids = data["entity_ids"] if entity_type == "version": error_info = self._trigger_version_loader( loader, @@ -346,8 +343,10 @@ class LoaderActionsModel: return ActionItem( LOADER_PLUGIN_ID, get_loader_identifier(loader), - entity_ids=entity_ids, - entity_type=entity_type, + data={ + "entity_ids": entity_ids, + "entity_type": entity_type, + }, label=label, group_label=None, icon=self._get_action_icon(loader), @@ -807,13 +806,12 @@ class LoaderActionsModel: items.append(ActionItem( action.plugin_identifier, action.identifier, - action.entity_ids, - action.entity_type, label=action.label, group_label=action.group_label, icon=action.icon, tooltip=None, # action.tooltip, order=action.order, + data=action.data, options=None, # action.options, )) return items diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 4d6ffcf9d4..2d0dcea5bf 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +from typing import Any from ayon_api import ( get_representations, @@ -315,16 +316,17 @@ class SiteSyncModel: self, identifier: str, project_name: str, - representation_ids: set[str], + data: dict[str, Any], ): """Resets status for site_name or remove local files. Args: identifier (str): Action identifier. project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. + data (dict[str, Any]): Action item data. """ + representation_ids = data["representation_ids"] active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -495,9 +497,10 @@ class SiteSyncModel: }, tooltip=tooltip, order=1, - entity_ids=representation_ids, - entity_type="representation", - options={}, + data={ + "representation_ids": representation_ids, + }, + options=None, ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 319108e8ea..384fed2ee9 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,15 +438,14 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - action_item.plugin_identifier, - action_item.identifier, - project_name, - action_item.entity_ids, - action_item.entity_type, - version_ids, - "version", - options, - {}, + plugin_identifier=action_item.plugin_identifier, + identifier=action_item.identifier, + project_name=project_name, + selected_ids=version_ids, + selected_entity_type="version", + data=action_item.data, + options=options, + form_values={}, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index bfbcc73503..dcfcfea81b 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,13 +399,12 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - action_item.plugin_identifier, - action_item.identifier, - self._selected_project_name, - action_item.entity_ids, - action_item.entity_type, - repre_ids, - "representation", - options, - {}, + plugin_identifier=action_item.plugin_identifier, + identifier=action_item.identifier, + project_name=self._selected_project_name, + selected_ids=repre_ids, + selected_entity_type="representation", + data=action_item.data, + options=options, + form_values={}, ) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 71679213e5..d2a4145707 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -582,17 +582,16 @@ class LoaderWindow(QtWidgets.QWidget): if result != QtWidgets.QDialog.Accepted: return - form_data = dialog.get_values() + form_values = dialog.get_values() self._controller.trigger_action_item( - event["plugin_identifier"], - event["identifier"], - event["project_name"], - event["entity_ids"], - event["entity_type"], - event["selected_ids"], - event["selected_entity_type"], - {}, - form_data, + plugin_identifier=event["plugin_identifier"], + identifier=event["identifier"], + project_name=event["project_name"], + selected_ids=event["selected_ids"], + selected_entity_type=event["selected_entity_type"], + options={}, + data=event["data"], + form_values=form_values, ) def _on_project_selection_changed(self, event): From 80ba7ea5ed209d3b3eb0f51cb2025ae046c3ec87 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:12:30 +0200 Subject: [PATCH 073/279] implement new 'get_representation_path_v2' function --- client/ayon_core/pipeline/load/__init__.py | 2 + client/ayon_core/pipeline/load/utils.py | 56 ++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index 2a33fa119b..9ad2a17d3d 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -25,6 +25,7 @@ from .utils import ( get_loader_identifier, get_loaders_by_name, + get_representation_path_v2, get_representation_path_from_context, get_representation_path, get_representation_path_with_anatomy, @@ -85,6 +86,7 @@ __all__ = ( "get_loader_identifier", "get_loaders_by_name", + "get_representation_path_v2", "get_representation_path_from_context", "get_representation_path", "get_representation_path_with_anatomy", diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index d1731d4cf9..7842741c85 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -14,9 +14,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__) @@ -638,6 +637,57 @@ def _fix_representation_context_compatibility(repre_context): repre_context["udim"] = udim[0] +def get_representation_path_v2( + 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')" + ) + + try: + context = copy.deepcopy(repre_entity["context"]) + _fix_representation_context_compatibility(context) + context["root"] = anatomy.roots + + path = StringTemplate.format_strict_template(template, context) + + except TemplateUnsolved as exc: + raise InvalidRepresentationContext( + "Couldn't resolve representation template with available data." + f" Reason: {str(exc)}" + ) + + return path.normalized() + + def get_representation_path_from_context(context): """Preparation wrapper using only context as a argument""" from ayon_core.pipeline import get_current_project_name From 60ff1ddb0c0b095114ba2630c7b72ca237e189f9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:14:21 +0200 Subject: [PATCH 074/279] use the new function --- .../pipeline/farm/pyblish_functions.py | 6 +- client/ayon_core/pipeline/load/utils.py | 104 ++++++++---------- .../extract_usd_layer_contributions.py | 3 + 3 files changed, 52 insertions(+), 61 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 0d8e70f9d2..1c8925d290 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -11,7 +11,7 @@ import clique from ayon_core.lib import Logger from ayon_core.pipeline import ( get_current_project_name, - get_representation_path, + get_representation_path_v2, ) from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline.farm.patterning import match_aov_pattern @@ -1044,7 +1044,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_v2( + project_name, representation + ) print("Source: ", directory) resources = sorted( [ diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 7842741c85..267a8c80c7 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import uuid import platform @@ -5,6 +7,7 @@ import logging import inspect import collections import numbers +import copy from typing import Optional, Union, Any import ayon_api @@ -694,15 +697,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_v2( + project_name, + representation, + project_entity=project_entity, + ) def get_representation_path_with_anatomy(repre_entity, anatomy): @@ -721,36 +724,18 @@ def get_representation_path_with_anatomy(repre_entity, anatomy): anatomy (Anatomy): Project anatomy object. Returns: - Union[None, TemplateResult]: None if path can't be received + TemplateResult: Resolved representation path. Raises: InvalidRepresentationContext: When representation data are probably invalid or not available. + """ - - try: - template = repre_entity["attrib"]["template"] - - except KeyError: - raise InvalidRepresentationContext(( - "Representation document does not" - " contain template in data ('data.template')" - )) - - try: - context = repre_entity["context"] - _fix_representation_context_compatibility(context) - context["root"] = anatomy.roots - - path = StringTemplate.format_strict_template(template, context) - - except TemplateUnsolved as exc: - raise InvalidRepresentationContext(( - "Couldn't resolve representation template with available data." - " Reason: {}".format(str(exc)) - )) - - return path.normalized() + return get_representation_path_v2( + anatomy.project_name, + repre_entity, + anatomy=anatomy, + ) def get_representation_path(representation, root=None): @@ -771,11 +756,12 @@ def get_representation_path(representation, root=None): """ if root is None: - from ayon_core.pipeline import get_current_project_name, Anatomy + from ayon_core.pipeline import get_current_project_name - anatomy = Anatomy(get_current_project_name()) - return get_representation_path_with_anatomy( - representation, anatomy + project_name = get_current_project_name() + return get_representation_path_v2( + project_name, + representation, ) def path_from_representation(): @@ -848,12 +834,13 @@ def get_representation_path(representation, root=None): 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: 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. @@ -870,22 +857,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_v2( + 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. @@ -902,7 +888,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 @@ -914,7 +900,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( @@ -926,7 +912,7 @@ def get_representation_by_names( version_entity = ayon_api.get_version_by_name( project_name, version_name, product_id=product_entity["id"]) if not version_entity: - return + return None return ayon_api.get_representation_by_name( project_name, representation_name, version_id=version_entity["id"]) diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 0dc9a5e34d..9db8c49a02 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -1,6 +1,7 @@ from operator import attrgetter import dataclasses import os +import platform from typing import Any, Dict, List import pyblish.api @@ -179,6 +180,8 @@ def get_instance_uri_path( # Ensure `None` for now is also a string path = str(path) + if platform.system().lower() == "windows": + path = path.replace("\\", "/") return path From d55ac4aa547d9f24f96b33a0c66ddf87f4c1186d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:34:47 +0200 Subject: [PATCH 075/279] Use 'get_representation_path' for both signatures. --- .../pipeline/farm/pyblish_functions.py | 4 +- client/ayon_core/pipeline/load/__init__.py | 4 +- client/ayon_core/pipeline/load/utils.py | 240 ++++++++---------- 3 files changed, 115 insertions(+), 133 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 1c8925d290..0dda91914c 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -11,7 +11,7 @@ import clique from ayon_core.lib import Logger from ayon_core.pipeline import ( get_current_project_name, - get_representation_path_v2, + get_representation_path, ) from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline.farm.patterning import match_aov_pattern @@ -1044,7 +1044,7 @@ def get_resources(project_name, version_entity, extension=None): filtered.append(repre_entity) representation = filtered[0] - directory = get_representation_path_v2( + directory = get_representation_path( project_name, representation ) print("Source: ", directory) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index 9ad2a17d3d..3bc8d4ba1f 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -25,7 +25,7 @@ from .utils import ( get_loader_identifier, get_loaders_by_name, - get_representation_path_v2, + get_representation_path, get_representation_path_from_context, get_representation_path, get_representation_path_with_anatomy, @@ -86,7 +86,7 @@ __all__ = ( "get_loader_identifier", "get_loaders_by_name", - "get_representation_path_v2", + "get_representation_path", "get_representation_path_from_context", "get_representation_path", "get_representation_path_with_anatomy", diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 267a8c80c7..7cf96a5409 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -2,12 +2,13 @@ from __future__ import annotations import os import uuid -import platform +import warnings import logging import inspect import collections import numbers import copy +from functools import wraps from typing import Optional, Union, Any import ayon_api @@ -640,57 +641,6 @@ def _fix_representation_context_compatibility(repre_context): repre_context["udim"] = udim[0] -def get_representation_path_v2( - 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')" - ) - - try: - context = copy.deepcopy(repre_entity["context"]) - _fix_representation_context_compatibility(context) - context["root"] = anatomy.roots - - path = StringTemplate.format_strict_template(template, context) - - except TemplateUnsolved as exc: - raise InvalidRepresentationContext( - "Couldn't resolve representation template with available data." - f" Reason: {str(exc)}" - ) - - return path.normalized() - - def get_representation_path_from_context(context): """Preparation wrapper using only context as a argument""" from ayon_core.pipeline import get_current_project_name @@ -701,7 +651,7 @@ def get_representation_path_from_context(context): project_name = project_entity["name"] else: project_name = get_current_project_name() - return get_representation_path_v2( + return _get_representation_path( project_name, representation, project_entity=project_entity, @@ -731,106 +681,138 @@ def get_representation_path_with_anatomy(repre_entity, anatomy): invalid or not available. """ - return get_representation_path_v2( + return get_representation_path( anatomy.project_name, repre_entity, anatomy=anatomy, ) -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 +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 document from the database + representation(dict): Representation entity. + roots (dict[str, str]): Roots to use. + Returns: - str: fullpath of the representation + Optional[TemplateResult]: Resolved representation path. """ - if root is None: - from ayon_core.pipeline import get_current_project_name + try: + template = representation["attrib"]["template"] + except KeyError: + return None - project_name = get_current_project_name() - return get_representation_path_v2( - project_name, - representation, + 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 _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')" ) - def path_from_representation(): - try: - template = representation["attrib"]["template"] - except KeyError: - return None + try: + context = copy.deepcopy(repre_entity["context"]) + _fix_representation_context_compatibility(context) + context["root"] = anatomy.roots - try: - context = representation["context"] + path = StringTemplate.format_strict_template(template, context) - _fix_representation_context_compatibility(context) + except TemplateUnsolved as exc: + raise InvalidRepresentationContext( + "Couldn't resolve representation template with available data." + f" Reason: {str(exc)}" + ) - 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 + return path.normalized() - if not path: - return path - normalized_path = os.path.normpath(path) - if os.path.exists(normalized_path): - return normalized_path - return path +def _get_representation_path_decorator(func): + @wraps(_get_representation_path) + def inner(arg_1, *args, **kwargs): + if isinstance(arg_1, str): + return _get_representation_path(arg_1, *args, **kwargs) + warnings.warn( + ( + "Used deprecated variant of 'get_representation_path'." + " Please change used arguments signature to follow" + " new definiton." + ), + DeprecationWarning, + stacklevel=2, + ) + return get_representation_path_with_roots(arg_1, *args, **kwargs) + return inner - 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("\\", "/") +@_get_representation_path_decorator +def get_representation_path(*args, **kwargs) -> TemplateResult: + """Get filled representation path. - if os.path.exists(path): - return os.path.normpath(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. - dir_path, file_name = os.path.split(path) - if not os.path.exists(dir_path): - return None + Returns: + TemplateResult: Resolved path to representation. - 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() - ) + Raises: + InvalidRepresentationContext: When representation data are probably + invalid or not available. + """ + pass def get_representation_path_by_names( @@ -859,7 +841,7 @@ def get_representation_path_by_names( if not representation: return None - return get_representation_path_v2( + return _get_representation_path( project_name, representation, anatomy=anatomy, From c9bb43059db7891da4b1cf9c30af8d2f4b5bc46c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:36:56 +0200 Subject: [PATCH 076/279] remove doubled import --- client/ayon_core/pipeline/load/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/__init__.py b/client/ayon_core/pipeline/load/__init__.py index 3bc8d4ba1f..b5b09a5dc9 100644 --- a/client/ayon_core/pipeline/load/__init__.py +++ b/client/ayon_core/pipeline/load/__init__.py @@ -27,7 +27,6 @@ from .utils import ( get_representation_path, get_representation_path_from_context, - get_representation_path, get_representation_path_with_anatomy, is_compatible_loader, @@ -88,7 +87,6 @@ __all__ = ( "get_representation_path", "get_representation_path_from_context", - "get_representation_path", "get_representation_path_with_anatomy", "is_compatible_loader", From 8c61e655216fca3597808707c4751d6db59e15d2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:52:59 +0200 Subject: [PATCH 077/279] handle backwards compatibility properly --- client/ayon_core/pipeline/load/utils.py | 42 ++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 7cf96a5409..9dcb2e3b43 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -778,9 +778,19 @@ def _get_representation_path( def _get_representation_path_decorator(func): @wraps(_get_representation_path) - def inner(arg_1, *args, **kwargs): - if isinstance(arg_1, str): - return _get_representation_path(arg_1, *args, **kwargs) + 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 _get_representation_path(*args, **kwargs) + + elif "project_name" in kwargs: + return _get_representation_path(*args, **kwargs) + warnings.warn( ( "Used deprecated variant of 'get_representation_path'." @@ -790,7 +800,31 @@ def _get_representation_path_decorator(func): DeprecationWarning, stacklevel=2, ) - return get_representation_path_with_roots(arg_1, *args, **kwargs) + + # 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 _get_representation_path(project_name, representation) + return inner From efcd4425b7acf0f46a949668522530fa007ee231 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:53:45 +0200 Subject: [PATCH 078/279] add signature to the original function --- client/ayon_core/pipeline/load/utils.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 9dcb2e3b43..b3582ed0a7 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -829,7 +829,12 @@ def _get_representation_path_decorator(func): @_get_representation_path_decorator -def get_representation_path(*args, **kwargs) -> TemplateResult: +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: @@ -846,7 +851,12 @@ def get_representation_path(*args, **kwargs) -> TemplateResult: InvalidRepresentationContext: When representation data are probably invalid or not available. """ - pass + return _get_representation_path( + project_name, + repre_entity, + anatomy=anatomy, + project_entity=project_entity, + ) def get_representation_path_by_names( From 80f84e95fc02e8f4b72e077e412213ae5fae4540 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:54:02 +0200 Subject: [PATCH 079/279] add formatting --- client/ayon_core/pipeline/load/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index b3582ed0a7..900740cadc 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -850,6 +850,7 @@ def get_representation_path(project_name: str, Raises: InvalidRepresentationContext: When representation data are probably invalid or not available. + """ return _get_representation_path( project_name, From dcf5db31d042eb9ad1f7325ca51f75107490151c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:00:28 +0200 Subject: [PATCH 080/279] formatting fix --- client/ayon_core/pipeline/load/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 900740cadc..1d0a0e54e4 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -829,7 +829,8 @@ def _get_representation_path_decorator(func): @_get_representation_path_decorator -def get_representation_path(project_name: str, +def get_representation_path( + project_name: str, repre_entity: dict[str, Any], *, anatomy: Optional[Anatomy] = None, From 3945655f217fc7703c3d145520a7f35071c34318 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:37:22 +0200 Subject: [PATCH 081/279] return type in docstring --- client/ayon_core/addon/interfaces.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index cc7e39218e..bc44fd2d2e 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -186,7 +186,12 @@ class IPluginPaths(AYONInterface): return self._get_plugin_paths_by_type("inventory") def get_loader_action_plugin_paths(self) -> list[str]: - """Receive loader action plugin paths.""" + """Receive loader action plugin paths. + + Returns: + list[str]: Paths to loader action plugins. + + """ return [] From 66b1a6e8adab48a299d5e52c396358dbdcc65e0b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:48:07 +0200 Subject: [PATCH 082/279] add small explanation to the code --- client/ayon_core/pipeline/actions/loader.py | 65 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 94e30c5114..bb903b7c54 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -1,3 +1,61 @@ +"""API for actions for loader tool. + +Even though the api is meant for the loader tool, the api should be possible + to use in a standalone way out of the loader tool. + +To use add actions, make sure your addon does inherit from + 'IPluginPaths' and implements 'get_loader_action_plugin_paths' which + returns paths to python files with loader actions. + +The plugin is used to collect available actions for the given context and to + execute them. Selection is defined with 'LoaderActionSelection' object + that also contains a cache of entities and project anatomy. + +Implementing 'get_action_items' allows the plugin to define what actions + are shown and available for the selection. Because for a single selection + can be shown multiple actions with the same action identifier, the action + items also have 'data' attribute which can be used to store additional + data for the action (they have to be json-serializable). + +The action is triggered by calling the 'execute_action' method. Which takes + the action identifier, the selection, the additional data from the action + item and form values from the form if any. + +Using 'LoaderActionResult' as the output of 'execute_action' can trigger to + show a message in UI or to show an additional form ('LoaderActionForm') + which would retrigger the action with the values from the form on + submitting. That allows handling of multistep actions. + +It is also recommended that the plugin does override the 'identifier' + attribute. The identifier has to be unique across all plugins. + Class name is used by default. + +The selection wrapper currently supports the following types of entity types: + - version + - representation +It is planned to add 'folder' and 'task' selection in the future. + +NOTE: It is possible to trigger 'execute_action' without ever calling + 'get_action_items', that can be handy in automations. + +The whole logic is wrapped into 'LoaderActionsContext'. It takes care of + the discovery of plugins and wraps the collection and execution of + action items. Method 'execute_action' on context also requires plugin + identifier. + +The flow of the logic is (in the loader tool): + 1. User selects entities in the UI. + 2. Right-click the selected entities. + 3. Use 'LoaderActionsContext' to collect items using 'get_action_items'. + 4. Show a menu (with submenus) in the UI. + 5. If a user selects an action, the action is triggered using + 'execute_action'. + 5a. If the action returns 'LoaderActionResult', show a 'message' if it is + filled and show a form dialog if 'form' is filled. + 5b. If the user submitted the form, trigger the action again with the + values from the form and repeat from 5a. + +""" from __future__ import annotations import os @@ -388,7 +446,7 @@ class LoaderActionItem: and ids to be executed on. Attributes: - identifier (str): Unique action identifier. What is sent to action + identifier (str): Unique action identifier. What is sent to the action plugin when the action is executed. label (str): Text shown in UI. order (int): Order of the action in UI. @@ -417,7 +475,10 @@ class LoaderActionForm: If an action needs to collect information from a user before or during of the action execution, it can return a response with a form. When the - form is confirmed, a new execution of the action is triggered. + form is submitted, a new execution of the action is triggered. + + It is also possible to just show a label message without the submit + button to make sure the user has seen the message. Attributes: title (str): Title of the form -> title of the window. From 4c492b6d4bce55f79c60fd84d850e7d54a77c4ce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:54:58 +0200 Subject: [PATCH 083/279] fetch only first representation --- client/ayon_core/plugins/loader/copy_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 8253a772eb..2c4a99dc4f 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -76,8 +76,8 @@ class CopyFileActionPlugin(LoaderActionPlugin): ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore - repre_ids = data["representation_ids"] - repre = next(iter(selection.entities.get_representations(repre_ids))) + repre_id = next(iter(data["representation_ids"])) + repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) From 76be69c4b2fb396600fc67a9c6f76ab7751e9b88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:01:48 +0200 Subject: [PATCH 084/279] add simple action plugin --- client/ayon_core/pipeline/actions/__init__.py | 2 + client/ayon_core/pipeline/actions/loader.py | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 247f64e890..6120fd6ac5 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -7,6 +7,7 @@ from .loader import ( LoaderActionSelection, LoaderActionsContext, SelectionEntitiesCache, + LoaderSimpleActionPlugin, ) from .launcher import ( @@ -37,6 +38,7 @@ __all__ = ( "LoaderActionSelection", "LoaderActionsContext", "SelectionEntitiesCache", + "LoaderSimpleActionPlugin", "LauncherAction", "LauncherActionSelection", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index bb903b7c54..a77eee82c7 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -804,3 +804,79 @@ class LoaderActionsContext: ) self._plugins = plugins return self._plugins + + +class LoaderSimpleActionPlugin(LoaderActionPlugin): + """Simple action plugin. + + This action will show exactly one action item defined by attributes + on the class. + + Attributes: + label: Label of the action item. + order: Order of the action item. + group_label: Label of the group to which the action belongs. + icon: Icon definition shown next to label. + + """ + + label: Optional[str] = None + order: int = 0 + group_label: Optional[str] = None + icon: Optional[dict[str, Any]] = None + + @abstractmethod + def is_compatible(self, selection: LoaderActionSelection) -> bool: + """Check if plugin is compatible with selection. + + Args: + selection (LoaderActionSelection): Selection information. + + Returns: + bool: True if plugin is compatible with selection. + + """ + pass + + @abstractmethod + def process( + self, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + """Process action based on selection. + + Args: + selection (LoaderActionSelection): Selection information. + form_values (dict[str, Any]): Values from a form if there are any. + + Returns: + Optional[LoaderActionResult]: Result of the action. + + """ + pass + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + if self.is_compatible(selection): + label = self.label or self.__class__.__name__ + return [ + LoaderActionItem( + identifier=self.identifier, + label=label, + order=self.order, + group_label=self.group_label, + icon=self.icon, + ) + ] + return [] + + def execute_action( + self, + identifier: str, + selection: LoaderActionSelection, + data: Optional[DataType], + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + return self.process(selection, form_values) From af196dd049855dd1d0cf95ca2f11fffebbe62687 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:02:04 +0200 Subject: [PATCH 085/279] use simple plugin in export otio action --- .../ayon_core/plugins/loader/export_otio.py | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 1ad9038c5e..f8cdbed0a5 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -20,8 +20,7 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.tools.utils import show_message_dialog from ayon_core.pipeline.actions import ( - LoaderActionPlugin, - LoaderActionItem, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionResult, ) @@ -37,47 +36,33 @@ def _import_otio(): OTIO = opentimelineio -class ExportOTIO(LoaderActionPlugin): +class ExportOTIO(LoaderSimpleActionPlugin): identifier = "core.export-otio" + label = "Export OTIO" + group_label = None + order = 35 + icon = { + "type": "material-symbols", + "name": "save", + "color": "#d8d8d8", + } - def get_action_items( + def is_compatible( self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: + ) -> bool: # Don't show in hosts if self.host_name is not None: - return [] + return False - version_ids = set() - if selection.selected_type == "version": - version_ids = set(selection.selected_ids) + return selection.versions_selected() - output = [] - if version_ids: - output.append( - LoaderActionItem( - identifier="copy-path", - label="Export OTIO", - group_label=None, - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "save", - "color": "#d8d8d8", - } - ) - ) - return output - - def execute_action( + def process( self, - identifier: str, selection: LoaderActionSelection, - data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: _import_otio() - version_ids = data["version_ids"] + version_ids = set(selection.selected_ids) versions_by_id = { version["id"]: version From 90497bdd5924ce94a7d04cb35142567cf4b40985 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:14:07 +0200 Subject: [PATCH 086/279] added some helper methods --- client/ayon_core/pipeline/actions/loader.py | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index a77eee82c7..7a5956160c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -433,6 +433,53 @@ class LoaderActionSelection: project_anatomy = property(get_project_anatomy) entities = property(get_entities_cache) + # --- Helper methods --- + def versions_selected(self) -> bool: + """Selected entity type is version. + + Returns: + bool: True if selected entity type is version. + + """ + return self._selected_type == LoaderSelectedType.version + + def representations_selected(self) -> bool: + """Selected entity type is representation. + + Returns: + bool: True if selected entity type is representation. + + """ + return self._selected_type == LoaderSelectedType.representation + + def get_selected_version_entities(self) -> list[dict[str, Any]]: + """Retrieve selected version entities. + + An empty list is returned if 'version' is not the selected + entity type. + + Returns: + list[dict[str, Any]]: List of selected version entities. + + """ + if self.versions_selected(): + return self.entities.get_versions(self.selected_ids) + return [] + + def get_selected_representation_entities(self) -> list[dict[str, Any]]: + """Retrieve selected representation entities. + + An empty list is returned if 'representation' is not the selected + entity type. + + Returns: + list[dict[str, Any]]: List of selected representation entities. + + """ + if self.representations_selected(): + return self.entities.get_representations(self.selected_ids) + return [] + @dataclass class LoaderActionItem: From 365d0a95e032d3612560fe4473b759cb332c2dc8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:20:14 +0200 Subject: [PATCH 087/279] fix typo --- client/ayon_core/plugins/loader/open_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 1ed470c06e..5b21a359f8 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -52,12 +52,12 @@ class OpenFileAction(LoaderActionPlugin): ) product_ids = {version["productId"] for version in versions} products = selection.entities.get_products(product_ids) - fitlered_product_ids = { + filtered_product_ids = { product["id"] for product in products if product["productType"] in self.product_types } - if not fitlered_product_ids: + if not filtered_product_ids: return [] versions_by_product_id = collections.defaultdict(list) @@ -69,7 +69,7 @@ class OpenFileAction(LoaderActionPlugin): repres_by_version_ids[repre["versionId"]].append(repre) filtered_repres = [] - for product_id in fitlered_product_ids: + for product_id in filtered_product_ids: for version in versions_by_product_id[product_id]: for repre in repres_by_version_ids[version["id"]]: filtered_repres.append(repre) From 81a0b6764028024ee40966c51866b0d59fe22de8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:43:32 +0200 Subject: [PATCH 088/279] remove action identifier --- client/ayon_core/pipeline/actions/loader.py | 23 +++++----------- client/ayon_core/plugins/loader/copy_file.py | 16 +++++++----- .../plugins/loader/delete_old_versions.py | 26 +++++++++++-------- client/ayon_core/plugins/loader/delivery.py | 2 -- client/ayon_core/plugins/loader/open_file.py | 2 -- .../plugins/loader/push_to_project.py | 2 -- client/ayon_core/tools/loader/abstract.py | 8 +----- client/ayon_core/tools/loader/control.py | 5 +--- .../ayon_core/tools/loader/models/actions.py | 15 ++++------- .../ayon_core/tools/loader/models/sitesync.py | 19 +++++++------- .../tools/loader/ui/products_widget.py | 1 - .../tools/loader/ui/repres_widget.py | 1 - client/ayon_core/tools/loader/ui/window.py | 1 - 13 files changed, 48 insertions(+), 73 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 7a5956160c..ccdae302b9 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -493,27 +493,24 @@ class LoaderActionItem: and ids to be executed on. Attributes: - identifier (str): Unique action identifier. What is sent to the action - plugin when the action is executed. label (str): Text shown in UI. order (int): Order of the action in UI. group_label (Optional[str]): Label of the group to which the action belongs. icon (Optional[dict[str, Any]): Icon definition. data (Optional[DataType]): Action item data. - plugin_identifier (Optional[str]): Identifier of the plugin which + identifier (Optional[str]): Identifier of the plugin which created the action item. Is filled automatically. Is not changed if is filled -> can lead to different plugin. """ - identifier: str label: str order: int = 0 group_label: Optional[str] = None icon: Optional[dict[str, Any]] = None data: Optional[DataType] = None # Is filled automatically - plugin_identifier: str = None + identifier: str = None @dataclass @@ -667,7 +664,6 @@ class LoaderActionPlugin(ABC): @abstractmethod def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], @@ -675,7 +671,6 @@ class LoaderActionPlugin(ABC): """Execute an action. Args: - identifier (str): Action identifier. selection (LoaderActionSelection): Selection wrapper. Can be used to get entities or get context of original selection. data (Optional[DataType]): Additional action item data. @@ -771,8 +766,8 @@ class LoaderActionsContext: for plugin_id, plugin in self._get_plugins().items(): try: for action_item in plugin.get_action_items(selection): - if action_item.plugin_identifier is None: - action_item.plugin_identifier = plugin_id + if action_item.identifier is None: + action_item.identifier = plugin_id output.append(action_item) except Exception: @@ -785,8 +780,7 @@ class LoaderActionsContext: def execute_action( self, - plugin_identifier: str, - action_identifier: str, + identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], @@ -794,8 +788,7 @@ class LoaderActionsContext: """Trigger action execution. Args: - plugin_identifier (str): Identifier of the plugin. - action_identifier (str): Identifier of the action. + identifier (str): Identifier of the plugin. selection (LoaderActionSelection): Selection wrapper. Can be used to get what is selected in UI and to get access to entity cache. @@ -805,9 +798,8 @@ class LoaderActionsContext: """ plugins_by_id = self._get_plugins() - plugin = plugins_by_id[plugin_identifier] + plugin = plugins_by_id[identifier] return plugin.execute_action( - action_identifier, selection, data, form_values, @@ -910,7 +902,6 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): label = self.label or self.__class__.__name__ return [ LoaderActionItem( - identifier=self.identifier, label=label, order=self.order, group_label=self.group_label, diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2c4a99dc4f..dd263383e4 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -41,10 +41,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): for repre_name, repre_ids in repre_ids_by_name.items(): output.append( LoaderActionItem( - identifier="copy-path", label=repre_name, group_label="Copy file path", - data={"representation_ids": list(repre_ids)}, + data={ + "representation_ids": list(repre_ids), + "action": "copy-path", + }, icon={ "type": "material-symbols", "name": "content_copy", @@ -54,10 +56,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): ) output.append( LoaderActionItem( - identifier="copy-file", label=repre_name, group_label="Copy file", - data={"representation_ids": list(repre_ids)}, + data={ + "representation_ids": list(repre_ids), + "action": "copy-file", + }, icon={ "type": "material-symbols", "name": "file_copy", @@ -69,13 +73,13 @@ class CopyFileActionPlugin(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore + action = data["action"] repre_id = next(iter(data["representation_ids"])) repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( @@ -90,7 +94,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): success=False, ) - if identifier == "copy-path": + if action == "copy-path": # Set to Clipboard clipboard.setText(os.path.normpath(path)) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index cc7d4d3fa6..f7f20fefef 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -58,10 +58,12 @@ class DeleteOldVersions(LoaderActionPlugin): return [ LoaderActionItem( - identifier="delete-versions", label="Delete Versions", order=35, - data={"product_ids": list(product_ids)}, + data={ + "product_ids": list(product_ids), + "action": "delete-versions", + }, icon={ "type": "material-symbols", "name": "delete", @@ -69,10 +71,12 @@ class DeleteOldVersions(LoaderActionPlugin): } ), LoaderActionItem( - identifier="calculate-versions-size", label="Calculate Versions size", order=30, - data={"product_ids": list(product_ids)}, + data={ + "product_ids": list(product_ids), + "action": "calculate-versions-size", + }, icon={ "type": "material-symbols", "name": "auto_delete", @@ -83,17 +87,17 @@ class DeleteOldVersions(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: step = form_values.get("step") + action = data["action"] versions_to_keep = form_values.get("versions_to_keep") remove_publish_folder = form_values.get("remove_publish_folder") if step is None: return self._first_step( - identifier, + action, versions_to_keep, remove_publish_folder, ) @@ -106,7 +110,7 @@ class DeleteOldVersions(LoaderActionPlugin): product_ids = data["product_ids"] if step == "prepare-data": return self._prepare_data_step( - identifier, + action, versions_to_keep, remove_publish_folder, product_ids, @@ -121,7 +125,7 @@ class DeleteOldVersions(LoaderActionPlugin): def _first_step( self, - identifier: str, + action: str, versions_to_keep: Optional[int], remove_publish_folder: Optional[bool], ) -> LoaderActionResult: @@ -137,7 +141,7 @@ class DeleteOldVersions(LoaderActionPlugin): default=2, ), ] - if identifier == "delete-versions": + if action == "delete-versions": fields.append( BoolDef( "remove_publish_folder", @@ -165,7 +169,7 @@ class DeleteOldVersions(LoaderActionPlugin): def _prepare_data_step( self, - identifier: str, + action: str, versions_to_keep: int, remove_publish_folder: bool, entity_ids: set[str], @@ -235,7 +239,7 @@ class DeleteOldVersions(LoaderActionPlugin): if os.path.exists(filepath): size += os.path.getsize(filepath) - if identifier == "calculate-versions-size": + if action == "calculate-versions-size": return LoaderActionResult( message="Calculated size", success=True, diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 538bdec414..c39b791dbb 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -51,7 +51,6 @@ class DeliveryAction(LoaderActionPlugin): return [ LoaderActionItem( - identifier="deliver-versions", label="Deliver Versions", order=35, data={"version_ids": list(version_ids)}, @@ -65,7 +64,6 @@ class DeliveryAction(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 5b21a359f8..9b5a6fec20 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -80,7 +80,6 @@ class OpenFileAction(LoaderActionPlugin): return [ LoaderActionItem( - identifier="open-file", label=repre_name, group_label="Open file", order=-10, @@ -96,7 +95,6 @@ class OpenFileAction(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index 275f5de88d..215e63be86 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -39,7 +39,6 @@ class PushToProject(LoaderActionPlugin): if version_ids and len(folder_ids) == 1: output.append( LoaderActionItem( - identifier="core.push-to-project", label="Push to project", order=35, data={"version_ids": list(version_ids)}, @@ -54,7 +53,6 @@ class PushToProject(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 90371204f9..3f86317e90 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -314,7 +314,6 @@ class ActionItem: use 'identifier' and context, it necessary also use 'options'. Args: - plugin_identifier (str): Action identifier. identifier (str): Action identifier. label (str): Action label. group_label (Optional[str]): Group label. @@ -328,7 +327,6 @@ class ActionItem: """ def __init__( self, - plugin_identifier: str, identifier: str, label: str, group_label: Optional[str], @@ -338,7 +336,6 @@ class ActionItem: data: Optional[dict[str, Any]], options: Optional[list], ): - self.plugin_identifier = plugin_identifier self.identifier = identifier self.label = label self.group_label = group_label @@ -366,7 +363,6 @@ class ActionItem: def to_data(self) -> dict[str, Any]: options = self._options_to_data() return { - "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, "label": self.label, "group_label": self.group_label, @@ -1003,7 +999,6 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -1028,8 +1023,7 @@ class FrontendLoaderController(_BaseLoaderController): } Args: - plugin_identifier (sttr): Plugin identifier. - identifier (sttr): Action identifier. + identifier (sttr): Plugin identifier. project_name (str): Project name. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index e406b30fe0..722cdf9653 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -315,7 +315,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -324,16 +323,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): options: dict[str, Any], form_values: dict[str, Any], ): - if self._sitesync_model.is_sitesync_action(plugin_identifier): + if self._sitesync_model.is_sitesync_action(identifier): self._sitesync_model.trigger_action_item( - identifier, project_name, data, ) return self._loader_actions_model.trigger_action_item( - plugin_identifier=plugin_identifier, identifier=identifier, project_name=project_name, selected_ids=selected_ids, diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 772befc22f..3db1792247 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -122,7 +122,6 @@ class LoaderActionsModel: def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -140,8 +139,7 @@ class LoaderActionsModel: happened. Args: - plugin_identifier (str): Plugin identifier. - identifier (str): Action identifier. + identifier (str): Plugin identifier. project_name (str): Project name. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. @@ -151,7 +149,6 @@ class LoaderActionsModel: """ event_data = { - "plugin_identifier": plugin_identifier, "identifier": identifier, "project_name": project_name, "selected_ids": list(selected_ids), @@ -164,13 +161,12 @@ class LoaderActionsModel: event_data, ACTIONS_MODEL_SENDER, ) - if plugin_identifier != LOADER_PLUGIN_ID: + if identifier != LOADER_PLUGIN_ID: result = None crashed = False try: result = self._loader_actions.execute_action( - plugin_identifier=plugin_identifier, - action_identifier=identifier, + identifier=identifier, selection=LoaderActionSelection( project_name, selected_ids, @@ -197,7 +193,7 @@ class LoaderActionsModel: return loader = self._get_loader_by_identifier( - project_name, identifier + project_name, data["loader"] ) entity_type = data["entity_type"] entity_ids = data["entity_ids"] @@ -342,10 +338,10 @@ class LoaderActionsModel: label = f"{label} ({repre_name})" return ActionItem( LOADER_PLUGIN_ID, - get_loader_identifier(loader), data={ "entity_ids": entity_ids, "entity_type": entity_type, + "loader": get_loader_identifier(loader), }, label=label, group_label=None, @@ -804,7 +800,6 @@ class LoaderActionsModel: items = [] for action in self._loader_actions.get_action_items(selection): items.append(ActionItem( - action.plugin_identifier, action.identifier, label=action.label, group_label=action.group_label, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 2d0dcea5bf..a7bbda18a3 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -300,33 +300,32 @@ class SiteSyncModel: return action_items - def is_sitesync_action(self, plugin_identifier: str) -> bool: + def is_sitesync_action(self, identifier: str) -> bool: """Should be `identifier` handled by SiteSync. Args: - plugin_identifier (str): Plugin identifier. + identifier (str): Plugin identifier. Returns: bool: Should action be handled by SiteSync. """ - return plugin_identifier == "sitesync.loader.action" + return identifier == "sitesync.loader.action" def trigger_action_item( self, - identifier: str, project_name: str, data: dict[str, Any], ): """Resets status for site_name or remove local files. Args: - identifier (str): Action identifier. project_name (str): Project name. data (dict[str, Any]): Action item data. """ representation_ids = data["representation_ids"] + action_identifier = data["action_identifier"] active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -350,17 +349,17 @@ class SiteSyncModel: for repre_id in representation_ids: repre_entity = repre_entities_by_id.get(repre_id) product_type = product_type_by_repre_id[repre_id] - if identifier == DOWNLOAD_IDENTIFIER: + if action_identifier == DOWNLOAD_IDENTIFIER: self._add_site( project_name, repre_entity, active_site, product_type ) - elif identifier == UPLOAD_IDENTIFIER: + elif action_identifier == UPLOAD_IDENTIFIER: self._add_site( project_name, repre_entity, remote_site, product_type ) - elif identifier == REMOVE_IDENTIFIER: + elif action_identifier == REMOVE_IDENTIFIER: self._sitesync_addon.remove_site( project_name, repre_id, @@ -480,14 +479,13 @@ class SiteSyncModel: self, project_name, representation_ids, - identifier, + action_identifier, label, tooltip, icon_name ): return ActionItem( "sitesync.loader.action", - identifier=identifier, label=label, group_label=None, icon={ @@ -499,6 +497,7 @@ class SiteSyncModel: order=1, data={ "representation_ids": representation_ids, + "action_identifier": action_identifier, }, options=None, ) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 384fed2ee9..ddd6ce8554 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,7 +438,6 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - plugin_identifier=action_item.plugin_identifier, identifier=action_item.identifier, project_name=project_name, selected_ids=version_ids, diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index dcfcfea81b..33bbf46b34 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,7 +399,6 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - plugin_identifier=action_item.plugin_identifier, identifier=action_item.identifier, project_name=self._selected_project_name, selected_ids=repre_ids, diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index d2a4145707..1c8b56f0c0 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -584,7 +584,6 @@ class LoaderWindow(QtWidgets.QWidget): form_values = dialog.get_values() self._controller.trigger_action_item( - plugin_identifier=event["plugin_identifier"], identifier=event["identifier"], project_name=event["project_name"], selected_ids=event["selected_ids"], From 0dfaa001655103b5690a0424a0ca987bac914242 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:06:39 +0200 Subject: [PATCH 089/279] remove unnecessary argument --- client/ayon_core/pipeline/actions/loader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ccdae302b9..c8b579614a 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -912,7 +912,6 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], From 55828c73414f999d9280af9309f4aaeb24bb7936 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:58:21 +0200 Subject: [PATCH 090/279] move LoaderActionForm as ActionForm to structures --- client/ayon_core/pipeline/actions/__init__.py | 7 +- client/ayon_core/pipeline/actions/loader.py | 67 ++----------------- .../ayon_core/pipeline/actions/structures.py | 60 +++++++++++++++++ 3 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/structures.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 6120fd6ac5..569047438c 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,6 +1,8 @@ +from .structures import ( + ActionForm, +) from .loader import ( LoaderSelectedType, - LoaderActionForm, LoaderActionResult, LoaderActionItem, LoaderActionPlugin, @@ -30,8 +32,9 @@ from .inventory import ( __all__ = ( + "ActionForm", + "LoaderSelectedType", - "LoaderActionForm", "LoaderActionResult", "LoaderActionItem", "LoaderActionPlugin", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c8b579614a..13f243bf66 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -22,7 +22,7 @@ The action is triggered by calling the 'execute_action' method. Which takes item and form values from the form if any. Using 'LoaderActionResult' as the output of 'execute_action' can trigger to - show a message in UI or to show an additional form ('LoaderActionForm') + show a message in UI or to show an additional form ('ActionForm') which would retrigger the action with the values from the form on submitting. That allows handling of multistep actions. @@ -71,17 +71,14 @@ import ayon_api from ayon_core import AYON_CORE_ROOT from ayon_core.lib import StrEnum, Logger -from ayon_core.lib.attribute_definitions import ( - AbstractAttrDef, - serialize_attr_defs, - deserialize_attr_defs, -) from ayon_core.host import AbstractHost from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins +from .structures import ActionForm + if typing.TYPE_CHECKING: from typing import Union @@ -513,58 +510,6 @@ class LoaderActionItem: identifier: str = None -@dataclass -class LoaderActionForm: - """Form for loader action. - - If an action needs to collect information from a user before or during of - the action execution, it can return a response with a form. When the - form is submitted, a new execution of the action is triggered. - - It is also possible to just show a label message without the submit - button to make sure the user has seen the message. - - Attributes: - title (str): Title of the form -> title of the window. - fields (list[AbstractAttrDef]): Fields of the form. - submit_label (Optional[str]): Label of the submit button. Is hidden - if is set to None. - submit_icon (Optional[dict[str, Any]]): Icon definition of the submit - button. - cancel_label (Optional[str]): Label of the cancel button. Is hidden - if is set to None. User can still close the window tho. - cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel - button. - - """ - title: str - fields: list[AbstractAttrDef] - submit_label: Optional[str] = "Submit" - submit_icon: Optional[dict[str, Any]] = None - cancel_label: Optional[str] = "Cancel" - cancel_icon: Optional[dict[str, Any]] = None - - def to_json_data(self) -> dict[str, Any]: - fields = self.fields - if fields is not None: - fields = serialize_attr_defs(fields) - return { - "title": self.title, - "fields": fields, - "submit_label": self.submit_label, - "submit_icon": self.submit_icon, - "cancel_label": self.cancel_label, - "cancel_icon": self.cancel_icon, - } - - @classmethod - def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionForm": - fields = data["fields"] - if fields is not None: - data["fields"] = deserialize_attr_defs(fields) - return cls(**data) - - @dataclass class LoaderActionResult: """Result of loader action execution. @@ -573,7 +518,7 @@ class LoaderActionResult: message (Optional[str]): Message to show in UI. success (bool): If the action was successful. Affects color of the message. - form (Optional[LoaderActionForm]): Form to show in UI. + form (Optional[ActionForm]): Form to show in UI. form_values (Optional[dict[str, Any]]): Values for the form. Can be used if the same form is re-shown e.g. because a user forgot to fill a required field. @@ -581,7 +526,7 @@ class LoaderActionResult: """ message: Optional[str] = None success: bool = True - form: Optional[LoaderActionForm] = None + form: Optional[ActionForm] = None form_values: Optional[dict[str, Any]] = None def to_json_data(self) -> dict[str, Any]: @@ -599,7 +544,7 @@ class LoaderActionResult: def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult": form = data["form"] if form is not None: - data["form"] = LoaderActionForm.from_json_data(form) + data["form"] = ActionForm.from_json_data(form) return LoaderActionResult(**data) diff --git a/client/ayon_core/pipeline/actions/structures.py b/client/ayon_core/pipeline/actions/structures.py new file mode 100644 index 0000000000..0283a7a272 --- /dev/null +++ b/client/ayon_core/pipeline/actions/structures.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + + +@dataclass +class ActionForm: + """Form for loader action. + + If an action needs to collect information from a user before or during of + the action execution, it can return a response with a form. When the + form is submitted, a new execution of the action is triggered. + + It is also possible to just show a label message without the submit + button to make sure the user has seen the message. + + Attributes: + title (str): Title of the form -> title of the window. + fields (list[AbstractAttrDef]): Fields of the form. + submit_label (Optional[str]): Label of the submit button. Is hidden + if is set to None. + submit_icon (Optional[dict[str, Any]]): Icon definition of the submit + button. + cancel_label (Optional[str]): Label of the cancel button. Is hidden + if is set to None. User can still close the window tho. + cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel + button. + + """ + title: str + fields: list[AbstractAttrDef] + submit_label: Optional[str] = "Submit" + submit_icon: Optional[dict[str, Any]] = None + cancel_label: Optional[str] = "Cancel" + cancel_icon: Optional[dict[str, Any]] = None + + def to_json_data(self) -> dict[str, Any]: + fields = self.fields + if fields is not None: + fields = serialize_attr_defs(fields) + return { + "title": self.title, + "fields": fields, + "submit_label": self.submit_label, + "submit_icon": self.submit_icon, + "cancel_label": self.cancel_label, + "cancel_icon": self.cancel_icon, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "ActionForm": + fields = data["fields"] + if fields is not None: + data["fields"] = deserialize_attr_defs(fields) + return cls(**data) From e9958811d44a46c832be8920587452c688386dc5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:15:53 +0200 Subject: [PATCH 091/279] added helper conversion function for webaction fields --- client/ayon_core/pipeline/actions/__init__.py | 4 + client/ayon_core/pipeline/actions/utils.py | 83 +++++++++++++++++++ .../tools/launcher/ui/actions_widget.py | 83 +------------------ 3 files changed, 90 insertions(+), 80 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/utils.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 569047438c..7af3ac1130 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,6 +1,9 @@ from .structures import ( ActionForm, ) +from .utils import ( + webaction_fields_to_attribute_defs, +) from .loader import ( LoaderSelectedType, LoaderActionResult, @@ -33,6 +36,7 @@ from .inventory import ( __all__ = ( "ActionForm", + "webaction_fields_to_attribute_defs", "LoaderSelectedType", "LoaderActionResult", diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py new file mode 100644 index 0000000000..00a8e91d68 --- /dev/null +++ b/client/ayon_core/pipeline/actions/utils.py @@ -0,0 +1,83 @@ +import uuid + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + UILabelDef, + BoolDef, + TextDef, + NumberDef, + EnumDef, + HiddenDef, +) + + +def webaction_fields_to_attribute_defs(fields) -> list[AbstractAttrDef]: + attr_defs = [] + for field in fields: + field_type = field["type"] + attr_def = None + if field_type == "label": + label = field.get("value") + if label is None: + label = field.get("text") + attr_def = UILabelDef( + label, key=uuid.uuid4().hex + ) + elif field_type == "boolean": + value = field["value"] + if isinstance(value, str): + value = value.lower() == "true" + + attr_def = BoolDef( + field["name"], + default=value, + label=field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + field["name"], + default=field.get("value"), + label=field.get("label"), + placeholder=field.get("placeholder"), + multiline=field.get("multiline", False), + regex=field.get("regex"), + # syntax=field["syntax"], + ) + elif field_type in ("integer", "float"): + value = field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) + attr_def = NumberDef( + field["name"], + default=value, + label=field.get("label"), + decimals=0 if field_type == "integer" else 5, + # placeholder=field.get("placeholder"), + minimum=field.get("min"), + maximum=field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + field["name"], + items=field["options"], + default=field.get("value"), + label=field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + field["name"], + default=field.get("value"), + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + return attr_defs diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 31b303ca2b..0e763a208a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,12 @@ import time -import uuid import collections from qtpy import QtWidgets, QtCore, QtGui from ayon_core.lib import Logger -from ayon_core.lib.attribute_definitions import ( - UILabelDef, - EnumDef, - TextDef, - BoolDef, - NumberDef, - HiddenDef, -) +from ayon_core.pipeline.actions import webaction_fields_to_attribute_defs from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - get_qt_icon, -) +from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -1173,74 +1163,7 @@ class ActionsWidget(QtWidgets.QWidget): float - 'label', 'value', 'placeholder', 'min', 'max' """ - attr_defs = [] - for config_field in config_fields: - field_type = config_field["type"] - attr_def = None - if field_type == "label": - label = config_field.get("value") - if label is None: - label = config_field.get("text") - attr_def = UILabelDef( - label, key=uuid.uuid4().hex - ) - elif field_type == "boolean": - value = config_field["value"] - if isinstance(value, str): - value = value.lower() == "true" - - attr_def = BoolDef( - config_field["name"], - default=value, - label=config_field.get("label"), - ) - elif field_type == "text": - attr_def = TextDef( - config_field["name"], - default=config_field.get("value"), - label=config_field.get("label"), - placeholder=config_field.get("placeholder"), - multiline=config_field.get("multiline", False), - regex=config_field.get("regex"), - # syntax=config_field["syntax"], - ) - elif field_type in ("integer", "float"): - value = config_field.get("value") - if isinstance(value, str): - if field_type == "integer": - value = int(value) - else: - value = float(value) - attr_def = NumberDef( - config_field["name"], - default=value, - label=config_field.get("label"), - decimals=0 if field_type == "integer" else 5, - # placeholder=config_field.get("placeholder"), - minimum=config_field.get("min"), - maximum=config_field.get("max"), - ) - elif field_type in ("select", "multiselect"): - attr_def = EnumDef( - config_field["name"], - items=config_field["options"], - default=config_field.get("value"), - label=config_field.get("label"), - multiselection=field_type == "multiselect", - ) - elif field_type == "hidden": - attr_def = HiddenDef( - config_field["name"], - default=config_field.get("value"), - ) - - if attr_def is None: - print(f"Unknown config field type: {field_type}") - attr_def = UILabelDef( - f"Unknown field type '{field_type}", - key=uuid.uuid4().hex - ) - attr_defs.append(attr_def) + attr_defs = webaction_fields_to_attribute_defs(config_fields) dialog = AttributeDefinitionsDialog( attr_defs, From 917c4e317cb9c36e1703857660864a2c7ca0e5e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:16:14 +0200 Subject: [PATCH 092/279] use ActionForm in delete old versions --- client/ayon_core/plugins/loader/delete_old_versions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index f7f20fefef..d6ddacf146 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -18,12 +18,12 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( + ActionForm, LoaderSelectedType, LoaderActionPlugin, LoaderActionItem, LoaderActionSelection, LoaderActionResult, - LoaderActionForm, ) @@ -160,7 +160,7 @@ class DeleteOldVersions(LoaderActionPlugin): } form_values["step"] = "prepare-data" return LoaderActionResult( - form=LoaderActionForm( + form=ActionForm( title="Delete Old Versions", fields=fields, ), @@ -243,7 +243,7 @@ class DeleteOldVersions(LoaderActionPlugin): return LoaderActionResult( message="Calculated size", success=True, - form=LoaderActionForm( + form=ActionForm( title="Calculated versions size", fields=[ UILabelDef( @@ -341,7 +341,7 @@ class DeleteOldVersions(LoaderActionPlugin): repre_ids_by_version_id: dict[str, list[str]], filepaths_by_repre_id: dict[str, list[str]], repeated: bool = False, - ) -> tuple[LoaderActionForm, dict[str, Any]]: + ) -> tuple[ActionForm, dict[str, Any]]: versions_len = len(repre_ids_by_version_id) fields = [ UILabelDef( @@ -375,7 +375,7 @@ class DeleteOldVersions(LoaderActionPlugin): ) ]) - form = LoaderActionForm( + form = ActionForm( title="Delete versions", submit_label="Delete", cancel_label="Close", From eedd982a84c76169b746a64288e51aea1bf89fa5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:04:07 +0200 Subject: [PATCH 093/279] use first representation in action item collection --- client/ayon_core/plugins/loader/copy_file.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index dd263383e4..2380b465ed 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -39,12 +39,15 @@ class CopyFileActionPlugin(LoaderActionPlugin): repre_ids_by_name[repre["name"]].add(repre["id"]) for repre_name, repre_ids in repre_ids_by_name.items(): + repre_id = next(iter(repre_ids), None) + if not repre_id: + continue output.append( LoaderActionItem( label=repre_name, group_label="Copy file path", data={ - "representation_ids": list(repre_ids), + "representation_id": repre_id, "action": "copy-path", }, icon={ @@ -59,7 +62,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): label=repre_name, group_label="Copy file", data={ - "representation_ids": list(repre_ids), + "representation_id": repre_id, "action": "copy-file", }, icon={ @@ -80,7 +83,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): from qtpy import QtWidgets, QtCore action = data["action"] - repre_id = next(iter(data["representation_ids"])) + repre_id = data["representation_id"] repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() From 6d1d1e01d486c020c4fd5227bb6a23606cd22880 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:04:34 +0200 Subject: [PATCH 094/279] use 'get_selected_version_entities' in delete old versions --- client/ayon_core/plugins/loader/delete_old_versions.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index d6ddacf146..97e9d43628 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -42,12 +42,7 @@ class DeleteOldVersions(LoaderActionPlugin): if self.host_name is not None: return [] - versions = None - if selection.selected_type == LoaderSelectedType.version: - versions = selection.entities.get_versions( - selection.selected_ids - ) - + versions = selection.get_selected_version_entities() if not versions: return [] From d465e4a9b3a97c75e721012609cc904c86bcdfc7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:05:54 +0200 Subject: [PATCH 095/279] rename 'process' to 'execute_simple_action' --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- client/ayon_core/plugins/loader/export_otio.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 13f243bf66..92de9c6cf8 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -823,7 +823,7 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): pass @abstractmethod - def process( + def execute_simple_action( self, selection: LoaderActionSelection, form_values: dict[str, Any], @@ -861,4 +861,4 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - return self.process(selection, form_values) + return self.execute_simple_action(selection, form_values) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index f8cdbed0a5..c86a72700e 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -56,7 +56,7 @@ class ExportOTIO(LoaderSimpleActionPlugin): return selection.versions_selected() - def process( + def execute_simple_action( self, selection: LoaderActionSelection, form_values: dict[str, Any], From 48cc1719e30c22de00d8c3c6e59850c4e8c1fffe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:07:17 +0200 Subject: [PATCH 096/279] delivery action uses simple action --- client/ayon_core/plugins/loader/delivery.py | 54 +++++++++++---------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index c39b791dbb..1ac1c465dc 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -13,7 +13,7 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( - LoaderActionPlugin, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionItem, LoaderActionResult, @@ -27,15 +27,33 @@ from ayon_core.pipeline.delivery import ( ) -class DeliveryAction(LoaderActionPlugin): +class DeliveryAction(LoaderSimpleActionPlugin): identifier = "core.delivery" + label = "Deliver Versions" + order = 35 + icon = { + "type": "material-symbols", + "name": "upload", + "color": "#d8d8d8", + } - def get_action_items( - self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: + def is_compatible(self, selection: LoaderActionSelection) -> bool: if self.host_name is not None: - return [] + return False + if not selection.selected_ids: + return False + + return ( + selection.versions_selected() + or selection.representations_selected() + ) + + def execute_simple_action( + self, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: version_ids = set() if selection.selected_type == "representation": versions = selection.entities.get_representations_versions( @@ -47,31 +65,15 @@ class DeliveryAction(LoaderActionPlugin): version_ids = set(selection.selected_ids) if not version_ids: - return [] - - return [ - LoaderActionItem( - label="Deliver Versions", - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "upload", - "color": "#d8d8d8", - } + return LoaderActionResult( + message="No versions found in your selection", + success=False, ) - ] - def execute_action( - self, - selection: LoaderActionSelection, - data: dict[str, Any], - form_values: dict[str, Any], - ) -> Optional[LoaderActionResult]: try: # TODO run the tool in subprocess dialog = DeliveryOptionsDialog( - selection.project_name, data["version_ids"], self.log + selection.project_name, version_ids, self.log ) dialog.exec_() except Exception: From bc5c162a000fa928be1055156f6fd0ed75eba90a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:07:40 +0200 Subject: [PATCH 097/279] push to project uses simple action --- .../plugins/loader/push_to_project.py | 76 ++++++++----------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index 215e63be86..d2ade736fd 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -5,65 +5,51 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.lib import get_ayon_launcher_args, run_detached_process from ayon_core.pipeline.actions import ( - LoaderActionPlugin, - LoaderActionItem, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionResult, ) -class PushToProject(LoaderActionPlugin): +class PushToProject(LoaderSimpleActionPlugin): identifier = "core.push-to-project" + label = "Push to project" + order = 35 + icon = { + "type": "material-symbols", + "name": "send", + "color": "#d8d8d8", + } - def get_action_items( + def is_compatible( self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: - folder_ids = set() - version_ids = set() - if selection.selected_type == "version": - version_ids = set(selection.selected_ids) - product_ids = { - product["id"] - for product in selection.entities.get_versions_products( - version_ids - ) - } - folder_ids = { - folder["id"] - for folder in selection.entities.get_products_folders( - product_ids - ) - } + ) -> bool: + if not selection.versions_selected(): + return False - output = [] - if version_ids and len(folder_ids) == 1: - output.append( - LoaderActionItem( - label="Push to project", - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "send", - "color": "#d8d8d8", - } - ) + version_ids = set(selection.selected_ids) + product_ids = { + product["id"] + for product in selection.entities.get_versions_products( + version_ids ) - return output + } + folder_ids = { + folder["id"] + for folder in selection.entities.get_products_folders( + product_ids + ) + } - def execute_action( + if len(folder_ids) == 1: + return True + return False + + def execute_simple_action( self, selection: LoaderActionSelection, - data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - version_ids = data["version_ids"] - if len(version_ids) > 1: - return LoaderActionResult( - message="Please select only one version", - success=False, - ) - push_tool_script_path = os.path.join( AYON_CORE_ROOT, "tools", @@ -74,7 +60,7 @@ class PushToProject(LoaderActionPlugin): args = get_ayon_launcher_args( push_tool_script_path, "--project", selection.project_name, - "--versions", ",".join(version_ids) + "--versions", ",".join(selection.selected_ids) ) run_detached_process(args) return LoaderActionResult( From d81f6eaa3e7fe4504e0f7684cb0347c8993e8387 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:08:42 +0200 Subject: [PATCH 098/279] remove unused import --- client/ayon_core/plugins/loader/delivery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 1ac1c465dc..5141bb1d3b 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -15,7 +15,6 @@ from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( LoaderSimpleActionPlugin, LoaderActionSelection, - LoaderActionItem, LoaderActionResult, ) from ayon_core.pipeline.load import get_representation_path_with_anatomy From 14fb34e4b64907a4fc2de6b2c7c5e3ef24c74ea8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:22:18 +0200 Subject: [PATCH 099/279] remove unused import --- client/ayon_core/plugins/loader/delete_old_versions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 97e9d43628..7499650cbe 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -19,7 +19,6 @@ from ayon_core.lib import ( from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( ActionForm, - LoaderSelectedType, LoaderActionPlugin, LoaderActionItem, LoaderActionSelection, From e59975fe95eebd0db025145a43c2ca46f9cce4e0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:10:17 +0200 Subject: [PATCH 100/279] add docstring --- client/ayon_core/pipeline/actions/utils.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py index 00a8e91d68..3502300ead 100644 --- a/client/ayon_core/pipeline/actions/utils.py +++ b/client/ayon_core/pipeline/actions/utils.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import uuid +from typing import Any from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -11,7 +14,21 @@ from ayon_core.lib.attribute_definitions import ( ) -def webaction_fields_to_attribute_defs(fields) -> list[AbstractAttrDef]: +def webaction_fields_to_attribute_defs( + fields: list[dict[str, Any]] +) -> list[AbstractAttrDef]: + """Helper function to convert fields definition from webactions form. + + Convert form fields to attribute definitions to be able to display them + using attribute definitions. + + Args: + fields (list[dict[str, Any]]): Fields from webaction form. + + Returns: + list[AbstractAttrDef]: Converted attribute definitions. + + """ attr_defs = [] for field in fields: field_type = field["type"] From 725e0f5a11298e119600b58321d628f19be4779b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:17:47 +0200 Subject: [PATCH 101/279] get rid of private function --- client/ayon_core/pipeline/load/utils.py | 146 +++++++++--------------- 1 file changed, 57 insertions(+), 89 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 1d0a0e54e4..371ce300ba 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -651,7 +651,7 @@ def get_representation_path_from_context(context): project_name = project_entity["name"] else: project_name = get_current_project_name() - return _get_representation_path( + return get_representation_path( project_name, representation, project_entity=project_entity, @@ -724,7 +724,60 @@ def get_representation_path_with_roots( return path.normalized() -def _get_representation_path( +def _get_representation_path_decorator(func): + @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." + ), + 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 + + +@_get_representation_path_decorator +def get_representation_path( project_name: str, repre_entity: dict[str, Any], *, @@ -743,7 +796,7 @@ def _get_representation_path( Returns: TemplateResult: Resolved path to representation. - Raises: + Raises: InvalidRepresentationContext: When representation data are probably invalid or not available. @@ -776,91 +829,6 @@ def _get_representation_path( return path.normalized() -def _get_representation_path_decorator(func): - @wraps(_get_representation_path) - 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 _get_representation_path(*args, **kwargs) - - elif "project_name" in kwargs: - return _get_representation_path(*args, **kwargs) - - warnings.warn( - ( - "Used deprecated variant of 'get_representation_path'." - " Please change used arguments signature to follow" - " new definiton." - ), - 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 _get_representation_path(project_name, representation) - - return inner - - -@_get_representation_path_decorator -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. - - """ - return _get_representation_path( - project_name, - repre_entity, - anatomy=anatomy, - project_entity=project_entity, - ) - - def get_representation_path_by_names( project_name: str, folder_path: str, @@ -887,7 +855,7 @@ def get_representation_path_by_names( if not representation: return None - return _get_representation_path( + return get_representation_path( project_name, representation, anatomy=anatomy, From f11800f1e77fc8537fa4cf31bbb4e26bb2777b44 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:22:32 +0200 Subject: [PATCH 102/279] fix type hint --- client/ayon_core/pipeline/load/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 371ce300ba..225d4e0d44 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -833,7 +833,7 @@ def get_representation_path_by_names( project_name: str, folder_path: str, product_name: str, - version_name: str, + version_name: Union[int, str], representation_name: str, anatomy: Optional[Anatomy] = None ) -> Optional[TemplateResult]: From b665bf3f79be43ba548d46fac8b970270c7085a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:10:39 +0200 Subject: [PATCH 103/279] add attribute to the function to be able to detect if new version should be used --- client/ayon_core/pipeline/load/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 225d4e0d44..e1c9c31db6 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -725,6 +725,11 @@ def get_representation_path_with_roots( def _get_representation_path_decorator(func): + # 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 From 4b2d2d5002c5ce3b6d1b8e126d43433778147353 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:33:49 +0200 Subject: [PATCH 104/279] added overload definitions --- client/ayon_core/pipeline/load/utils.py | 55 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index e1c9c31db6..13c560bbd4 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -9,7 +9,7 @@ import collections import numbers import copy from functools import wraps -from typing import Optional, Union, Any +from typing import Optional, Union, Any, overload import ayon_api @@ -730,6 +730,7 @@ def _get_representation_path_decorator(func): # >>> 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 @@ -781,6 +782,58 @@ def _get_representation_path_decorator(func): 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 + + @_get_representation_path_decorator def get_representation_path( project_name: str, From b1cba11f6b796e8a0ed5225c0c52c243648f74a7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 16 Oct 2025 11:32:36 +0200 Subject: [PATCH 105/279] Skips audio collection in editorial contexts. Prevents duplicate audio collection when editorial context already handles audio processing. - Introduces function to get audio instances. - Checks for existing audio instances to avoid duplication. - Skips default audio collection if audio is already provided. --- .../plugins/publish/collect_audio.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index 2949ff1196..901c589ddc 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -46,6 +46,19 @@ class CollectAudio(pyblish.api.ContextPlugin): audio_product_name = "audioMain" def process(self, context): + # Make sure Editorial related products are excluded + # since those are maintained by ExtractOtioAudioTracks + audio_instances = self.get_audio_instances(context) + self.log.debug("Audio instances: {}".format(len(audio_instances))) + + # QUESTION: perhaps there is a better way to do this? + # This is having limitation for cases where no audio instance + # is found but still in editorial context. We should perhaps rather + # check if the instance is in particular editorial context. + if len(audio_instances) >= 1: + self.log.info("Audio provided from related instances") + return + # Fake filtering by family inside context plugin filtered_instances = [] for instance in pyblish.api.instances_by_plugin( @@ -102,6 +115,24 @@ class CollectAudio(pyblish.api.ContextPlugin): }] self.log.debug("Audio Data added to instance ...") + 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") + ] + def query_representations(self, project_name, folder_paths): """Query representations related to audio products for passed folders. From d8dab916196e6177d1a49c50ead3c320ec7f6f76 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 16 Oct 2025 11:32:55 +0200 Subject: [PATCH 106/279] Adds audio to sibling reviewable instances Ensures audio is added to sibling instances needing audio for reviewable media. - Checks for sibling instances with the same parent ID. - Adds audio information to those instances. --- .../publish/extract_otio_audio_tracks.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 3a450a4f33..77e71e587f 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -125,6 +125,31 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): }) inst.data["audio"] = audio_attr + # 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 = inst.data["parent_instance_id"] + for sibl_instance in inst.context: + sibl_parent_instance_id = sibl_instance.data.get( + "parent_instance_id") + # make sure the instance is not the same instance + # and the parent instance id is the same + if ( + sibl_instance.id is not inst.id and + sibl_parent_instance_id == parent_instance_id + ): + 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": audio_fpath, + "offset": 0 + }) + sibl_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) From 4a1755c7c58f60706f048e628e6005017406aaef Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 17 Oct 2025 16:06:37 +0200 Subject: [PATCH 107/279] Fixes source metadata key in OTIO review extraction The change corrects the metadata key prefixes used when extracting source width and height information for OTIO reviewable representations. It removes the trailing period from the prefixes "ayon.source." and "openpype.source." to ensure accurate retrieval of resolution data. This resolves an issue where incorrect or missing resolution information could lead to squished reviewables. --- client/ayon_core/plugins/publish/extract_otio_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 90215bd2c9..f338fba746 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -130,7 +130,7 @@ class ExtractOTIOReview( # NOTE it looks like it is set only in hiero integration res_data = {"width": self.to_width, "height": self.to_height} for key in res_data: - for meta_prefix in ("ayon.source.", "openpype.source."): + for meta_prefix in ("ayon.source", "openpype.source"): meta_key = f"{meta_prefix}.{key}" value = media_metadata.get(meta_key) if value is not None: From 0b51e17a8a6bfa1d411fab3442f4f7bbabaadc6f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 17 Oct 2025 16:21:32 +0200 Subject: [PATCH 108/279] Fixes audio duplication in sibling instances. Ensures audio is only added to relevant sibling instances, preventing duplication. - Prevents adding audio to the same instance. - Streamlines audio assignment logic. --- .../plugins/publish/extract_otio_audio_tracks.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 77e71e587f..925ea03964 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -65,9 +65,9 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): # 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): created_files = [] - for inst in instances: + for inst in audio_instances: name = inst.data["folderPath"] recycling_file = [f for f in created_files if name in f] @@ -134,11 +134,10 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): sibl_parent_instance_id = sibl_instance.data.get( "parent_instance_id") # make sure the instance is not the same instance + if sibl_instance.id == inst.id: + continue # and the parent instance id is the same - if ( - sibl_instance.id is not inst.id and - sibl_parent_instance_id == parent_instance_id - ): + if sibl_parent_instance_id == parent_instance_id: self.log.info( "Adding audio to Sibling instance: " f"{sibl_instance.data['label']}" From 680766418869b7a2f82f096c5c3af4ec85db4b3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 12:04:09 +0200 Subject: [PATCH 109/279] rename decorator function --- client/ayon_core/pipeline/load/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 13c560bbd4..70e2936e6f 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -724,7 +724,7 @@ def get_representation_path_with_roots( return path.normalized() -def _get_representation_path_decorator(func): +def _backwards_compatibility_repre_path(func): # 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 @@ -834,7 +834,7 @@ def get_representation_path( pass -@_get_representation_path_decorator +@_backwards_compatibility_repre_path def get_representation_path( project_name: str, repre_entity: dict[str, Any], From 50531fa35af7a93a695eca086b3590018fded2e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:23:39 +0200 Subject: [PATCH 110/279] added docstring --- client/ayon_core/pipeline/load/utils.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 70e2936e6f..7fb0c30331 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -725,6 +725,27 @@ def get_representation_path_with_roots( 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) + + """ # 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 From fbf370befa36a0ea9f471338d9d05cbea2e1710a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:23:53 +0200 Subject: [PATCH 111/279] raise from previous exception --- client/ayon_core/pipeline/load/utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 7fb0c30331..c4cf37d69d 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -886,11 +886,10 @@ def get_representation_path( try: template = repre_entity["attrib"]["template"] - except KeyError: + except KeyError as exc: raise InvalidRepresentationContext( - "Representation document does not" - " contain template in data ('data.template')" - ) + "Failed to receive template from representation entity." + ) from exc try: context = copy.deepcopy(repre_entity["context"]) @@ -901,9 +900,8 @@ def get_representation_path( except TemplateUnsolved as exc: raise InvalidRepresentationContext( - "Couldn't resolve representation template with available data." - f" Reason: {str(exc)}" - ) + "Failed to resolve representation template with available data." + ) from exc return path.normalized() From bd0320f56fd4ff2986f7afe4fa99f23a9c7702f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:24:04 +0200 Subject: [PATCH 112/279] added planned break of backwards compatibility --- client/ayon_core/pipeline/load/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index c4cf37d69d..8aed7b8b52 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -745,6 +745,8 @@ def _backwards_compatibility_repre_path(func): >>> 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. @@ -770,7 +772,7 @@ def _backwards_compatibility_repre_path(func): ( "Used deprecated variant of 'get_representation_path'." " Please change used arguments signature to follow" - " new definition." + " new definition. Will be removed 1.1.2026." ), DeprecationWarning, stacklevel=2, From 0dd5620de61614e90efe0632bc54cc0e99089347 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 16:27:23 +0200 Subject: [PATCH 113/279] better typehints --- client/ayon_core/lib/path_templates.py | 93 +++++++++++++------------- 1 file changed, 48 insertions(+), 45 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index c6e9e14eac..ccbea01fa6 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import re import copy @@ -5,11 +7,7 @@ import numbers import warnings import platform from string import Formatter -import typing -from typing import List, Dict, Any, Set - -if typing.TYPE_CHECKING: - from typing import Union +from typing import Any, Union SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") @@ -84,7 +82,7 @@ class StringTemplate: if substr: new_parts.append(substr) - self._parts: List["Union[str, OptionalPart, FormattingPart]"] = ( + self._parts: list[Union[str, OptionalPart, FormattingPart]] = ( self.find_optional_parts(new_parts) ) @@ -105,7 +103,7 @@ class StringTemplate: def template(self) -> str: return self._template - def format(self, data: Dict[str, Any]) -> "TemplateResult": + def format(self, data: dict[str, Any]) -> "TemplateResult": """ Figure out with whole formatting. Separate advanced keys (*Like '{project[name]}') from string which must @@ -145,29 +143,29 @@ class StringTemplate: invalid_types ) - def format_strict(self, data: Dict[str, Any]) -> "TemplateResult": + def format_strict(self, data: dict[str, Any]) -> "TemplateResult": result = self.format(data) result.validate() return result @classmethod def format_template( - cls, template: str, data: Dict[str, Any] + cls, template: str, data: dict[str, Any] ) -> "TemplateResult": objected_template = cls(template) return objected_template.format(data) @classmethod def format_strict_template( - cls, template: str, data: Dict[str, Any] + cls, template: str, data: dict[str, Any] ) -> "TemplateResult": objected_template = cls(template) return objected_template.format_strict(data) @staticmethod def find_optional_parts( - parts: List["Union[str, FormattingPart]"] - ) -> List["Union[str, OptionalPart, FormattingPart]"]: + parts: list[Union[str, FormattingPart]] + ) -> list[Union[str, OptionalPart, FormattingPart]]: new_parts = [] tmp_parts = {} counted_symb = -1 @@ -192,7 +190,7 @@ class StringTemplate: len(parts) == 1 and isinstance(parts[0], str) ): - value = "<{}>".format(parts[0]) + value = f"<{parts[0]}>" else: value = OptionalPart(parts) @@ -223,7 +221,7 @@ class TemplateResult(str): only used keys. solved (bool): For check if all required keys were filled. template (str): Original template. - missing_keys (Iterable[str]): Missing keys that were not in the data. + missing_keys (list[str]): Missing keys that were not in the data. Include missing optional keys. invalid_types (dict): When key was found in data, but value had not allowed DataType. Allowed data types are `numbers`, @@ -232,11 +230,11 @@ class TemplateResult(str): of number. """ - used_values: Dict[str, Any] = None + used_values: dict[str, Any] = None solved: bool = None template: str = None - missing_keys: List[str] = None - invalid_types: Dict[str, Any] = None + missing_keys: list[str] = None + invalid_types: dict[str, Any] = None def __new__( cls, filled_template, template, solved, @@ -296,21 +294,21 @@ class TemplatePartResult: """Result to store result of template parts.""" def __init__(self, optional: bool = False): # Missing keys or invalid value types of required keys - self._missing_keys: Set[str] = set() - self._invalid_types: Dict[str, Any] = {} + self._missing_keys: set[str] = set() + self._invalid_types: dict[str, Any] = {} # Missing keys or invalid value types of optional keys - self._missing_optional_keys: Set[str] = set() - self._invalid_optional_types: Dict[str, Any] = {} + self._missing_optional_keys: set[str] = set() + self._invalid_optional_types: dict[str, Any] = {} # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} - self._used_values: Dict[str, Any] = {} + self._used_values: dict[str, Any] = {} # Used values stored by key with all modifirs # - value is already formatted string # Example: {"version:0>3": "001"} - self._really_used_values: Dict[str, Any] = {} + self._really_used_values: dict[str, Any] = {} # Concatenated string output after formatting self._output: str = "" # Is this result from optional part @@ -336,8 +334,9 @@ class TemplatePartResult: self._really_used_values.update(other.really_used_values) else: - raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( - str(type(other)), self.__class__.__name__) + raise TypeError( + f"Cannot add data from \"{type(other)}\"" + f" to \"{self.__class__.__name__}\"" ) @property @@ -362,40 +361,41 @@ class TemplatePartResult: return self._output @property - def missing_keys(self) -> Set[str]: + def missing_keys(self) -> set[str]: return self._missing_keys @property - def missing_optional_keys(self) -> Set[str]: + def missing_optional_keys(self) -> set[str]: return self._missing_optional_keys @property - def invalid_types(self) -> Dict[str, Any]: + def invalid_types(self) -> dict[str, Any]: return self._invalid_types @property - def invalid_optional_types(self) -> Dict[str, Any]: + def invalid_optional_types(self) -> dict[str, Any]: return self._invalid_optional_types @property - def really_used_values(self) -> Dict[str, Any]: + def really_used_values(self) -> dict[str, Any]: return self._really_used_values @property - def realy_used_values(self) -> Dict[str, Any]: + def realy_used_values(self) -> dict[str, Any]: warnings.warn( "Property 'realy_used_values' is deprecated." " Use 'really_used_values' instead.", - DeprecationWarning + DeprecationWarning, + stacklevel=2, ) return self._really_used_values @property - def used_values(self) -> Dict[str, Any]: + def used_values(self) -> dict[str, Any]: return self._used_values @staticmethod - def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]: + def split_keys_to_subdicts(values: dict[str, Any]) -> dict[str, Any]: output = {} formatter = Formatter() for key, value in values.items(): @@ -410,7 +410,7 @@ class TemplatePartResult: data[last_key] = value return output - def get_clean_used_values(self) -> Dict[str, Any]: + def get_clean_used_values(self) -> dict[str, Any]: new_used_values = {} for key, value in self.used_values.items(): if isinstance(value, FormatObject): @@ -426,7 +426,8 @@ class TemplatePartResult: warnings.warn( "Method 'add_realy_used_value' is deprecated." " Use 'add_really_used_value' instead.", - DeprecationWarning + DeprecationWarning, + stacklevel=2, ) self.add_really_used_value(key, value) @@ -479,7 +480,7 @@ class FormattingPart: self, field_name: str, format_spec: str, - conversion: "Union[str, None]", + conversion: Union[str, None], ): format_spec_v = "" if format_spec: @@ -546,7 +547,7 @@ class FormattingPart: return not queue @staticmethod - def keys_to_template_base(keys: List[str]): + def keys_to_template_base(keys: list[str]): if not keys: return None # Create copy of keys @@ -556,7 +557,7 @@ class FormattingPart: return f"{template_base}{joined_keys}" def format( - self, data: Dict[str, Any], result: TemplatePartResult + self, data: dict[str, Any], result: TemplatePartResult ) -> TemplatePartResult: """Format the formattings string. @@ -687,23 +688,25 @@ class OptionalPart: def __init__( self, - parts: List["Union[str, OptionalPart, FormattingPart]"] + parts: list[Union[str, OptionalPart, FormattingPart]] ): - self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts + self._parts: list[Union[str, OptionalPart, FormattingPart]] = parts @property - def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]: + def parts(self) -> list[Union[str, OptionalPart, FormattingPart]]: return self._parts def __str__(self) -> str: - return "<{}>".format("".join([str(p) for p in self._parts])) + joined_parts = "".join([str(p) for p in self._parts]) + return f"<{joined_parts}>" def __repr__(self) -> str: - return "".format("".join([str(p) for p in self._parts])) + joined_parts = "".join([str(p) for p in self._parts]) + return f"" def format( self, - data: Dict[str, Any], + data: dict[str, Any], result: TemplatePartResult, ) -> TemplatePartResult: new_result = TemplatePartResult(True) From 4fca5bcde5f086cc37ea3a961cd0b872276fb438 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:04:56 +0200 Subject: [PATCH 114/279] Implemented helper dict to handle str -> dict conversion --- client/ayon_core/lib/path_templates.py | 68 +++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index ccbea01fa6..131a2efaa4 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -7,7 +7,7 @@ import numbers import warnings import platform from string import Formatter -from typing import Any, Union +from typing import Any, Union, Iterable SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") @@ -42,6 +42,66 @@ class TemplateUnsolved(Exception): ) +class DefaultValueDict(dict): + """Dictionary that supports the default key to use for str conversion. + + Is helpful for changes of a key in a template from string to dictionary + for example '{folder}' -> '{folder[name]}'. + >>> data = DefaultValueDict( + >>> "name", + >>> {"folder": {"name": "FolderName"}} + >>> ) + >>> print("{folder[name]}".format_map(data)) + FolderName + >>> print("{folder}".format_map(data)) + FolderName + + Args: + default_key (Union[str, Iterable[str]]): Default key to use for str + conversion. Can also expect multiple keys for more nested + dictionary. + + """ + def __init__( + self, default_keys: Union[str, Iterable[str]], *args, **kwargs + ) -> None: + if isinstance(default_keys, str): + default_keys = [default_keys] + else: + default_keys = list(default_keys) + if not default_keys: + raise ValueError( + "Default key must be set. Got empty default keys." + ) + + self._default_keys = default_keys + super().__init__(*args, **kwargs) + + def __str__(self) -> str: + return str(self.get_default_value()) + + def __copy__(self) -> "DefaultValueDict": + return DefaultValueDict( + self.get_default_keys(), dict(self.items()) + ) + + def __deepcopy__(self) -> "DefaultValueDict": + data_copy = { + key: copy.deepcopy(value) + for key, value in self.items() + } + return DefaultValueDict(self.get_default_keys(), data_copy) + + def get_default_keys(self) -> list[str]: + return list(self._default_keys) + + def get_default_value(self) -> Any: + value = self + for key in self._default_keys: + value = value[key] + return value + + class StringTemplate: """String that can be formatted.""" def __init__(self, template: str): @@ -636,6 +696,12 @@ class FormattingPart: result.add_output(self.template) return result + if isinstance(value, DefaultValueDict): + try: + value = value.get_default_value() + except KeyError: + pass + if not self.validate_value_type(value): result.add_invalid_type(key, value) result.add_output(self.template) From f35521a943b14ae0d38e2b2bb005b7ace2fc25cd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:00:00 +0200 Subject: [PATCH 115/279] rename 'DefaultValueDict' to 'DefaultKeysDict' --- client/ayon_core/lib/__init__.py | 2 ++ client/ayon_core/lib/path_templates.py | 14 +++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 5ccc8d03e5..2a25e949a5 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -73,6 +73,7 @@ from .log import ( ) from .path_templates import ( + DefaultKeysDict, TemplateUnsolved, StringTemplate, FormatObject, @@ -228,6 +229,7 @@ __all__ = [ "get_version_from_path", "get_last_version_from_path", + "DefaultKeysDict", "TemplateUnsolved", "StringTemplate", "FormatObject", diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 131a2efaa4..c01de6f1a6 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -42,12 +42,12 @@ class TemplateUnsolved(Exception): ) -class DefaultValueDict(dict): +class DefaultKeysDict(dict): """Dictionary that supports the default key to use for str conversion. Is helpful for changes of a key in a template from string to dictionary for example '{folder}' -> '{folder[name]}'. - >>> data = DefaultValueDict( + >>> data = DefaultKeysDict( >>> "name", >>> {"folder": {"name": "FolderName"}} >>> ) @@ -80,17 +80,17 @@ class DefaultValueDict(dict): def __str__(self) -> str: return str(self.get_default_value()) - def __copy__(self) -> "DefaultValueDict": - return DefaultValueDict( + def __copy__(self) -> "DefaultKeysDict": + return DefaultKeysDict( self.get_default_keys(), dict(self.items()) ) - def __deepcopy__(self) -> "DefaultValueDict": + def __deepcopy__(self) -> "DefaultKeysDict": data_copy = { key: copy.deepcopy(value) for key, value in self.items() } - return DefaultValueDict(self.get_default_keys(), data_copy) + return DefaultKeysDict(self.get_default_keys(), data_copy) def get_default_keys(self) -> list[str]: return list(self._default_keys) @@ -696,7 +696,7 @@ class FormattingPart: result.add_output(self.template) return result - if isinstance(value, DefaultValueDict): + if isinstance(value, DefaultKeysDict): try: value = value.get_default_value() except KeyError: From a798d9b92ba957959b483c3ed28c116e9d2df22c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:01:40 +0200 Subject: [PATCH 116/279] added helper function to get ayon user data --- client/ayon_core/lib/__init__.py | 2 + client/ayon_core/lib/local_settings.py | 73 ++++++++++++++++++++------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 2a25e949a5..d5629cbf3d 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -11,6 +11,7 @@ from .local_settings import ( get_launcher_storage_dir, get_addons_resources_dir, get_local_site_id, + get_ayon_user_entity, get_ayon_username, ) from .ayon_connection import initialize_ayon_connection @@ -149,6 +150,7 @@ __all__ = [ "get_launcher_storage_dir", "get_addons_resources_dir", "get_local_site_id", + "get_ayon_user_entity", "get_ayon_username", "initialize_ayon_connection", diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 1edfc3c1b6..4402e3c8a1 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -5,6 +5,7 @@ import json import platform import configparser import warnings +import copy from datetime import datetime from abc import ABC, abstractmethod from functools import lru_cache @@ -13,6 +14,8 @@ from typing import Optional, Any import platformdirs import ayon_api +from .cache import NestedCacheItem, CacheItem + _PLACEHOLDER = object() @@ -23,6 +26,7 @@ class RegistryItemNotFound(ValueError): class _Cache: username = None + user_entities_by_name = NestedCacheItem() def _get_ayon_appdirs(*args: str) -> str: @@ -569,6 +573,56 @@ def get_local_site_id(): return site_id +def _get_ayon_service_username() -> Optional[str]: + # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather + # use public method to get username from connection stack. + con = ayon_api.get_server_api_connection() + user_stack = getattr(con, "_as_user_stack", None) + if user_stack is None: + return None + return user_stack.username + + +def get_ayon_user_entity(username: Optional[str] = None) -> dict[str, Any]: + """AYON user entity used for templates and publishing.""" + service_username = _get_ayon_service_username() + # Handle service user handling first + if service_username: + if username is None: + username = service_username + cache: CacheItem = _Cache.user_entities_by_name[username] + if not cache.is_valid: + if username == service_username: + user = ayon_api.get_user() + else: + user = ayon_api.get_user(username) + cache.update_data(user) + return copy.deepcopy(cache.get_data()) + + # Cache current user + current_user = None + if _Cache.username is None: + current_user = ayon_api.get_user() + _Cache.username = current_user["name"] + + if username is None: + username = _Cache.username + + cache: CacheItem = _Cache.user_entities_by_name[username] + if not cache.is_valid: + user = None + if username == _Cache.username: + if current_user is None: + current_user = ayon_api.get_user() + user = current_user + + if user is None: + user = ayon_api.get_user(username) + cache.update_data(user) + + return copy.deepcopy(cache.get_data()) + + def get_ayon_username(): """AYON username used for templates and publishing. @@ -578,20 +632,5 @@ def get_ayon_username(): str: Username. """ - # Look for username in the connection stack - # - this is used when service is working as other user - # (e.g. in background sync) - # TODO @iLLiCiTiT - do not use private attribute of 'ServerAPI', rather - # use public method to get username from connection stack. - con = ayon_api.get_server_api_connection() - user_stack = getattr(con, "_as_user_stack", None) - if user_stack is not None: - username = user_stack.username - if username is not None: - return username - - # Cache the username to avoid multiple API calls - # - it is not expected that user would change - if _Cache.username is None: - _Cache.username = ayon_api.get_user()["name"] - return _Cache.username + user = get_ayon_user_entity() + return user["name"] From d7f913d00478bc5b29f0e54c32ac0a548e9aa870 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:03:46 +0200 Subject: [PATCH 117/279] fill user data as dictionary --- client/ayon_core/pipeline/template_data.py | 41 +++++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py index 0a95a98be8..2f9a7e3421 100644 --- a/client/ayon_core/pipeline/template_data.py +++ b/client/ayon_core/pipeline/template_data.py @@ -1,27 +1,50 @@ +from __future__ import annotations + +from typing import Optional, Any + import ayon_api from ayon_core.settings import get_studio_settings -from ayon_core.lib.local_settings import get_ayon_username +from ayon_core.lib import DefaultKeysDict +from ayon_core.lib.local_settings import get_ayon_user_entity -def get_general_template_data(settings=None, username=None): +def get_general_template_data( + settings: Optional[dict[str, Any]] = None, + username: Optional[str] = None, + user_entity: Optional[dict[str, Any]] = None, +): """General template data based on system settings or machine. Output contains formatting keys: - - 'studio[name]' - Studio name filled from system settings - - 'studio[code]' - Studio code filled from system settings - - 'user' - User's name using 'get_ayon_username' + - 'studio[name]' - Studio name filled from system settings + - 'studio[code]' - Studio code filled from system settings + - 'user[name]' - User's name + - 'user[attrib][...]' - User's attributes + - 'user[data][...]' - User's data Args: settings (Dict[str, Any]): Studio or project settings. username (Optional[str]): AYON Username. - """ + user_entity (Optional[dict[str, Any]]): User entity. + """ if not settings: settings = get_studio_settings() - if username is None: - username = get_ayon_username() + if user_entity is None: + user_entity = get_ayon_user_entity(username) + + # Use dictionary with default value for backwards compatibility + # - we did support '{user}' now it should be '{user[name]}' + user_data = DefaultKeysDict( + "name", + { + "name": user_entity["name"], + "attrib": user_entity["attrib"], + "data": user_entity["data"], + } + ) core_settings = settings["core"] return { @@ -29,7 +52,7 @@ def get_general_template_data(settings=None, username=None): "name": core_settings["studio_name"], "code": core_settings["studio_code"] }, - "user": username + "user": user_data, } From 0d49f5a8dfda7b2b3b5189befbdf4ac34388c5e4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 11:14:10 +0200 Subject: [PATCH 118/279] Fixes: Corrects file sequence frame offset Corrects the calculation of the frame offset for file sequences in editorial workflows. - Ensures accurate frame mapping. - Resolves issues with incorrect frame ranges. --- client/ayon_core/pipeline/editorial.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index b553fae3fb..716035aa1c 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -202,7 +202,8 @@ def is_clip_from_media_sequence(otio_clip): def remap_range_on_file_sequence(otio_clip, otio_range): - """ + """ Remap the provided range on a file sequence clip. + Args: otio_clip (otio.schema.Clip): The OTIO clip to check. otio_range (otio.schema.TimeRange): The trim range to apply. @@ -256,10 +257,14 @@ def remap_range_on_file_sequence(otio_clip, otio_range): ) src_offset_in = otio_range.start_time - media_in - frame_in = otio.opentime.RationalTime.from_frames( - media_ref.start_frame + src_offset_in.to_frames(), + # make sure that only if any offset is present + if media_ref.start_frame == src_offset_in.to_frames(): + frame_in = src_offset_in.to_frames() + else: + frame_in = otio.opentime.RationalTime.from_frames( + media_ref.start_frame + src_offset_in.to_frames(), rate=available_range_rate, - ).to_frames() + ).to_frames() # e.g.: # duration = 10 frames at 24fps From 9494472a7dbc1726b3fbe1face11feff2c8b4c9e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 14:18:31 +0200 Subject: [PATCH 119/279] Fixes: Corrects reviewable output resolution. Updates the expected resolution in the ffmpeg commands used in reviewable extraction tests to match the intended output. This resolves a squashed reviewables issue where the output resolution was incorrect. --- .../editorial/test_extract_otio_review.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 6a74df7f43..ed441edc63 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -246,75 +246,75 @@ def test_multiple_review_clips_no_gap(): expected = [ # 10 head black frames generated from gap (991-1000) '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi' - ' -i color=c=black:s=1280x720 -tune ' + ' -i color=c=black:s=1920x1080 -tune ' 'stillimage -start_number 991 -pix_fmt rgba C:/result/output.%04d.png', # Alternance 25fps tiff sequence and 24fps exr sequence # for 100 frames each '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1198 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1299 -pix_fmt rgba C:/result/output.%04d.png', # Repeated 25fps tiff sequence multiple times till the end '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1395 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1496 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1597 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1698 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1799 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1900 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2001 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2102 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 2203 -pix_fmt rgba C:/result/output.%04d.png' ] @@ -348,12 +348,12 @@ def test_multiple_review_clips_with_gap(): '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1003 -pix_fmt rgba C:/result/output.%04d.png', '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' f'C:\\with_tc{os.sep}output.%04d.exr ' - '-vf scale=1280:720:flags=lanczos -compression_level 5 ' + '-vf scale=1920:1080:flags=lanczos -compression_level 5 ' '-start_number 1091 -pix_fmt rgba C:/result/output.%04d.png' ] From 34b292b06a2a5d0f999ed0093252b10081c9e186 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 15:33:55 +0200 Subject: [PATCH 120/279] revert audio collector changes --- .../plugins/publish/collect_audio.py | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index 901c589ddc..2949ff1196 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -46,19 +46,6 @@ class CollectAudio(pyblish.api.ContextPlugin): audio_product_name = "audioMain" def process(self, context): - # Make sure Editorial related products are excluded - # since those are maintained by ExtractOtioAudioTracks - audio_instances = self.get_audio_instances(context) - self.log.debug("Audio instances: {}".format(len(audio_instances))) - - # QUESTION: perhaps there is a better way to do this? - # This is having limitation for cases where no audio instance - # is found but still in editorial context. We should perhaps rather - # check if the instance is in particular editorial context. - if len(audio_instances) >= 1: - self.log.info("Audio provided from related instances") - return - # Fake filtering by family inside context plugin filtered_instances = [] for instance in pyblish.api.instances_by_plugin( @@ -115,24 +102,6 @@ class CollectAudio(pyblish.api.ContextPlugin): }] self.log.debug("Audio Data added to instance ...") - 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") - ] - def query_representations(self, project_name, folder_paths): """Query representations related to audio products for passed folders. From 182e457505a15d8872a311b56147dbd600a67dac Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 15:35:12 +0200 Subject: [PATCH 121/279] Improve logic for checking already existing audio key --- client/ayon_core/plugins/publish/collect_audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_audio.py b/client/ayon_core/plugins/publish/collect_audio.py index 2949ff1196..273e966cfd 100644 --- a/client/ayon_core/plugins/publish/collect_audio.py +++ b/client/ayon_core/plugins/publish/collect_audio.py @@ -52,7 +52,7 @@ class CollectAudio(pyblish.api.ContextPlugin): context, self.__class__ ): # Skip instances that already have audio filled - if instance.data.get("audio"): + if "audio" in instance.data: self.log.debug( "Skipping Audio collection. It is already collected" ) From d2fdae67e75e5a5b99c74793ba0b0210518b785e Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 15:47:11 +0200 Subject: [PATCH 122/279] Adds audio instance attribute collection Adds a collector to identify audio instances and link them to sibling instances. This ensures that sibling instances, requiring audio for reviewable media, inherit audio attributes. The collector checks and links audio if: - The sibling instance shares the same parent ID. - The instance is not the audio instance itself. --- .../publish/extract_otio_audio_tracks.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 925ea03964..e0bea02082 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -8,6 +8,56 @@ from ayon_core.lib import ( run_subprocess ) +# pridat collector + +class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): + """Collect audio instance attribute""" + + order = pyblish.api.CollectorOrder + label = "Collect Audio Instance Attribute" + hosts = ["hiero", "resolve", "flame"] + + def process(self, context): + + audio_instances = self.get_audio_instances(context) + + for inst in audio_instances: + # 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 = inst.data["parent_instance_id"] + for sibl_instance in inst.context: + sibl_parent_instance_id = sibl_instance.data.get( + "parent_instance_id") + # make sure the instance is not the same instance + if sibl_instance.id == inst.id: + continue + # and the parent instance id is the same + if sibl_parent_instance_id == parent_instance_id: + self.log.info( + "Adding audio to Sibling instance: " + f"{sibl_instance.data['label']}" + ) + sibl_instance.data["audio"] = None + + 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") + ] class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): """Extract Audio tracks from OTIO timeline. From 90852663d1ee89fb6734cd1793090c29eb5ee3d2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 15:49:15 +0200 Subject: [PATCH 123/279] ruff improvements --- client/ayon_core/plugins/publish/extract_otio_audio_tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index e0bea02082..08e786f067 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -8,7 +8,6 @@ from ayon_core.lib import ( run_subprocess ) -# pridat collector class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): """Collect audio instance attribute""" @@ -59,6 +58,7 @@ class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): ) or _i.data.get("reviewAudio") ] + class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): """Extract Audio tracks from OTIO timeline. From 54aedc84263fb2adc838cbaf40e59bce6aa84a24 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:31:34 +0200 Subject: [PATCH 124/279] use user entity when getting template data during publishing --- .../plugins/publish/collect_anatomy_context_data.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_anatomy_context_data.py b/client/ayon_core/plugins/publish/collect_anatomy_context_data.py index cccf392e40..5d2ecec433 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_context_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_context_data.py @@ -16,6 +16,7 @@ Provides: import json import pyblish.api +from ayon_core.lib import get_ayon_user_entity from ayon_core.pipeline.template_data import get_template_data @@ -55,17 +56,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): if folder_entity: task_entity = context.data["taskEntity"] + username = context.data["user"] + user_entity = get_ayon_user_entity(username) anatomy_data = get_template_data( project_entity, folder_entity, task_entity, - host_name, - project_settings + host_name=host_name, + settings=project_settings, + user_entity=user_entity, ) anatomy_data.update(context.data.get("datetimeData") or {}) - username = context.data["user"] - anatomy_data["user"] = username # Backwards compatibility for 'username' key anatomy_data["username"] = username From 2e2d67c2438b53f12354f32b46c2cfb57ecf5ab8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:31:57 +0200 Subject: [PATCH 125/279] allow to pass user entity to get_template_data --- client/ayon_core/pipeline/template_data.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py index 2f9a7e3421..dc7e95c788 100644 --- a/client/ayon_core/pipeline/template_data.py +++ b/client/ayon_core/pipeline/template_data.py @@ -173,7 +173,8 @@ def get_template_data( task_entity=None, host_name=None, settings=None, - username=None + username=None, + user_entity=None, ): """Prepare data for templates filling from entered documents and info. @@ -196,13 +197,18 @@ def get_template_data( host_name (Optional[str]): Used to fill '{app}' key. settings (Union[Dict, None]): Prepared studio or project settings. They're queried if not passed (may be slower). - username (Optional[str]): AYON Username. + username (Optional[str]): DEPRECATED AYON Username. + user_entity (Optional[dict[str, Any]): AYON user entity. Returns: Dict[str, Any]: Data prepared for filling workdir template. """ - template_data = get_general_template_data(settings, username=username) + template_data = get_general_template_data( + settings, + username=username, + user_entity=user_entity, + ) template_data.update(get_project_template_data(project_entity)) if folder_entity: template_data.update(get_folder_template_data( From d700f9f09b307243ef7c30ad5096e3beb8b97d9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:32:12 +0200 Subject: [PATCH 126/279] custom handling of 'user' data used for template --- client/ayon_core/plugins/publish/integrate.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index f1e066018c..d18e546392 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -121,7 +121,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "version", "representation", "username", - "user", "output", # OpenPype keys - should be removed "asset", # folder[name] @@ -796,6 +795,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if value is not None: repre_context[key] = value + # Keep only username + # NOTE This is to avoid storing all user attributes and data + # to representation + if "user" not in repre_context: + repre_context["user"] = { + "name": template_data["user"]["name"] + } + # Use previous representation's id if there is a name match existing = existing_repres_by_name.get(repre["name"].lower()) repre_id = None From b094cbd0cb17ba0216867503c9d87eef81e112f3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:33:48 +0200 Subject: [PATCH 127/279] use same logic in integrate hero as in integrate --- .../ayon_core/plugins/publish/integrate_hero_version.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 90e6f15568..a591cfe880 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -89,7 +89,6 @@ class IntegrateHeroVersion( "family", "representation", "username", - "user", "output" ] # QUESTION/TODO this process should happen on server if crashed due to @@ -364,6 +363,14 @@ class IntegrateHeroVersion( if value is not None: repre_context[key] = value + # Keep only username + # NOTE This is to avoid storing all user attributes and data + # to representation + if "user" not in repre_context: + repre_context["user"] = { + "name": anatomy_data["user"]["name"] + } + # Prepare new repre repre_entity = copy.deepcopy(repre_info["representation"]) repre_entity.pop("id", None) From db11ba743708d9550357f3129b7be9356c09b4c2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:59:30 +0200 Subject: [PATCH 128/279] add docstring --- client/ayon_core/lib/local_settings.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 4402e3c8a1..8a17b7af38 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -584,7 +584,19 @@ def _get_ayon_service_username() -> Optional[str]: def get_ayon_user_entity(username: Optional[str] = None) -> dict[str, Any]: - """AYON user entity used for templates and publishing.""" + """AYON user entity used for templates and publishing. + + Note: + Usually only service and admin users can receive the full user entity. + + Args: + username (Optional[str]): Username of the user. If not passed, then + the current user in 'ayon_api' is used. + + Returns: + dict[str, Any]: User entity. + + """ service_username = _get_ayon_service_username() # Handle service user handling first if service_username: From 062f756413ec17332005d59ce7039cedf76cff21 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 22 Oct 2025 16:07:26 +0200 Subject: [PATCH 129/279] Typing At least some, dont know how to import NewFolderDict --- .../tools/push_to_project/models/integrate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ef49838152..472500a55d 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,7 +5,7 @@ import itertools import sys import traceback import uuid -from typing import Optional, Dict +from typing import Optional, Dict, Any import ayon_api from ayon_api.utils import create_entity_id @@ -650,10 +650,10 @@ class ProjectPushItemProcess: def _create_folder( self, - src_folder_entity, - project_entity, - parent_folder_entity, - folder_name + src_folder_entity: Dict[str, Any], + project_entity: Dict[str, Any], + parent_folder_entity: Dict[str, Any], + folder_name: str ): parent_id = None if parent_folder_entity: From 475d4800a2e86f83ed508e0b33c425a7d6ab9eb7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 22 Oct 2025 16:13:44 +0200 Subject: [PATCH 130/279] Check that source folder type could be pushed to destination --- .../tools/push_to_project/models/integrate.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 472500a55d..bd309d935f 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -702,7 +702,11 @@ class ProjectPushItemProcess: if new_folder_name != folder_name: folder_label = folder_name - # TODO find out how to define folder type + src_folder_type = src_folder_entity["folderType"] + self._check_src_folder_type( + project_entity, + src_folder_type + ) folder_entity = new_folder_entity( folder_name, "Folder", @@ -727,6 +731,24 @@ class ProjectPushItemProcess: folder_entity["path"] = "/".join([parent_path, folder_name]) return folder_entity + def _check_src_folder_type( + self, + project_entity: Dict[str, Any], + src_folder_type: str + ): + """Confirm that folder type exists in destination project""" + folder_types = [ + folder_type["name"].lower() + for folder_type in project_entity["folderTypes"] + ] + + if src_folder_type.lower() not in folder_types: + self._status.set_failed( + f"'{src_folder_type}' folder type is not configured in " + f"project Anatomy." + ) + raise PushToProjectError(self._status.fail_reason) + def _fill_or_create_destination_folder(self): dst_project_name = self._item.dst_project_name dst_folder_id = self._item.dst_folder_id @@ -1205,7 +1227,7 @@ class ProjectPushItemProcess: value_to_update = formatting_data.get(context_key) if value_to_update: repre_context[context_key] = value_to_update - if "task" not in formatting_data: + if "task" not in formatting_data and "task" in repre_context: repre_context.pop("task") return repre_context From 9340df7a250543658d673d95716047931ea981fa Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 22 Oct 2025 16:14:01 +0200 Subject: [PATCH 131/279] Copy source folder type to destination --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index bd309d935f..22fcb5cf9f 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -709,7 +709,7 @@ class ProjectPushItemProcess: ) folder_entity = new_folder_entity( folder_name, - "Folder", + src_folder_type, parent_id=parent_id, attribs=new_folder_attrib ) From a077c57eee066f2c38082ea3d023451bde66a487 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 22 Oct 2025 14:50:25 +0000 Subject: [PATCH 132/279] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index d3b3454fd1..4aeeb94ea8 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.5+dev" +__version__ = "1.6.6" diff --git a/package.py b/package.py index 2889039502..ced8763100 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.5+dev" +version = "1.6.6" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f43846ec2b..7460ddc831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.5+dev" +version = "1.6.6" description = "" authors = ["Ynput Team "] readme = "README.md" From 45ddec53d30a50d169c33cea530fde83cd5a7821 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 22 Oct 2025 14:51:09 +0000 Subject: [PATCH 133/279] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 4aeeb94ea8..8e0834b8da 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.6" +__version__ = "1.6.6+dev" diff --git a/package.py b/package.py index ced8763100..5fa4d165d2 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.6" +version = "1.6.6+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 7460ddc831..73b9a4a916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.6" +version = "1.6.6+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 110018487f15f9a49156d1947592bf8541c12bc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Oct 2025 14:52:19 +0000 Subject: [PATCH 134/279] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 646a2dd1ee..60693f088d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.6 - 1.6.5 - 1.6.4 - 1.6.3 From 0d235ed8cacffb9196a54e1b3cf40fbcc36fd57e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 22 Oct 2025 18:58:20 +0200 Subject: [PATCH 135/279] Create destination task if no task selected --- .../tools/push_to_project/models/integrate.py | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index ef49838152..a7cb1de95a 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -762,8 +762,11 @@ class ProjectPushItemProcess: ) self._folder_entity = folder_entity if not dst_task_name: - self._task_info = {} - return + dst_task_name = self._make_sure_task_exists(folder_entity) + + if not dst_task_name: # really no task selected nor on source + self._task_info = {} + return folder_path = folder_entity["path"] folder_tasks = { @@ -962,6 +965,28 @@ class ProjectPushItemProcess: ) self._version_entity = version_entity + def _make_sure_task_exists(self, folder_entity: Dict[str, Any]) -> str: + """Creates destination task from source task information""" + project_name = self._item.dst_project_name + src_version_entity = self._src_version_entity + src_task = ayon_api.get_task_by_id( + self._item.src_project_name, src_version_entity["taskId"] + ) + if not src_task: + self._status.set_failed( + f"No task selected and couldn't find source task" + ) + raise PushToProjectError(self._status.fail_reason) + _task_id = ayon_api.create_task( + project_name, + src_task["name"], + folder_id=folder_entity["id"], + task_type=src_task["taskType"], + attrib=src_task["attrib"], + ) + + return src_task["name"] + def _integrate_representations(self): try: self._real_integrate_representations() From 0ebbd0a23224e4fd539f8b1a479892de38af96dc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 10:55:37 +0200 Subject: [PATCH 136/279] Extracted logic to methods --- .../tools/push_to_project/models/integrate.py | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index a7cb1de95a..9365379148 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -482,6 +482,8 @@ class ProjectPushItemProcess: self._log_info("Destination project was found") self._fill_or_create_destination_folder() self._log_info("Destination folder was determined") + self._fill_or_create_destination_task() + self._log_info("Destination task was determined") self._determine_product_type() self._determine_publish_template_name() self._determine_product_name() @@ -730,7 +732,6 @@ class ProjectPushItemProcess: def _fill_or_create_destination_folder(self): dst_project_name = self._item.dst_project_name dst_folder_id = self._item.dst_folder_id - dst_task_name = self._item.dst_task_name new_folder_name = self._item.new_folder_name if not dst_folder_id and not new_folder_name: self._status.set_failed( @@ -761,12 +762,11 @@ class ProjectPushItemProcess: new_folder_name ) self._folder_entity = folder_entity - if not dst_task_name: - dst_task_name = self._make_sure_task_exists(folder_entity) - if not dst_task_name: # really no task selected nor on source - self._task_info = {} - return + def _fill_or_create_destination_task(self): + folder_entity = self._folder_entity + dst_task_name = self._item.dst_task_name + dst_project_name = self._item.dst_project_name folder_path = folder_entity["path"] folder_tasks = { @@ -775,6 +775,21 @@ class ProjectPushItemProcess: dst_project_name, folder_ids=[folder_entity["id"]] ) } + + if not dst_task_name: + src_task_info = self._get_src_task_info() + if not src_task_info: # really no task selected nor on source + self._task_info = {} + return + + dst_task_name = src_task_info["name"].lower() + if dst_task_name not in folder_tasks: + self._make_sure_task_exists( + folder_entity, src_task_info + ) + task_info = copy.deepcopy(src_task_info) + folder_tasks[dst_task_name] = task_info + task_info = folder_tasks.get(dst_task_name.lower()) if not task_info: self._status.set_failed( @@ -965,9 +980,22 @@ class ProjectPushItemProcess: ) self._version_entity = version_entity - def _make_sure_task_exists(self, folder_entity: Dict[str, Any]) -> str: + def _make_sure_task_exists( + self, + folder_entity: Dict[str, Any], + task_info: Dict[str, Any], + ): """Creates destination task from source task information""" project_name = self._item.dst_project_name + _task_id = ayon_api.create_task( + project_name, + task_info["name"], + folder_id=folder_entity["id"], + task_type=task_info["taskType"], + attrib=task_info["attrib"], + ) + + def _get_src_task_info(self): src_version_entity = self._src_version_entity src_task = ayon_api.get_task_by_id( self._item.src_project_name, src_version_entity["taskId"] @@ -977,15 +1005,7 @@ class ProjectPushItemProcess: f"No task selected and couldn't find source task" ) raise PushToProjectError(self._status.fail_reason) - _task_id = ayon_api.create_task( - project_name, - src_task["name"], - folder_id=folder_entity["id"], - task_type=src_task["taskType"], - attrib=src_task["attrib"], - ) - - return src_task["name"] + return src_task def _integrate_representations(self): try: From d8dd2a23a895b06a45ec27a1c24842729aaa7189 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 10:55:48 +0200 Subject: [PATCH 137/279] Typing --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 9365379148..b2475ac7d1 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,7 +5,7 @@ import itertools import sys import traceback import uuid -from typing import Optional, Dict +from typing import Optional, Dict, Any import ayon_api from ayon_api.utils import create_entity_id From ef0f5ac023ebf463567d52074ba10f33caa5936c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:06:35 +0200 Subject: [PATCH 138/279] remove custom copy and deepcopy implementation --- client/ayon_core/lib/path_templates.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index c01de6f1a6..aba2f296e3 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -80,18 +80,6 @@ class DefaultKeysDict(dict): def __str__(self) -> str: return str(self.get_default_value()) - def __copy__(self) -> "DefaultKeysDict": - return DefaultKeysDict( - self.get_default_keys(), dict(self.items()) - ) - - def __deepcopy__(self) -> "DefaultKeysDict": - data_copy = { - key: copy.deepcopy(value) - for key, value in self.items() - } - return DefaultKeysDict(self.get_default_keys(), data_copy) - def get_default_keys(self) -> list[str]: return list(self._default_keys) From f0230e24a7bc3a2e321caeb14bf7e33acd523eab Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 11:20:03 +0200 Subject: [PATCH 139/279] Fix use operations instead of ayon_api Must be in same session as create folder if 'Create New Folder' --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index b2475ac7d1..027416922b 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -987,7 +987,7 @@ class ProjectPushItemProcess: ): """Creates destination task from source task information""" project_name = self._item.dst_project_name - _task_id = ayon_api.create_task( + _task_id = self._operations.create_task( project_name, task_info["name"], folder_id=folder_entity["id"], From f13a40aa73b740e4cc715cd54acadf564fdb750a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 11:24:41 +0200 Subject: [PATCH 140/279] Fix typing Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 22fcb5cf9f..6871936e2c 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -650,9 +650,9 @@ class ProjectPushItemProcess: def _create_folder( self, - src_folder_entity: Dict[str, Any], - project_entity: Dict[str, Any], - parent_folder_entity: Dict[str, Any], + src_folder_entity: dict[str, Any], + project_entity: dict[str, Any], + parent_folder_entity: dict[str, Any], folder_name: str ): parent_id = None From 87f1d458b8c6cab2195583e0e3beeef8b80e6db1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 11:25:44 +0200 Subject: [PATCH 141/279] Change return of _check_src_folder_type Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../tools/push_to_project/models/integrate.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 6871936e2c..bf14034673 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -731,23 +731,21 @@ class ProjectPushItemProcess: folder_entity["path"] = "/".join([parent_path, folder_name]) return folder_entity - def _check_src_folder_type( + def _get_dst_folder_type( self, - project_entity: Dict[str, Any], + project_entity: dict[str, Any], src_folder_type: str - ): - """Confirm that folder type exists in destination project""" - folder_types = [ - folder_type["name"].lower() - for folder_type in project_entity["folderTypes"] - ] + ) -> str: + """Get new folder type.""" + for folder_type in project_entity["folderTypes"]: + if folder_type["name"].lower() == src_folder_type.lower(): + return folder_type["name"] - if src_folder_type.lower() not in folder_types: - self._status.set_failed( - f"'{src_folder_type}' folder type is not configured in " - f"project Anatomy." - ) - raise PushToProjectError(self._status.fail_reason) + self._status.set_failed( + f"'{src_folder_type}' folder type is not configured in " + f"project Anatomy." + ) + raise PushToProjectError(self._status.fail_reason) def _fill_or_create_destination_folder(self): dst_project_name = self._item.dst_project_name From c50406a279b61651ef6f099862dfe6874d7dcb10 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 11:26:09 +0200 Subject: [PATCH 142/279] Simplify pop Co-authored-by: Roy Nieterau --- client/ayon_core/tools/push_to_project/models/integrate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index bf14034673..48e5763345 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1225,8 +1225,8 @@ class ProjectPushItemProcess: value_to_update = formatting_data.get(context_key) if value_to_update: repre_context[context_key] = value_to_update - if "task" not in formatting_data and "task" in repre_context: - repre_context.pop("task") + if "task" not in formatting_data: + repre_context.pop("task", None) return repre_context From 0bade2d940ced81d27d70240b153a813abf8a6c8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 11:28:33 +0200 Subject: [PATCH 143/279] Update usage of renamed _get_dst_folder_type --- client/ayon_core/tools/push_to_project/models/integrate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 48e5763345..68a0e2affb 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -703,13 +703,13 @@ class ProjectPushItemProcess: folder_label = folder_name src_folder_type = src_folder_entity["folderType"] - self._check_src_folder_type( + dst_folder_type = self._get_dst_folder_type( project_entity, src_folder_type ) folder_entity = new_folder_entity( folder_name, - src_folder_type, + dst_folder_type, parent_id=parent_id, attribs=new_folder_attrib ) From 1ee701b52fdaafcabe9ce6a726f4d74e9a8a9da5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 11:32:30 +0200 Subject: [PATCH 144/279] Fix dict typing --- .../tools/push_to_project/models/integrate.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 027416922b..1ecf8a8a59 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -5,7 +5,7 @@ import itertools import sys import traceback import uuid -from typing import Optional, Dict, Any +from typing import Optional, Any import ayon_api from ayon_api.utils import create_entity_id @@ -225,8 +225,8 @@ class ProjectPushRepreItem: but filenames are not template based. Args: - repre_entity (Dict[str, Ant]): Representation entity. - roots (Dict[str, str]): Project roots (based on project anatomy). + repre_entity (dict[str, Ant]): Representation entity. + roots (dict[str, str]): Project roots (based on project anatomy). """ def __init__(self, repre_entity, roots): @@ -982,8 +982,8 @@ class ProjectPushItemProcess: def _make_sure_task_exists( self, - folder_entity: Dict[str, Any], - task_info: Dict[str, Any], + folder_entity: dict[str, Any], + task_info: dict[str, Any], ): """Creates destination task from source task information""" project_name = self._item.dst_project_name @@ -1326,6 +1326,6 @@ class IntegrateModel: return item.integrate() - def get_items(self) -> Dict[str, ProjectPushItemProcess]: + def get_items(self) -> dict[str, ProjectPushItemProcess]: """Returns dict of all ProjectPushItemProcess items """ return self._process_items From 7b5ca16993bbc844d14ca27bc042e23ee58ce9bf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 17:03:50 +0200 Subject: [PATCH 145/279] Use lower only for comparison Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 1ecf8a8a59..1cd9e2deaf 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -782,8 +782,8 @@ class ProjectPushItemProcess: self._task_info = {} return - dst_task_name = src_task_info["name"].lower() - if dst_task_name not in folder_tasks: + dst_task_name = src_task_info["name"] + if dst_task_name.lower() not in folder_tasks: self._make_sure_task_exists( folder_entity, src_task_info ) From 67994bb5a3bc8dc527a4fd09f6110049c551238e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 17:04:07 +0200 Subject: [PATCH 146/279] Remove unnecessary variable Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 1cd9e2deaf..2030027bb0 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -987,7 +987,7 @@ class ProjectPushItemProcess: ): """Creates destination task from source task information""" project_name = self._item.dst_project_name - _task_id = self._operations.create_task( + self._operations.create_task( project_name, task_info["name"], folder_id=folder_entity["id"], From cea56fbe5322075dbdb0826e78d0b2a27069b5ac Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 17:04:32 +0200 Subject: [PATCH 147/279] Formatting change Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 2030027bb0..7e92d82f41 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1002,7 +1002,7 @@ class ProjectPushItemProcess: ) if not src_task: self._status.set_failed( - f"No task selected and couldn't find source task" + "No task selected and couldn't find source task" ) raise PushToProjectError(self._status.fail_reason) return src_task From 04322ef94d673cd3b4356afb6897ec7d30d8d8bb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 17:08:35 +0200 Subject: [PATCH 148/279] Removed hard fail, unnecessary --- client/ayon_core/tools/push_to_project/models/integrate.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 5127afd0ee..164b73e0ef 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1020,11 +1020,7 @@ class ProjectPushItemProcess: src_task = ayon_api.get_task_by_id( self._item.src_project_name, src_version_entity["taskId"] ) - if not src_task: - self._status.set_failed( - f"No task selected and couldn't find source task" - ) - raise PushToProjectError(self._status.fail_reason) + return src_task def _integrate_representations(self): From 42722c08960e528a9e9cfa735aedfd7023533bde Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 23 Oct 2025 17:20:16 +0200 Subject: [PATCH 149/279] Added validation that task type is in destination project --- .../tools/push_to_project/models/integrate.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 164b73e0ef..e80a525204 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1007,11 +1007,26 @@ class ProjectPushItemProcess: ): """Creates destination task from source task information""" project_name = self._item.dst_project_name + found_task_type = False + src_task_type = task_info["taskType"] + for task_type in self._project_entity["taskTypes"]: + if task_type["name"].lower() == src_task_type.lower(): + found_task_type = True + break + + if not found_task_type: + self._status.set_failed( + f"'{src_task_type}' task type is not configured in " + "project Anatomy." + ) + + raise PushToProjectError(self._status.fail_reason) + _task_id = self._operations.create_task( project_name, task_info["name"], folder_id=folder_entity["id"], - task_type=task_info["taskType"], + task_type=src_task_type, attrib=task_info["attrib"], ) From 7e3e5855b86a31e1206c199478bddeb9829a6c80 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 11:01:24 +0200 Subject: [PATCH 150/279] Fix use of lower task name --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 5370a35c37..a98c045893 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -808,7 +808,7 @@ class ProjectPushItemProcess: folder_entity, src_task_info ) task_info = copy.deepcopy(src_task_info) - folder_tasks[dst_task_name] = task_info + folder_tasks[dst_task_name.lower()] = task_info task_info = folder_tasks.get(dst_task_name.lower()) if not task_info: From f33b13c19449aed3ab9b1aa9fb45716c6418d52e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 11:22:13 +0200 Subject: [PATCH 151/279] Fix if source version doesn't have task Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index a98c045893..a8cd3be2cc 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1032,6 +1032,8 @@ class ProjectPushItemProcess: def _get_src_task_info(self): src_version_entity = self._src_version_entity + if not src_version_entity["taskId"]: + return None src_task = ayon_api.get_task_by_id( self._item.src_project_name, src_version_entity["taskId"] ) From f6e4d50137f4dbcbc830639bc6e73efc054d27e1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 12:19:31 +0200 Subject: [PATCH 152/279] Fix overwriting real task name with name of task type --- client/ayon_core/tools/push_to_project/models/integrate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index a98c045893..5be7dbe2c1 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -828,7 +828,10 @@ class ProjectPushItemProcess: task_type["name"]: task_type for task_type in self._project_entity["taskTypes"] } - task_type_info = task_types_by_name.get(task_type_name, {}) + task_type_info = copy.deepcopy( + task_types_by_name.get(task_type_name, {}) + ) + task_type_info.pop("name") # do not overwrite real task name task_info.update(task_type_info) self._task_info = task_info From efec97fda3938f883b166fa4de26b127fc488920 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 12:20:14 +0200 Subject: [PATCH 153/279] Return task info from created object --- .../ayon_core/tools/push_to_project/models/integrate.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 5be7dbe2c1..2d02316db0 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -804,10 +804,9 @@ class ProjectPushItemProcess: dst_task_name = src_task_info["name"] if dst_task_name.lower() not in folder_tasks: - self._make_sure_task_exists( + task_info = self._make_sure_task_exists( folder_entity, src_task_info ) - task_info = copy.deepcopy(src_task_info) folder_tasks[dst_task_name.lower()] = task_info task_info = folder_tasks.get(dst_task_name.lower()) @@ -1007,7 +1006,7 @@ class ProjectPushItemProcess: self, folder_entity: dict[str, Any], task_info: dict[str, Any], - ): + ) -> dict[str, Any]: """Creates destination task from source task information""" project_name = self._item.dst_project_name found_task_type = False @@ -1025,13 +1024,15 @@ class ProjectPushItemProcess: raise PushToProjectError(self._status.fail_reason) - self._operations.create_task( + task_info = self._operations.create_task( project_name, task_info["name"], folder_id=folder_entity["id"], task_type=src_task_type, attrib=task_info["attrib"], ) + self._task_info = task_info.data + return self._task_info def _get_src_task_info(self): src_version_entity = self._src_version_entity From 636ef024b786dafe45233522aea2d92ecccd8440 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 12:20:34 +0200 Subject: [PATCH 154/279] Task is optional --- client/ayon_core/tools/push_to_project/models/integrate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 2d02316db0..fc61204bf3 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -965,8 +965,8 @@ class ProjectPushItemProcess: version = get_versioning_start( project_name, self.host_name, - task_name=self._task_info["name"], - task_type=self._task_info["taskType"], + task_name=self._task_info.get("name"), + task_type=self._task_info.get("taskType"), product_type=product_type, product_name=product_entity["name"], ) From 49162f228e9cb3bd8ceee2bad8ad54ca665b4c75 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 12:20:54 +0200 Subject: [PATCH 155/279] Fix pushed products not attaching to version --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index fc61204bf3..45035671b2 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -994,6 +994,7 @@ class ProjectPushItemProcess: version_entity = new_version_entity( version, product_id, + task_id=self._task_info.get("id"), attribs=dst_attrib, thumbnail_id=thumbnail_id, ) From fcc82a8e463388e205aac4460a73fbff16552386 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 14:14:21 +0200 Subject: [PATCH 156/279] Transfer status and tags --- .../tools/push_to_project/models/integrate.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index f8360c520b..8c125dd3dc 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -990,10 +990,15 @@ class ProjectPushItemProcess: existing_version_entity["attrib"].update(dst_attrib) self._version_entity = existing_version_entity return + copied_tags = self._get_transferable_tags(src_version_entity) + copied_status = self._get_transferable_status(src_version_entity) version_entity = new_version_entity( version, product_id, + author=src_version_entity["author"], + status=copied_status, + tags=copied_tags, task_id=self._task_info.get("id"), attribs=dst_attrib, thumbnail_id=thumbnail_id, @@ -1291,6 +1296,30 @@ class ProjectPushItemProcess: repre_context.pop("task", None) return repre_context + def _get_transferable_tags(self, src_version_entity): + """Copy over only tags present in destination project""" + dst_project_tags = [ + tag["name"] for tag in self._project_entity["tags"] + ] + copied_tags = [] + for src_tag in src_version_entity["tags"]: + if src_tag in dst_project_tags: + copied_tags.append(src_tag) + return copied_tags + + def _get_transferable_status(self, src_version_entity): + """Copy over status, first status if not matching found""" + dst_project_statuses = { + status["name"]: status + for status in self._project_entity["statuses"] + } + copied_status = dst_project_statuses.get(src_version_entity["status"]) + if not copied_status: + copied_status = dst_project_statuses[ + dst_project_statuses.keys()[0] + ] + return copied_status["name"] + class IntegrateModel: def __init__(self, controller): From 3104e07c78dd62ff438b122c97dbe63cf1e3b665 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 15:30:52 +0200 Subject: [PATCH 157/279] Fix access to dict keys --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 8c125dd3dc..838bf079ec 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1316,7 +1316,7 @@ class ProjectPushItemProcess: copied_status = dst_project_statuses.get(src_version_entity["status"]) if not copied_status: copied_status = dst_project_statuses[ - dst_project_statuses.keys()[0] + next(iter(dst_project_statuses)) ] return copied_status["name"] From 542acd0896e1ad266f7764811817212dc4ab6d06 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 15:33:15 +0200 Subject: [PATCH 158/279] Fix access to dict keys --- client/ayon_core/tools/push_to_project/models/integrate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 838bf079ec..e23d2a8eb2 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1313,8 +1313,13 @@ class ProjectPushItemProcess: status["name"]: status for status in self._project_entity["statuses"] } - copied_status = dst_project_statuses.get(src_version_entity["status"]) + source_status = src_version_entity["status"] + copied_status = dst_project_statuses.get(source_status) if not copied_status: + self._log_warning( + f"'{source_status}' not found in destination project. " + "Used first configured status from there." + ) copied_status = dst_project_statuses[ next(iter(dst_project_statuses)) ] From fcebdaf13006aeeefd2979ceac24304dee9cd618 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 24 Oct 2025 16:00:42 +0200 Subject: [PATCH 159/279] Do not send dummy status if not found --- .../tools/push_to_project/models/integrate.py | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index e23d2a8eb2..2adc708cf3 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1284,10 +1284,12 @@ class ProjectPushItemProcess: if context_value and isinstance(context_value, dict): for context_sub_key in context_value.keys(): value_to_update = formatting_data.get(context_key, {}).get( - context_sub_key) + context_sub_key + ) if value_to_update: - repre_context[context_key][ - context_sub_key] = value_to_update + repre_context[context_key][context_sub_key] = ( + value_to_update + ) else: value_to_update = formatting_data.get(context_key) if value_to_update: @@ -1313,17 +1315,10 @@ class ProjectPushItemProcess: status["name"]: status for status in self._project_entity["statuses"] } - source_status = src_version_entity["status"] - copied_status = dst_project_statuses.get(source_status) - if not copied_status: - self._log_warning( - f"'{source_status}' not found in destination project. " - "Used first configured status from there." - ) - copied_status = dst_project_statuses[ - next(iter(dst_project_statuses)) - ] - return copied_status["name"] + copied_status = dst_project_statuses.get(src_version_entity["status"]) + if copied_status: + return copied_status["name"] + return None class IntegrateModel: From 3ee7c30cae8b75726875e58970f19fe08af14ee4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 24 Oct 2025 17:35:28 +0200 Subject: [PATCH 160/279] Handles missing media references in OTIO clips Adds a check for missing media references in OTIO clips during publishing. --- .../ayon_core/plugins/publish/collect_otio_frame_ranges.py | 6 ++++++ .../plugins/publish/collect_otio_subset_resources.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index d68970d428..543277f37e 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -71,6 +71,12 @@ class CollectOtioRanges(pyblish.api.InstancePlugin): import opentimelineio as otio otio_clip = instance.data["otioClip"] + if isinstance( + otio_clip.media_reference, + otio.schema.MissingReference + ): + self.log.info("Clip has no media reference") + return # Collect timeline ranges if workfile start frame is available if "workfileFrameStart" in instance.data: diff --git a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py index 275b8a7f55..4d3c1cfb13 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -60,6 +60,13 @@ class CollectOtioSubsetResources( # get basic variables otio_clip = instance.data["otioClip"] + if isinstance( + otio_clip.media_reference, + otio.schema.MissingReference + ): + self.log.info("Clip has no media reference") + return + otio_available_range = otio_clip.available_range() media_fps = otio_available_range.start_time.rate available_duration = otio_available_range.duration.value From 373683890c07ac137df771c5e5d58c82f00fe87d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 27 Oct 2025 10:05:30 +0100 Subject: [PATCH 161/279] Use correct publish template in `get_instance_expected_output_path` --- client/ayon_core/pipeline/publish/lib.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index fb84417730..f6198bd45e 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -955,7 +955,26 @@ def get_instance_expected_output_path( "version": version }) - path_template_obj = anatomy.get_template_item("publish", "default")["path"] + # Get instance publish template name + task_name = task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template_name = get_publish_template_name( + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=instance.data["productType"], + task_name=task_name, + task_type=task_type, + project_settings=instance.context.data["project_settings"], + ) + + path_template_obj = anatomy.get_template_item( + "publish", + template_name + )["path"] template_filled = path_template_obj.format_strict(template_data) return os.path.normpath(template_filled) From a162d6bce1c4ec9fe20c918d50bc831ffeb54e2f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:50:13 +0100 Subject: [PATCH 162/279] fix mytasks filtering --- client/ayon_core/tools/workfiles/widgets/window.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 3f96f0bb15..c7ff98f25e 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -358,9 +358,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget): if not self._host_is_valid: return - self._folders_widget.set_project_name( - self._controller.get_current_project_name() - ) + self._project_name = self._controller.get_current_project_name() + self._folders_widget.set_project_name(self._project_name) def _on_save_as_finished(self, event): if event["failed"]: @@ -412,6 +411,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget): entity_ids = self._controller.get_my_tasks_entity_ids( self._project_name ) + print(entity_ids) folder_ids = entity_ids["folder_ids"] task_ids = entity_ids["task_ids"] self._folders_widget.set_folder_ids_filter(folder_ids) From c03fe908a74b0d5e815f94526363980097bbe676 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:57:12 +0100 Subject: [PATCH 163/279] lock pyobjc-core to 11.1 --- client/pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/pyproject.toml b/client/pyproject.toml index 6416d9b8e1..c98591b707 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -19,3 +19,6 @@ OpenTimelineIO = "0.16.0" opencolorio = "^2.3.2,<2.4.0" Pillow = "9.5.0" websocket-client = ">=0.40.0,<2" + +[ayon.runtimeDependencies.darwin] +pyobjc-core = "^11.1" From 5d74d9dc514b5ca8b4b4298c1346222c18ac0ca4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:52:18 +0100 Subject: [PATCH 164/279] Remove dev print --- client/ayon_core/tools/workfiles/widgets/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index c7ff98f25e..00362ea866 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -411,7 +411,6 @@ class WorkfilesToolWindow(QtWidgets.QWidget): entity_ids = self._controller.get_my_tasks_entity_ids( self._project_name ) - print(entity_ids) folder_ids = entity_ids["folder_ids"] task_ids = entity_ids["task_ids"] self._folders_widget.set_folder_ids_filter(folder_ids) From 425dbc6db1addf6cb603d0774f5aa6abf4637a01 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 27 Oct 2025 18:07:49 +0100 Subject: [PATCH 165/279] Implemented copy of source folder thumbnail --- .../tools/push_to_project/models/integrate.py | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 2adc708cf3..8a6000122f 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -3,6 +3,7 @@ import re import copy import itertools import sys +import tempfile import traceback import uuid from typing import Optional, Any @@ -709,11 +710,14 @@ class ProjectPushItemProcess: project_entity, src_folder_type ) + new_thumbnail_id = self._get_new_folder_thumbnail_id( + project_entity, src_folder_entity) folder_entity = new_folder_entity( folder_name, dst_folder_type, parent_id=parent_id, - attribs=new_folder_attrib + attribs=new_folder_attrib, + thumbnail_id=new_thumbnail_id ) if folder_label: folder_entity["label"] = folder_label @@ -733,6 +737,36 @@ class ProjectPushItemProcess: folder_entity["path"] = "/".join([parent_path, folder_name]) return folder_entity + def _get_new_folder_thumbnail_id( + self, + project_entity: dict[str, Any], + src_folder_entity: dict[str, Any] + ) -> Optional[str]: + """Copy thumbnail possibly set on folder. + + Could be different from representation thumbnails, and it is only shown + when folder is selected. + """ + new_thumbnail_id = None + if src_folder_entity["thumbnailId"]: + thumbnail = ayon_api.get_thumbnail_by_id( + self._item.src_project_name, src_folder_entity["thumbnailId"] + ) + if not thumbnail.id: + return new_thumbnail_id + + try: + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_file.write(thumbnail.content) + temp_file_path = tmp_file.name + + new_thumbnail_id = ayon_api.create_thumbnail( + project_entity["name"], temp_file_path) + finally: + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + return new_thumbnail_id + def _get_dst_folder_type( self, project_entity: dict[str, Any], From e184c1b3dd5de7fbd95bcf7afe643c1738659e2c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:22:29 +0100 Subject: [PATCH 166/279] don't require 'AYON_STUDIO_BUNDLE_NAME' to be set --- client/ayon_core/addon/base.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 9207bb74c0..a04aedb8cc 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -141,6 +141,9 @@ def _get_ayon_bundle_data() -> tuple[ ]: studio_bundle_name = os.environ.get("AYON_STUDIO_BUNDLE_NAME") project_bundle_name = os.getenv("AYON_BUNDLE_NAME") + # If AYON launcher <1.4.0 was used + if not studio_bundle_name: + studio_bundle_name = project_bundle_name bundles = ayon_api.get_bundles()["bundles"] studio_bundle = next( ( From 9d3585a0c0d73aae50ab2dd444fa1e0aea3bec71 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 29 Oct 2025 15:12:38 +0100 Subject: [PATCH 167/279] Renamed method Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 8a6000122f..0968b99eb5 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -737,7 +737,7 @@ class ProjectPushItemProcess: folder_entity["path"] = "/".join([parent_path, folder_name]) return folder_entity - def _get_new_folder_thumbnail_id( + def _create_new_folder_thumbnail( self, project_entity: dict[str, Any], src_folder_entity: dict[str, Any] From 6dc68606222c88ffaac871bc25f68e5fe0856a19 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 29 Oct 2025 15:13:35 +0100 Subject: [PATCH 168/279] Reorganized flow Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../tools/push_to_project/models/integrate.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 0968b99eb5..33eac6c3d6 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -747,24 +747,28 @@ class ProjectPushItemProcess: Could be different from representation thumbnails, and it is only shown when folder is selected. """ + if not src_folder_entity["thumbnailId"]: + return None + + thumbnail = ayon_api.get_folder_thumbnail( + self._item.src_project_name, + src_folder_entity["id"], + src_folder_entity["thumbnailId"] + ) + if not thumbnail.id: + return None + + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_file.write(thumbnail.content) + temp_file_path = tmp_file.name + new_thumbnail_id = None - if src_folder_entity["thumbnailId"]: - thumbnail = ayon_api.get_thumbnail_by_id( - self._item.src_project_name, src_folder_entity["thumbnailId"] - ) - if not thumbnail.id: - return new_thumbnail_id - - try: - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - tmp_file.write(thumbnail.content) - temp_file_path = tmp_file.name - - new_thumbnail_id = ayon_api.create_thumbnail( - project_entity["name"], temp_file_path) - finally: - if temp_file_path and os.path.exists(temp_file_path): - os.remove(temp_file_path) + try: + new_thumbnail_id = ayon_api.create_thumbnail( + project_entity["name"], temp_file_path) + finally: + if os.path.exists(temp_file_path): + os.remove(temp_file_path) return new_thumbnail_id def _get_dst_folder_type( From 35926269a624b8bbde492defa6aaf756e10c8316 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 29 Oct 2025 15:14:38 +0100 Subject: [PATCH 169/279] Used renamed method --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 33eac6c3d6..cacce44942 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -710,7 +710,7 @@ class ProjectPushItemProcess: project_entity, src_folder_type ) - new_thumbnail_id = self._get_new_folder_thumbnail_id( + new_thumbnail_id = self._create_new_folder_thumbnail( project_entity, src_folder_entity) folder_entity = new_folder_entity( folder_name, From e5265ccdc01829ee817327368a164778a40ad155 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 29 Oct 2025 15:31:57 +0000 Subject: [PATCH 170/279] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 8e0834b8da..e40a2e3663 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.6+dev" +__version__ = "1.6.7" diff --git a/package.py b/package.py index 5fa4d165d2..8cd5df8dfc 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.6+dev" +version = "1.6.7" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 73b9a4a916..11e7d4d3c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.6+dev" +version = "1.6.7" description = "" authors = ["Ynput Team "] readme = "README.md" From 757d42148e7476f4065fb4418c2d129319236090 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 29 Oct 2025 15:32:36 +0000 Subject: [PATCH 171/279] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e40a2e3663..6aa30b935a 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.7" +__version__ = "1.6.7+dev" diff --git a/package.py b/package.py index 8cd5df8dfc..ff3fad5b19 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.7" +version = "1.6.7+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 11e7d4d3c2..6656f15249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.7" +version = "1.6.7+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 5cd46678b473505384fa8b0b1ba061adab671bd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Oct 2025 15:33:32 +0000 Subject: [PATCH 172/279] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 60693f088d..c79ca69fca 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.7 - 1.6.6 - 1.6.5 - 1.6.4 From b3dbee7664f23289f8507a375fe8fae063817800 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 29 Oct 2025 16:43:38 -0400 Subject: [PATCH 173/279] Fix legacy OTIO clips detection on range remap. --- client/ayon_core/pipeline/editorial.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 716035aa1c..a53f1b5ae5 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -250,21 +250,17 @@ def remap_range_on_file_sequence(otio_clip, otio_range): if ( is_clip_from_media_sequence(otio_clip) and available_range_start_frame == media_ref.start_frame - and conformed_src_in.to_frames() < media_ref.start_frame + and round(conformed_src_in.value) < media_ref.start_frame ): media_in = otio.opentime.RationalTime( 0, rate=available_range_rate ) src_offset_in = otio_range.start_time - media_in - # make sure that only if any offset is present - if media_ref.start_frame == src_offset_in.to_frames(): - frame_in = src_offset_in.to_frames() - else: - frame_in = otio.opentime.RationalTime.from_frames( - media_ref.start_frame + src_offset_in.to_frames(), + frame_in = otio.opentime.RationalTime.from_frames( + media_ref.start_frame + src_offset_in.to_frames(), rate=available_range_rate, - ).to_frames() + ).to_frames() # e.g.: # duration = 10 frames at 24fps From 9eef269aafa5d3e79e94b69e4938375b55805f53 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 29 Oct 2025 16:57:49 -0400 Subject: [PATCH 174/279] Add comment. --- client/ayon_core/pipeline/editorial.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index a53f1b5ae5..21468e6ddd 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -250,6 +250,10 @@ def remap_range_on_file_sequence(otio_clip, otio_range): if ( is_clip_from_media_sequence(otio_clip) and available_range_start_frame == media_ref.start_frame + + # source range should be included in available range from media + # using round instead of conformed_src_in.to_frames() to avoid + # any precision issue with frame rate. and round(conformed_src_in.value) < media_ref.start_frame ): media_in = otio.opentime.RationalTime( From 13e88e70a2f21ceabc31a99ff1e2b0c28af04bb2 Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Thu, 30 Oct 2025 13:32:26 -0400 Subject: [PATCH 175/279] Fix for missing workfiles details with "Published" filter on - Implemented `get_published_workfile_info` and `get_published_workfile_version_comment` methods in the WorkfilesModel. - Updated AbstractWorkfilesFrontend to define these methods as abstract. - Enhanced BaseWorkfileController to call the new methods. - Modified SidePanelWidget to handle published workfile context and display relevant information. --- client/ayon_core/tools/workfiles/abstract.py | 27 ++++ client/ayon_core/tools/workfiles/control.py | 10 ++ .../tools/workfiles/models/workfiles.py | 64 ++++++++- .../tools/workfiles/widgets/side_panel.py | 126 +++++++++++++----- 4 files changed, 189 insertions(+), 38 deletions(-) diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index 863d6bb9bc..e7a038575a 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -787,6 +787,33 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): """ pass + @abstractmethod + def get_published_workfile_info(self, representation_id: str): + """Get published workfile info by representation ID. + + Args: + representation_id (str): Representation id. + + Returns: + Optional[PublishedWorkfileInfo]: Published workfile info or None + if not found. + + """ + pass + + @abstractmethod + def get_published_workfile_version_comment(self, representation_id: str): + """Get version comment for published workfile. + + Args: + representation_id (str): Representation id. + + Returns: + Optional[str]: Version comment or None. + + """ + pass + @abstractmethod def get_workfile_info(self, folder_id, task_id, rootless_path): """Workfile info from database. diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index f0e0f0e416..13a88e325f 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -432,6 +432,16 @@ class BaseWorkfileController( folder_id, task_id ) + def get_published_workfile_info(self, representation_id): + return self._workfiles_model.get_published_workfile_info( + representation_id + ) + + def get_published_workfile_version_comment(self, representation_id): + return self._workfiles_model.get_published_workfile_version_comment( + representation_id + ) + def get_workfile_info(self, folder_id, task_id, rootless_path): return self._workfiles_model.get_workfile_info( folder_id, task_id, rootless_path diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 5b5591fe43..5055c203be 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -79,6 +79,7 @@ class WorkfilesModel: # Published workfiles self._repre_by_id = {} + self._version_by_repre_id = {} self._published_workfile_items_cache = NestedCacheItem( levels=1, default_factory=list ) @@ -95,6 +96,7 @@ class WorkfilesModel: self._workarea_file_items_cache.reset() self._repre_by_id = {} + self._version_by_repre_id = {} self._published_workfile_items_cache.reset() self._workfile_entities_by_task_id = {} @@ -586,7 +588,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 +602,20 @@ class WorkfilesModel: repre_entity["id"]: repre_entity for repre_entity in repre_entities }) + + # Map versions by representation ID for easy lookup + version_by_id = { + version_entity["id"]: version_entity + for version_entity in version_entities + } + for repre_entity in repre_entities: + repre_id = repre_entity["id"] + version_id = repre_entity.get("versionId") + if version_id and version_id in version_by_id: + self._version_by_repre_id[repre_id] = version_by_id[ + version_id + ] + project_entity = self._controller.get_project_entity(project_name) prepared_data = ListPublishedWorkfilesOptionalData( @@ -626,6 +642,52 @@ class WorkfilesModel: ] return items + def get_published_workfile_info( + self, representation_id: str + ) -> Optional[PublishedWorkfileInfo]: + """Get published workfile info by representation ID. + + Args: + representation_id (str): Representation id. + + Returns: + Optional[PublishedWorkfileInfo]: Published workfile info or None + if not found. + + """ + if not representation_id: + return None + + # Search through all cached published workfile items + cache_items = self._published_workfile_items_cache._data_by_key + for folder_cache in cache_items.values(): + if folder_cache.is_valid: + for item in folder_cache.get_data(): + if item.representation_id == representation_id: + return item + return None + + 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 + + version_entity = self._version_by_repre_id.get(representation_id) + if version_entity: + attrib = version_entity.get("attrib") or {} + return attrib.get("comment") + return None + @property def _project_name(self) -> str: return self._controller.get_current_project_name() diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index b1b91d9721..2834f1dec8 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -1,17 +1,13 @@ import datetime -from qtpy import QtWidgets, QtCore +from qtpy import QtCore, QtWidgets def file_size_to_string(file_size): if not file_size: return "N/A" size = 0 - size_ending_mapping = { - "KB": 1024 ** 1, - "MB": 1024 ** 2, - "GB": 1024 ** 3 - } + size_ending_mapping = {"KB": 1024**1, "MB": 1024**2, "GB": 1024**3} ending = "B" for _ending, _size in size_ending_mapping.items(): if file_size < _size: @@ -70,7 +66,11 @@ 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,10 +82,11 @@ 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(None, None, None, None, None) def set_published_mode(self, published_mode): """Change published mode. @@ -95,14 +96,21 @@ class SidePanelWidget(QtWidgets.QWidget): """ self._description_widget.setVisible(not published_mode) + # Clear the context when switching modes to avoid showing stale data + self._set_context(None, None, None, None, None) - 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_context(folder_id, task_id, rootless_path, filepath, None) + + def _on_representation_selection_change(self, event): + representation_id = event["representation_id"] + + self._set_context(None, None, None, None, representation_id) def _on_description_change(self): text = self._description_input.toPlainText() @@ -118,23 +126,45 @@ 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_context( + self, folder_id, task_id, rootless_path, filepath, representation_id + ): workfile_info = None - # Check if folder, task and file are selected + published_workfile_info = None + + # Check if folder, task and file are selected (workarea mode) 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 + # Check if representation is selected (published mode) + elif representation_id: + published_workfile_info = ( + self._controller.get_published_workfile_info(representation_id) + ) + + # Get version comment for published workfiles + version_comment = None + if representation_id and published_workfile_info: + version_comment = ( + self._controller.get_published_workfile_version_comment( + representation_id + ) + ) + + enabled = ( + workfile_info is not None or published_workfile_info is not None + ) self._details_input.setEnabled(enabled) - self._description_input.setEnabled(enabled) - self._btn_description_save.setEnabled(enabled) + self._description_input.setEnabled(workfile_info is not None) + self._btn_description_save.setEnabled(workfile_info is not None) self._folder_id = folder_id self._task_id = task_id self._filepath = filepath self._rootless_path = rootless_path + self._representation_id = representation_id # Disable inputs and remove texts if any required arguments are # missing @@ -144,19 +174,28 @@ class SidePanelWidget(QtWidgets.QWidget): self._description_input.setPlainText("") return - description = workfile_info.description - size_value = file_size_to_string(workfile_info.file_size) + # Use published workfile info if available, otherwise use workarea + # info + info = ( + published_workfile_info + if published_workfile_info + else workfile_info + ) + + description = info.description if hasattr(info, "description") else "" + size_value = file_size_to_string(info.file_size) # Append html string datetime_format = "%b %d %Y %H:%M:%S" - file_created = workfile_info.file_created - modification_time = workfile_info.file_modified + file_created = info.file_created + modification_time = info.file_modified if file_created: file_created = datetime.datetime.fromtimestamp(file_created) if modification_time: modification_time = datetime.datetime.fromtimestamp( - modification_time) + modification_time + ) user_items_by_name = self._controller.get_user_items_by_name() @@ -167,34 +206,47 @@ class SidePanelWidget(QtWidgets.QWidget): return username 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)) + # For published workfiles, use 'author' field + if published_workfile_info: + if published_workfile_info.author: + created_lines.append( + convert_username(published_workfile_info.author) + ) + if file_created: + created_lines.append(file_created.strftime(datetime_format)) + else: + # For workarea workfiles, use 'created_by' field + 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)) if created_lines: created_lines.insert(0, "Created:") modified_lines = [] - if workfile_info.updated_by: - modified_lines.append( - convert_username(workfile_info.updated_by) - ) + # For workarea workfiles, show 'updated_by' + if workfile_info and workfile_info.updated_by: + modified_lines.append(convert_username(workfile_info.updated_by)) if modification_time: - modified_lines.append( - modification_time.strftime(datetime_format) - ) + modified_lines.append(modification_time.strftime(datetime_format)) if modified_lines: modified_lines.insert(0, "Modified:") - lines = ( + lines = [ "Size:", size_value, - "
".join(created_lines), - "
".join(modified_lines), - ) + ] + # Add version comment for published workfiles + if version_comment: + lines.append(f"Comment:
{version_comment}") + if created_lines: + lines.append("
".join(created_lines)) + if modified_lines: + lines.append("
".join(modified_lines)) + self._orig_description = description self._description_input.setPlainText(description) From cb81a57dddf6024318af16dfbde4a7e63cf7546a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Oct 2025 22:31:49 +0100 Subject: [PATCH 176/279] Allow visualizing Alpha only layers for review as a color matte --- client/ayon_core/lib/transcoding.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1762881846..38033d084f 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -487,6 +487,11 @@ def get_review_info_by_layer_name(channel_names): 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 From 66c6bdd96025f94ddb39fbcd5b442bd9d9f736c2 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Oct 2025 22:57:26 +0100 Subject: [PATCH 177/279] Do not fail on thumbnail creation if it can't resolve a reviewable channel with OIIO --- .../ayon_core/plugins/publish/extract_thumbnail.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index b5885178d0..2a43c12af3 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -17,6 +17,7 @@ from ayon_core.lib import ( run_subprocess, ) from ayon_core.lib.transcoding import ( + MissingRGBAChannelsError, oiio_color_convert, get_oiio_input_and_channel_args, get_oiio_info_for_input, @@ -477,7 +478,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False input_info = get_oiio_info_for_input(src_path, logger=self.log) - input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) + try: + input_arg, channels_arg = get_oiio_input_and_channel_args( + input_info + ) + except MissingRGBAChannelsError: + self.log.debug( + "Unable to find relevant reviewable channel for thumbnail " + "creation" + ) + return False oiio_cmd = get_oiio_tool_args( "oiiotool", input_arg, src_path, From 5e877f9b05730ac9bc704d2741614e6fbeef9837 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 15:10:03 +0100 Subject: [PATCH 178/279] Copy over attrib to copy product groupping --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index cacce44942..761eb175cd 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -952,6 +952,7 @@ class ProjectPushItemProcess: product_name, product_type, folder_id, + attribs=self._src_product_entity["attrib"] ) self._operations.create_entity( project_name, "product", product_entity From 758e232b6c14b872f27b3d5a22bda7fc4db762a0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 15:35:27 +0100 Subject: [PATCH 179/279] Copy over only limited set of attributes for safety --- client/ayon_core/tools/push_to_project/models/integrate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 761eb175cd..a72a74f805 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -948,11 +948,16 @@ class ProjectPushItemProcess: self._product_entity = product_entity return product_entity + src_attrib = self._src_product_entity["attrib"] + copied_attrib = { + "description": src_attrib.get("description"), + "productGroup": src_attrib.get("productGroup") + } product_entity = new_product_entity( product_name, product_type, folder_id, - attribs=self._src_product_entity["attrib"] + attribs=copied_attrib ) self._operations.create_entity( project_name, "product", product_entity From 43f7ace90e298c59a06983cc4857426ab85c1629 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 15:58:36 +0100 Subject: [PATCH 180/279] Make copy of selected attributes closer to existing --- .../tools/push_to_project/models/integrate.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index a72a74f805..b2a987e121 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -949,15 +949,20 @@ class ProjectPushItemProcess: return product_entity src_attrib = self._src_product_entity["attrib"] - copied_attrib = { - "description": src_attrib.get("description"), - "productGroup": src_attrib.get("productGroup") - } + + dst_attrib = {} + for key in { + "description", + "productGroup", + }: + if key in src_attrib: + dst_attrib[key] = src_attrib[key] + product_entity = new_product_entity( product_name, product_type, folder_id, - attribs=copied_attrib + attribs=dst_attrib ) self._operations.create_entity( project_name, "product", product_entity From 23fd59f23ae728b2eadf143ab938c0bec737441f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:04:23 +0100 Subject: [PATCH 181/279] Added version_up checkbox If selected it creates new version for existing product, otherwise it overwrites the version. --- .../tools/push_to_project/control.py | 1 + .../tools/push_to_project/ui/window.py | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index b4e0d56dfd..41cd9bf823 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -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) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index f382ccce64..c392c6b519 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -153,7 +153,12 @@ 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 + ) + version_up_checkbox = NiceCheckbox(True, parent=inputs_widget) + inputs_layout.addRow( + "Version up existing Product", version_up_checkbox + ) inputs_layout.addRow("Comment", comment_input) main_splitter.addWidget(context_widget) @@ -209,8 +214,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 +267,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) @@ -328,6 +338,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 +355,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): @@ -424,6 +436,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): use_original_name = bool(state) self._invalidate_use_original_names(use_original_name) + def _on_version_up_checkbox_change(self, state: int) -> None: + self._controller._version_up = bool(state) + self._version_up_checkbox.setChecked(bool(state)) + def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled folder_name = self._new_folder_name_input_text From 8bb4b2096a66cfd678c29c8c4b816c0140ad3295 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:04:54 +0100 Subject: [PATCH 182/279] Implemented propagation of version up value --- client/ayon_core/tools/push_to_project/control.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 41cd9bf823..56e587a4e2 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -203,6 +203,9 @@ class PushToContextController: return item_ids = [] + dst_version = 1 + if self._version_up: + dst_version = None for src_version_entity in self._src_version_entities: item_id = self._integrate_model.create_process_item( self._src_project_name, @@ -213,7 +216,7 @@ class PushToContextController: self._user_values.variant, comment=self._user_values.comment, new_folder_name=self._user_values.new_folder_name, - dst_version=1, + dst_version=dst_version, use_original_name=self._use_original_name, ) item_ids.append(item_id) From 23a6578d6fc0235b8ba2ec3b75ddef8695373bc9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:09:59 +0100 Subject: [PATCH 183/279] Formatting change --- .../tools/push_to_project/models/integrate.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index b2a987e121..8dd8d8145c 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -955,8 +955,9 @@ class ProjectPushItemProcess: "description", "productGroup", }: - if key in src_attrib: - dst_attrib[key] = src_attrib[key] + value = src_attrib.get(key) + if value: + dst_attrib[key] = value product_entity = new_product_entity( product_name, @@ -1001,8 +1002,9 @@ 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( From 3bc92d88f04db94c9ffb9f7cbf9b2c1bd866a87c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:13:48 +0100 Subject: [PATCH 184/279] Reorganized lines --- client/ayon_core/tools/push_to_project/ui/window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index c392c6b519..7eb90425ab 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -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 >") @@ -155,7 +157,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout.addRow( "Use original product names", original_names_checkbox ) - version_up_checkbox = NiceCheckbox(True, parent=inputs_widget) inputs_layout.addRow( "Version up existing Product", version_up_checkbox ) From 9713852deb28a5136c54156803723929f4067365 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:15:19 +0100 Subject: [PATCH 185/279] Unnecessary and wrong manual set --- client/ayon_core/tools/push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 7eb90425ab..23aa710cd3 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -439,7 +439,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): def _on_version_up_checkbox_change(self, state: int) -> None: self._controller._version_up = bool(state) - self._version_up_checkbox.setChecked(bool(state)) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled From 23b0378a0e0fffc68f206c7800f1552d9f9e764f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:17:49 +0100 Subject: [PATCH 186/279] Use public method to set private variable --- client/ayon_core/tools/push_to_project/control.py | 3 +++ client/ayon_core/tools/push_to_project/ui/window.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 56e587a4e2..8fd4b6053e 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -233,6 +233,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 diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 23aa710cd3..6be4d3c237 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -438,7 +438,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._invalidate_use_original_names(use_original_name) def _on_version_up_checkbox_change(self, state: int) -> None: - self._controller._version_up = bool(state) + self._controller.set_version_up(bool(state)) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled From 7229f5d794861e71842fd6c1d20707ea174c02a9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:57:50 +0100 Subject: [PATCH 187/279] Reworked hardcoded version to version_up variable 1 was used as hardcoded version, this way updated will be always last version if exists. Hardcoding 1 doesnt make sense with `get_versioning_start` which should be source of truth. Incoming value of version would make sense if we would like to start/reset specific version, which is unlikely (and currently impossible without updates to UI). --- .../tools/push_to_project/control.py | 5 +- .../tools/push_to_project/models/integrate.py | 49 ++++++++++--------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 8fd4b6053e..a24cedf455 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -203,9 +203,6 @@ class PushToContextController: return item_ids = [] - dst_version = 1 - if self._version_up: - dst_version = None for src_version_entity in self._src_version_entities: item_id = self._integrate_model.create_process_item( self._src_project_name, @@ -216,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=dst_version, + version_up=self._version_up, use_original_name=self._use_original_name, ) item_ids.append(item_id) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index cacce44942..7130922ba0 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -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, @@ -962,7 +962,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"] @@ -993,24 +993,25 @@ class ProjectPushItemProcess: if key in src_attrib: dst_attrib[key] = src_attrib[key] - 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 +1033,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 +1381,7 @@ class IntegrateModel: variant, comment, new_folder_name, - dst_version, + version_up, use_original_name ): """Create new item for integration. @@ -1394,7 +1395,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 +1412,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) From 9e6dd82c7477ab10f2c0640b467b863fbf1e705a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 17:23:18 +0100 Subject: [PATCH 188/279] Removed unnecessary call to private method --- client/ayon_core/tools/push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index f382ccce64..c1308cece0 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -376,7 +376,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): From 87ba72eb002635d0cd1c69d8739db97a25a4150e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Nov 2025 09:44:20 +0100 Subject: [PATCH 189/279] Typos --- client/ayon_core/plugins/publish/extract_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 580aa27eef..56863921c0 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -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 From 7648d6cc817ad993edd489a8fa8ca66f2d5a01c4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Nov 2025 09:50:50 +0100 Subject: [PATCH 190/279] Fix passing on correct filename to the representation instead of including the `1001-1025%04d` pattern used solely for OIIO sequence conversion --- .../publish/extract_color_transcode.py | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index f80ca7e150..2aefd15fd9 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -117,8 +117,6 @@ class ExtractOIIOTranscode(publish.Extractor): repre_files_to_convert = copy.deepcopy(repre["files"]) else: repre_files_to_convert = [repre["files"]] - repre_files_to_convert = self._translate_to_sequence( - repre_files_to_convert) # Process each output definition for output_def in profile_output_defs: @@ -176,11 +174,17 @@ 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)) + 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 files_to_convert: + for file_name in sequence_files: + if isinstance(file_name, clique.Collection): + # Convert to filepath that can be directly converted + # by oiio like `frame.1001-1025%04d.exr` + file_name: str = file_name.format( + "{head}{range}{padding}{tail}" + ) + self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) @@ -238,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. @@ -292,8 +296,7 @@ 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. @@ -302,32 +305,26 @@ class ExtractOIIOTranscode(publish.Extractor): 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 From 76dfbaeb68e23bebc0a263af6172ccea34b2e781 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Nov 2025 09:55:18 +0100 Subject: [PATCH 191/279] Update docstring --- client/ayon_core/plugins/publish/extract_color_transcode.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 2aefd15fd9..1a2c85e597 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -298,9 +298,8 @@ class ExtractOIIOTranscode(publish.Extractor): def _translate_to_sequence(self, files_to_convert): """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 From 6b6001dc42e4011da76344a1c161f3eef45f2001 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 4 Nov 2025 14:07:58 +0100 Subject: [PATCH 192/279] Refactored usage of Qt.CheckState --- .../tools/push_to_project/ui/window.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index c19a756bd7..b77cca0e09 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -319,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 @@ -427,17 +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, state: int) -> None: - self._controller.set_version_up(bool(state)) + 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 From 64f511a43b2704d5e967884baa4e59d5de7861fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:12:17 +0100 Subject: [PATCH 193/279] don't use set for value conversion --- client/ayon_core/lib/attribute_definitions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index cb74fea0f1..36c6429f5e 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -604,7 +604,11 @@ class EnumDef(AbstractAttrDef): if value is None: return copy.deepcopy(self.default) - return list(self._item_values.intersection(value)) + return [ + v + for v in value + if v in self._item_values + ] def is_value_valid(self, value: Any) -> bool: """Check if item is available in possible values.""" From 434098903979a79e3c06ab0399ce7f3c2db156f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:12:45 +0100 Subject: [PATCH 194/279] avoid 'value' variable conflicts --- client/ayon_core/pipeline/create/structures.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index b2be377b42..1729d91c62 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -245,11 +245,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): From 026eb67e918b055caa7a68ee98ccfd75c07fd563 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:13:06 +0100 Subject: [PATCH 195/279] deepcopy value --- client/ayon_core/tools/publisher/models/create.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 5098826b8b..3f5352ae8b 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -1,5 +1,6 @@ import logging import re +import copy from typing import ( Union, List, @@ -1098,7 +1099,7 @@ class CreateModel: creator_attributes[key] = attr_def.default elif attr_def.is_value_valid(value): - creator_attributes[key] = value + creator_attributes[key] = copy.deepcopy(value) def _set_instances_publish_attr_values( self, instance_ids, plugin_name, key, value From 2ed1d42f35d4d95f34b4c71fef756c7c062b237a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:18:59 +0100 Subject: [PATCH 196/279] add comment to converted value --- client/ayon_core/pipeline/create/structures.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 1729d91c62..fecb3a5ca4 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -137,6 +137,7 @@ class AttributeValues: if value is None: continue converted_value = attr_def.convert_value(value) + # QUESTION Could we just use converted value all the time? if converted_value == value: self._data[attr_def.key] = value From 5dc462c62acc03c9619de980da5632543e9cf702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 5 Nov 2025 16:53:23 +0100 Subject: [PATCH 197/279] Update client/ayon_core/plugins/publish/extract_otio_audio_tracks.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/extract_otio_audio_tracks.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 08e786f067..fce6eb4e93 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -49,14 +49,14 @@ class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): 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") - ] + audio_instances = [] + for instance in context: + if ( + instace.data["productType"] == "audio" + or instace.data.get("reviewAudio") + ): + audio_instances.append(instance) + return audio_instances class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): From e4b3aafc9491ad3fdafdefb18ec4c5740af5e76d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 5 Nov 2025 17:07:53 +0100 Subject: [PATCH 198/279] Refactors audio instance collection for clarity Simplifies audio instance identification. The code now uses dedicated functions to collect and manage audio instances and their associations. --- .../publish/extract_otio_audio_tracks.py | 105 ++++++++---------- 1 file changed, 46 insertions(+), 59 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index fce6eb4e93..6a955df725 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -1,5 +1,6 @@ import os import tempfile +import collections import pyblish @@ -9,54 +10,58 @@ from ayon_core.lib import ( ) +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 + + class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): """Collect audio instance attribute""" order = pyblish.api.CollectorOrder label = "Collect Audio Instance Attribute" - hosts = ["hiero", "resolve", "flame"] def process(self, context): - audio_instances = self.get_audio_instances(context) + audio_instances = get_audio_instances(context) - for inst in audio_instances: - # 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 = inst.data["parent_instance_id"] - for sibl_instance in inst.context: - sibl_parent_instance_id = sibl_instance.data.get( - "parent_instance_id") - # make sure the instance is not the same instance - if sibl_instance.id == inst.id: - continue - # and the parent instance id is the same - if sibl_parent_instance_id == parent_instance_id: - self.log.info( - "Adding audio to Sibling instance: " - f"{sibl_instance.data['label']}" - ) - sibl_instance.data["audio"] = None - - 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 - """ - audio_instances = [] + # create mapped instances by parent id + instances_by_parent_id = collections.defaultdict(list) for instance in context: - if ( - instace.data["productType"] == "audio" - or instace.data.get("reviewAudio") - ): - audio_instances.append(instance) - return audio_instances + parent_instance_id = instance.data.get("parent_instance_id") + if not parent_instance_id: + continue + instances_by_parent_id[parent_instance_id].append(instance) + + # 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): @@ -69,7 +74,6 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.44 label = "Extract OTIO Audio Tracks" - hosts = ["hiero", "resolve", "flame"] def process(self, context): """Convert otio audio track's content to audio representations @@ -78,13 +82,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") return + self.log.debug("Audio instances: {}".format(len(audio_instances))) + # get sequence otio_timeline = context.data["otioTimeline"] @@ -203,24 +208,6 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): 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") - ] - def get_audio_track_items(self, otio_timeline): """Get all audio clips form OTIO audio tracks From 84db5d396517c74fd9634b3e0d5a8df0938acf52 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Nov 2025 23:00:22 +0100 Subject: [PATCH 199/279] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 38033d084f..22396a5324 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -441,16 +441,16 @@ def get_review_info_by_layer_name(channel_names): def _sort(_layer_name: str) -> int: # Prioritize "" layer name # Prioritize layers with RGB channels - order = 0 if _layer_name == "rgba": - order -= 11 + return 0 + if _layer_name == "": - order -= 10 + return 1 channels = channels_by_layer_name[_layer_name] if all(channel in channels for channel in "RGB"): - order -= 1 - return order + return 2 + return 10 layer_names_order.sort(key=_sort) output = [] From f22ec30e34cd08e754e3721215e55eb446d81334 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Nov 2025 11:48:50 +0100 Subject: [PATCH 200/279] Refactors audio extraction for correct publishing Updates audio extraction logic to address issues when publishing without an audio product. - Improves audio file handling and ensures correct representation assignments. - Adds helper functions for better code organization and readability. - Improves sibling instance processing. - Fixes an issue where audio wasn't extracted correctly for certain cases. --- .../publish/extract_otio_audio_tracks.py | 156 ++++++++++-------- 1 file changed, 88 insertions(+), 68 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 6a955df725..6ad7dd85db 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -1,13 +1,12 @@ +import collections +import hashlib import os import tempfile -import collections +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): @@ -31,6 +30,24 @@ def get_audio_instances(context): 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""" @@ -42,12 +59,7 @@ class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): audio_instances = get_audio_instances(context) # create mapped instances by 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) + instances_by_parent_id = map_instances_by_parent_id(context) # distribute audio related attribute for audio_instance in audio_instances: @@ -75,6 +87,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.44 label = "Extract OTIO Audio Tracks" + temp_dir = None + def process(self, context): """Convert otio audio track's content to audio representations @@ -99,8 +113,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) @@ -114,19 +128,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, audio_instances): + def add_audio_to_instances( + self, audio_file, audio_instances, instances_by_parent_id): created_files = [] - for inst in audio_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() @@ -139,74 +159,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 # 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 = inst.data["parent_instance_id"] - for sibl_instance in inst.context: - sibl_parent_instance_id = sibl_instance.data.get( - "parent_instance_id") - # make sure the instance is not the same instance - if sibl_instance.id == inst.id: + 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 - # and the parent instance id is the same - if sibl_parent_instance_id == parent_instance_id: - 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": audio_fpath, - "offset": 0 - }) - sibl_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) + 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 @@ -382,19 +398,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: + audio_temp_dir = tempfile.mkdtemp(prefix="AYON_audio_") + self.temp_dir = Path(audio_temp_dir) + self.temp_dir.mkdir(parents=True, exist_ok=True) + + return (self.temp_dir / file_name).as_posix() From 6ae58b458477b15325634fa0e4771ca44044464d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 6 Nov 2025 12:34:47 +0100 Subject: [PATCH 201/279] Refactors: Avoids errors when publishing no audio Refactors the audio extraction process to avoid errors when no audio instances are present in the scene. - Prevents processing if no audio instances are found. - Ensures correct handling of missing audio data. - Renames temp directory variable for clarity. --- .../publish/extract_otio_audio_tracks.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py index 6ad7dd85db..1df96b2918 100644 --- a/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py +++ b/client/ayon_core/plugins/publish/extract_otio_audio_tracks.py @@ -58,6 +58,10 @@ class CollectParentAudioInstanceAttribute(pyblish.api.ContextPlugin): 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) @@ -87,7 +91,7 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.44 label = "Extract OTIO Audio Tracks" - temp_dir = None + temp_dir_path = None def process(self, context): """Convert otio audio track's content to audio representations @@ -98,8 +102,8 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): # split the long audio file to peces devided by isntances 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))) @@ -412,9 +416,9 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): hash = hashlib.md5(str(uuid.uuid4()).encode()).hexdigest()[:8] file_name = f"{hash}_{file_suffix}{extension}" - if not self.temp_dir: - audio_temp_dir = tempfile.mkdtemp(prefix="AYON_audio_") - self.temp_dir = Path(audio_temp_dir) - self.temp_dir.mkdir(parents=True, exist_ok=True) + 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 / file_name).as_posix() + return (self.temp_dir_path / file_name).as_posix() From 0f480ee410e99cd72d7cfcd576df4c44af9211e6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:20:38 +0100 Subject: [PATCH 202/279] fix updates of the label --- .../publisher/widgets/card_view_widgets.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 8df16f24c2..fdb05e1268 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -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") @@ -384,15 +386,18 @@ class InstanceCardWidget(CardWidget): self._context_warning.setVisible(not valid) @staticmethod - def _get_card_widget_sub_label(folder_path, task_name): + def _get_card_widget_sub_label( + folder_path: Optional[str], + task_name: Optional[str], + ) -> str: parts = [] if folder_path: folder_name = folder_path.split("/")[-1] parts.append(f"{folder_name}") if task_name: - parts.append(folder_name) + parts.append(task_name) if not parts: - return None + return "" sublabel = " - ".join(parts) return f"{sublabel}" @@ -400,25 +405,30 @@ class InstanceCardWidget(CardWidget): variant = self.instance.variant product_name = self.instance.product_name label = self.instance.label - folder_path = self.instance.get_folder_path() - task_name = self.instance.get_task_name() + folder_path = self.instance.folder_path + task_name = self.instance.task_name if ( variant == self._last_variant and product_name == self._last_product_name and label == self._last_label + and folder_path == self._last_folder_path + and task_name == self._last_task_name ): return self._last_variant = variant self._last_product_name = product_name self._last_label = label + self._last_folder_path = folder_path + self._last_task_name = task_name + # Make `variant` bold label = html_escape(self.instance.label) found_parts = set(re.findall(variant, label, re.IGNORECASE)) if found_parts: for part in found_parts: - replacement = "{}".format(part) + replacement = f"{part}" label = label.replace(part, replacement) sublabel = self._get_card_widget_sub_label(folder_path, task_name) if sublabel: From 606fc39ee35c6a4f7b7b3b8622fa33398dc98659 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:32:29 +0100 Subject: [PATCH 203/279] use string concatenation --- .../tools/publisher/widgets/card_view_widgets.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index fdb05e1268..8f2c1ce0f8 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -390,15 +390,14 @@ class InstanceCardWidget(CardWidget): folder_path: Optional[str], task_name: Optional[str], ) -> str: - parts = [] + sublabel = "" if folder_path: folder_name = folder_path.split("/")[-1] - parts.append(f"{folder_name}") + sublabel = f"- {folder_name}" if task_name: - parts.append(task_name) - if not parts: - return "" - sublabel = " - ".join(parts) + sublabel += f" - {task_name}" + if not sublabel: + return sublabel return f"{sublabel}" def _update_product_name(self): From 447c0f45e5d6c3795589f948bee4f86aa09e3350 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:34:50 +0100 Subject: [PATCH 204/279] use cursive for task --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 8f2c1ce0f8..6023676913 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -395,7 +395,7 @@ class InstanceCardWidget(CardWidget): folder_name = folder_path.split("/")[-1] sublabel = f"- {folder_name}" if task_name: - sublabel += f" - {task_name}" + sublabel += f" - {task_name}" if not sublabel: return sublabel return f"{sublabel}" From b8714b386461e4b0c8e9a8ae5e1370ed41310572 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:54:15 +0100 Subject: [PATCH 205/279] faster split Co-authored-by: Roy Nieterau --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 6023676913..0ee11bfc67 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -392,7 +392,7 @@ class InstanceCardWidget(CardWidget): ) -> str: sublabel = "" if folder_path: - folder_name = folder_path.split("/")[-1] + folder_name = folder_path.rsplit("/", 1)[-1] sublabel = f"- {folder_name}" if task_name: sublabel += f" - {task_name}" From 1cf16961089a2cc884973516f5202be7fbbff0f3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:43:06 +0100 Subject: [PATCH 206/279] remove usage of 'desktop' from codebase --- client/ayon_core/tools/publisher/window.py | 9 ++------- .../tools/utils/color_widgets/color_screen_pick.py | 12 ++++-------- client/ayon_core/tools/utils/lib.py | 10 ++-------- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index dc086a3b48..19994f9f62 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -678,13 +678,8 @@ class PublisherWindow(QtWidgets.QDialog): self._help_dialog.show() window = self.window() - if hasattr(QtWidgets.QApplication, "desktop"): - desktop = QtWidgets.QApplication.desktop() - screen_idx = desktop.screenNumber(window) - screen_geo = desktop.screenGeometry(screen_idx) - else: - screen = window.screen() - screen_geo = screen.geometry() + screen = window.screen() + screen_geo = screen.geometry() window_geo = window.geometry() dialog_x = window_geo.x() + window_geo.width() diff --git a/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py b/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py index 542db2831a..03ed5d3ceb 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py +++ b/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py @@ -6,7 +6,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 +53,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 +74,10 @@ class PickLabel(QtWidgets.QLabel): self.show() self.windowHandle().setScreen(screen_obj) geo = screen_obj.geometry() - args = ( - QtWidgets.QApplication.desktop().winId(), + pix = screen_obj.grabWindow( + self.winId(), geo.x(), geo.y(), geo.width(), geo.height() ) - if qtpy.API in ("pyqt4", "pyside"): - pix = QtGui.QPixmap.grabWindow(*args) - else: - pix = screen_obj.grabWindow(*args) if pix.width() > pix.height(): size = pix.height() diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index a99c46199b..e087112a04 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -53,14 +53,8 @@ def checkstate_enum_to_int(state): def center_window(window): """Move window to center of it's screen.""" - - if hasattr(QtWidgets.QApplication, "desktop"): - desktop = QtWidgets.QApplication.desktop() - screen_idx = desktop.screenNumber(window) - screen_geo = desktop.screenGeometry(screen_idx) - else: - screen = window.screen() - screen_geo = screen.geometry() + screen = window.screen() + screen_geo = screen.geometry() geo = window.frameGeometry() geo.moveCenter(screen_geo.center()) From ef2600ae5a52125279d7b660d563624a20a80d82 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:48:49 +0100 Subject: [PATCH 207/279] remove unused import --- client/ayon_core/tools/utils/color_widgets/color_screen_pick.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py b/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py index 03ed5d3ceb..c900ad1f48 100644 --- a/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py +++ b/client/ayon_core/tools/utils/color_widgets/color_screen_pick.py @@ -1,4 +1,3 @@ -import qtpy from qtpy import QtWidgets, QtCore, QtGui From 3338dbe4733b1d6d93f43a01284e5e9404fd8239 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:51:30 +0100 Subject: [PATCH 208/279] selection keeps track about every value --- .../tools/workfiles/models/selection.py | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/tools/workfiles/models/selection.py b/client/ayon_core/tools/workfiles/models/selection.py index 9a6440b2a1..65caa287d1 100644 --- a/client/ayon_core/tools/workfiles/models/selection.py +++ b/client/ayon_core/tools/workfiles/models/selection.py @@ -17,6 +17,8 @@ class SelectionModel(object): self._task_name = None self._task_id = None self._workfile_path = None + self._rootless_workfile_path = None + self._workfile_entity_id = None self._representation_id = None def get_selected_folder_id(self): @@ -62,39 +64,49 @@ class SelectionModel(object): def get_selected_workfile_path(self): return self._workfile_path + def get_selected_workfile_data(self): + return { + "project_name": self._controller.get_current_project_name(), + "path": self._workfile_path, + "rootless_path": self._rootless_workfile_path, + "folder_id": self._folder_id, + "task_name": self._task_name, + "task_id": self._task_id, + "workfile_entity_id": self._workfile_entity_id, + } + def set_selected_workfile_path( self, rootless_path, path, workfile_entity_id ): if path == self._workfile_path: return + self._rootless_workfile_path = rootless_path self._workfile_path = path + self._workfile_entity_id = workfile_entity_id self._controller.emit_event( "selection.workarea.changed", - { - "project_name": self._controller.get_current_project_name(), - "path": path, - "rootless_path": rootless_path, - "folder_id": self._folder_id, - "task_name": self._task_name, - "task_id": self._task_id, - "workfile_entity_id": workfile_entity_id, - }, + self.get_selected_workfile_data(), self.event_source ) def get_selected_representation_id(self): return self._representation_id + def get_selected_representation_data(self): + return { + "project_name": self._controller.get_current_project_name(), + "folder_id": self._folder_id, + "task_id": self._task_id, + "representation_id": self._representation_id, + } + def set_selected_representation_id(self, representation_id): if representation_id == self._representation_id: return self._representation_id = representation_id self._controller.emit_event( "selection.representation.changed", - { - "project_name": self._controller.get_current_project_name(), - "representation_id": representation_id, - }, + self.get_selected_representation_data(), self.event_source ) From ece086c03f9eede9ce8214afbb8a2261b2fbd8d5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:52:35 +0100 Subject: [PATCH 209/279] merge tho methods into one --- client/ayon_core/tools/workfiles/abstract.py | 45 +++++---- client/ayon_core/tools/workfiles/control.py | 17 ++-- .../tools/workfiles/models/workfiles.py | 91 +++++++++---------- 3 files changed, 82 insertions(+), 71 deletions(-) diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index e7a038575a..b41553acc4 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -1,8 +1,18 @@ +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 ( + WorkfileInfo, + PublishedWorkfileInfo, + ) + class FolderItem: """Item representing folder entity on a server. @@ -159,6 +169,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): @@ -788,32 +809,24 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def get_published_workfile_info(self, representation_id: str): + def get_published_workfile_info( + self, + folder_id: Optional[str], + representation_id: Optional[str], + ) -> PublishedWorkfileWrap: """Get published workfile info by representation ID. Args: - representation_id (str): Representation id. + folder_id (Optional[str]): Folder id. + representation_id (Optional[str]): Representation id. Returns: - Optional[PublishedWorkfileInfo]: Published workfile info or None + PublishedWorkfileWrap: Published workfile info or None if not found. """ pass - @abstractmethod - def get_published_workfile_version_comment(self, representation_id: str): - """Get version comment for published workfile. - - Args: - representation_id (str): Representation id. - - Returns: - Optional[str]: Version comment or None. - - """ - pass - @abstractmethod def get_workfile_info(self, folder_id, task_id, rootless_path): """Workfile info from database. diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 13a88e325f..c399a1bf33 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import os +from typing import Optional import ayon_api @@ -18,6 +21,7 @@ from ayon_core.tools.common_models import ( from .abstract import ( AbstractWorkfilesBackend, AbstractWorkfilesFrontend, + PublishedWorkfileWrap, ) from .models import SelectionModel, WorkfilesModel @@ -432,14 +436,13 @@ class BaseWorkfileController( folder_id, task_id ) - def get_published_workfile_info(self, representation_id): + def get_published_workfile_info( + self, + folder_id: Optional[str], + representation_id: Optional[str], + ) -> PublishedWorkfileWrap: return self._workfiles_model.get_published_workfile_info( - representation_id - ) - - def get_published_workfile_version_comment(self, representation_id): - return self._workfiles_model.get_published_workfile_version_comment( - representation_id + folder_id, representation_id ) def get_workfile_info(self, folder_id, task_id, rootless_path): diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index 5055c203be..c15dda2b4f 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -39,6 +39,7 @@ from ayon_core.pipeline.workfile import ( from ayon_core.pipeline.version_start import get_versioning_start from ayon_core.tools.workfiles.abstract import ( WorkareaFilepathResult, + PublishedWorkfileWrap, AbstractWorkfilesBackend, ) @@ -79,7 +80,7 @@ class WorkfilesModel: # Published workfiles self._repre_by_id = {} - self._version_by_repre_id = {} + self._version_comment_by_id = {} self._published_workfile_items_cache = NestedCacheItem( levels=1, default_factory=list ) @@ -96,7 +97,7 @@ class WorkfilesModel: self._workarea_file_items_cache.reset() self._repre_by_id = {} - self._version_by_repre_id = {} + self._version_comment_by_id = {} self._published_workfile_items_cache.reset() self._workfile_entities_by_task_id = {} @@ -554,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. @@ -604,17 +605,10 @@ class WorkfilesModel: }) # Map versions by representation ID for easy lookup - version_by_id = { - version_entity["id"]: version_entity + self._version_comment_by_id.update({ + version_entity["id"]: version_entity["attrib"].get("comment") for version_entity in version_entities - } - for repre_entity in repre_entities: - repre_id = repre_entity["id"] - version_id = repre_entity.get("versionId") - if version_id and version_id in version_by_id: - self._version_by_repre_id[repre_id] = version_by_id[ - version_id - ] + }) project_entity = self._controller.get_project_entity(project_name) @@ -643,50 +637,32 @@ class WorkfilesModel: return items def get_published_workfile_info( - self, representation_id: str - ) -> Optional[PublishedWorkfileInfo]: + self, + folder_id: Optional[str], + representation_id: Optional[str], + ) -> PublishedWorkfileWrap: """Get published workfile info by representation ID. Args: - representation_id (str): Representation id. + folder_id (Optional[str]): Folder id. + representation_id (Optional[str]): Representation id. Returns: - Optional[PublishedWorkfileInfo]: Published workfile info or None + PublishedWorkfileWrap: Published workfile info or None if not found. """ if not representation_id: - return None + return PublishedWorkfileWrap() # Search through all cached published workfile items - cache_items = self._published_workfile_items_cache._data_by_key - for folder_cache in cache_items.values(): - if folder_cache.is_valid: - for item in folder_cache.get_data(): - if item.representation_id == representation_id: - return item - return None - - 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 - - version_entity = self._version_by_repre_id.get(representation_id) - if version_entity: - attrib = version_entity.get("attrib") or {} - return attrib.get("comment") - return None + 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: @@ -704,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 From 0cc99003f6a35cb942bd9a486255ee0b2f6a1cda Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 15:52:47 +0100 Subject: [PATCH 210/279] separate logic for workare and publishe workfiles --- .../tools/workfiles/widgets/side_panel.py | 235 ++++++++++-------- 1 file changed, 131 insertions(+), 104 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index 2834f1dec8..f79dbca032 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -1,4 +1,5 @@ import datetime +from typing import Optional from qtpy import QtCore, QtWidgets @@ -7,7 +8,11 @@ def file_size_to_string(file_size): if not file_size: return "N/A" size = 0 - size_ending_mapping = {"KB": 1024**1, "MB": 1024**2, "GB": 1024**3} + size_ending_mapping = { + "KB": 1024**1, + "MB": 1024**2, + "GB": 1024**3, + } ending = "B" for _ending, _size in size_ending_mapping.items(): if file_size < _size: @@ -66,7 +71,8 @@ class SidePanelWidget(QtWidgets.QWidget): btn_description_save.clicked.connect(self._on_save_click) controller.register_event_callback( - "selection.workarea.changed", self._on_workarea_selection_change + "selection.workarea.changed", + self._on_workarea_selection_change ) controller.register_event_callback( "selection.representation.changed", @@ -86,9 +92,9 @@ class SidePanelWidget(QtWidgets.QWidget): self._orig_description = "" self._controller = controller - self._set_context(None, 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: @@ -97,7 +103,19 @@ class SidePanelWidget(QtWidgets.QWidget): self._description_widget.setVisible(not published_mode) # Clear the context when switching modes to avoid showing stale data - self._set_context(None, None, None, None, None) + 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_workarea_selection_change(self, event): folder_id = event["folder_id"] @@ -105,12 +123,16 @@ class SidePanelWidget(QtWidgets.QWidget): filepath = event["path"] rootless_path = event["rootless_path"] - self._set_context(folder_id, task_id, rootless_path, filepath, None) + 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_context(None, None, None, None, representation_id) + self._set_publish_context(folder_id, task_id, representation_id) def _on_description_change(self): text = self._description_input.toPlainText() @@ -126,130 +148,135 @@ 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, representation_id - ): - workfile_info = None - published_workfile_info = None + 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 - # Check if folder, task and file are selected (workarea mode) + 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 ) - # Check if representation is selected (published mode) - elif representation_id: - published_workfile_info = ( - self._controller.get_published_workfile_info(representation_id) - ) - # Get version comment for published workfiles - version_comment = None - if representation_id and published_workfile_info: - version_comment = ( - self._controller.get_published_workfile_version_comment( - representation_id - ) - ) - - enabled = ( - workfile_info is not None or published_workfile_info is not None - ) - - self._details_input.setEnabled(enabled) - self._description_input.setEnabled(workfile_info is not None) - self._btn_description_save.setEnabled(workfile_info is not None) - - self._folder_id = folder_id - self._task_id = task_id - self._filepath = filepath - self._rootless_path = rootless_path - self._representation_id = representation_id - - # 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 - # Use published workfile info if available, otherwise use workarea - # info - info = ( - published_workfile_info - if published_workfile_info - else workfile_info + 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 = info.description if hasattr(info, "description") else "" - size_value = file_size_to_string(info.file_size) + 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 = info.file_created - modification_time = 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 = [] - # For published workfiles, use 'author' field - if published_workfile_info: - if published_workfile_info.author: - created_lines.append( - convert_username(published_workfile_info.author) - ) - if file_created: - created_lines.append(file_created.strftime(datetime_format)) - else: - # For workarea workfiles, use 'created_by' field - if workfile_info.created_by: - created_lines.append( - convert_username(workfile_info.created_by) - ) - if file_created: - created_lines.append(file_created.strftime(datetime_format)) + lines = [] + if size_value is not None: + size_value = file_size_to_string(size_value) + lines.append(f"Size:
{size_value}") - if created_lines: - created_lines.insert(0, "Created:") - - modified_lines = [] - # For workarea workfiles, show 'updated_by' - if workfile_info and workfile_info.updated_by: - modified_lines.append(convert_username(workfile_info.updated_by)) - if modification_time: - modified_lines.append(modification_time.strftime(datetime_format)) - if modified_lines: - modified_lines.insert(0, "Modified:") - - lines = [ - "Size:", - size_value, - ] # Add version comment for published workfiles - if version_comment: - lines.append(f"Comment:
{version_comment}") - if created_lines: - lines.append("
".join(created_lines)) - if modified_lines: - lines.append("
".join(modified_lines)) + if comment: + lines.append(f"Comment:
{comment}") - self._orig_description = description - self._description_input.setPlainText(description) + if created_by or file_created: + lines.append("Created:") + if created_by: + lines.append(convert_username(created_by)) + if file_created: + lines.append(file_created.strftime(datetime_format)) + + if updated_by or file_modified: + lines.append("Modified:") + if updated_by: + lines.append(convert_username(updated_by)) + if file_modified: + lines.append(file_modified.strftime(datetime_format)) # Set as empty string self._details_input.setPlainText("") - self._details_input.appendHtml("
".join(lines)) + self._details_input.appendHtml("
".join(lines)) \ No newline at end of file From 48d2151d059b68a34e9c00ea989c6dd897fb9673 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:11:31 +0100 Subject: [PATCH 211/279] ruff fixes --- client/ayon_core/tools/workfiles/widgets/side_panel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index f79dbca032..2929ac780d 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -175,7 +175,7 @@ class SidePanelWidget(QtWidgets.QWidget): True, folder_id, task_id, - file_created = workfile_info.file_created, + file_created=workfile_info.file_created, file_modified=workfile_info.file_modified, size_value=workfile_info.file_size, created_by=workfile_info.created_by, @@ -279,4 +279,4 @@ class SidePanelWidget(QtWidgets.QWidget): # Set as empty string self._details_input.setPlainText("") - self._details_input.appendHtml("
".join(lines)) \ No newline at end of file + self._details_input.appendHtml("
".join(lines)) From f8e4b29a6cd8200bdbc12d71e22b6fb94851626b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:16:08 +0100 Subject: [PATCH 212/279] remove unused import --- client/ayon_core/tools/workfiles/abstract.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index b41553acc4..1b92c0d334 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -8,10 +8,7 @@ from typing import Optional from ayon_core.style import get_default_entity_icon_color if typing.TYPE_CHECKING: - from ayon_core.host import ( - WorkfileInfo, - PublishedWorkfileInfo, - ) + from ayon_core.host import PublishedWorkfileInfo class FolderItem: From 8ba1a4068500567ad5caf9b933806079e9bdef27 Mon Sep 17 00:00:00 2001 From: marvill85 <32180676+marvill85@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:23:32 +0100 Subject: [PATCH 213/279] Update workfile_template_builder.py Add optional folder_path_regex filtering to linked folder retrieval --- .../pipeline/workfile/workfile_template_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 52e27baa80..461515987a 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -300,7 +300,7 @@ 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 +317,7 @@ 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 +1638,7 @@ 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: From ad3c4c9317fa5bd7c81310106e017bc3460f623a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:52:45 +0100 Subject: [PATCH 214/279] remove first dash Co-authored-by: Roy Nieterau --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 0ee11bfc67..9c8288b415 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -393,7 +393,7 @@ class InstanceCardWidget(CardWidget): sublabel = "" if folder_path: folder_name = folder_path.rsplit("/", 1)[-1] - sublabel = f"- {folder_name}" + sublabel = f"{folder_name}" if task_name: sublabel += f" - {task_name}" if not sublabel: From 527b1f979570b55ac2bebcf3bf0665fdec6c373a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 17:53:57 +0100 Subject: [PATCH 215/279] deselect entities when previous selection widget is focused --- .../tools/launcher/ui/hierarchy_page.py | 44 +++++++++++++++++-- .../tools/launcher/ui/workfiles_page.py | 8 +++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 47388d9685..ff1ba92976 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -1,5 +1,5 @@ import qtawesome -from qtpy import QtWidgets, QtCore +from qtpy import QtWidgets, QtCore, QtGui from ayon_core.tools.utils import ( PlaceholderLineEdit, @@ -15,6 +15,36 @@ from ayon_core.tools.utils.lib import checkstate_int_to_enum 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) @@ -68,12 +98,12 @@ class HierarchyPage(QtWidgets.QWidget): filters_layout.addWidget(my_tasks_checkbox, 0) # - 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) @@ -97,6 +127,8 @@ class HierarchyPage(QtWidgets.QWidget): my_tasks_checkbox.stateChanged.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 @@ -151,3 +183,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._tasks_widget.deselect() + + def _on_tasks_focus(self): + self._workfiles_page.deselect() diff --git a/client/ayon_core/tools/launcher/ui/workfiles_page.py b/client/ayon_core/tools/launcher/ui/workfiles_page.py index 1ea223031e..d81221f38d 100644 --- a/client/ayon_core/tools/launcher/ui/workfiles_page.py +++ b/client/ayon_core/tools/launcher/ui/workfiles_page.py @@ -3,7 +3,7 @@ from typing import Optional import ayon_api from qtpy import QtCore, QtWidgets, QtGui -from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.utils import get_qt_icon, DeselectableTreeView from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd VERSION_ROLE = QtCore.Qt.UserRole + 1 @@ -127,7 +127,7 @@ class WorkfilesModel(QtGui.QStandardItemModel): return icon -class WorkfilesView(QtWidgets.QTreeView): +class WorkfilesView(DeselectableTreeView): def drawBranches(self, painter, rect, index): return @@ -165,6 +165,10 @@ class WorkfilesPage(QtWidgets.QWidget): def refresh(self) -> None: self._workfiles_model.refresh() + def deselect(self): + sel_model = self._workfiles_view.selectionModel() + sel_model.clearSelection() + def _on_refresh(self) -> None: self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder) From 503e627fb5805d23ab5e505bc25b1a9a63f6817f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:05:26 +0100 Subject: [PATCH 216/279] remove unused import --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index ff1ba92976..faebf14ab6 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -1,5 +1,5 @@ import qtawesome -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtWidgets, QtCore from ayon_core.tools.utils import ( PlaceholderLineEdit, From c1d0510fd34fbab644f03652cecfbb6f76d65214 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:05:07 +0100 Subject: [PATCH 217/279] deselect only workfiles --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index faebf14ab6..dc535db5fc 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -185,7 +185,7 @@ class HierarchyPage(QtWidgets.QWidget): self._tasks_widget.set_task_ids_filter(task_ids) def _on_folders_focus(self): - self._tasks_widget.deselect() + self._workfiles_page.deselect() def _on_tasks_focus(self): self._workfiles_page.deselect() From feba551e99d6217f6d8b033fdffc2c67873c79dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:46:08 +0100 Subject: [PATCH 218/279] remove dash between folder and task --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 9c8288b415..80c253da01 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -395,7 +395,7 @@ class InstanceCardWidget(CardWidget): folder_name = folder_path.rsplit("/", 1)[-1] sublabel = f"{folder_name}" if task_name: - sublabel += f" - {task_name}" + sublabel += f" {task_name}" if not sublabel: return sublabel return f"{sublabel}" From 9dbd46d86663a127840d6dfb40589dedd1ca826d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:46:16 +0100 Subject: [PATCH 219/279] indent context a little --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 80c253da01..f40730fdf9 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -393,7 +393,7 @@ class InstanceCardWidget(CardWidget): sublabel = "" if folder_path: folder_name = folder_path.rsplit("/", 1)[-1] - sublabel = f"{folder_name}" + sublabel = f"  {folder_name}" if task_name: sublabel += f" {task_name}" if not sublabel: From a07fc4bfaad5c472facc00ff6e0772951dc99084 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 10 Nov 2025 17:55:14 +0800 Subject: [PATCH 220/279] make sure the confirm message box on top after creating hero version --- client/ayon_core/plugins/load/create_hero_version.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index aef0cf8863..d01a97e2ff 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -75,6 +75,7 @@ class CreateHeroVersion(load.ProductLoaderPlugin): msgBox.setStyleSheet(style.load_stylesheet()) msgBox.setWindowFlags( msgBox.windowFlags() | QtCore.Qt.WindowType.FramelessWindowHint + | QtCore.Qt.WindowType.WindowStaysOnTopHint ) msgBox.exec_() From 770b94bde52a4541b16e56a254228f22ff83c056 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:08:35 +0100 Subject: [PATCH 221/279] show context only if is not same as current context --- .../publisher/widgets/card_view_widgets.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index f40730fdf9..9ae545c213 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -23,6 +23,7 @@ from __future__ import annotations import re import collections +from dataclasses import dataclass from typing import Optional from qtpy import QtWidgets, QtCore @@ -58,6 +59,13 @@ class SelectionTypes: extend_to = "extend_to" +@dataclass +class _SharedInfo: + """Shared information for multiple widgets.""" + current_folder_path: Optional[str] = None + current_task_name: Optional[str] = None + + class BaseGroupWidget(QtWidgets.QWidget): selected = QtCore.Signal(str, str, str) removed_selected = QtCore.Signal() @@ -202,11 +210,12 @@ 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, shared_info: _SharedInfo, parent: QtWidgets.QWidget): super().__init__(parent) self._id = CONTEXT_ID self._group_identifier = CONTEXT_GROUP + self._shared_info = shared_info icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("ProductTypeIconLabel") @@ -273,9 +282,12 @@ class InstanceCardWidget(CardWidget): is_parent_active: bool, group_icon, parent: BaseGroupWidget, + shared_info: _SharedInfo, ): super().__init__(parent) + self._shared_info = shared_info + self.instance = instance self._is_active = instance.is_active @@ -389,8 +401,15 @@ class InstanceCardWidget(CardWidget): def _get_card_widget_sub_label( folder_path: Optional[str], task_name: Optional[str], + shared_info: _SharedInfo, ) -> str: sublabel = "" + if ( + shared_info.current_folder_path == folder_path + and shared_info.current_task_name == task_name + ): + return sublabel + if folder_path: folder_name = folder_path.rsplit("/", 1)[-1] sublabel = f"  {folder_name}" @@ -429,7 +448,9 @@ class InstanceCardWidget(CardWidget): for part in found_parts: replacement = f"{part}" label = label.replace(part, replacement) - sublabel = self._get_card_widget_sub_label(folder_path, task_name) + sublabel = self._get_card_widget_sub_label( + folder_path, task_name, self._shared_info + ) if sublabel: label += f"
{sublabel}" @@ -514,6 +535,7 @@ class InstanceCardView(AbstractInstanceView): super().__init__(parent) self._controller: AbstractPublisherFrontend = controller + self._shared_info: _SharedInfo = _SharedInfo() scroll_area = QtWidgets.QScrollArea(self) scroll_area.setWidgetResizable(True) @@ -729,11 +751,16 @@ class InstanceCardView(AbstractInstanceView): def refresh(self): """Refresh instances in view based on CreatedContext.""" + self._shared_info.current_folder_path = ( + self._controller.get_current_folder_path() + ) + self._shared_info.current_task_name = ( + self._controller.get_current_task_name() + ) 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 @@ -841,6 +868,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 @@ -908,7 +937,8 @@ class InstanceCardView(AbstractInstanceView): context_info, is_parent_active, group_icon, - group_widget + group_widget, + self._shared_info, ) widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self._on_active_changed) @@ -927,7 +957,7 @@ class InstanceCardView(AbstractInstanceView): if self._context_widget is not None: return - widget = ContextCardWidget(self._content_widget) + widget = ContextCardWidget(self._shared_info, self._content_widget) widget.selected.connect(self._on_widget_selection) widget.double_clicked.connect(self.double_clicked) From 9a70ecdd7e0d6388dd60eaa3bace6c33f2bf7b5c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:50:16 +0100 Subject: [PATCH 222/279] revert the last changes --- .../publisher/widgets/card_view_widgets.py | 47 ++++--------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 9ae545c213..68b206c262 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -59,13 +59,6 @@ class SelectionTypes: extend_to = "extend_to" -@dataclass -class _SharedInfo: - """Shared information for multiple widgets.""" - current_folder_path: Optional[str] = None - current_task_name: Optional[str] = None - - class BaseGroupWidget(QtWidgets.QWidget): selected = QtCore.Signal(str, str, str) removed_selected = QtCore.Signal() @@ -210,12 +203,11 @@ class ContextCardWidget(CardWidget): Is not visually under group widget and is always at the top of card view. """ - def __init__(self, shared_info: _SharedInfo, parent: QtWidgets.QWidget): + def __init__(self, parent: QtWidgets.QWidget): super().__init__(parent) self._id = CONTEXT_ID self._group_identifier = CONTEXT_GROUP - self._shared_info = shared_info icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("ProductTypeIconLabel") @@ -282,12 +274,9 @@ class InstanceCardWidget(CardWidget): is_parent_active: bool, group_icon, parent: BaseGroupWidget, - shared_info: _SharedInfo, ): super().__init__(parent) - self._shared_info = shared_info - self.instance = instance self._is_active = instance.is_active @@ -401,23 +390,14 @@ class InstanceCardWidget(CardWidget): def _get_card_widget_sub_label( folder_path: Optional[str], task_name: Optional[str], - shared_info: _SharedInfo, ) -> str: sublabel = "" - if ( - shared_info.current_folder_path == folder_path - and shared_info.current_task_name == task_name - ): - return sublabel - if folder_path: folder_name = folder_path.rsplit("/", 1)[-1] - sublabel = f"  {folder_name}" + sublabel = f"{folder_name}" if task_name: - sublabel += f" {task_name}" - if not sublabel: - return sublabel - return f"{sublabel}" + sublabel += f" - {task_name}" + return sublabel def _update_product_name(self): variant = self.instance.variant @@ -448,11 +428,11 @@ class InstanceCardWidget(CardWidget): for part in found_parts: replacement = f"{part}" label = label.replace(part, replacement) - sublabel = self._get_card_widget_sub_label( - folder_path, task_name, self._shared_info - ) + + label = f"{label}" + sublabel = self._get_card_widget_sub_label(folder_path, task_name) if sublabel: - label += f"
{sublabel}" + label += f"
{sublabel}" self._label_widget.setText(label) # HTML text will cause that label start catch mouse clicks @@ -535,7 +515,6 @@ class InstanceCardView(AbstractInstanceView): super().__init__(parent) self._controller: AbstractPublisherFrontend = controller - self._shared_info: _SharedInfo = _SharedInfo() scroll_area = QtWidgets.QScrollArea(self) scroll_area.setWidgetResizable(True) @@ -751,13 +730,6 @@ class InstanceCardView(AbstractInstanceView): def refresh(self): """Refresh instances in view based on CreatedContext.""" - self._shared_info.current_folder_path = ( - self._controller.get_current_folder_path() - ) - self._shared_info.current_task_name = ( - self._controller.get_current_task_name() - ) - self._make_sure_context_widget_exists() self._update_convertors_group() @@ -938,7 +910,6 @@ class InstanceCardView(AbstractInstanceView): is_parent_active, group_icon, group_widget, - self._shared_info, ) widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self._on_active_changed) @@ -957,7 +928,7 @@ class InstanceCardView(AbstractInstanceView): if self._context_widget is not None: return - widget = ContextCardWidget(self._shared_info, self._content_widget) + widget = ContextCardWidget(self._content_widget) widget.selected.connect(self._on_widget_selection) widget.double_clicked.connect(self.double_clicked) From 9be4493a9ea888062f3476bee7e1d883c1062581 Mon Sep 17 00:00:00 2001 From: Mustafa Zaky Jafar Date: Mon, 10 Nov 2025 12:56:51 +0200 Subject: [PATCH 223/279] remove unused import --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 68b206c262..aef3f85e0c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -23,7 +23,6 @@ from __future__ import annotations import re import collections -from dataclasses import dataclass from typing import Optional from qtpy import QtWidgets, QtCore From 0f8339ac922f204cd5bf7c5f8396c6111e00d915 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:45:22 +0100 Subject: [PATCH 224/279] use span for context label too --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index aef3f85e0c..ca95b1ff1a 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -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"{CONTEXT_LABEL}", self) icon_layout = QtWidgets.QHBoxLayout() icon_layout.setContentsMargins(5, 5, 5, 5) From d1ef11defa7f193bede9cbeebfe03771085fee02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:06:56 +0100 Subject: [PATCH 225/279] define helper widget for folders filtering --- client/ayon_core/tools/utils/__init__.py | 2 + .../ayon_core/tools/utils/folders_widget.py | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 111b7c614b..56989927ee 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -76,6 +76,7 @@ from .folders_widget import ( FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, SimpleFoldersWidget, + FoldersFiltersWidget, ) from .tasks_widget import ( @@ -160,6 +161,7 @@ __all__ = ( "FoldersQtModel", "FOLDERS_MODEL_SENDER_NAME", "SimpleFoldersWidget", + "FoldersFiltersWidget", "TasksWidget", "TasksQtModel", diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index 7b71dd087c..12f4bebdae 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -15,6 +15,8 @@ from ayon_core.tools.common_models import ( from .models import RecursiveSortFilterProxyModel from .views import TreeView from .lib import RefreshThread, get_qt_icon +from .widgets import PlaceholderLineEdit +from .nice_checkbox import NiceCheckbox FOLDERS_MODEL_SENDER_NAME = "qt_folders_model" @@ -794,3 +796,41 @@ 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 _on_my_tasks_change(self, _state: int) -> None: + self.my_tasks_changed.emit(self._my_tasks_checkbox.isChecked()) From cef3bc229a4508e0f19c2dd5fadcb2b0f96c2233 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:07:12 +0100 Subject: [PATCH 226/279] disable case sensitivity for folders proxy --- client/ayon_core/tools/utils/folders_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index 12f4bebdae..126363086b 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -345,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]]): From 0dd47211c54850066d6440599b6fb540b388949d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:13:24 +0100 Subject: [PATCH 227/279] add 'get_current_username' to UsersModel --- client/ayon_core/tools/common_models/users.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/users.py b/client/ayon_core/tools/common_models/users.py index f7939e5cd3..42a76d8d7d 100644 --- a/client/ayon_core/tools/common_models/users.py +++ b/client/ayon_core/tools/common_models/users.py @@ -1,10 +1,13 @@ import json import collections +from typing import Optional import ayon_api from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict -from ayon_core.lib import NestedCacheItem +from ayon_core.lib import NestedCacheItem, get_ayon_username + +NOT_SET = object() # --- Implementation that should be in ayon-python-api --- @@ -105,9 +108,18 @@ class UserItem: class UsersModel: def __init__(self, controller): + self._current_username = NOT_SET self._controller = controller self._users_cache = NestedCacheItem(default_factory=list) + def get_current_username(self) -> Optional[str]: + if self._current_username is NOT_SET: + self._current_username = get_ayon_username() + return self._current_username + + def reset(self) -> None: + self._users_cache.reset() + def get_user_items(self, project_name): """Get user items. From ad83d827e2a1dcb092d191360a1756086813f9cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:14:32 +0100 Subject: [PATCH 228/279] move private methods below public one --- client/ayon_core/tools/loader/control.py | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 9f159bfb21..9ec3e580d9 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -476,20 +476,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def is_standard_projects_filter_enabled(self): return self._host is not None - def _get_project_anatomy(self, project_name): - if not project_name: - return None - cache = self._project_anatomy_cache[project_name] - if not cache.is_valid: - cache.update_data(Anatomy(project_name)) - return cache.get_data() - - def _create_event_system(self): - return QueuedEventSystem() - - def _emit_event(self, topic, data=None): - self._event_system.emit(topic, data or {}, "controller") - def get_product_types_filter(self): output = ProductTypesFilter( is_allow_list=False, @@ -545,3 +531,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() From 91d44a833b7915b9bbbccb23ad81fd45df295db2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:22:44 +0100 Subject: [PATCH 229/279] implemented my tasks filter to browser --- client/ayon_core/tools/loader/abstract.py | 15 +++++++++ client/ayon_core/tools/loader/control.py | 20 +++++++++++- .../tools/loader/ui/folders_widget.py | 19 ++++++++--- .../ayon_core/tools/loader/ui/tasks_widget.py | 21 ++++++++++-- client/ayon_core/tools/loader/ui/window.py | 32 ++++++++++++++----- 5 files changed, 91 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 9c7934d2db..089d298b2c 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -666,6 +666,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 diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 9ec3e580d9..2a86a50b6d 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -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 ( @@ -32,6 +37,8 @@ from .models import ( SiteSyncModel ) +NOT_SET = object() + class ExpectedSelection: def __init__(self, controller): @@ -124,6 +131,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loader_actions_model = LoaderActionsModel(self) self._thumbnails_model = ThumbnailsModel() self._sitesync_model = SiteSyncModel(self) + self._users_model = UsersModel(self) @property def log(self): @@ -160,6 +168,7 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._projects_model.reset() self._thumbnails_model.reset() self._sitesync_model.reset() + self._users_model.reset() self._projects_model.refresh() @@ -235,6 +244,15 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output + def get_my_tasks_entity_ids(self, project_name: 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]]: diff --git a/client/ayon_core/tools/loader/ui/folders_widget.py b/client/ayon_core/tools/loader/ui/folders_widget.py index f238eabcef..6de0b17ea2 100644 --- a/client/ayon_core/tools/loader/ui/folders_widget.py +++ b/client/ayon_core/tools/loader/ui/folders_widget.py @@ -1,11 +1,11 @@ +from typing import Optional + import qtpy from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import ( - RecursiveSortFilterProxyModel, - DeselectableTreeView, -) from ayon_core.style import get_objected_colors +from ayon_core.tools.utils import DeselectableTreeView +from ayon_core.tools.utils.folders_widget import FoldersProxyModel from ayon_core.tools.utils import ( FoldersQtModel, @@ -260,7 +260,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget): QtWidgets.QAbstractItemView.ExtendedSelection) folders_model = LoaderFoldersModel(controller) - folders_proxy_model = RecursiveSortFilterProxyModel() + folders_proxy_model = FoldersProxyModel() folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) @@ -314,6 +314,15 @@ class LoaderFoldersWidget(QtWidgets.QWidget): if name: self._folders_view.expandAll() + def set_folder_ids_filter(self, folder_ids: Optional[list[str]]): + """Set filter of folder ids. + + Args: + folder_ids (list[str]): The list of folder ids. + + """ + self._folders_proxy_model.set_folder_ids_filter(folder_ids) + def set_merged_products_selection(self, items): """ diff --git a/client/ayon_core/tools/loader/ui/tasks_widget.py b/client/ayon_core/tools/loader/ui/tasks_widget.py index cc7e2e9c95..3a38739cf0 100644 --- a/client/ayon_core/tools/loader/ui/tasks_widget.py +++ b/client/ayon_core/tools/loader/ui/tasks_widget.py @@ -1,11 +1,11 @@ import collections import hashlib +from typing import Optional from qtpy import QtWidgets, QtCore, QtGui from ayon_core.style import get_default_entity_icon_color from ayon_core.tools.utils import ( - RecursiveSortFilterProxyModel, DeselectableTreeView, TasksQtModel, TASKS_MODEL_SENDER_NAME, @@ -15,9 +15,11 @@ from ayon_core.tools.utils.tasks_widget import ( ITEM_NAME_ROLE, PARENT_ID_ROLE, TASK_TYPE_ROLE, + TasksProxyModel, ) from ayon_core.tools.utils.lib import RefreshThread, get_qt_icon + # Role that can't clash with default 'tasks_widget' roles FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 100 NO_TASKS_ID = "--no-task--" @@ -295,7 +297,7 @@ class LoaderTasksQtModel(TasksQtModel): return super().data(index, role) -class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): +class LoaderTasksProxyModel(TasksProxyModel): def lessThan(self, left, right): if left.data(ITEM_ID_ROLE) == NO_TASKS_ID: return False @@ -303,6 +305,12 @@ class LoaderTasksProxyModel(RecursiveSortFilterProxyModel): return True return super().lessThan(left, right) + def filterAcceptsRow(self, row, parent_index): + source_index = self.sourceModel().index(row, 0, parent_index) + if source_index.data(ITEM_ID_ROLE) == NO_TASKS_ID: + return True + return super().filterAcceptsRow(row, parent_index) + class LoaderTasksWidget(QtWidgets.QWidget): refreshed = QtCore.Signal() @@ -363,6 +371,15 @@ class LoaderTasksWidget(QtWidgets.QWidget): if name: self._tasks_view.expandAll() + def set_task_ids_filter(self, task_ids: Optional[list[str]]): + """Set filter of folder ids. + + Args: + task_ids (list[str]): The list of folder ids. + + """ + self._tasks_proxy_model.set_task_ids_filter(task_ids) + def refresh(self): self._tasks_model.refresh() diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index df5beb708f..d1d1222f51 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -5,14 +5,14 @@ from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath from ayon_core.style import load_stylesheet from ayon_core.tools.utils import ( - PlaceholderLineEdit, ErrorMessageBox, ThumbnailPainterWidget, RefreshButton, GoToCurrentButton, + FoldersFiltersWidget, ) from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox +from ayon_core.tools.utils import ProjectsCombobox, NiceCheckbox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController @@ -170,15 +170,14 @@ class LoaderWindow(QtWidgets.QWidget): context_top_layout.addWidget(go_to_current_btn, 0) context_top_layout.addWidget(refresh_btn, 0) - folders_filter_input = PlaceholderLineEdit(context_widget) - folders_filter_input.setPlaceholderText("Folder name filter...") + filters_widget = FoldersFiltersWidget(context_widget) folders_widget = LoaderFoldersWidget(controller, context_widget) context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) context_layout.addWidget(context_top_widget, 0) - context_layout.addWidget(folders_filter_input, 0) + context_layout.addWidget(filters_widget, 0) context_layout.addWidget(folders_widget, 1) tasks_widget = LoaderTasksWidget(controller, context_widget) @@ -247,9 +246,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 @@ -303,7 +305,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 @@ -421,9 +423,23 @@ 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: + # self._folders_widget + 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() From e6325fa2e81580555cb07847e6e78cfac2c6376e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:27:49 +0100 Subject: [PATCH 230/279] use 'FoldersFiltersWidget' in launcher --- .../tools/launcher/ui/hierarchy_page.py | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index dc535db5fc..8554a5af8c 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -2,14 +2,13 @@ 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.folders_widget import FoldersFiltersWidget from ayon_core.tools.utils.lib import checkstate_int_to_enum from .workfiles_page import WorkfilesPage @@ -76,26 +75,7 @@ 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 = LauncherFoldersWidget(controller, content_body) @@ -123,8 +103,8 @@ 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) @@ -135,7 +115,6 @@ class HierarchyPage(QtWidgets.QWidget): 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 @@ -158,9 +137,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) @@ -171,11 +147,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 ) From 9c3dec09c9382aa210f59583081e7224b0e8ec22 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:53:36 +0100 Subject: [PATCH 231/279] small cleanup --- client/ayon_core/tools/launcher/control.py | 23 +++++++++++----------- client/ayon_core/tools/loader/control.py | 4 +++- client/ayon_core/tools/loader/ui/window.py | 2 -- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/launcher/control.py b/client/ayon_core/tools/launcher/control.py index 85b362f9d7..f4656de787 100644 --- a/client/ayon_core/tools/launcher/control.py +++ b/client/ayon_core/tools/launcher/control.py @@ -1,10 +1,14 @@ from typing import Optional -from ayon_core.lib import Logger, get_ayon_username +from ayon_core.lib import Logger from ayon_core.lib.events import QueuedEventSystem from ayon_core.addon import AddonsManager from ayon_core.settings import get_project_settings, get_studio_settings -from ayon_core.tools.common_models import ProjectsModel, HierarchyModel +from ayon_core.tools.common_models import ( + ProjectsModel, + HierarchyModel, + UsersModel, +) from .abstract import ( AbstractLauncherFrontEnd, @@ -30,13 +34,12 @@ class BaseLauncherController( self._addons_manager = None - self._username = NOT_SET - self._selection_model = LauncherSelectionModel(self) self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) self._actions_model = ActionsModel(self) self._workfiles_model = WorkfilesModel(self) + self._users_model = UsersModel(self) @property def log(self): @@ -209,6 +212,7 @@ class BaseLauncherController( self._projects_model.reset() self._hierarchy_model.reset() + self._users_model.reset() self._actions_model.refresh() self._projects_model.refresh() @@ -229,8 +233,10 @@ class BaseLauncherController( self._emit_event("controller.refresh.actions.finished") - def get_my_tasks_entity_ids(self, project_name: str): - username = self._get_my_username() + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + username = self._users_model.get_current_username() assignees = [] if username: assignees.append(username) @@ -238,10 +244,5 @@ class BaseLauncherController( project_name, assignees ) - def _get_my_username(self): - if self._username is NOT_SET: - self._username = get_ayon_username() - return self._username - def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 2a86a50b6d..d0cc9db2f5 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -244,7 +244,9 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): output[folder_id] = label return output - def get_my_tasks_entity_ids(self, project_name: str): + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: username = self._users_model.get_current_username() assignees = [] if username: diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index d1d1222f51..48704e7481 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -427,7 +427,6 @@ class LoaderWindow(QtWidgets.QWidget): self._folders_widget.set_name_filter(text) def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None: - # self._folders_widget folder_ids = None task_ids = None if enabled: @@ -439,7 +438,6 @@ class LoaderWindow(QtWidgets.QWidget): 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() From 3a6ee43f22e94d9c3f304787820433b3277e3761 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:22:20 +0100 Subject: [PATCH 232/279] added doption to change filters --- client/ayon_core/tools/utils/folders_widget.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index 126363086b..f506af5352 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -834,5 +834,11 @@ class FoldersFiltersWidget(QtWidgets.QWidget): 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()) From f9f55b48b03fefa20cb2c361e676858afba1c78e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 16:23:19 +0100 Subject: [PATCH 233/279] added my tasks filtering to publisher --- client/ayon_core/tools/publisher/abstract.py | 15 +++ client/ayon_core/tools/publisher/control.py | 19 +++- .../widgets/create_context_widgets.py | 33 ++++-- .../tools/publisher/widgets/create_widget.py | 12 +- .../tools/publisher/widgets/folders_dialog.py | 107 ++++++++++-------- 5 files changed, 126 insertions(+), 60 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 14da15793d..bfd0948519 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -295,6 +295,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): """Get folder id from folder path.""" pass + @abstractmethod + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + """Get entity ids for my tasks. + + Args: + project_name (str): Project name. + + Returns: + dict[str, list[str]]: Folder and task ids. + + """ + pass + # --- Create --- @abstractmethod def get_creator_items(self) -> Dict[str, "CreatorItem"]: diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 038816c6fc..3d11131dc3 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -11,7 +11,11 @@ from ayon_core.pipeline import ( registered_host, get_process_id, ) -from ayon_core.tools.common_models import ProjectsModel, HierarchyModel +from ayon_core.tools.common_models import ( + ProjectsModel, + HierarchyModel, + UsersModel, +) from .models import ( PublishModel, @@ -101,6 +105,7 @@ class PublisherController( # Cacher of avalon documents self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) + self._users_model = UsersModel(self) @property def log(self): @@ -317,6 +322,17 @@ class PublisherController( return False return True + def get_my_tasks_entity_ids( + self, project_name: str + ) -> dict[str, list[str]]: + username = self._users_model.get_current_username() + assignees = [] + if username: + assignees.append(username) + return self._hierarchy_model.get_entity_ids_for_assignees( + project_name, assignees + ) + # --- Publish specific callbacks --- def get_context_title(self): """Get context title for artist shown at the top of main window.""" @@ -359,6 +375,7 @@ class PublisherController( self._emit_event("controller.reset.started") self._hierarchy_model.reset() + self._users_model.reset() # Publish part must be reset after plugins self._create_model.reset() diff --git a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py index faf2248181..49d236353f 100644 --- a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py @@ -1,10 +1,14 @@ from qtpy import QtWidgets, QtCore from ayon_core.lib.events import QueuedEventSystem -from ayon_core.tools.utils import PlaceholderLineEdit, GoToCurrentButton from ayon_core.tools.common_models import HierarchyExpectedSelection -from ayon_core.tools.utils import FoldersWidget, TasksWidget +from ayon_core.tools.utils import ( + FoldersWidget, + TasksWidget, + FoldersFiltersWidget, + GoToCurrentButton, +) from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -180,8 +184,7 @@ class CreateContextWidget(QtWidgets.QWidget): headers_widget = QtWidgets.QWidget(self) - folder_filter_input = PlaceholderLineEdit(headers_widget) - folder_filter_input.setPlaceholderText("Filter folders..") + filters_widget = FoldersFiltersWidget(headers_widget) current_context_btn = GoToCurrentButton(headers_widget) current_context_btn.setToolTip("Go to current context") @@ -189,7 +192,8 @@ class CreateContextWidget(QtWidgets.QWidget): headers_layout = QtWidgets.QHBoxLayout(headers_widget) headers_layout.setContentsMargins(0, 0, 0, 0) - headers_layout.addWidget(folder_filter_input, 1) + headers_layout.setSpacing(5) + headers_layout.addWidget(filters_widget, 1) headers_layout.addWidget(current_context_btn, 0) hierarchy_controller = CreateHierarchyController(controller) @@ -207,15 +211,16 @@ class CreateContextWidget(QtWidgets.QWidget): main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) main_layout.addWidget(headers_widget, 0) + main_layout.addSpacing(5) main_layout.addWidget(folders_widget, 2) main_layout.addWidget(tasks_widget, 1) folders_widget.selection_changed.connect(self._on_folder_change) tasks_widget.selection_changed.connect(self._on_task_change) current_context_btn.clicked.connect(self._on_current_context_click) - folder_filter_input.textChanged.connect(self._on_folder_filter_change) + filters_widget.text_changed.connect(self._on_folder_filter_change) + filters_widget.my_tasks_changed.connect(self._on_my_tasks_change) - self._folder_filter_input = folder_filter_input self._current_context_btn = current_context_btn self._folders_widget = folders_widget self._tasks_widget = tasks_widget @@ -303,5 +308,17 @@ class CreateContextWidget(QtWidgets.QWidget): self._last_project_name, folder_id, task_name ) - def _on_folder_filter_change(self, text): + def _on_folder_filter_change(self, text: str) -> None: self._folders_widget.set_name_filter(text) + + def _on_my_tasks_change(self, enabled: bool) -> None: + folder_ids = None + task_ids = None + if enabled: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._last_project_name + ) + folder_ids = entity_ids["folder_ids"] + task_ids = entity_ids["task_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) + self._tasks_widget.set_task_ids_filter(task_ids) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index b9b3afd895..d98bc95eb2 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -710,11 +710,13 @@ class CreateWidget(QtWidgets.QWidget): def _on_first_show(self): width = self.width() - part = int(width / 4) - rem_width = width - part - self._main_splitter_widget.setSizes([part, rem_width]) - rem_width = rem_width - part - self._creators_splitter.setSizes([part, rem_width]) + part = int(width / 9) + context_width = part * 3 + create_sel_width = part * 2 + rem_width = width - context_width + self._main_splitter_widget.setSizes([context_width, rem_width]) + rem_width -= create_sel_width + self._creators_splitter.setSizes([create_sel_width, rem_width]) def showEvent(self, event): super().showEvent(event) diff --git a/client/ayon_core/tools/publisher/widgets/folders_dialog.py b/client/ayon_core/tools/publisher/widgets/folders_dialog.py index d2eb68310e..e0d9c098d8 100644 --- a/client/ayon_core/tools/publisher/widgets/folders_dialog.py +++ b/client/ayon_core/tools/publisher/widgets/folders_dialog.py @@ -1,7 +1,10 @@ from qtpy import QtWidgets from ayon_core.lib.events import QueuedEventSystem -from ayon_core.tools.utils import PlaceholderLineEdit, FoldersWidget +from ayon_core.tools.utils import ( + FoldersWidget, + FoldersFiltersWidget, +) from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend @@ -43,8 +46,7 @@ class FoldersDialog(QtWidgets.QDialog): super().__init__(parent) self.setWindowTitle("Select folder") - filter_input = PlaceholderLineEdit(self) - filter_input.setPlaceholderText("Filter folders..") + filters_widget = FoldersFiltersWidget(self) folders_controller = FoldersDialogController(controller) folders_widget = FoldersWidget(folders_controller, self) @@ -59,7 +61,8 @@ class FoldersDialog(QtWidgets.QDialog): btns_layout.addWidget(cancel_btn) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(filter_input, 0) + layout.setSpacing(5) + layout.addWidget(filters_widget, 0) layout.addWidget(folders_widget, 1) layout.addLayout(btns_layout, 0) @@ -68,12 +71,13 @@ class FoldersDialog(QtWidgets.QDialog): ) folders_widget.double_clicked.connect(self._on_ok_clicked) - filter_input.textChanged.connect(self._on_filter_change) + filters_widget.text_changed.connect(self._on_filter_change) + filters_widget.my_tasks_changed.connect(self._on_my_tasks_change) ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) self._controller = controller - self._filter_input = filter_input + self._filters_widget = filters_widget self._ok_btn = ok_btn self._cancel_btn = cancel_btn @@ -88,6 +92,49 @@ class FoldersDialog(QtWidgets.QDialog): self._first_show = True self._default_height = 500 + self._project_name = None + + def showEvent(self, event): + """Refresh folders widget on show.""" + super().showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + # Refresh on show + self.reset(False) + + def reset(self, force=True): + """Reset widget.""" + if not force and not self._soft_reset_enabled: + return + + self._project_name = self._controller.get_current_project_name() + if self._soft_reset_enabled: + self._soft_reset_enabled = False + + self._folders_widget.set_project_name(self._project_name) + + def get_selected_folder_path(self): + """Get selected folder path.""" + return self._selected_folder_path + + def set_selected_folders(self, folder_paths: list[str]) -> None: + """Change preselected folder before showing the dialog. + + This also resets model and clean filter. + """ + self.reset(False) + self._filters_widget.set_text("") + self._filters_widget.set_my_tasks_checked(False) + + folder_id = None + for folder_path in folder_paths: + folder_id = self._controller.get_folder_id_from_path(folder_path) + if folder_id: + break + if folder_id: + self._folders_widget.set_selected_folder(folder_id) + def _on_first_show(self): center = self.rect().center() size = self.size() @@ -103,27 +150,6 @@ class FoldersDialog(QtWidgets.QDialog): # Change reset enabled so model is reset on show event self._soft_reset_enabled = True - def showEvent(self, event): - """Refresh folders widget on show.""" - super().showEvent(event) - if self._first_show: - self._first_show = False - self._on_first_show() - # Refresh on show - self.reset(False) - - def reset(self, force=True): - """Reset widget.""" - if not force and not self._soft_reset_enabled: - return - - if self._soft_reset_enabled: - self._soft_reset_enabled = False - - self._folders_widget.set_project_name( - self._controller.get_current_project_name() - ) - def _on_filter_change(self, text): """Trigger change of filter of folders.""" self._folders_widget.set_name_filter(text) @@ -137,22 +163,11 @@ class FoldersDialog(QtWidgets.QDialog): ) self.done(1) - def set_selected_folders(self, folder_paths): - """Change preselected folder before showing the dialog. - - This also resets model and clean filter. - """ - self.reset(False) - self._filter_input.setText("") - - folder_id = None - for folder_path in folder_paths: - folder_id = self._controller.get_folder_id_from_path(folder_path) - if folder_id: - break - if folder_id: - self._folders_widget.set_selected_folder(folder_id) - - def get_selected_folder_path(self): - """Get selected folder path.""" - return self._selected_folder_path + def _on_my_tasks_change(self, enabled: bool) -> None: + folder_ids = None + if enabled: + entity_ids = self._controller.get_my_tasks_entity_ids( + self._project_name + ) + folder_ids = entity_ids["folder_ids"] + self._folders_widget.set_folder_ids_filter(folder_ids) From ba4ecc6f80b0894e3c9345924106b0eeae6a109b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:04:29 +0100 Subject: [PATCH 234/279] use filters widget in workfiles tool --- .../tools/workfiles/widgets/window.py | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 00362ea866..811fe602d1 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -6,12 +6,11 @@ from ayon_core.tools.utils import ( FoldersWidget, GoToCurrentButton, MessageOverlayObject, - NiceCheckbox, PlaceholderLineEdit, RefreshButton, TasksWidget, + FoldersFiltersWidget, ) -from ayon_core.tools.utils.lib import checkstate_int_to_enum from ayon_core.tools.workfiles.control import BaseWorkfileController from .files_widget import FilesWidget @@ -69,7 +68,6 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._default_window_flags = flags self._folders_widget = None - self._folder_filter_input = None self._files_widget = None @@ -178,48 +176,33 @@ class WorkfilesToolWindow(QtWidgets.QWidget): col_widget = QtWidgets.QWidget(parent) header_widget = QtWidgets.QWidget(col_widget) - folder_filter_input = PlaceholderLineEdit(header_widget) - folder_filter_input.setPlaceholderText("Filter folders..") + filters_widget = FoldersFiltersWidget(header_widget) go_to_current_btn = GoToCurrentButton(header_widget) refresh_btn = RefreshButton(header_widget) + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(filters_widget, 1) + header_layout.addWidget(go_to_current_btn, 0) + header_layout.addWidget(refresh_btn, 0) + folder_widget = FoldersWidget( controller, col_widget, handle_expected_selection=True ) - my_tasks_tooltip = ( - "Filter folders and task to only those you are assigned to." - ) - - my_tasks_label = QtWidgets.QLabel("My tasks") - my_tasks_label.setToolTip(my_tasks_tooltip) - - my_tasks_checkbox = NiceCheckbox(folder_widget) - my_tasks_checkbox.setChecked(False) - my_tasks_checkbox.setToolTip(my_tasks_tooltip) - - header_layout = QtWidgets.QHBoxLayout(header_widget) - header_layout.setContentsMargins(0, 0, 0, 0) - header_layout.addWidget(folder_filter_input, 1) - header_layout.addWidget(go_to_current_btn, 0) - header_layout.addWidget(refresh_btn, 0) - header_layout.addWidget(my_tasks_label, 0) - header_layout.addWidget(my_tasks_checkbox, 0) - col_layout = QtWidgets.QVBoxLayout(col_widget) col_layout.setContentsMargins(0, 0, 0, 0) col_layout.addWidget(header_widget, 0) col_layout.addWidget(folder_widget, 1) - folder_filter_input.textChanged.connect(self._on_folder_filter_change) - go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) - refresh_btn.clicked.connect(self._on_refresh_clicked) - my_tasks_checkbox.stateChanged.connect( + filters_widget.text_changed.connect(self._on_folder_filter_change) + filters_widget.my_tasks_changed.connect( self._on_my_tasks_checkbox_state_changed ) + go_to_current_btn.clicked.connect(self._on_go_to_current_clicked) + refresh_btn.clicked.connect(self._on_refresh_clicked) - self._folder_filter_input = folder_filter_input self._folders_widget = folder_widget return col_widget @@ -403,11 +386,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget): else: self.close() - def _on_my_tasks_checkbox_state_changed(self, state): + def _on_my_tasks_checkbox_state_changed(self, enabled: bool) -> None: folder_ids = None task_ids = None - state = checkstate_int_to_enum(state) - if state == QtCore.Qt.Checked: + if enabled: entity_ids = self._controller.get_my_tasks_entity_ids( self._project_name ) From dc7f1556750fdcece3b6391ae83fd8bb7e95eeac Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:07:24 +0100 Subject: [PATCH 235/279] remove unused imports --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 1 - client/ayon_core/tools/loader/ui/window.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 8554a5af8c..3c8be4679e 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -9,7 +9,6 @@ from ayon_core.tools.utils import ( TasksWidget, ) from ayon_core.tools.utils.folders_widget import FoldersFiltersWidget -from ayon_core.tools.utils.lib import checkstate_int_to_enum from .workfiles_page import WorkfilesPage diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 48704e7481..27e416b495 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -12,7 +12,7 @@ from ayon_core.tools.utils import ( FoldersFiltersWidget, ) from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox, NiceCheckbox +from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController From 7c8e7c23e9440c7a7d41de4973b8001cffd82bc0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:19:04 +0100 Subject: [PATCH 236/279] Change code formatting --- .../workfile/workfile_template_builder.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 461515987a..9ce9579b58 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -300,7 +300,11 @@ class AbstractTemplateBuilder(ABC): self._loaders_by_name = get_loaders_by_name() return self._loaders_by_name - def get_linked_folder_entities(self, link_type: Optional[str], folder_path_regex: 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_path_regex=folder_path_regex, 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, folder_path_regex=folder_path_regex)) + link_type=link_type, + folder_path_regex=folder_path_regex + ) + ) ] if not folder_ids: From 76cfa3e148e5a5d3064614ed82d83c1d71f111ea Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 11 Nov 2025 14:05:22 +0000 Subject: [PATCH 237/279] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 6aa30b935a..83a7d0a51d 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.7+dev" +__version__ = "1.6.8" diff --git a/package.py b/package.py index ff3fad5b19..b3e41b2e81 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.7+dev" +version = "1.6.8" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 6656f15249..212fe505b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.7+dev" +version = "1.6.8" description = "" authors = ["Ynput Team "] readme = "README.md" From 8fdc943553687c4d43dea9d855e906df1d8dbb4e Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 11 Nov 2025 14:05:58 +0000 Subject: [PATCH 238/279] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 83a7d0a51d..e481e81356 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.8" +__version__ = "1.6.8+dev" diff --git a/package.py b/package.py index b3e41b2e81..eb30148176 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.8" +version = "1.6.8+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 212fe505b9..6708432e85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.8" +version = "1.6.8+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From ccd54e16cc8be451c08b8b0da296f8ed10d43730 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Nov 2025 14:07:19 +0000 Subject: [PATCH 239/279] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c79ca69fca..513e088fef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.8 - 1.6.7 - 1.6.6 - 1.6.5 From f38a6dffba44e264aa06f9b424943378bbf4dd98 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 16:40:59 +0100 Subject: [PATCH 240/279] Avoid repeating input channel names if e.g. R, G and B are reading from Y channel --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 22396a5324..0255b5a9d4 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1545,7 +1545,7 @@ 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) + input_channels_str = ",".join(set(input_channels)) subimages = oiio_input_info.get("subimages") input_arg = "-i" From e2c668769032b7a68753f31164887032a6b0ce2e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 16:46:04 +0100 Subject: [PATCH 241/279] Preserve order when making unique to avoid error on `R,G,B` becoming `B,G,R` but the channels being using in `R,G,B` order in `--ch` argument --- client/ayon_core/lib/transcoding.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 0255b5a9d4..076ee79665 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1545,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(set(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" From 42642ebd34f5b35bc42cf6e5ace0c7a6866a2426 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:41:55 +0100 Subject: [PATCH 242/279] use graphql to get projects --- .../ayon_core/tools/common_models/projects.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 250c3b020d..3e090e18b8 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import contextlib from abc import ABC, abstractmethod from typing import Any, Optional from dataclasses import dataclass import ayon_api +from ayon_api.graphql_queries import projects_graphql_query from ayon_core.style import get_default_entity_icon_color from ayon_core.lib import CacheItem, NestedCacheItem @@ -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_projects_bc(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_projects_bc() + user = ayon_api.get_user() pinned_projects = ( user From f4824cdc426c47f5db65d4ac417d1328259d0cb4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Nov 2025 14:41:24 +0100 Subject: [PATCH 243/279] Allow creation of farm instances without colorspace data --- .../pipeline/farm/pyblish_functions.py | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 2193e96cb1..5e632c3599 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -591,22 +591,6 @@ def create_instances_for_aov( # AOV product of its own. log = Logger.get_logger("farm_publishing") - additional_color_data = { - "renderProducts": instance.data["renderProducts"], - "colorspaceConfig": instance.data["colorspaceConfig"], - "display": instance.data["colorspaceDisplay"], - "view": instance.data["colorspaceView"] - } - - # Get templated path from absolute config path. - anatomy = instance.context.data["anatomy"] - colorspace_template = instance.data["colorspaceConfig"] - try: - additional_color_data["colorspaceTemplate"] = remap_source( - colorspace_template, anatomy) - except ValueError as e: - log.warning(e) - additional_color_data["colorspaceTemplate"] = colorspace_template # if there are product to attach to and more than one AOV, # we cannot proceed. @@ -618,6 +602,29 @@ def create_instances_for_aov( "attaching multiple AOVs or renderable cameras to " "product is not supported yet.") + additional_data = { + "renderProducts": instance.data["renderProducts"], + } + + # Collect color management data if present + if "colorspaceConfig" in instance.data: + additional_data.update({ + "colorspaceConfig": instance.data["colorspaceConfig"], + # Display/View are optional + "display": instance.data.get("colorspaceDisplay"), + "view": instance.data.get("colorspaceView") + }) + + # Get templated path from absolute config path. + anatomy = instance.context.data["anatomy"] + colorspace_template = instance.data["colorspaceConfig"] + try: + additional_data["colorspaceTemplate"] = remap_source( + colorspace_template, anatomy) + except ValueError as e: + log.warning(e) + additional_data["colorspaceTemplate"] = colorspace_template + # create instances for every AOV we found in expected files. # NOTE: this is done for every AOV and every render camera (if # there are multiple renderable cameras in scene) @@ -625,7 +632,7 @@ def create_instances_for_aov( instance, skeleton, aov_filter, - additional_color_data, + additional_data, skip_integration_repre_list, do_not_add_review, frames_to_render @@ -936,16 +943,28 @@ def _create_instances_for_aov( "stagingDir": staging_dir, "fps": new_instance.get("fps"), "tags": ["review"] if preview else [], - "colorspaceData": { + } + + if colorspace and additional_data["colorspaceConfig"]: + # Only apply colorspace data if the image has a colorspace + colorspace_data: dict = { "colorspace": colorspace, "config": { "path": additional_data["colorspaceConfig"], "template": additional_data["colorspaceTemplate"] }, - "display": additional_data["display"], - "view": additional_data["view"] } - } + # Display/View are optional + display = additional_data.get("display") + if display: + additional_data["display"] = display + view = additional_data.get("view") + if view: + additional_data["view"] = view + + rep["colorspaceData"] = colorspace_data + else: + log.debug("No colorspace data for representation: {}".format(rep)) # support conversion from tiled to scanline if instance.data.get("convertToScanline"): From 5ede9cb091504f0ff2c3d0f730039862b102fc66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:21:34 +0100 Subject: [PATCH 244/279] add less/greater than to allowed chars --- server/settings/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index f40c7c3627..3b75a9ba23 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -34,7 +34,7 @@ class ProductNameProfile(BaseSettingsModel): enum_resolver=task_types_enum ) tasks: list[str] = SettingsField(default_factory=list, title="Task names") - template: str = SettingsField("", title="Template") + template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") class FilterCreatorProfile(BaseSettingsModel): From ca8b776ce12f86dc9ce9709ac59f14d34be9718c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:23:26 +0100 Subject: [PATCH 245/279] added conversion function --- server/settings/conversion.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 34820b5b32..4620202346 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -1,8 +1,26 @@ +import re import copy from typing import Any from .publish_plugins import DEFAULT_PUBLISH_VALUES +PRODUCT_NAME_REPL_REGEX = re.compile(r"[^<>{}\[\]a-zA-Z0-9_.]") + + +def _convert_imageio_configs_1_6_5(overrides): + product_name_profiles = ( + overrides + .get("tools", {}) + .get("creator", {}) + .get("product_name_profiles") + ) + if isinstance(product_name_profiles, list): + for item in product_name_profiles: + # Remove unsupported product name characters + template = item.get("template") + if isinstance(template, str): + item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template) + def _convert_imageio_configs_0_4_5(overrides): """Imageio config settings did change to profiles since 0.4.5.""" From 2f893574f4ac4b4f67e39d11ba4f2a0f50a97cfa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:24:02 +0100 Subject: [PATCH 246/279] change 'tasks' and 'hosts' to full attr names --- server/settings/conversion.py | 7 ++++++ server/settings/tools.py | 44 +++++++++++++++++------------------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 4620202346..846b91edab 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -21,6 +21,13 @@ def _convert_imageio_configs_1_6_5(overrides): if isinstance(template, str): item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template) + for new_key, old_key in ( + ("host_names", "hosts"), + ("task_names", "tasks"), + ): + if old_key in item: + item[new_key] = item.get(old_key) + def _convert_imageio_configs_0_4_5(overrides): """Imageio config settings did change to profiles since 0.4.5.""" diff --git a/server/settings/tools.py b/server/settings/tools.py index 3b75a9ba23..7e397d4874 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -27,13 +27,13 @@ class ProductNameProfile(BaseSettingsModel): product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField(default_factory=list, title="Hosts") + host_names: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", enum_resolver=task_types_enum ) - tasks: list[str] = SettingsField(default_factory=list, title="Task names") + task_names: list[str] = SettingsField(default_factory=list, title="Task names") template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") @@ -433,27 +433,27 @@ DEFAULT_TOOLS_VALUES = { "product_name_profiles": [ { "product_types": [], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{variant}" }, { "product_types": [ "workfile" ], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}" }, { "product_types": [ "render" ], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}{Variant}<_{Aov}>" }, { @@ -461,11 +461,11 @@ DEFAULT_TOOLS_VALUES = { "renderLayer", "renderPass" ], - "hosts": [ + "host_names": [ "tvpaint" ], "task_types": [], - "tasks": [], + "task_names": [], "template": ( "{product[type]}{Task[name]}_{Renderlayer}_{Renderpass}" ) @@ -475,65 +475,65 @@ DEFAULT_TOOLS_VALUES = { "review", "workfile" ], - "hosts": [ + "host_names": [ "aftereffects", "tvpaint" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}" }, { "product_types": ["render"], - "hosts": [ + "host_names": [ "aftereffects" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}{Composition}{Variant}" }, { "product_types": [ "staticMesh" ], - "hosts": [ + "host_names": [ "maya" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "S_{folder[name]}{variant}" }, { "product_types": [ "skeletalMesh" ], - "hosts": [ + "host_names": [ "maya" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "SK_{folder[name]}{variant}" }, { "product_types": [ "hda" ], - "hosts": [ + "host_names": [ "houdini" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{folder[name]}_{variant}" }, { "product_types": [ "textureSet" ], - "hosts": [ + "host_names": [ "substancedesigner" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "T_{folder[name]}{variant}" } ], From 7622c150cf5c33a81b830cafce5330d2ddf2caed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:49:48 +0100 Subject: [PATCH 247/279] fix formatting --- server/settings/tools.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index 7e397d4874..da3b4ebff8 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -25,16 +25,27 @@ class ProductNameProfile(BaseSettingsModel): _layout = "expanded" product_types: list[str] = SettingsField( - default_factory=list, title="Product types" + default_factory=list, + title="Product types", + ) + host_names: list[str] = SettingsField( + default_factory=list, + title="Host names", ) - host_names: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", - enum_resolver=task_types_enum + enum_resolver=task_types_enum, + ) + task_names: list[str] = SettingsField( + default_factory=list, + title="Task names", + ) + template: str = SettingsField( + "", + title="Template", + regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$", ) - task_names: list[str] = SettingsField(default_factory=list, title="Task names") - template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") class FilterCreatorProfile(BaseSettingsModel): From 1cdde6d7779785deafd4996000e025af6dfa4bce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:03:23 +0100 Subject: [PATCH 248/279] fix typo Thanks @BigRoy --- client/ayon_core/tools/common_models/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 3e090e18b8..d81b581894 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -277,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]: """ @@ -575,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 From be9b476151b455408d8d076ac944ebbe7bc1e3a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:03:31 +0100 Subject: [PATCH 249/279] use better method name --- client/ayon_core/tools/common_models/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index d81b581894..0c1f912fd1 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -541,7 +541,7 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _fetch_projects_bc(self) -> list[dict[str, Any]]: + 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'. @@ -565,7 +565,7 @@ class ProjectsModel(object): return projects def _query_projects(self) -> list[ProjectItem]: - projects = self._fetch_projects_bc() + projects = self._fetch_graphql_projects() user = ayon_api.get_user() pinned_projects = ( From 26839fa5c1b52ac4535eb0fbf19283991d05b411 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 12 Nov 2025 17:07:05 +0000 Subject: [PATCH 250/279] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e481e81356..869831b3ab 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.8+dev" +__version__ = "1.6.9" diff --git a/package.py b/package.py index eb30148176..cbfae1a4b3 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.8+dev" +version = "1.6.9" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 6708432e85..92c336770d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.8+dev" +version = "1.6.9" description = "" authors = ["Ynput Team "] readme = "README.md" From ea81e643f2d7158a50d4a6feebd6ee8ef3113ec4 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 12 Nov 2025 17:07:38 +0000 Subject: [PATCH 251/279] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 869831b3ab..da0cbff11d 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.9" +__version__ = "1.6.9+dev" diff --git a/package.py b/package.py index cbfae1a4b3..99524be8aa 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.9" +version = "1.6.9+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 92c336770d..f69f4f843a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.9" +version = "1.6.9+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 2ce5ba257502e279e9f5474ee88b27623fed75b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Nov 2025 17:08:36 +0000 Subject: [PATCH 252/279] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 513e088fef..e48e4b3b29 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ 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 From 2cdcfa3f22278623c2dcdbb171359484a781f22d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:49:55 +0100 Subject: [PATCH 253/279] store host name to version entity data --- client/ayon_core/plugins/publish/integrate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index d18e546392..6182598e14 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -457,6 +457,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): else: version_data[key] = value + host_name = instance.context.data["hostName"] + version_data["host_name"] = host_name + version_entity = new_version_entity( version_number, product_entity["id"], From 4d90d35fc7204e97e4588c9f7969d58e7037630a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:01:08 +0100 Subject: [PATCH 254/279] Extended open file possibilities --- client/ayon_core/plugins/loader/open_file.py | 286 ++++++++++++++++--- 1 file changed, 254 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 9b5a6fec20..80ddf925d3 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -1,8 +1,10 @@ import os import sys import subprocess +import platform import collections -from typing import Optional, Any +import ctypes +from typing import Optional, Any, Callable from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.actions import ( @@ -13,6 +15,232 @@ from ayon_core.pipeline.actions import ( ) +WINDOWS_USER_REG_PATH = ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts" + r"\{ext}\UserChoice" +) + + +class _Cache: + supported_exts: set[str] = set() + unsupported_exts: set[str] = set() + + @classmethod + def is_supported(cls, ext: str) -> bool: + return ext in cls.supported_exts + + @classmethod + def already_checked(cls, ext: str) -> bool: + return ( + ext in cls.supported_exts + or ext in cls.unsupported_exts + ) + + @classmethod + def set_ext_support(cls, ext: str, supported: bool) -> None: + if supported: + cls.supported_exts.add(ext) + else: + cls.unsupported_exts.add(ext) + + +def _extension_has_assigned_app_windows(ext: str) -> bool: + import winreg + progid = None + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + WINDOWS_USER_REG_PATH.format(ext=ext), + ) as k: + progid, _ = winreg.QueryValueEx(k, "ProgId") + except OSError: + pass + + if progid: + return True + + try: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k: + progid = winreg.QueryValueEx(k, None)[0] + except OSError: + pass + return bool(progid) + + +def _linux_find_desktop_file(desktop: str) -> Optional[str]: + for p in ( + os.path.join(os.path.expanduser("~/.local/share/applications"), desktop), + os.path.join("/usr/share/applications", desktop), + os.path.join("/usr/local/share/applications", desktop), + ): + if os.path.isfile(p): + return p + return None + + +def _extension_has_assigned_app_linux(ext: str) -> bool: + import mimetypes + + mime, _ = mimetypes.guess_type(f"file{ext}") + if not mime: + return False + + try: + # xdg-mime query default + desktop = subprocess.check_output( + ["xdg-mime", "query", "default", mime], + text=True + ).strip() or None + except Exception: + desktop = None + + if not desktop: + return False + + desktop_path = _linux_find_desktop_file(desktop) + if not desktop_path: + return False + if desktop_path and os.path.isfile(desktop_path): + return True + return False + + +def _extension_has_assigned_app_macos(ext: str): + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via ctypes. + # Steps: ext -> UTI -> default handler bundle id for role 'all'. + cf = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + ls = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/Frameworks" + "/LaunchServices.framework/LaunchServices" + ) + + # CFType/CFString helpers + CFStringRef = ctypes.c_void_p + CFURLRef = ctypes.c_void_p + CFAllocatorRef = ctypes.c_void_p + CFIndex = ctypes.c_long + UniChar = ctypes.c_ushort + + kCFStringEncodingUTF8 = 0x08000100 + + cf.CFStringCreateWithCString.argtypes = [CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32] + cf.CFStringCreateWithCString.restype = CFStringRef + + cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] + cf.CFStringGetCStringPtr.restype = ctypes.c_char_p + + cf.CFStringGetCString.argtypes = [CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32] + cf.CFStringGetCString.restype = ctypes.c_bool + + cf.CFRelease.argtypes = [ctypes.c_void_p] + cf.CFRelease.restype = None + + try: + UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/CoreServices" + ).UTTypeCreatePreferredIdentifierForTag + except OSError: + # Fallback path (older systems) + UTTypeCreatePreferredIdentifierForTag = ( + ls.UTTypeCreatePreferredIdentifierForTag + ) + UTTypeCreatePreferredIdentifierForTag.argtypes = [ + CFStringRef, CFStringRef, CFStringRef + ] + UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef + + LSRolesMask = ctypes.c_uint + kLSRolesAll = 0xFFFFFFFF + ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [ + CFStringRef, LSRolesMask + ] + ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef + + def cfstr(py_s: str) -> CFStringRef: + return cf.CFStringCreateWithCString( + None, py_s.encode("utf-8"), kCFStringEncodingUTF8 + ) + + def to_pystr(cf_s: CFStringRef) -> Optional[str]: + if not cf_s: + return None + # Try fast pointer + ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8) + if ptr: + return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8") + + # Fallback buffer + buf_size = 1024 + buf = ctypes.create_string_buffer(buf_size) + ok = cf.CFStringGetCString( + cf_s, buf, buf_size, kCFStringEncodingUTF8 + ) + if ok: + return buf.value.decode("utf-8") + return None + + # Convert extension (without dot) to UTI + tag_class = cfstr("public.filename-extension") + tag_value = cfstr(ext.lstrip(".")) + + uti_ref = UTTypeCreatePreferredIdentifierForTag( + tag_class, tag_value, None + ) + uti = to_pystr(uti_ref) + + # Clean up temporary CFStrings + for ref in (tag_class, tag_value): + if ref: + cf.CFRelease(ref) + + bundle_id = None + if uti_ref: + # Get default handler for the UTI + default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType( + uti_ref, kLSRolesAll + ) + bundle_id = to_pystr(default_bundle_ref) + if default_bundle_ref: + cf.CFRelease(default_bundle_ref) + cf.CFRelease(uti_ref) + return bundle_id is not None + + +def _filter_supported_exts( + extensions: set[str], test_func: Callable +) -> set[str]: + filtered_exs: set[str] = set() + for ext in extensions: + if not _Cache.already_checked(ext): + r = test_func(ext) + print(ext, r) + _Cache.set_ext_support(ext, r) + if _Cache.is_supported(ext): + filtered_exs.add(ext) + return filtered_exs + + +def filter_supported_exts(extensions: set[str]) -> set[str]: + if not extensions: + return set() + platform_name = platform.system().lower() + if platform_name == "windows": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_windows + ) + if platform_name == "linux": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_linux + ) + if platform_name == "darwin": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_macos + ) + return set() + + def open_file(filepath: str) -> None: """Open file with system default executable""" if sys.platform.startswith("darwin"): @@ -27,8 +255,6 @@ class OpenFileAction(LoaderActionPlugin): """Open Image Sequence or Video with system default""" identifier = "core.open-file" - product_types = {"render2d"} - def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -46,37 +272,32 @@ class OpenFileAction(LoaderActionPlugin): if not repres: return [] - repre_ids = {repre["id"] for repre in repres} - versions = selection.entities.get_representations_versions( - repre_ids - ) - product_ids = {version["productId"] for version in versions} - products = selection.entities.get_products(product_ids) - filtered_product_ids = { - product["id"] - for product in products - if product["productType"] in self.product_types - } - if not filtered_product_ids: + repres_by_ext = collections.defaultdict(list) + for repre in repres: + repre_context = repre.get("context") + if not repre_context: + continue + ext = repre_context.get("ext") + if not ext: + path = repre["attrib"].get("path") + if path: + ext = os.path.splitext(path)[1] + + if ext: + ext = ext.lower() + if not ext.startswith("."): + ext = f".{ext}" + repres_by_ext[ext.lower()].append(repre) + + if not repres_by_ext: return [] - versions_by_product_id = collections.defaultdict(list) - for version in versions: - versions_by_product_id[version["productId"]].append(version) - - repres_by_version_ids = collections.defaultdict(list) - for repre in repres: - repres_by_version_ids[repre["versionId"]].append(repre) - - filtered_repres = [] - for product_id in filtered_product_ids: - for version in versions_by_product_id[product_id]: - for repre in repres_by_version_ids[version["id"]]: - filtered_repres.append(repre) + filtered_exts = filter_supported_exts(set(repres_by_ext)) repre_ids_by_name = collections.defaultdict(set) - for repre in filtered_repres: - repre_ids_by_name[repre["name"]].add(repre["id"]) + for ext in filtered_exts: + for repre in repres_by_ext[ext]: + repre_ids_by_name[repre["name"]].add(repre["id"]) return [ LoaderActionItem( @@ -86,8 +307,8 @@ class OpenFileAction(LoaderActionPlugin): data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", - "name": "play_circle", - "color": "#FFA500", + "name": "file_open", + "color": "#ffffff", } ) for repre_name, repre_ids in repre_ids_by_name.items() @@ -122,6 +343,7 @@ class OpenFileAction(LoaderActionPlugin): ) self.log.info(f"Opening: {path}") + open_file(path) return LoaderActionResult( From 3936270266f67e5e4707a39a3ba845f9eda7d023 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:16:51 +0100 Subject: [PATCH 255/279] fix formatting --- client/ayon_core/plugins/loader/open_file.py | 24 ++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 80ddf925d3..b29dfd1710 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -68,13 +68,14 @@ def _extension_has_assigned_app_windows(ext: str) -> bool: def _linux_find_desktop_file(desktop: str) -> Optional[str]: - for p in ( - os.path.join(os.path.expanduser("~/.local/share/applications"), desktop), - os.path.join("/usr/share/applications", desktop), - os.path.join("/usr/local/share/applications", desktop), + for dirpath in ( + os.path.expanduser("~/.local/share/applications"), + "/usr/share/applications", + "/usr/local/share/applications", ): - if os.path.isfile(p): - return p + path = os.path.join(dirpath, desktop) + if os.path.isfile(path): + return path return None @@ -106,7 +107,8 @@ def _extension_has_assigned_app_linux(ext: str) -> bool: def _extension_has_assigned_app_macos(ext: str): - # Uses CoreServices/LaunchServices and Uniform Type Identifiers via ctypes. + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via + # ctypes. # Steps: ext -> UTI -> default handler bundle id for role 'all'. cf = ctypes.cdll.LoadLibrary( "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" @@ -125,13 +127,17 @@ def _extension_has_assigned_app_macos(ext: str): kCFStringEncodingUTF8 = 0x08000100 - cf.CFStringCreateWithCString.argtypes = [CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32] + cf.CFStringCreateWithCString.argtypes = [ + CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32 + ] cf.CFStringCreateWithCString.restype = CFStringRef cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] cf.CFStringGetCStringPtr.restype = ctypes.c_char_p - cf.CFStringGetCString.argtypes = [CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32] + cf.CFStringGetCString.argtypes = [ + CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32 + ] cf.CFStringGetCString.restype = ctypes.c_bool cf.CFRelease.argtypes = [ctypes.c_void_p] From 84a40336065b93f057e616ddb7775640770b8687 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:21:14 +0100 Subject: [PATCH 256/279] remove unused variables --- client/ayon_core/plugins/loader/open_file.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index b29dfd1710..13d255a682 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -106,7 +106,7 @@ def _extension_has_assigned_app_linux(ext: str) -> bool: return False -def _extension_has_assigned_app_macos(ext: str): +def _extension_has_assigned_app_macos(ext: str) -> bool: # Uses CoreServices/LaunchServices and Uniform Type Identifiers via # ctypes. # Steps: ext -> UTI -> default handler bundle id for role 'all'. @@ -120,10 +120,8 @@ def _extension_has_assigned_app_macos(ext: str): # CFType/CFString helpers CFStringRef = ctypes.c_void_p - CFURLRef = ctypes.c_void_p CFAllocatorRef = ctypes.c_void_p CFIndex = ctypes.c_long - UniChar = ctypes.c_ushort kCFStringEncodingUTF8 = 0x08000100 @@ -194,7 +192,6 @@ def _extension_has_assigned_app_macos(ext: str): uti_ref = UTTypeCreatePreferredIdentifierForTag( tag_class, tag_value, None ) - uti = to_pystr(uti_ref) # Clean up temporary CFStrings for ref in (tag_class, tag_value): From 0262a8e7630080a5c4d8e5a64a729febb2dee3c5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 16:33:00 +0100 Subject: [PATCH 257/279] Apply suggestion from @BigRoy --- client/ayon_core/pipeline/farm/pyblish_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 5e632c3599..6d116dcece 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -957,10 +957,10 @@ def _create_instances_for_aov( # Display/View are optional display = additional_data.get("display") if display: - additional_data["display"] = display + colorspace_data["display"] = display view = additional_data.get("view") if view: - additional_data["view"] = view + colorspace_data["view"] = view rep["colorspaceData"] = colorspace_data else: From f29470a08ca34d467c1070e9b05ec08b6fcc1f26 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 16:34:08 +0100 Subject: [PATCH 258/279] Apply suggestion from @iLLiCiTiT Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 6d116dcece..265d79b53e 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -607,9 +607,10 @@ def create_instances_for_aov( } # Collect color management data if present - if "colorspaceConfig" in instance.data: + colorspace_config = instance.data.get("colorspaceConfig") + if colorspace_config: additional_data.update({ - "colorspaceConfig": instance.data["colorspaceConfig"], + "colorspaceConfig": colorspace_config, # Display/View are optional "display": instance.data.get("colorspaceDisplay"), "view": instance.data.get("colorspaceView") @@ -617,13 +618,12 @@ def create_instances_for_aov( # Get templated path from absolute config path. anatomy = instance.context.data["anatomy"] - colorspace_template = instance.data["colorspaceConfig"] try: additional_data["colorspaceTemplate"] = remap_source( - colorspace_template, anatomy) + colorspace_config, anatomy) except ValueError as e: log.warning(e) - additional_data["colorspaceTemplate"] = colorspace_template + additional_data["colorspaceTemplate"] = colorspace_config # create instances for every AOV we found in expected files. # NOTE: this is done for every AOV and every render camera (if From bab249a54a4f50e018d4f403abf5b6f9e04b2b4a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:11:02 +0100 Subject: [PATCH 259/279] remove debug print Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/loader/open_file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 13d255a682..3118bfa3db 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -218,7 +218,6 @@ def _filter_supported_exts( for ext in extensions: if not _Cache.already_checked(ext): r = test_func(ext) - print(ext, r) _Cache.set_ext_support(ext, r) if _Cache.is_supported(ext): filtered_exs.add(ext) From 46b534cfcce245dd0a7231e86efdd9e2685629eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:11:38 +0100 Subject: [PATCH 260/279] merge two lines into one --- client/ayon_core/plugins/loader/open_file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 3118bfa3db..8bc4913da5 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -217,8 +217,7 @@ def _filter_supported_exts( filtered_exs: set[str] = set() for ext in extensions: if not _Cache.already_checked(ext): - r = test_func(ext) - _Cache.set_ext_support(ext, r) + _Cache.set_ext_support(ext, test_func(ext)) if _Cache.is_supported(ext): filtered_exs.add(ext) return filtered_exs From efa702405c75d016a46f692e8f678598adf9c91c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:26:55 +0100 Subject: [PATCH 261/279] tune out orders --- client/ayon_core/plugins/loader/copy_file.py | 2 ++ client/ayon_core/plugins/loader/delete_old_versions.py | 2 +- client/ayon_core/plugins/loader/open_file.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2380b465ed..a1a98a2bf0 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -45,6 +45,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=32, group_label="Copy file path", data={ "representation_id": repre_id, @@ -60,6 +61,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=33, group_label="Copy file", data={ "representation_id": repre_id, diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 7499650cbe..ce67df1c0c 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -66,7 +66,7 @@ class DeleteOldVersions(LoaderActionPlugin): ), LoaderActionItem( label="Calculate Versions size", - order=30, + order=34, data={ "product_ids": list(product_ids), "action": "calculate-versions-size", diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 8bc4913da5..ef92990f57 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -304,7 +304,7 @@ class OpenFileAction(LoaderActionPlugin): LoaderActionItem( label=repre_name, group_label="Open file", - order=-10, + order=30, data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", From 42b249a6b3732bafa3557abc5462857fe03e855e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:32:22 +0100 Subject: [PATCH 262/279] add note about caching --- client/ayon_core/plugins/loader/open_file.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index ef92990f57..018b9aeab0 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -22,6 +22,13 @@ WINDOWS_USER_REG_PATH = ( class _Cache: + """Cache extensions information. + + Notes: + The cache is cleared when loader tool is refreshed so it might be + moved to other place which is not cleared of refresh. + + """ supported_exts: set[str] = set() unsupported_exts: set[str] = set() From 8478899b67c7c3aeec4b62ee179ebaaba87bcc0a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 17:40:47 +0100 Subject: [PATCH 263/279] Apply suggestion from @BigRoy --- client/ayon_core/plugins/loader/open_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 018b9aeab0..d226786bc2 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -26,7 +26,7 @@ class _Cache: Notes: The cache is cleared when loader tool is refreshed so it might be - moved to other place which is not cleared of refresh. + moved to other place which is not cleared on refresh. """ supported_exts: set[str] = set() From b307cc6227db88347d70bdb149076d9c1ae66e41 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Nov 2025 22:40:23 +0100 Subject: [PATCH 264/279] Run plug-in for all hosts --- .../publish/collect_scene_loaded_versions.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1c28c28f5b..5370b52492 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -9,28 +9,6 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.0001 label = "Collect Versions Loaded in Scene" - hosts = [ - "aftereffects", - "blender", - "celaction", - "cinema4d", - "flame", - "fusion", - "harmony", - "hiero", - "houdini", - "max", - "maya", - "motionbuilder", - "nuke", - "photoshop", - "silhouette", - "substancepainter", - "substancedesigner", - "resolve", - "tvpaint", - "zbrush", - ] def process(self, context): host = registered_host() From 1c25e357776f0a3a04686ffc96439f6b567635e4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 12:18:07 +0100 Subject: [PATCH 265/279] Fix Context card being clickable in Nuke 14/15 only outside the Context label area. Previously you could only click on the far left or far right side of the context card to be able to select it and access the Context attributes. Cosmetically the removal of the `` doesn't do much to the Context card because it doesn't have a sublabel. --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index ca95b1ff1a..aef3f85e0c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -211,7 +211,7 @@ class ContextCardWidget(CardWidget): icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("ProductTypeIconLabel") - label_widget = QtWidgets.QLabel(f"{CONTEXT_LABEL}", self) + label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) icon_layout = QtWidgets.QHBoxLayout() icon_layout.setContentsMargins(5, 5, 5, 5) From 82128c30c5d8a1e12939ffd3db09002f3c87b9d6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 14:55:23 +0100 Subject: [PATCH 266/279] Disable text interaction instead --- .../ayon_core/tools/publisher/widgets/card_view_widgets.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index aef3f85e0c..a9abd56584 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -211,7 +211,12 @@ class ContextCardWidget(CardWidget): icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("ProductTypeIconLabel") - label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) + label_widget = QtWidgets.QLabel(f"{CONTEXT_LABEL}", self) + # HTML text will cause that label start catch mouse clicks + # - disabling with changing interaction flag + label_widget.setTextInteractionFlags( + QtCore.Qt.NoTextInteraction + ) icon_layout = QtWidgets.QHBoxLayout() icon_layout.setContentsMargins(5, 5, 5, 5) From 3b86b3612823c4ab4cc75feabaf2843a21602359 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 18 Nov 2025 13:05:19 +0000 Subject: [PATCH 267/279] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index da0cbff11d..ebf7e34a32 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.9+dev" +__version__ = "1.6.10" diff --git a/package.py b/package.py index 99524be8aa..461e09c38a 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.9+dev" +version = "1.6.10" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f69f4f843a..798405d3a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.9+dev" +version = "1.6.10" description = "" authors = ["Ynput Team "] readme = "README.md" From 90eef3f6b7cf9485f8385b2c94bc1c7929ebdf82 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 18 Nov 2025 13:05:58 +0000 Subject: [PATCH 268/279] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index ebf7e34a32..a220d400d4 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.10" +__version__ = "1.6.10+dev" diff --git a/package.py b/package.py index 461e09c38a..bb9c1b0c7e 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.10" +version = "1.6.10+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 798405d3a8..e61c2708de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.10" +version = "1.6.10+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 55f7ff6a46c2fe49f9398582bc2fba3b7a42c685 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 18 Nov 2025 13:06:52 +0000 Subject: [PATCH 269/279] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e48e4b3b29..78e86f43e4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.10 - 1.6.9 - 1.6.8 - 1.6.7 From 2375dda43bb72ac8c5396423a8b76ec35c41878f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 18 Nov 2025 17:10:38 +0200 Subject: [PATCH 270/279] Add `folderpaths` for template profile filtering --- .../pipeline/workfile/workfile_template_builder.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 52e27baa80..9c77d2f7f7 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -832,14 +832,18 @@ class AbstractTemplateBuilder(ABC): host_name = self.host_name task_name = self.current_task_name task_type = self.current_task_type + folder_path = self.current_folder_path build_profiles = self._get_build_profiles() + filter_data = { + "task_types": task_type, + "task_names": task_name, + "folder_paths": folder_path + } profile = filter_profiles( build_profiles, - { - "task_types": task_type, - "task_names": task_name - } + filter_data, + logger=self.log ) if not profile: raise TemplateProfileNotFound(( From 2af5e918a745075c814c9b4d8dec08b3bfe5862b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:11:50 +0100 Subject: [PATCH 271/279] safe guard downloading of image for ayon_url --- client/ayon_core/tools/utils/lib.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index e087112a04..3308b943f0 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -548,11 +548,17 @@ class _IconsCache: elif icon_type == "ayon_url": url = icon_def["url"].lstrip("/") url = f"{ayon_api.get_base_url()}/{url}" - stream = io.BytesIO() - ayon_api.download_file_to_stream(url, stream) - pix = QtGui.QPixmap() - pix.loadFromData(stream.getvalue()) - icon = QtGui.QIcon(pix) + try: + stream = io.BytesIO() + ayon_api.download_file_to_stream(url, stream) + pix = QtGui.QPixmap() + pix.loadFromData(stream.getvalue()) + icon = QtGui.QIcon(pix) + except Exception: + log.warning( + "Failed to download image '%s'", url, exc_info=True + ) + icon = None elif icon_type == "transparent": size = icon_def.get("size") From 17f5788e43e980d308ef7f3442d59a05cbe7a216 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Nov 2025 12:56:01 +0200 Subject: [PATCH 272/279] Template Builder: Add folder types to filter data --- .../pipeline/workfile/workfile_template_builder.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 9c77d2f7f7..25b1c6737b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -834,12 +834,17 @@ class AbstractTemplateBuilder(ABC): task_type = self.current_task_type folder_path = self.current_folder_path - build_profiles = self._get_build_profiles() filter_data = { "task_types": task_type, "task_names": task_name, "folder_paths": folder_path } + + folder_entity = self.current_folder_entity + if folder_entity: + filter_data.update({"folder_types": folder_entity["folderType"]}) + + build_profiles = self._get_build_profiles() profile = filter_profiles( build_profiles, filter_data, From 6125a7db803dd2bfe878fa0fe255822b9a0655c6 Mon Sep 17 00:00:00 2001 From: Mustafa Zaky Jafar Date: Wed, 19 Nov 2025 13:15:37 +0200 Subject: [PATCH 273/279] Update client/ayon_core/pipeline/workfile/workfile_template_builder.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../pipeline/workfile/workfile_template_builder.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index dfb17b850f..6c0bc4c602 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -841,16 +841,18 @@ class AbstractTemplateBuilder(ABC): task_name = self.current_task_name task_type = self.current_task_type folder_path = self.current_folder_path + folder_type = None + folder_entity = self.current_folder_entity + if folder_entity: + folder_type = folder_entity["folderType"] filter_data = { "task_types": task_type, "task_names": task_name, - "folder_paths": folder_path + "folder_types": folder_type, + "folder_paths": folder_path, } - folder_entity = self.current_folder_entity - if folder_entity: - filter_data.update({"folder_types": folder_entity["folderType"]}) build_profiles = self._get_build_profiles() profile = filter_profiles( From 42da0fb424a0b3c07b2da8b38897851ebc025b61 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Nov 2025 13:17:12 +0200 Subject: [PATCH 274/279] Make Ruff Happy. --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 6c0bc4c602..cbe9f82a81 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -853,7 +853,6 @@ class AbstractTemplateBuilder(ABC): "folder_paths": folder_path, } - build_profiles = self._get_build_profiles() profile = filter_profiles( build_profiles, From 17925292670462c518664d7fc2fb41b20c50c9db Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 19 Nov 2025 16:27:42 +0100 Subject: [PATCH 275/279] Safe-guard workfile template builder for versionless products --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index cbe9f82a81..2f9e7250c0 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1687,6 +1687,8 @@ class PlaceholderLoadMixin(object): for version in get_last_versions( project_name, filtered_product_ids, fields={"id"} ).values() + # Version may be none if a product has no versions + if version is not None ) return list(get_representations( project_name, From 626f627f581793749b6b74ccd98f8d733a23ee56 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:59:51 +0100 Subject: [PATCH 276/279] fix product types fetching in loader tool --- .../ayon_core/tools/loader/models/products.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 7915a75bcf..83a017613d 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Iterable, Optional import arrow import ayon_api +from ayon_api.graphql_queries import project_graphql_query from ayon_api.operations import OperationsSession from ayon_core.lib import NestedCacheItem @@ -202,7 +203,7 @@ class ProductsModel: cache = self._product_type_items_cache[project_name] if not cache.is_valid: icons_mapping = self._get_product_type_icons(project_name) - product_types = ayon_api.get_project_product_types(project_name) + product_types = self._get_project_product_types(project_name) cache.update_data([ ProductTypeItem( product_type["name"], @@ -462,6 +463,24 @@ class ProductsModel: PRODUCTS_MODEL_SENDER ) + def _get_project_product_types(self, project_name: str) -> list[dict]: + """This is a temporary solution for product types fetching. + + There was a bug in ayon_api.get_project(...) which did not use GraphQl + but REST instead. That is fixed in ayon-python-api 1.2.6 that will + be as part of ayon launcher 1.4.3 release. + + """ + if not project_name: + return [] + query = project_graphql_query({"productTypes.name"}) + query.set_variable_value("projectName", project_name) + parsed_data = query.query(ayon_api.get_server_api_connection()) + project = parsed_data["project"] + if project is None: + return [] + return project["productTypes"] + def _get_product_type_icons( self, project_name: Optional[str] ) -> ProductTypeIconMapping: From cebf3be97fd2dc4f7f2192ad08e7001359a41917 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 20 Nov 2025 14:37:22 +0000 Subject: [PATCH 277/279] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index a220d400d4..5c9b1cdce8 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.10+dev" +__version__ = "1.6.11" diff --git a/package.py b/package.py index bb9c1b0c7e..9bdf309bab 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.10+dev" +version = "1.6.11" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index e61c2708de..e30eb1d5d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.10+dev" +version = "1.6.11" description = "" authors = ["Ynput Team "] readme = "README.md" From 1ac26453d5f926a6692ca4127dc6820899d0eecb Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 20 Nov 2025 14:38:01 +0000 Subject: [PATCH 278/279] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 5c9b1cdce8..a3e1a6c939 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.11" +__version__ = "1.6.11+dev" diff --git a/package.py b/package.py index 9bdf309bab..62231060f0 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.11" +version = "1.6.11+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index e30eb1d5d9..d568edefc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.11" +version = "1.6.11+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 5bccc7cf2bcdc51e446cf7794f09d6081ff7255a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Nov 2025 14:39:23 +0000 Subject: [PATCH 279/279] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 78e86f43e4..7fc253b1b8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.11 - 1.6.10 - 1.6.9 - 1.6.8