From 148ce21a9ac25a446a3ce2233501d288b396d34b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 6 Nov 2024 09:09:12 +0100 Subject: [PATCH 01/65] 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 02/65] 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 03/65] 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 2d7bd487bac24b28c78e5bf67a19c4f69c6e3db7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 13 May 2025 15:40:40 +0200 Subject: [PATCH 04/65] 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 05/65] 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 06/65] 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 07/65] 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 08/65] 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 09/65] 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 10/65] 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 11/65] 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 12/65] 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 13/65] 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 14/65] 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 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 15/65] 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 16/65] 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 17/65] 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 18/65] 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 19/65] 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 20/65] 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 21/65] 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 22/65] 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 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 23/65] 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 24/65] 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 25/65] 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 26/65] 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 27/65] 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 28/65] 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 29/65] 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 30/65] 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 31/65] 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 32/65] 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 33/65] 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 34/65] 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 35/65] 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 36/65] 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 37/65] 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 cb81a57dddf6024318af16dfbde4a7e63cf7546a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Oct 2025 22:31:49 +0100 Subject: [PATCH 38/65] 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 39/65] Do not fail on thumbnail creation if it can't resolve a reviewable channel with OIIO --- .../ayon_core/plugins/publish/extract_thumbnail.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index b5885178d0..2a43c12af3 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -17,6 +17,7 @@ from ayon_core.lib import ( run_subprocess, ) from ayon_core.lib.transcoding import ( + MissingRGBAChannelsError, oiio_color_convert, get_oiio_input_and_channel_args, get_oiio_info_for_input, @@ -477,7 +478,16 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False input_info = get_oiio_info_for_input(src_path, logger=self.log) - input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) + try: + input_arg, channels_arg = get_oiio_input_and_channel_args( + input_info + ) + except MissingRGBAChannelsError: + self.log.debug( + "Unable to find relevant reviewable channel for thumbnail " + "creation" + ) + return False oiio_cmd = get_oiio_tool_args( "oiiotool", input_arg, src_path, From 5e877f9b05730ac9bc704d2741614e6fbeef9837 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 15:10:03 +0100 Subject: [PATCH 40/65] Copy over attrib to copy product groupping --- client/ayon_core/tools/push_to_project/models/integrate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index cacce44942..761eb175cd 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -952,6 +952,7 @@ class ProjectPushItemProcess: product_name, product_type, folder_id, + attribs=self._src_product_entity["attrib"] ) self._operations.create_entity( project_name, "product", product_entity From 758e232b6c14b872f27b3d5a22bda7fc4db762a0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 15:35:27 +0100 Subject: [PATCH 41/65] Copy over only limited set of attributes for safety --- client/ayon_core/tools/push_to_project/models/integrate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 761eb175cd..a72a74f805 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -948,11 +948,16 @@ class ProjectPushItemProcess: self._product_entity = product_entity return product_entity + src_attrib = self._src_product_entity["attrib"] + copied_attrib = { + "description": src_attrib.get("description"), + "productGroup": src_attrib.get("productGroup") + } product_entity = new_product_entity( product_name, product_type, folder_id, - attribs=self._src_product_entity["attrib"] + attribs=copied_attrib ) self._operations.create_entity( project_name, "product", product_entity From 43f7ace90e298c59a06983cc4857426ab85c1629 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 15:58:36 +0100 Subject: [PATCH 42/65] Make copy of selected attributes closer to existing --- .../tools/push_to_project/models/integrate.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index a72a74f805..b2a987e121 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -949,15 +949,20 @@ class ProjectPushItemProcess: return product_entity src_attrib = self._src_product_entity["attrib"] - copied_attrib = { - "description": src_attrib.get("description"), - "productGroup": src_attrib.get("productGroup") - } + + dst_attrib = {} + for key in { + "description", + "productGroup", + }: + if key in src_attrib: + dst_attrib[key] = src_attrib[key] + product_entity = new_product_entity( product_name, product_type, folder_id, - attribs=copied_attrib + attribs=dst_attrib ) self._operations.create_entity( project_name, "product", product_entity From 23fd59f23ae728b2eadf143ab938c0bec737441f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:04:23 +0100 Subject: [PATCH 43/65] Added version_up checkbox If selected it creates new version for existing product, otherwise it overwrites the version. --- .../tools/push_to_project/control.py | 1 + .../tools/push_to_project/ui/window.py | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index b4e0d56dfd..41cd9bf823 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -41,6 +41,7 @@ class PushToContextController: self._process_item_id = None self._use_original_name = False + self._version_up = False self.set_source(project_name, version_ids) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index f382ccce64..c392c6b519 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -153,7 +153,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) inputs_layout.addRow( - "Use original product names", original_names_checkbox) + "Use original product names", original_names_checkbox + ) + version_up_checkbox = NiceCheckbox(True, parent=inputs_widget) + inputs_layout.addRow( + "Version up existing Product", version_up_checkbox + ) inputs_layout.addRow("Comment", comment_input) main_splitter.addWidget(context_widget) @@ -209,8 +214,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): "Show error detail dialog to copy full error." ) original_names_checkbox.setToolTip( - "Required for multi copy, doesn't allow changes " - "variant values." + "Required for multi copy, doesn't allow changes variant values." + ) + version_up_checkbox.setToolTip( + "Version up existing product. If not selected version will be " + "updated." ) overlay_close_btn = QtWidgets.QPushButton( @@ -259,6 +267,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): library_only_checkbox.stateChanged.connect(self._on_library_only_change) original_names_checkbox.stateChanged.connect( self._on_original_names_change) + version_up_checkbox.stateChanged.connect( + self._on_version_up_checkbox_change) publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) @@ -328,6 +338,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._new_folder_name_input_text = None self._variant_input_text = None self._comment_input_text = None + self._version_up_checkbox = version_up_checkbox self._first_show = True self._show_timer = show_timer @@ -344,6 +355,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): show_detail_btn.setVisible(False) overlay_close_btn.setVisible(False) overlay_try_btn.setVisible(False) + version_up_checkbox.setChecked(False) # Support of public api function of controller def set_source(self, project_name, version_ids): @@ -424,6 +436,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): use_original_name = bool(state) self._invalidate_use_original_names(use_original_name) + def _on_version_up_checkbox_change(self, state: int) -> None: + self._controller._version_up = bool(state) + self._version_up_checkbox.setChecked(bool(state)) + def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled folder_name = self._new_folder_name_input_text From 8bb4b2096a66cfd678c29c8c4b816c0140ad3295 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:04:54 +0100 Subject: [PATCH 44/65] Implemented propagation of version up value --- client/ayon_core/tools/push_to_project/control.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 41cd9bf823..56e587a4e2 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -203,6 +203,9 @@ class PushToContextController: return item_ids = [] + dst_version = 1 + if self._version_up: + dst_version = None for src_version_entity in self._src_version_entities: item_id = self._integrate_model.create_process_item( self._src_project_name, @@ -213,7 +216,7 @@ class PushToContextController: self._user_values.variant, comment=self._user_values.comment, new_folder_name=self._user_values.new_folder_name, - dst_version=1, + dst_version=dst_version, use_original_name=self._use_original_name, ) item_ids.append(item_id) From 23a6578d6fc0235b8ba2ec3b75ddef8695373bc9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:09:59 +0100 Subject: [PATCH 45/65] Formatting change --- .../tools/push_to_project/models/integrate.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index b2a987e121..8dd8d8145c 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -955,8 +955,9 @@ class ProjectPushItemProcess: "description", "productGroup", }: - if key in src_attrib: - dst_attrib[key] = src_attrib[key] + value = src_attrib.get(key) + if value: + dst_attrib[key] = value product_entity = new_product_entity( product_name, @@ -1001,8 +1002,9 @@ class ProjectPushItemProcess: "description", "intent", }: - if key in src_attrib: - dst_attrib[key] = src_attrib[key] + value = src_attrib.get(key) + if value: + dst_attrib[key] = value if version is None: last_version_entity = ayon_api.get_last_version_by_product_id( From 3bc92d88f04db94c9ffb9f7cbf9b2c1bd866a87c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:13:48 +0100 Subject: [PATCH 46/65] Reorganized lines --- client/ayon_core/tools/push_to_project/ui/window.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index c392c6b519..7eb90425ab 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -144,6 +144,8 @@ class PushToContextSelectWindow(QtWidgets.QWidget): variant_input.setPlaceholderText("< Variant >") variant_input.setObjectName("ValidatedLineEdit") + version_up_checkbox = NiceCheckbox(True, parent=inputs_widget) + comment_input = PlaceholderLineEdit(inputs_widget) comment_input.setPlaceholderText("< Publish comment >") @@ -155,7 +157,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout.addRow( "Use original product names", original_names_checkbox ) - version_up_checkbox = NiceCheckbox(True, parent=inputs_widget) inputs_layout.addRow( "Version up existing Product", version_up_checkbox ) From 9713852deb28a5136c54156803723929f4067365 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:15:19 +0100 Subject: [PATCH 47/65] Unnecessary and wrong manual set --- client/ayon_core/tools/push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 7eb90425ab..23aa710cd3 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -439,7 +439,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): def _on_version_up_checkbox_change(self, state: int) -> None: self._controller._version_up = bool(state) - self._version_up_checkbox.setChecked(bool(state)) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled From 23b0378a0e0fffc68f206c7800f1552d9f9e764f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:17:49 +0100 Subject: [PATCH 48/65] Use public method to set private variable --- client/ayon_core/tools/push_to_project/control.py | 3 +++ client/ayon_core/tools/push_to_project/ui/window.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 56e587a4e2..8fd4b6053e 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -233,6 +233,9 @@ class PushToContextController: thread.start() return item_ids + def set_version_up(self, state): + self._version_up = state + def wait_for_process_thread(self): if self._process_thread is None: return diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index 23aa710cd3..6be4d3c237 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -438,7 +438,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._invalidate_use_original_names(use_original_name) def _on_version_up_checkbox_change(self, state: int) -> None: - self._controller._version_up = bool(state) + self._controller.set_version_up(bool(state)) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled From 7229f5d794861e71842fd6c1d20707ea174c02a9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 16:57:50 +0100 Subject: [PATCH 49/65] Reworked hardcoded version to version_up variable 1 was used as hardcoded version, this way updated will be always last version if exists. Hardcoding 1 doesnt make sense with `get_versioning_start` which should be source of truth. Incoming value of version would make sense if we would like to start/reset specific version, which is unlikely (and currently impossible without updates to UI). --- .../tools/push_to_project/control.py | 5 +- .../tools/push_to_project/models/integrate.py | 49 ++++++++++--------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/control.py b/client/ayon_core/tools/push_to_project/control.py index 8fd4b6053e..a24cedf455 100644 --- a/client/ayon_core/tools/push_to_project/control.py +++ b/client/ayon_core/tools/push_to_project/control.py @@ -203,9 +203,6 @@ class PushToContextController: return item_ids = [] - dst_version = 1 - if self._version_up: - dst_version = None for src_version_entity in self._src_version_entities: item_id = self._integrate_model.create_process_item( self._src_project_name, @@ -216,7 +213,7 @@ class PushToContextController: self._user_values.variant, comment=self._user_values.comment, new_folder_name=self._user_values.new_folder_name, - dst_version=dst_version, + version_up=self._version_up, use_original_name=self._use_original_name, ) item_ids.append(item_id) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index cacce44942..7130922ba0 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -89,7 +89,7 @@ class ProjectPushItem: variant, comment, new_folder_name, - dst_version, + version_up, item_id=None, use_original_name=False ): @@ -100,7 +100,7 @@ class ProjectPushItem: self.dst_project_name = dst_project_name self.dst_folder_id = dst_folder_id self.dst_task_name = dst_task_name - self.dst_version = dst_version + self.version_up = version_up self.variant = variant self.new_folder_name = new_folder_name self.comment = comment or "" @@ -118,7 +118,7 @@ class ProjectPushItem: str(self.dst_folder_id), str(self.new_folder_name), str(self.dst_task_name), - str(self.dst_version), + str(self.version_up), self.use_original_name ]) return self._repr_value @@ -133,7 +133,7 @@ class ProjectPushItem: "dst_project_name": self.dst_project_name, "dst_folder_id": self.dst_folder_id, "dst_task_name": self.dst_task_name, - "dst_version": self.dst_version, + "version_up": self.version_up, "variant": self.variant, "comment": self.comment, "new_folder_name": self.new_folder_name, @@ -962,7 +962,7 @@ class ProjectPushItemProcess: """Make sure version document exits in database.""" project_name = self._item.dst_project_name - version = self._item.dst_version + version_up = self._item.version_up src_version_entity = self._src_version_entity product_entity = self._product_entity product_id = product_entity["id"] @@ -993,24 +993,25 @@ class ProjectPushItemProcess: if key in src_attrib: dst_attrib[key] = src_attrib[key] - if version is None: - last_version_entity = ayon_api.get_last_version_by_product_id( - project_name, product_id + last_version_entity = ayon_api.get_last_version_by_product_id( + project_name, product_id + ) + if last_version_entity is None: + dst_version = get_versioning_start( + project_name, + self.host_name, + task_name=self._task_info.get("name"), + task_type=self._task_info.get("taskType"), + product_type=product_type, + product_name=product_entity["name"], ) - if last_version_entity: - version = int(last_version_entity["version"]) + 1 - else: - version = get_versioning_start( - project_name, - self.host_name, - task_name=self._task_info.get("name"), - task_type=self._task_info.get("taskType"), - product_type=product_type, - product_name=product_entity["name"], - ) + else: + dst_version = int(last_version_entity["version"]) + if version_up: + dst_version += 1 existing_version_entity = ayon_api.get_version_by_name( - project_name, version, product_id + project_name, dst_version, product_id ) thumbnail_id = self._copy_version_thumbnail() @@ -1032,7 +1033,7 @@ class ProjectPushItemProcess: copied_status = self._get_transferable_status(src_version_entity) version_entity = new_version_entity( - version, + dst_version, product_id, author=src_version_entity["author"], status=copied_status, @@ -1380,7 +1381,7 @@ class IntegrateModel: variant, comment, new_folder_name, - dst_version, + version_up, use_original_name ): """Create new item for integration. @@ -1394,7 +1395,7 @@ class IntegrateModel: variant (str): Variant name. comment (Union[str, None]): Comment. new_folder_name (Union[str, None]): New folder name. - dst_version (int): Destination version number. + version_up (bool): Should destination product be versioned up use_original_name (bool): If original product names should be used Returns: @@ -1411,7 +1412,7 @@ class IntegrateModel: variant, comment=comment, new_folder_name=new_folder_name, - dst_version=dst_version, + version_up=version_up, use_original_name=use_original_name ) process_item = ProjectPushItemProcess(self, item) From 9e6dd82c7477ab10f2c0640b467b863fbf1e705a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 31 Oct 2025 17:23:18 +0100 Subject: [PATCH 50/65] Removed unnecessary call to private method --- client/ayon_core/tools/push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index f382ccce64..c1308cece0 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -376,7 +376,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._invalidate_new_folder_name( new_folder_name, user_values["is_new_folder_name_valid"] ) - self._controller._invalidate() self._projects_combobox.refresh() def _on_first_show(self): From 87ba72eb002635d0cd1c69d8739db97a25a4150e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Nov 2025 09:44:20 +0100 Subject: [PATCH 51/65] 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 52/65] 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 53/65] Update docstring --- client/ayon_core/plugins/publish/extract_color_transcode.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 2aefd15fd9..1a2c85e597 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -298,9 +298,8 @@ class ExtractOIIOTranscode(publish.Extractor): def _translate_to_sequence(self, files_to_convert): """Returns original list or a clique.Collection of a sequence. - Uses clique to find frame sequence, in this case it merges all frames - into sequence format (FRAMESTART-FRAMEEND#) and returns it. - If sequence not found, it returns original list + Uses clique to find frame sequence Collection. + If sequence not found, it returns original list. Args: files_to_convert (list): list of file names From 6b6001dc42e4011da76344a1c161f3eef45f2001 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 4 Nov 2025 14:07:58 +0100 Subject: [PATCH 54/65] Refactored usage of Qt.CheckState --- .../tools/push_to_project/ui/window.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/ui/window.py b/client/ayon_core/tools/push_to_project/ui/window.py index c19a756bd7..b77cca0e09 100644 --- a/client/ayon_core/tools/push_to_project/ui/window.py +++ b/client/ayon_core/tools/push_to_project/ui/window.py @@ -319,6 +319,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._folder_name_input = folder_name_input self._comment_input = comment_input self._use_original_names_checkbox = original_names_checkbox + self._library_only_checkbox = library_only_checkbox self._publish_btn = publish_btn @@ -427,17 +428,18 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._comment_input_text = text self._user_input_changed_timer.start() - def _on_library_only_change(self, state: int) -> None: + def _on_library_only_change(self) -> None: """Change toggle state, reset filter, recalculate dropdown""" - state = bool(state) - self._projects_combobox.set_standard_filter_enabled(state) + is_checked = self._library_only_checkbox.isChecked() + self._projects_combobox.set_standard_filter_enabled(is_checked) - def _on_original_names_change(self, state: int) -> None: - use_original_name = bool(state) - self._invalidate_use_original_names(use_original_name) + def _on_original_names_change(self) -> None: + is_checked = self._use_original_names_checkbox.isChecked() + self._invalidate_use_original_names(is_checked) - def _on_version_up_checkbox_change(self, state: int) -> None: - self._controller.set_version_up(bool(state)) + def _on_version_up_checkbox_change(self) -> None: + is_checked = self._version_up_checkbox.isChecked() + self._controller.set_version_up(is_checked) def _on_user_input_timer(self): folder_name_enabled = self._new_folder_name_enabled From 64f511a43b2704d5e967884baa4e59d5de7861fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:12:17 +0100 Subject: [PATCH 55/65] 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 56/65] 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 57/65] 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 58/65] 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 59/65] 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 60/65] 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 61/65] 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 62/65] 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 63/65] 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 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 64/65] 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 65/65] 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