From 148ce21a9ac25a446a3ce2233501d288b396d34b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Nov 2024 09:09:12 +0100 Subject: [PATCH 001/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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/208] 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 0b51e17a8a6bfa1d411fab3442f4f7bbabaadc6f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 17 Oct 2025 16:21:32 +0200 Subject: [PATCH 107/208] 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 108/208] 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 109/208] 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 110/208] 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 111/208] 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 34b292b06a2a5d0f999ed0093252b10081c9e186 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 21 Oct 2025 15:33:55 +0200 Subject: [PATCH 112/208] 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 113/208] 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 114/208] 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 115/208] 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 13e88e70a2f21ceabc31a99ff1e2b0c28af04bb2 Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Thu, 30 Oct 2025 13:32:26 -0400 Subject: [PATCH 116/208] 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 117/208] 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 118/208] 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 87ba72eb002635d0cd1c69d8739db97a25a4150e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Nov 2025 09:44:20 +0100 Subject: [PATCH 119/208] 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 120/208] 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 121/208] 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 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 122/208] 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 123/208] 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 124/208] 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 125/208] 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 126/208] 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 127/208] 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 128/208] 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 129/208] 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 130/208] 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 131/208] 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 132/208] 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 133/208] 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 134/208] 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 135/208] 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 136/208] 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 137/208] 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 138/208] 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 139/208] 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 140/208] 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 141/208] 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 142/208] 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 143/208] 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 144/208] 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 145/208] 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 146/208] 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 147/208] 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 148/208] 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 149/208] 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 150/208] 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 151/208] 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 152/208] 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 153/208] 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 154/208] 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 155/208] 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 156/208] 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 157/208] 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 158/208] 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 159/208] 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 160/208] 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 161/208] 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 162/208] 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 163/208] 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 164/208] 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 165/208] 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 166/208] [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 167/208] [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 168/208] 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 169/208] 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 170/208] 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 171/208] 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 172/208] 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 173/208] 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 174/208] 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 175/208] 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 176/208] 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 177/208] 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 178/208] 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 179/208] [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 180/208] [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 181/208] 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 182/208] 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 183/208] 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 184/208] 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 185/208] 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 186/208] 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 187/208] 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 188/208] 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 189/208] 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 190/208] 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 191/208] 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 192/208] 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 193/208] 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 194/208] 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 195/208] 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 196/208] [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 197/208] [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 198/208] 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 199/208] 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 200/208] 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 201/208] 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 202/208] 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 203/208] 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 204/208] 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 205/208] 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 206/208] [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 207/208] [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 208/208] 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