From cb125a192f0728562d5e76d2b510370d65c4f1f8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 31 Mar 2025 23:14:17 +0200 Subject: [PATCH 001/386] Optimize oiio tool conversion for ffmpeg. - Prepare attributes to remove list just once. - Process sequences as a single `oiiotool` call --- client/ayon_core/lib/transcoding.py | 203 +++++++--------------------- 1 file changed, 50 insertions(+), 153 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..39995083c0 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -10,6 +10,8 @@ from typing import Optional import xml.etree.ElementTree +import clique + from .execute import run_subprocess from .vendor_bin_utils import ( get_ffmpeg_tool_args, @@ -526,135 +528,36 @@ def should_convert_for_ffmpeg(src_filepath): return False -# Deprecated since 2022 4 20 -# - Reason - Doesn't convert sequences right way: Can't handle gaps, reuse -# first frame for all frames and changes filenames when input -# is sequence. -# - use 'convert_input_paths_for_ffmpeg' instead -def convert_for_ffmpeg( - first_input_path, - output_dir, - input_frame_start=None, - input_frame_end=None, - logger=None -): - """Convert source file to format supported in ffmpeg. - - Currently can convert only exrs. - - Args: - first_input_path (str): Path to first file of a sequence or a single - file path for non-sequential input. - output_dir (str): Path to directory where output will be rendered. - Must not be same as input's directory. - input_frame_start (int): Frame start of input. - input_frame_end (int): Frame end of input. - logger (logging.Logger): Logger used for logging. - - Raises: - ValueError: If input filepath has extension not supported by function. - Currently is supported only ".exr" extension. - """ - if logger is None: - logger = logging.getLogger(__name__) - - logger.warning(( - "DEPRECATED: 'ayon_core.lib.transcoding.convert_for_ffmpeg' is" - " deprecated function of conversion for FFMpeg. Please replace usage" - " with 'ayon_core.lib.transcoding.convert_input_paths_for_ffmpeg'" - )) - - ext = os.path.splitext(first_input_path)[1].lower() - if ext != ".exr": - raise ValueError(( - "Function 'convert_for_ffmpeg' currently support only" - " \".exr\" extension. Got \"{}\"." - ).format(ext)) - - is_sequence = False - if input_frame_start is not None and input_frame_end is not None: - is_sequence = int(input_frame_end) != int(input_frame_start) - - input_info = get_oiio_info_for_input(first_input_path, logger=logger) - - # Change compression only if source compression is "dwaa" or "dwab" - # - they're not supported in ffmpeg - compression = input_info["attribs"].get("compression") - if compression in ("dwaa", "dwab"): - compression = "none" - - # Prepare subprocess arguments - oiio_cmd = get_oiio_tool_args( - "oiiotool", - # Don't add any additional attributes - "--nosoftwareattrib", - ) - # Add input compression if available - if compression: - oiio_cmd.extend(["--compression", compression]) - - # Collect channels to export - input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - - oiio_cmd.extend([ - input_arg, first_input_path, - # Tell oiiotool which channels should be put to top stack (and output) - "--ch", channels_arg, - # Use first subimage - "--subimage", "0" - ]) - - # Add frame definitions to arguments - if is_sequence: - oiio_cmd.extend([ - "--frames", "{}-{}".format(input_frame_start, input_frame_end) - ]) - +def _get_attributes_to_erase(input_info: dict, logger: logging.Logger = None) -> list[str]: + """FFMPEG does not support some attributes in metadata.""" + erase_attr_names = [] + reasons = [] for attr_name, attr_value in input_info["attribs"].items(): if not isinstance(attr_value, str): continue # Remove attributes that have string value longer than allowed length # for ffmpeg or when contain prohibited symbols - erase_reason = "Missing reason" - erase_attribute = False if len(attr_value) > MAX_FFMPEG_STRING_LEN: - erase_reason = "has too long value ({} chars).".format( - len(attr_value) - ) - erase_attribute = True + reason = f"has too long value ({len(attr_value)} chars)." + erase_attr_names.append(attr_name) + reasons.append(reason) - if not erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break + for char in NOT_ALLOWED_FFMPEG_CHARS: + if char not in attr_value: + continue + reason = f"contains unsupported character \"{char}\"." + erase_attr_names.append(attr_name) + reasons.append(reason) - if erase_attribute: + if logger is not None: + for attr_name, reason in zip(erase_attr_names, reasons): # Set attribute to empty string - logger.info(( - "Removed attribute \"{}\" from metadata because {}." - ).format(attr_name, erase_reason)) - oiio_cmd.extend(["--eraseattrib", attr_name]) - - # Add last argument - path to output - if is_sequence: - ext = os.path.splitext(first_input_path)[1] - base_filename = "tmp.%{:0>2}d{}".format( - len(str(input_frame_end)), ext - ) - else: - base_filename = os.path.basename(first_input_path) - output_path = os.path.join(output_dir, base_filename) - oiio_cmd.extend([ - "-o", output_path - ]) - - logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) - run_subprocess(oiio_cmd, logger=logger) + logger.info( + f"Removed attribute \"{attr_name}\" from metadata" + f" because {reason}." + ) + return erase_attr_names def convert_input_paths_for_ffmpeg( @@ -664,7 +567,7 @@ def convert_input_paths_for_ffmpeg( ): """Convert source file to format supported in ffmpeg. - Currently can convert only exrs. The input filepaths should be files + Currently, can convert only exrs. The input filepaths should be files with same type. Information about input is loaded only from first found file. @@ -682,7 +585,7 @@ def convert_input_paths_for_ffmpeg( Raises: ValueError: If input filepath has extension not supported by function. - Currently is supported only ".exr" extension. + Currently, only ".exr" extension is supported. """ if logger is None: logger = logging.getLogger(__name__) @@ -707,7 +610,19 @@ def convert_input_paths_for_ffmpeg( # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - for input_path in input_paths: + # Find which attributes to strip + erase_attributes: list[str] = _get_attributes_to_erase( + input_info, logger=logger + ) + + input_collections, input_remainders = clique.assemble( + input_paths, + patterns=[clique.PATTERNS["frames"]], + assume_padded_when_ambiguous=True, + ) + process_inputs = list(input_collections) + process_inputs.extend(input_remainders) + for _input in process_inputs: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -718,8 +633,17 @@ def convert_input_paths_for_ffmpeg( if compression: oiio_cmd.extend(["--compression", compression]) + # Convert a sequence of files using a single oiiotool command + # using its sequence syntax + if isinstance(_input, clique.Collection): + oiio_cmd.extend([ + "--framepadding", _input.padding, + "--frames", _input.format("{ranges}"), + ]) + _input: str = _input.format("{head}#{tail}") + oiio_cmd.extend([ - input_arg, input_path, + input_arg, _input, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, @@ -727,38 +651,11 @@ def convert_input_paths_for_ffmpeg( "--subimage", "0" ]) - for attr_name, attr_value in input_info["attribs"].items(): - if not isinstance(attr_value, str): - continue - - # Remove attributes that have string value longer than allowed - # length for ffmpeg or when containing prohibited symbols - erase_reason = "Missing reason" - erase_attribute = False - if len(attr_value) > MAX_FFMPEG_STRING_LEN: - erase_reason = "has too long value ({} chars).".format( - len(attr_value) - ) - erase_attribute = True - - if not erase_attribute: - for char in NOT_ALLOWED_FFMPEG_CHARS: - if char in attr_value: - erase_attribute = True - erase_reason = ( - "contains unsupported character \"{}\"." - ).format(char) - break - - if erase_attribute: - # Set attribute to empty string - logger.info(( - "Removed attribute \"{}\" from metadata because {}." - ).format(attr_name, erase_reason)) - oiio_cmd.extend(["--eraseattrib", attr_name]) + for attr_name in erase_attributes: + oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output - base_filename = os.path.basename(input_path) + base_filename = os.path.basename(_input) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path From c7c2a4a7eccc543d0262701f9b868a73bc76766e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Apr 2025 09:07:02 +0200 Subject: [PATCH 002/386] Cleanup --- client/ayon_core/lib/transcoding.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 39995083c0..2238b24c3b 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -528,10 +528,11 @@ def should_convert_for_ffmpeg(src_filepath): return False -def _get_attributes_to_erase(input_info: dict, logger: logging.Logger = None) -> list[str]: +def _get_attributes_to_erase( + input_info: dict, logger: logging.Logger +) -> list[str]: """FFMPEG does not support some attributes in metadata.""" - erase_attr_names = [] - reasons = [] + erase_attrs: dict[str, str] = {} # Attr name to reason mapping for attr_name, attr_value in input_info["attribs"].items(): if not isinstance(attr_value, str): continue @@ -540,24 +541,23 @@ def _get_attributes_to_erase(input_info: dict, logger: logging.Logger = None) -> # for ffmpeg or when contain prohibited symbols if len(attr_value) > MAX_FFMPEG_STRING_LEN: reason = f"has too long value ({len(attr_value)} chars)." - erase_attr_names.append(attr_name) - reasons.append(reason) + erase_attrs[attr_name] = reason + continue for char in NOT_ALLOWED_FFMPEG_CHARS: if char not in attr_value: continue reason = f"contains unsupported character \"{char}\"." - erase_attr_names.append(attr_name) - reasons.append(reason) + erase_attrs[attr_name] = reason + break if logger is not None: - for attr_name, reason in zip(erase_attr_names, reasons): - # Set attribute to empty string + for attr_name, reason in erase_attrs.items(): logger.info( f"Removed attribute \"{attr_name}\" from metadata" f" because {reason}." ) - return erase_attr_names + return list(erase_attrs.keys()) def convert_input_paths_for_ffmpeg( @@ -615,14 +615,14 @@ def convert_input_paths_for_ffmpeg( input_info, logger=logger ) - input_collections, input_remainders = clique.assemble( + input_collections, input_remainder = clique.assemble( input_paths, patterns=[clique.PATTERNS["frames"]], assume_padded_when_ambiguous=True, ) - process_inputs = list(input_collections) - process_inputs.extend(input_remainders) - for _input in process_inputs: + input_items = list(input_collections) + input_items.extend(input_remainder) + for _input in input_items: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", From b43969da1c020af1fc209804d39363ef4d3c30c1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Apr 2025 09:08:12 +0200 Subject: [PATCH 003/386] add `from __future__ import annotations` --- client/ayon_core/lib/transcoding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 2238b24c3b..f249213f2a 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1,3 +1,4 @@ +from __future__ import annotations import os import re import logging From 04c14cab7a9b5109d35d44ed9f441f15b0e7da1c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Apr 2025 09:09:50 +0200 Subject: [PATCH 004/386] Remove deprecated function import --- client/ayon_core/lib/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 92c3966e77..8d8cc6af49 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -98,7 +98,6 @@ from .profiles_filtering import ( from .transcoding import ( get_transcode_temp_directory, should_convert_for_ffmpeg, - convert_for_ffmpeg, convert_input_paths_for_ffmpeg, get_ffprobe_data, get_ffprobe_streams, @@ -198,7 +197,6 @@ __all__ = [ "get_transcode_temp_directory", "should_convert_for_ffmpeg", - "convert_for_ffmpeg", "convert_input_paths_for_ffmpeg", "get_ffprobe_data", "get_ffprobe_streams", From 98e0ec105156d096f9dde519016760d68ae68e6d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Apr 2025 19:44:39 +0200 Subject: [PATCH 005/386] Improve parallelization for ExtractReview and ExtractOIIOTranscode - Support ExtractReview convert to FFMPEG in one `oiiotool` call for sequences - Support sequences with holes in both plug-ins by using dedicated `--frames` argument to `oiiotool` for more complex frame patterns. - Add `--parallel-frames` argument to `oiiotool` to allow parallelizing more of the OIIO tool process, improving throughput. Note: This requires OIIO 2.5.2.0 or higher. See https://github.com/AcademySoftwareFoundation/OpenImageIO/commit/f40f9800c83e2c596c127777bea1e468564fbb10 --- client/ayon_core/lib/transcoding.py | 57 ++++++++++++++++++- .../publish/extract_color_transcode.py | 48 ++++++++++------ 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1fda014bd8..948bea3685 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -10,6 +10,8 @@ from typing import Optional import xml.etree.ElementTree +import clique + from .execute import run_subprocess from .vendor_bin_utils import ( get_ffmpeg_tool_args, @@ -707,7 +709,29 @@ def convert_input_paths_for_ffmpeg( # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) - for input_path in input_paths: + # Process input files + # If a sequence of files is detected we process it in one go + # with the dedicated --frames argument for faster processing + collections, remainder = clique.assemble( + input_paths, patterns=clique.PATTERNS["frame"]) + process_queue = collections + remainder + + for input_item in process_queue: + if isinstance(input_item, clique.Collection): + # Support sequences with holes by supplying dedicated `--frames` + # Create `frames` string like "1001-1002,1004,1010-1012 + frames: str = input_item.format("{ranges}").replace(" ", "") + # Create `filename` string like "file.%04d.exr" + input_path = input_item.format("{head}{padding}{tail}") + elif isinstance(input_item, str): + # Single filepath + frames = None + input_path = input_item + else: + raise TypeError( + f"Input is not a string or Collection: {input_item}" + ) + # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -718,6 +742,14 @@ def convert_input_paths_for_ffmpeg( if compression: oiio_cmd.extend(["--compression", compression]) + if frames: + oiio_cmd.extend([ + "--frames", frames, + # TODO: Handle potential toggle for parallel frames + # to support older OIIO releases. + "--parallel-frames" + ]) + oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack @@ -1106,6 +1138,8 @@ def convert_colorspace( view=None, display=None, additional_command_args=None, + frames=None, + parallel_frames=False, logger=None, ): """Convert source file from one color space to another. @@ -1114,7 +1148,7 @@ def convert_colorspace( input_path (str): Path that should be converted. It is expected that contains single file or image sequence of same type (sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, - eg `big.1-3#.tif`) + eg `big.1-3#.tif` or `big.%04d.ext` with `frames` argument) output_path (str): Path to output filename. (must follow format of 'input_path', eg. single file or sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) @@ -1128,6 +1162,11 @@ def convert_colorspace( both 'view' and 'display' must be filled (if 'target_colorspace') additional_command_args (list): arguments for oiiotool (like binary depth for .dpx) + frames (Optional[str]): Complex frame range to process. This requires + input path and output path to use frame token placeholder like + e.g. file.%04d.exr + parallel_frames (bool): If True, process frames in parallel inside + the `oiiotool` process. Only supported in OIIO 2.5.20.0+. logger (logging.Logger): Logger used for logging. Raises: ValueError: if misconfigured @@ -1145,9 +1184,21 @@ def convert_colorspace( "oiiotool", # Don't add any additional attributes "--nosoftwareattrib", - "--colorconfig", config_path + "--colorconfig", config_path, ) + if frames: + # If `frames` is specified, then process the input and output + # as if it's a sequence of frames (must contain `%04d` as frame + # token placeholder in filepaths) + oiio_cmd.extend([ + "--frames", frames, + ]) + if parallel_frames: + oiio_cmd.extend([ + "--parallel-frames" + ]) + oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1f2c2a89af..25ac747302 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -159,9 +159,18 @@ class ExtractOIIOTranscode(publish.Extractor): files_to_convert) self.log.debug("Files to convert: {}".format(files_to_convert)) for file_name in files_to_convert: + # Handle special case for sequences where we specify + # the --frames argument to oiiotool + frames = None + parallel_frames = False + if isinstance(file_name, tuple): + file_name, frames = file_name + # TODO: Handle potential toggle for parallel frames + # to support older OIIO releases. + parallel_frames = True + self.log.debug("Transcoding file: `{}`".format(file_name)) - input_path = os.path.join(original_staging_dir, - file_name) + input_path = os.path.join(original_staging_dir, file_name) output_path = self._get_output_file_path(input_path, new_staging_dir, output_extension) @@ -175,7 +184,9 @@ class ExtractOIIOTranscode(publish.Extractor): view, display, additional_command_args, - self.log + frames=frames, + parallel_frames=parallel_frames, + logger=self.log ) # cleanup temporary transcoded files @@ -256,17 +267,22 @@ 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 individual filepaths or list of a single two-tuple + representating sequence filename with its frames. 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 + into sequence format (`%04d`) together with all its frames to support + both regular sequences and sequences with holes. + + If sequence not detected in input filenames, it returns original list. Args: - files_to_convert (list): list of file names + files_to_convert (list[str]): list of file names Returns: - (list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr] + list[Union[str, tuple[str, str]]: List of + or filepaths ['fileA.exr', 'fileB.exr'] + or sequence with frames [('file.%04d.exr', '1001-1002,1004')] + """ pattern = [clique.PATTERNS["frames"]] collections, _ = clique.assemble( @@ -279,15 +295,13 @@ class ExtractOIIOTranscode(publish.Extractor): "Too many collections {}".format(collections)) collection = collections[0] - frames = list(collection.indexes) - if collection.holes(): - return files_to_convert - frame_str = "{}-{}#".format(frames[0], frames[-1]) - file_name = "{}{}{}".format(collection.head, frame_str, - collection.tail) - - files_to_convert = [file_name] + # Support sequences with holes by supplying dedicated `--frames` + # Create `frames` string like "1001-1002,1004,1010-1012 + frames: str = collection.format("{ranges}").replace(" ", "") + # Create `filename` string like "file.%04d.exr" + filename = collection.format("{head}{padding}{tail}") + return [(filename, frames)] return files_to_convert From 0aa0673b5769d2b2ca4474d0bf1b1a8b94daeb3b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Apr 2025 19:50:59 +0200 Subject: [PATCH 006/386] Use correct variable --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index fe42429851..806d7481f2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -640,7 +640,7 @@ def convert_input_paths_for_ffmpeg( frames = _input.format("{head}#{tail}").replace(" ", "") oiio_cmd.extend([ "--framepadding", _input.padding, - "--frames", _input.format("{ranges}"), + "--frames", frames, "--parallel-frames" ]) _input: str = _input.format("{head}#{tail}") From 01174c9b1181f046f25f1bcb00f6641e4d60c024 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 09:10:44 +0200 Subject: [PATCH 007/386] Provide more sensible return type for `_translate_to_sequence` --- .../publish/extract_color_transcode.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 25ac747302..52b2af6128 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -159,15 +159,21 @@ class ExtractOIIOTranscode(publish.Extractor): files_to_convert) self.log.debug("Files to convert: {}".format(files_to_convert)) for file_name in files_to_convert: - # Handle special case for sequences where we specify - # the --frames argument to oiiotool - frames = None - parallel_frames = False - if isinstance(file_name, tuple): - file_name, frames = file_name - # TODO: Handle potential toggle for parallel frames - # to support older OIIO releases. + if isinstance(file_name, clique.Collection): + # Support sequences with holes by supplying + # dedicated `--frames` argument to `oiiotool` + # Create `filename` string like "file.%04d.exr" + file_name = file_name.format("{head}{padding}{tail}") + # Create `frames` string like "1001-1002,1004,1010-1012 + frames: str = file_name.format("{ranges}").replace( + " ", "") parallel_frames = True + elif isinstance(file_name, str): + # Single file + frames = None + parallel_frames = False + else: + raise TypeError("Unsupported files to ") self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) @@ -279,9 +285,9 @@ class ExtractOIIOTranscode(publish.Extractor): Args: files_to_convert (list[str]): list of file names Returns: - list[Union[str, tuple[str, str]]: List of - or filepaths ['fileA.exr', 'fileB.exr'] - or sequence with frames [('file.%04d.exr', '1001-1002,1004')] + list[str | clique.Collection]: List of + filepaths ['fileA.exr', 'fileB.exr'] + or clique.Collection for a sequence. """ pattern = [clique.PATTERNS["frames"]] @@ -294,14 +300,7 @@ class ExtractOIIOTranscode(publish.Extractor): raise ValueError( "Too many collections {}".format(collections)) - collection = collections[0] - - # Support sequences with holes by supplying dedicated `--frames` - # Create `frames` string like "1001-1002,1004,1010-1012 - frames: str = collection.format("{ranges}").replace(" ", "") - # Create `filename` string like "file.%04d.exr" - filename = collection.format("{head}{padding}{tail}") - return [(filename, frames)] + return collections return files_to_convert From 849a999744853e6390021759822f0fbb932ff58c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 09:11:44 +0200 Subject: [PATCH 008/386] Fix TypeError message --- client/ayon_core/plugins/publish/extract_color_transcode.py | 5 ++++- 1 file changed, 4 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 52b2af6128..8988db59ec 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -173,7 +173,10 @@ class ExtractOIIOTranscode(publish.Extractor): frames = None parallel_frames = False else: - raise TypeError("Unsupported files to ") + raise TypeError( + f"Unsupported file name type: {type(file_name)}." + " Expected str or clique.Collection." + ) self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) From ea5f1c81d61e049f306ef555f539fe24188f31a9 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:21:34 +0200 Subject: [PATCH 009/386] Fix passing sequence to `oiiotool` --- client/ayon_core/lib/transcoding.py | 13 +++++++++++-- .../plugins/publish/extract_color_transcode.py | 15 +++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 806d7481f2..41835fc8e4 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1011,6 +1011,7 @@ def convert_colorspace( display=None, additional_command_args=None, frames=None, + frame_padding=None, parallel_frames=False, logger=None, ): @@ -1020,7 +1021,7 @@ def convert_colorspace( input_path (str): Path that should be converted. It is expected that contains single file or image sequence of same type (sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, - eg `big.1-3#.tif` or `big.%04d.ext` with `frames` argument) + eg `big.1-3#.tif` or `big.1-3%d.ext` with `frames` argument) output_path (str): Path to output filename. (must follow format of 'input_path', eg. single file or sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) @@ -1036,9 +1037,11 @@ def convert_colorspace( depth for .dpx) frames (Optional[str]): Complex frame range to process. This requires input path and output path to use frame token placeholder like - e.g. file.%04d.exr + `#` or `%d`, e.g. file.#.exr parallel_frames (bool): If True, process frames in parallel inside the `oiiotool` process. Only supported in OIIO 2.5.20.0+. + frame_padding (Optional[int]): Frame padding to use for the input and + output when using a sequence filepath. logger (logging.Logger): Logger used for logging. Raises: ValueError: if misconfigured @@ -1066,6 +1069,12 @@ def convert_colorspace( oiio_cmd.extend([ "--frames", frames, ]) + + if frame_padding: + oiio_cmd.extend([ + "--framepadding", frame_padding, + ]) + if parallel_frames: oiio_cmd.extend([ "--parallel-frames" diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 8988db59ec..9d315052a2 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -162,15 +162,16 @@ class ExtractOIIOTranscode(publish.Extractor): if isinstance(file_name, clique.Collection): # Support sequences with holes by supplying # dedicated `--frames` argument to `oiiotool` - # Create `filename` string like "file.%04d.exr" - file_name = file_name.format("{head}{padding}{tail}") + # Create `filename` string like "file.#.exr" # Create `frames` string like "1001-1002,1004,1010-1012 - frames: str = file_name.format("{ranges}").replace( - " ", "") + file_name = file_name.format("{head}#{tail}") + frames = file_name.format("{ranges}").replace(" ", "") + frame_padding = file_name.padding parallel_frames = True elif isinstance(file_name, str): # Single file frames = None + frame_padding = None parallel_frames = False else: raise TypeError( @@ -194,6 +195,7 @@ class ExtractOIIOTranscode(publish.Extractor): display, additional_command_args, frames=frames, + frame_padding=frame_padding, parallel_frames=parallel_frames, logger=self.log ) @@ -279,10 +281,7 @@ class ExtractOIIOTranscode(publish.Extractor): """Returns original individual filepaths or list of a single two-tuple representating sequence filename with its frames. - Uses clique to find frame sequence, in this case it merges all frames - into sequence format (`%04d`) together with all its frames to support - both regular sequences and sequences with holes. - + Uses clique to find frame sequence, and return the collections instead. If sequence not detected in input filenames, it returns original list. Args: From 7bf2bfd6b16368f4aeb1fd5bd797a869d460c071 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:22:22 +0200 Subject: [PATCH 010/386] Improve docstring --- client/ayon_core/plugins/publish/extract_color_transcode.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 9d315052a2..13678610aa 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -278,8 +278,7 @@ class ExtractOIIOTranscode(publish.Extractor): new_repre["files"] = renamed_files def _translate_to_sequence(self, files_to_convert): - """Returns original individual filepaths or list of a single two-tuple - representating sequence filename with its frames. + """Returns original individual filepaths or list of clique.Collection. Uses clique to find frame sequence, and return the collections instead. If sequence not detected in input filenames, it returns original list. From 422febf4419bcdf165b8c81297c383e9ca171096 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:26:50 +0200 Subject: [PATCH 011/386] Fix variable usage --- client/ayon_core/plugins/publish/extract_color_transcode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 13678610aa..c549ff8a63 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -162,11 +162,11 @@ class ExtractOIIOTranscode(publish.Extractor): if isinstance(file_name, clique.Collection): # Support sequences with holes by supplying # dedicated `--frames` argument to `oiiotool` - # Create `filename` string like "file.#.exr" # Create `frames` string like "1001-1002,1004,1010-1012 - file_name = file_name.format("{head}#{tail}") + # Create `filename` string like "file.#.exr" frames = file_name.format("{ranges}").replace(" ", "") frame_padding = file_name.padding + file_name = file_name.format("{head}#{tail}") parallel_frames = True elif isinstance(file_name, str): # Single file From 537dac603358bea4b8b07806f6ce2854919aeab5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 12:47:20 +0200 Subject: [PATCH 012/386] Fix `get_oiio_info_for_input` call for sequences in `convert_colorspace` --- client/ayon_core/lib/transcoding.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 41835fc8e4..06ea353aa2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1049,7 +1049,16 @@ def convert_colorspace( if logger is None: logger = logging.getLogger(__name__) - input_info = get_oiio_info_for_input(input_path, logger=logger) + # Get oiioinfo only from first image, otherwise file can't be found + first_input_path = input_path + if frames: + assert isinstance(frames, str) # for type hints + first_frame = int(frames.split(" x-,")[0]) + first_frame = str(first_frame).zfill(frame_padding or 0) + for token in ["#", "%d"]: + first_input_path = first_input_path.replace(token, first_frame) + + input_info = get_oiio_info_for_input(first_input_path, logger=logger) # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) From ec9c6c510a8d0fdb1143f2708141736a50d06dec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 14:14:27 +0200 Subject: [PATCH 013/386] Split on any of the characters as intended, instead of on literal ` x-` --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 06ea353aa2..7073ba6b89 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1053,7 +1053,7 @@ def convert_colorspace( first_input_path = input_path if frames: assert isinstance(frames, str) # for type hints - first_frame = int(frames.split(" x-,")[0]) + first_frame = int(re.split("[ x-]", frames, 1)[0]) first_frame = str(first_frame).zfill(frame_padding or 0) for token in ["#", "%d"]: first_input_path = first_input_path.replace(token, first_frame) From 3248faff40225ecd884609e05bcaeafbd6c7a825 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Apr 2025 14:57:40 +0200 Subject: [PATCH 014/386] Fix `int` -> `str` frame padding argument to subprocess --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 7073ba6b89..82038ed543 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1081,7 +1081,7 @@ def convert_colorspace( if frame_padding: oiio_cmd.extend([ - "--framepadding", frame_padding, + "--framepadding", str(frame_padding), ]) if parallel_frames: From 204625b5c855d98f3eaf8f8d4336b3a524b12b56 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 20 May 2025 23:55:29 +0200 Subject: [PATCH 015/386] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 82038ed543..e60d9d75c8 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -552,12 +552,11 @@ def _get_attributes_to_erase( erase_attrs[attr_name] = reason break - if logger is not None: - for attr_name, reason in erase_attrs.items(): - logger.info( - f"Removed attribute \"{attr_name}\" from metadata" - f" because {reason}." - ) + for attr_name, reason in erase_attrs.items(): + logger.info( + f"Removed attribute \"{attr_name}\" from metadata" + f" because {reason}." + ) return list(erase_attrs.keys()) From bcdeba18ac65d401b9b90add03f4c8bdce3be1ee Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 2 Jun 2025 09:29:04 +0200 Subject: [PATCH 016/386] :wrench: implementation WIP --- client/ayon_core/pipeline/create/context.py | 18 +++++++++++ .../pipeline/create/creator_plugins.py | 32 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f0d9fa8927..e6cc4393c5 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -18,6 +18,7 @@ from typing import ( Callable, Union, ) +from warnings import warn import pyblish.logic import pyblish.api @@ -31,6 +32,7 @@ from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult +from ayon_core.pipeline import is_supporting_product_base_type from .exceptions import ( CreatorError, @@ -1194,6 +1196,22 @@ class CreateContext: "productType": creator.product_type, "variant": variant } + + # Add product base type if supported. + # TODO (antirotor): Once all creators support product base type + # remove this check. + if is_supporting_product_base_type(): + + if hasattr(creator, "product_base_type"): + instance_data["productBaseType"] = creator.product_base_type + else: + warn( + f"Creator {creator_identifier} does not support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2, + ) + if active is not None: if not isinstance(active, bool): self.log.warning( diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index cbc06145fb..ad4b777db5 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -3,6 +3,7 @@ import os import copy import collections from typing import TYPE_CHECKING, Optional, Dict, Any +from warnings import warn from abc import ABC, abstractmethod @@ -16,6 +17,7 @@ from ayon_core.pipeline.plugin_discover import ( deregister_plugin_path ) from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir +from ayon_core.pipeline import is_supporting_product_base_type from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name @@ -308,6 +310,9 @@ class BaseCreator(ABC): Default implementation returns plugin's product type. """ + if is_supporting_product_base_type(): + return self.product_base_type + return self.product_type @property @@ -317,6 +322,16 @@ class BaseCreator(ABC): pass + @property + @abstractmethod + def product_base_type(self): + """Base product type that plugin represents. + + This is used to group products in UI. + """ + + pass + @property def project_name(self): """Current project name. @@ -378,7 +393,8 @@ class BaseCreator(ABC): self, product_name: str, data: Dict[str, Any], - product_type: Optional[str] = None + product_type: Optional[str] = None, + product_base_type: Optional[str] = None ) -> CreatedInstance: """Create instance and add instance to context. @@ -387,6 +403,8 @@ class BaseCreator(ABC): data (Dict[str, Any]): Instance data. product_type (Optional[str]): Product type, object attribute 'product_type' is used if not passed. + product_base_type (Optional[str]): Product base type, object + attribute 'product_type' is used if not passed. Returns: CreatedInstance: Created instance. @@ -394,6 +412,18 @@ class BaseCreator(ABC): """ if product_type is None: product_type = self.product_type + + if is_supporting_product_base_type() and not product_base_type: + if not self.product_base_type: + warn( + f"Creator {self.identifier} does not support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2, + ) + else: + product_base_type = self.product_base_type + instance = CreatedInstance( product_type, product_name, From 9e730a6b5b1ecdf1cc67ea8609034e43929ab303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 3 Jun 2025 10:58:43 +0200 Subject: [PATCH 017/386] :wrench: changes in Plugin anc CreateInstance WIP --- .../pipeline/create/creator_plugins.py | 63 +++++++++++-------- .../ayon_core/pipeline/create/structures.py | 20 ++++++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index ad4b777db5..bb824f52e3 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -17,7 +17,7 @@ from ayon_core.pipeline.plugin_discover import ( deregister_plugin_path ) from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir -from ayon_core.pipeline import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name @@ -308,12 +308,15 @@ class BaseCreator(ABC): """Identifier of creator (must be unique). Default implementation returns plugin's product type. + """ - + identifier = self.product_type if is_supporting_product_base_type(): - return self.product_base_type + identifier = self.product_base_type + if self.product_type: + identifier = f"{identifier}.{self.product_type}" + return identifier - return self.product_type @property @abstractmethod @@ -323,14 +326,19 @@ class BaseCreator(ABC): pass @property - @abstractmethod - def product_base_type(self): + def product_base_type(self) -> Optional[str]: """Base product type that plugin represents. - This is used to group products in UI. - """ + Todo (antirotor): This should be required in future - it + should be made abstract then. - pass + Returns: + Optional[str]: Base product type that plugin represents. + If not set, it is assumed that the creator plugin is obsolete + and does not support product base type. + + """ + return None @property def project_name(self): @@ -361,13 +369,14 @@ class BaseCreator(ABC): Default implementation use attributes in this order: - 'group_label' -> 'label' -> 'identifier' - Keep in mind that 'identifier' use 'product_type' by default. + + Keep in mind that 'identifier' uses 'product_base_type' by default. Returns: str: Group label that can be used for grouping of instances in UI. - Group label can be overridden by instance itself. + Group label can be overridden by the instance itself. + """ - if self._cached_group_label is None: label = self.identifier if self.group_label: @@ -413,22 +422,26 @@ class BaseCreator(ABC): if product_type is None: product_type = self.product_type - if is_supporting_product_base_type() and not product_base_type: - if not self.product_base_type: - warn( - f"Creator {self.identifier} does not support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2, - ) - else: - product_base_type = self.product_base_type + if ( + is_supporting_product_base_type() + and not product_base_type + and not self.product_base_type + ): + warn( + f"Creator {self.identifier} does not support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2, + ) + else: + product_base_type = self.product_base_type instance = CreatedInstance( - product_type, - product_name, - data, + product_type=product_type, + product_name=product_name, + data=data, creator=self, + product_base_type=product_base_type ) self._add_instance_to_context(instance) return instance diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index d7ba6b9c24..389ce25961 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -3,6 +3,7 @@ import collections from uuid import uuid4 import typing from typing import Optional, Dict, List, Any +import warnings from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -10,6 +11,9 @@ from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, ) + +from ayon_core.pipeline.compatibility import is_supporting_product_base_type + from ayon_core.pipeline import ( AYON_INSTANCE_ID, AVALON_INSTANCE_ID, @@ -471,6 +475,7 @@ class CreatedInstance: "id", "instance_id", "productType", + "productBaseType", "creator_identifier", "creator_attributes", "publish_attributes" @@ -490,7 +495,17 @@ class CreatedInstance: data: Dict[str, Any], creator: "BaseCreator", transient_data: Optional[Dict[str, Any]] = None, + product_base_type: Optional[str] = None ): + + if is_supporting_product_base_type() and product_base_type is None: + warnings.warn( + f"Creator {creator!r} doesn't support " + "product base type. This will be required in future.", + DeprecationWarning, + stacklevel=2 + ) + self._creator = creator creator_identifier = creator.identifier group_label = creator.get_group_label() @@ -540,6 +555,11 @@ class CreatedInstance: self._data["id"] = item_id self._data["productType"] = product_type self._data["productName"] = product_name + + if is_supporting_product_base_type(): + data.pop("productBaseType", None) + self._data["productBaseType"] = product_base_type + self._data["active"] = data.get("active", True) self._data["creator_identifier"] = creator_identifier From d237e5f54cd466957699a350e9f8978a743eac7f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 3 Jun 2025 17:25:32 +0200 Subject: [PATCH 018/386] :art: add support for product base type to basic creator logic --- client/ayon_core/pipeline/create/context.py | 2 +- .../pipeline/create/creator_plugins.py | 23 ++-- .../ayon_core/pipeline/create/product_name.py | 117 ++++++++++++------ .../ayon_core/pipeline/create/structures.py | 26 ++-- .../tools/publisher/models/create.py | 7 ++ 5 files changed, 119 insertions(+), 56 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index e6cc4393c5..f267450543 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -32,7 +32,7 @@ from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from .exceptions import ( CreatorError, diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index bb824f52e3..155a443b53 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -317,7 +317,6 @@ class BaseCreator(ABC): identifier = f"{identifier}.{self.product_type}" return identifier - @property @abstractmethod def product_type(self): @@ -562,14 +561,15 @@ class BaseCreator(ABC): def get_product_name( self, - project_name, - folder_entity, - task_entity, - variant, - host_name=None, - instance=None, - project_entity=None, - ): + project_name: str, + folder_entity: dict[str, Any], + task_entity: dict[str, Any], + variant: str, + host_name: Optional[str] = None, + instance: Optional[CreatedInstance] = None, + project_entity: Optional[dict[str, Any]] = None, + product_base_type: Optional[str] = None, + ) -> str: """Return product name for passed context. Method is also called on product name update. In that case origin @@ -586,8 +586,12 @@ class BaseCreator(ABC): for which is product name updated. Passed only on product name update. project_entity (Optional[dict[str, Any]]): Project entity. + product_base_type (Optional[str]): Product base type. """ + if is_supporting_product_base_type() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 + product_base_type = instance.product_base_type + if host_name is None: host_name = self.create_context.host_name @@ -619,6 +623,7 @@ class BaseCreator(ABC): dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, + product_base_type=product_base_type ) def get_instance_attr_defs(self): diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ecffa4a340..3fdf786b0e 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,9 +1,16 @@ +"""Functions for handling product names.""" +from __future__ import annotations + +from typing import Any, Optional, Union +from warnings import warn + import ayon_api from ayon_core.lib import ( StringTemplate, filter_profiles, prepare_template_data, ) +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -11,14 +18,15 @@ from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( - project_name, - product_type, - task_name, - task_type, - host_name, - default_template=None, - project_settings=None -): + project_name: str, + product_type: str, + task_name: str, + task_type: str, + host_name: str, + default_template: Optional[str] = None, + project_settings: Optional[dict[str, Any]] = None, + product_base_type: Optional[str] = None +) -> str: """Get product name template based on passed context. Args: @@ -28,13 +36,17 @@ def get_product_name_template( host_name (str): Name of host in which the product name is calculated. task_name (str): Name of task in which context the product is created. task_type (str): Type of task in which context the product is created. - default_template (Union[str, None]): Default template which is used if + default_template (Optional, str): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. project_settings (Union[Dict[str, Any], None]): Prepared settings for project. Settings are queried if not passed. - """ + product_base_type (Optional[str]): Base type of product. + Returns: + str: Product name template. + + """ if project_settings is None: project_settings = get_project_settings(project_name) tools_settings = project_settings["core"]["tools"] @@ -46,6 +58,15 @@ def get_product_name_template( "task_types": task_type } + if is_supporting_product_base_type(): + if product_base_type: + filtering_criteria["product_base_types"] = product_base_type + else: + warn( + "Product base type is not provided, please update your" + "creation code to include it. It will be required in " + "the future.", DeprecationWarning, stacklevel=2) + matching_profile = filter_profiles(profiles, filtering_criteria) template = None if matching_profile: @@ -70,17 +91,18 @@ def get_product_name_template( def get_product_name( - project_name, - task_name, - task_type, - host_name, - product_type, - variant, - default_template=None, - dynamic_data=None, - project_settings=None, - product_type_filter=None, - project_entity=None, + project_name: str, + task_name: str, + task_type: str, + host_name: str, + product_type: str, + variant: str, + default_template: Optional[str] = None, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + product_type_filter: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + product_base_type: Optional[str] = None ): """Calculate product name based on passed context and AYON settings. @@ -92,14 +114,20 @@ def get_product_name( That's main reason why so many arguments are required to calculate product name. + Deprecation: + The `product_base_type` argument is optional now, but it will be + mandatory in future versions. It is recommended to pass it now to + avoid issues in the future. If it is not passed, a warning will be raised + to inform about this change. + Todos: Find better filtering options to avoid requirement of argument 'family_filter'. Args: project_name (str): Project name. - task_name (Union[str, None]): Task name. - task_type (Union[str, None]): Task type. + task_name (str): Task name. + task_type (str): Task type. host_name (str): Host name. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. @@ -115,6 +143,8 @@ def get_product_name( not passed. project_entity (Optional[Dict[str, Any]]): Project entity used when task short name is required by template. + product_base_type (Optional[str]): Base type of product. + This will be mandatory in future versions. Returns: str: Product name. @@ -129,13 +159,14 @@ def get_product_name( return "" template = get_product_name_template( - project_name, - product_type_filter or product_type, - task_name, - task_type, - host_name, + project_name=project_name, + product_type=product_type_filter or product_type, + task_name=task_name, + task_type=task_type, + host_name=host_name, default_template=default_template, - project_settings=project_settings + project_settings=project_settings, + product_base_type=product_base_type, ) # Simple check of task name existence for template with {task} in # - missing task should be possible only in Standalone publisher @@ -147,7 +178,7 @@ def get_product_name( "type": task_type, } if "{task}" in template.lower(): - task_value = task_name + task_value["name"] = task_name elif "{task[short]}" in template.lower(): if project_entity is None: @@ -159,14 +190,25 @@ def get_product_name( task_short = task_types_by_name.get(task_type, {}).get("shortName") task_value["short"] = task_short - fill_pairs = { + # look what we have to do to make mypy happy. We should stop using + # those undefined dict based types. + product: dict[str, str] = {"type": product_type} + if is_supporting_product_base_type(): + if product_base_type: + product["baseType"] = product_base_type + elif "{product[basetype]}" in template.lower(): + warn( + "You have Product base type in product name template," + "but it is not provided by the creator, please update your" + "creation code to include it. It will be required in " + "the future.", DeprecationWarning, stacklevel=2) + fill_pairs: dict[str, Union[str, dict[str, str]]] = { "variant": variant, "family": product_type, "task": task_value, - "product": { - "type": product_type - } + "product": product, } + if dynamic_data: # Dynamic data may override default values for key, value in dynamic_data.items(): @@ -178,7 +220,8 @@ def get_product_name( data=prepare_template_data(fill_pairs) ) except KeyError as exp: - raise TemplateFillError( - "Value for {} key is missing in template '{}'." - " Available values are {}".format(str(exp), template, fill_pairs) + msg = ( + f"Value for {exp} key is missing in template '{template}'." + f" Available values are {fill_pairs}" ) + raise TemplateFillError(msg) from exp diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 389ce25961..a6b57c29ca 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -3,7 +3,7 @@ import collections from uuid import uuid4 import typing from typing import Optional, Dict, List, Any -import warnings +from warnings import warn from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -465,6 +465,10 @@ class CreatedInstance: data (Dict[str, Any]): Data used for filling product name or override data from already existing instance. creator (BaseCreator): Creator responsible for instance. + product_base_type (Optional[str]): Product base type that will be + created. If not provided then product base type is taken from + creator plugin. If creator does not have product base type then + deprecation warning is raised. """ # Keys that can't be changed or removed from data after loading using @@ -497,14 +501,18 @@ class CreatedInstance: transient_data: Optional[Dict[str, Any]] = None, product_base_type: Optional[str] = None ): - - if is_supporting_product_base_type() and product_base_type is None: - warnings.warn( - f"Creator {creator!r} doesn't support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2 - ) + """Initialize CreatedInstance.""" + if is_supporting_product_base_type(): + if not hasattr(creator, "product_base_type"): + warn( + f"Provided creator {creator!r} doesn't have " + "product base type attribute defined. This will be " + "required in future.", + DeprecationWarning, + stacklevel=2 + ) + elif not product_base_type: + product_base_type = creator.product_base_type self._creator = creator creator_identifier = creator.identifier diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 900168eaef..862bd1ea03 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -34,6 +34,8 @@ from ayon_core.pipeline.create import ( ConvertorsOperationFailed, ConvertorItem, ) +from ayon_core.pipeline.compatibility import is_supporting_product_base_type + from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, CardMessageTypes, @@ -631,12 +633,17 @@ class CreateModel: "instance": instance, "project_entity": project_entity, } + + if is_supporting_product_base_type() and hasattr(creator, "product_base_type"): # noqa: E501 + kwargs["product_base_type"] = creator.product_base_type + # Backwards compatibility for 'project_entity' argument # - 'get_product_name' signature changed 24/07/08 if not is_func_signature_supported( creator.get_product_name, *args, **kwargs ): kwargs.pop("project_entity") + kwargs.pop("product_base_type") return creator.get_product_name(*args, **kwargs) def create( From 67db5c123ff72b41b53209310c6bae339de1d8ed Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 4 Jun 2025 11:24:22 +0200 Subject: [PATCH 019/386] :dog: linter fixes --- .../pipeline/create/creator_plugins.py | 99 +++++-------------- 1 file changed, 25 insertions(+), 74 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 155a443b53..040ed073f2 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,32 +1,32 @@ -# -*- coding: utf-8 -*- -import os -import copy +"""Creator plugins for the create process.""" import collections -from typing import TYPE_CHECKING, Optional, Dict, Any +import copy +import os +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, Optional from warnings import warn -from abc import ABC, abstractmethod - -from ayon_core.settings import get_project_settings from ayon_core.lib import Logger, get_version_from_path +from ayon_core.pipeline.compatibility import is_supporting_product_base_type from ayon_core.pipeline.plugin_discover import ( + deregister_plugin, + deregister_plugin_path, discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) -from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info +from ayon_core.settings import get_project_settings from .constants import DEFAULT_VARIANT_VALUE -from .product_name import get_product_name -from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator +from .product_name import get_product_name from .structures import CreatedInstance +from .utils import get_next_versions_for_instances if TYPE_CHECKING: from ayon_core.lib import AbstractAttrDef + # Avoid cyclic imports from .context import CreateContext, UpdateData # noqa: F401 @@ -70,7 +70,6 @@ class ProductConvertorPlugin(ABC): Returns: logging.Logger: Logger with name of the plugin. """ - if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @@ -86,9 +85,8 @@ class ProductConvertorPlugin(ABC): Returns: str: Converted identifier unique for all converters in host. - """ - pass + """ @abstractmethod def find_instances(self): @@ -98,14 +96,10 @@ class ProductConvertorPlugin(ABC): convert. """ - pass - @abstractmethod def convert(self): """Conversion code.""" - pass - @property def create_context(self): """Quick access to create context. @@ -113,7 +107,6 @@ class ProductConvertorPlugin(ABC): Returns: CreateContext: Context which initialized the plugin. """ - return self._create_context @property @@ -126,7 +119,6 @@ class ProductConvertorPlugin(ABC): Raises: UnavailableSharedData: When called out of collection phase. """ - return self._create_context.collection_shared_data def add_convertor_item(self, label): @@ -135,12 +127,10 @@ class ProductConvertorPlugin(ABC): Args: label (str): Label of item which will show in UI. """ - self._create_context.add_convertor_item(self.identifier, label) def remove_convertor_item(self): """Remove legacy item from create context when conversion finished.""" - self._create_context.remove_convertor_item(self.identifier) @@ -159,7 +149,6 @@ class BaseCreator(ABC): create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ - # Label shown in UI label = None group_label = None @@ -223,7 +212,6 @@ class BaseCreator(ABC): Returns: Optional[dict[str, Any]]: Settings values or None. """ - settings = project_settings.get(category_name) if not settings: return None @@ -269,7 +257,6 @@ class BaseCreator(ABC): Args: project_settings (dict[str, Any]): Project settings. """ - settings_category = self.settings_category if not settings_category: return @@ -281,18 +268,17 @@ class BaseCreator(ABC): project_settings, settings_category, settings_name ) if settings is None: - self.log.debug("No settings found for {}".format(cls_name)) + self.log.debug(f"No settings found for {cls_name}") return for key, value in settings.items(): # Log out attributes that are not defined on plugin object # - those may be potential dangerous typos in settings if not hasattr(self, key): - self.log.debug(( - "Applying settings to unknown attribute '{}' on '{}'." - ).format( + self.log.debug( + "Applying settings to unknown attribute '%' on '%'.", key, cls_name - )) + ) setattr(self, key, value) def register_callbacks(self): @@ -301,7 +287,6 @@ class BaseCreator(ABC): Default implementation does nothing. It can be overridden to register callbacks for creator. """ - pass @property def identifier(self): @@ -322,8 +307,6 @@ class BaseCreator(ABC): def product_type(self): """Family that plugin represents.""" - pass - @property def product_base_type(self) -> Optional[str]: """Base product type that plugin represents. @@ -346,7 +329,6 @@ class BaseCreator(ABC): Returns: str: Name of a project. """ - return self.create_context.project_name @property @@ -356,7 +338,6 @@ class BaseCreator(ABC): Returns: Anatomy: Project anatomy object. """ - return self.create_context.project_anatomy @property @@ -368,13 +349,13 @@ class BaseCreator(ABC): Default implementation use attributes in this order: - 'group_label' -> 'label' -> 'identifier' - + Keep in mind that 'identifier' uses 'product_base_type' by default. Returns: str: Group label that can be used for grouping of instances in UI. Group label can be overridden by the instance itself. - + """ if self._cached_group_label is None: label = self.identifier @@ -392,7 +373,6 @@ class BaseCreator(ABC): Returns: logging.Logger: Logger with name of the plugin. """ - if self._log is None: self._log = Logger.get_logger(self.__class__.__name__) return self._log @@ -456,7 +436,6 @@ class BaseCreator(ABC): Args: instance (CreatedInstance): New created instance. """ - self.create_context.creator_adds_instance(instance) def _remove_instance_from_context(self, instance): @@ -469,7 +448,6 @@ class BaseCreator(ABC): Args: instance (CreatedInstance): Instance which should be removed. """ - self.create_context.creator_removed_instance(instance) @abstractmethod @@ -481,8 +459,6 @@ class BaseCreator(ABC): implementation """ - pass - @abstractmethod def collect_instances(self): """Collect existing instances related to this creator plugin. @@ -508,8 +484,6 @@ class BaseCreator(ABC): ``` """ - pass - @abstractmethod def update_instances(self, update_list): """Store changes of existing instances so they can be recollected. @@ -519,8 +493,6 @@ class BaseCreator(ABC): contain changed instance and it's changes. """ - pass - @abstractmethod def remove_instances(self, instances): """Method called on instance removal. @@ -533,14 +505,11 @@ class BaseCreator(ABC): removed. """ - pass - def get_icon(self): """Icon of creator (product type). Can return path to image file or awesome icon name. """ - return self.icon def get_dynamic_data( @@ -556,7 +525,6 @@ class BaseCreator(ABC): These may be dynamically created based on current context of workfile. """ - return {} def get_product_name( @@ -633,15 +601,15 @@ class BaseCreator(ABC): and values are stored to metadata for future usage and for publishing purposes. - NOTE: - Convert method should be implemented which should care about updating - keys/values when plugin attributes change. + Note: + Convert method should be implemented which should care about + updating keys/values when plugin attributes change. Returns: list[AbstractAttrDef]: Attribute definitions that can be tweaked for created instance. - """ + """ return self.instance_attr_defs def get_attr_defs_for_instance(self, instance): @@ -664,12 +632,10 @@ class BaseCreator(ABC): Raises: UnavailableSharedData: When called out of collection phase. """ - return self.create_context.collection_shared_data def set_instance_thumbnail_path(self, instance_id, thumbnail_path=None): """Set path to thumbnail for instance.""" - self.create_context.thumbnail_paths_by_instance_id[instance_id] = ( thumbnail_path ) @@ -690,7 +656,6 @@ class BaseCreator(ABC): Returns: dict[str, int]: Next versions by instance id. """ - return get_next_versions_for_instances( self.create_context.project_name, instances ) @@ -757,7 +722,6 @@ class Creator(BaseCreator): int: Order in which is creator shown (less == earlier). By default is using Creator's 'order' or processing. """ - return self.order @abstractmethod @@ -772,11 +736,9 @@ class Creator(BaseCreator): pre_create_data(dict): Data based on pre creation attributes. Those may affect how creator works. """ - # instance = CreatedInstance( # self.product_type, product_name, instance_data # ) - pass def get_description(self): """Short description of product type and plugin. @@ -784,7 +746,6 @@ class Creator(BaseCreator): Returns: str: Short description of product type. """ - return self.description def get_detail_description(self): @@ -795,7 +756,6 @@ class Creator(BaseCreator): Returns: str: Detailed description of product type for artist. """ - return self.detailed_description def get_default_variants(self): @@ -809,7 +769,6 @@ class Creator(BaseCreator): Returns: list[str]: Whisper variants for user input. """ - return copy.deepcopy(self.default_variants) def get_default_variant(self, only_explicit=False): @@ -829,7 +788,6 @@ class Creator(BaseCreator): Returns: str: Variant value. """ - if only_explicit or self._default_variant: return self._default_variant @@ -850,7 +808,6 @@ class Creator(BaseCreator): Returns: str: Variant value. """ - return self.get_default_variant() def _set_default_variant_wrap(self, variant): @@ -862,7 +819,6 @@ class Creator(BaseCreator): Args: variant (str): New default variant value. """ - self._default_variant = variant default_variant = property( @@ -1012,7 +968,6 @@ class AutoCreator(BaseCreator): def remove_instances(self, instances): """Skip removal.""" - pass def discover_creator_plugins(*args, **kwargs): @@ -1036,9 +991,7 @@ def discover_legacy_creator_plugins(): plugin.apply_settings(project_settings) except Exception: log.warning( - "Failed to apply settings to creator {}".format( - plugin.__name__ - ), + "Failed to apply settings to creator %s", plugin.__name__, exc_info=True ) return plugins @@ -1055,7 +1008,6 @@ def get_legacy_creator_by_name(creator_name, case_sensitive=False): Returns: Creator: Return first matching plugin or `None`. """ - # Lower input creator name if is not case sensitive if not case_sensitive: creator_name = creator_name.lower() @@ -1127,7 +1079,6 @@ def cache_and_get_instances(creator, shared_key, list_instances_func): dict[str, dict[str, Any]]: Cached instances by creator identifier from result of passed function. """ - if shared_key not in creator.collection_shared_data: value = collections.defaultdict(list) for instance in list_instances_func(): From fce1ef248d4292b80a479eb156326aa002d4c48f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 4 Jun 2025 11:28:07 +0200 Subject: [PATCH 020/386] :dog: some more linter fixes --- client/ayon_core/pipeline/create/product_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 3fdf786b0e..1f0e8f3ba5 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -117,8 +117,8 @@ def get_product_name( Deprecation: The `product_base_type` argument is optional now, but it will be mandatory in future versions. It is recommended to pass it now to - avoid issues in the future. If it is not passed, a warning will be raised - to inform about this change. + avoid issues in the future. If it is not passed, a warning will be + raised to inform about this change. Todos: Find better filtering options to avoid requirement of From dfd8fe6e8cd9fbd5fe6e930189065623183b3020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 6 Jun 2025 10:33:25 +0200 Subject: [PATCH 021/386] :bug: report correctly skipped abstract creators --- client/ayon_core/pipeline/create/context.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f267450543..fcc18555b5 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -731,13 +731,12 @@ class CreateContext: manual_creators = {} report = discover_creator_plugins(return_report=True) self.creator_discover_result = report - for creator_class in report.plugins: - if inspect.isabstract(creator_class): - self.log.debug( - "Skipping abstract Creator {}".format(str(creator_class)) - ) - continue + for creator_class in report.abstract_plugins: + self.log.debug( + f"Skipping abstract Creator '%s'", str(creator_class) + ) + for creator_class in report.plugins: creator_identifier = creator_class.identifier if creator_identifier in creators: self.log.warning( From 8fbb8c93c12b75dd29e1ca16e6e5567bf846baec Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Jun 2025 14:47:41 +0200 Subject: [PATCH 022/386] Allow more frames patterns --- client/ayon_core/lib/transcoding.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index f249213f2a..3f30f771c1 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -616,9 +616,12 @@ def convert_input_paths_for_ffmpeg( input_info, logger=logger ) + # clique.PATTERNS["frames"] supports only `.1001.exr` not `_1001.exr` so + # we use a customized pattern. + pattern = "[_.](?P(?P0*)\\d+)\\.\\D+\\d?$" input_collections, input_remainder = clique.assemble( input_paths, - patterns=[clique.PATTERNS["frames"]], + patterns=[pattern], assume_padded_when_ambiguous=True, ) input_items = list(input_collections) From fa8c05488943dcd4f8195b4bcffc83e9cd45c37a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 9 Jun 2025 13:54:41 +0200 Subject: [PATCH 023/386] :recycle: refactor support feature check function name --- client/ayon_core/pipeline/create/context.py | 4 ++-- client/ayon_core/pipeline/create/creator_plugins.py | 8 ++++---- client/ayon_core/pipeline/create/product_name.py | 6 +++--- client/ayon_core/pipeline/create/structures.py | 6 +++--- client/ayon_core/tools/publisher/models/create.py | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index fcc18555b5..a5a9d5a64a 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -32,7 +32,7 @@ from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from .exceptions import ( CreatorError, @@ -1199,7 +1199,7 @@ class CreateContext: # Add product base type if supported. # TODO (antirotor): Once all creators support product base type # remove this check. - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if hasattr(creator, "product_base_type"): instance_data["productBaseType"] = creator.product_base_type diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 040ed073f2..78fb723567 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from warnings import warn from ayon_core.lib import Logger, get_version_from_path -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path, @@ -296,7 +296,7 @@ class BaseCreator(ABC): """ identifier = self.product_type - if is_supporting_product_base_type(): + if is_product_base_type_supported(): identifier = self.product_base_type if self.product_type: identifier = f"{identifier}.{self.product_type}" @@ -402,7 +402,7 @@ class BaseCreator(ABC): product_type = self.product_type if ( - is_supporting_product_base_type() + is_product_base_type_supported() and not product_base_type and not self.product_base_type ): @@ -557,7 +557,7 @@ class BaseCreator(ABC): product_base_type (Optional[str]): Product base type. """ - if is_supporting_product_base_type() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 + if is_product_base_type_supported() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 product_base_type = instance.product_base_type if host_name is None: diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 1f0e8f3ba5..ab7de0c9e8 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -10,7 +10,7 @@ from ayon_core.lib import ( filter_profiles, prepare_template_data, ) -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -58,7 +58,7 @@ def get_product_name_template( "task_types": task_type } - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if product_base_type: filtering_criteria["product_base_types"] = product_base_type else: @@ -193,7 +193,7 @@ def get_product_name( # look what we have to do to make mypy happy. We should stop using # those undefined dict based types. product: dict[str, str] = {"type": product_type} - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if product_base_type: product["baseType"] = product_base_type elif "{product[basetype]}" in template.lower(): diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index a6b57c29ca..aad85a546a 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -12,7 +12,7 @@ from ayon_core.lib.attribute_definitions import ( deserialize_attr_defs, ) -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline import ( AYON_INSTANCE_ID, @@ -502,7 +502,7 @@ class CreatedInstance: product_base_type: Optional[str] = None ): """Initialize CreatedInstance.""" - if is_supporting_product_base_type(): + if is_product_base_type_supported(): if not hasattr(creator, "product_base_type"): warn( f"Provided creator {creator!r} doesn't have " @@ -564,7 +564,7 @@ class CreatedInstance: self._data["productType"] = product_type self._data["productName"] = product_name - if is_supporting_product_base_type(): + if is_product_base_type_supported(): data.pop("productBaseType", None) self._data["productBaseType"] = product_base_type diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 862bd1ea03..77e50dc788 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -34,7 +34,7 @@ from ayon_core.pipeline.create import ( ConvertorsOperationFailed, ConvertorItem, ) -from ayon_core.pipeline.compatibility import is_supporting_product_base_type +from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, @@ -634,7 +634,7 @@ class CreateModel: "project_entity": project_entity, } - if is_supporting_product_base_type() and hasattr(creator, "product_base_type"): # noqa: E501 + if is_product_base_type_supported() and hasattr(creator, "product_base_type"): # noqa: E501 kwargs["product_base_type"] = creator.product_base_type # Backwards compatibility for 'project_entity' argument From da286e3cfb5e8fb76a7328e5aab5de685316bb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Jun 2025 11:23:37 +0200 Subject: [PATCH 024/386] :recycle: remove check for attribute --- client/ayon_core/pipeline/create/context.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a5a9d5a64a..a37fefc1f9 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1201,15 +1201,14 @@ class CreateContext: # remove this check. if is_product_base_type_supported(): - if hasattr(creator, "product_base_type"): - instance_data["productBaseType"] = creator.product_base_type - else: - warn( - f"Creator {creator_identifier} does not support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2, + instance_data["productBaseType"] = creator.product_base_type + if creator.product_base_type is None: + msg = ( + f"Creator {creator_identifier} does not set " + "product base type. This will be required in future." ) + warn(msg, DeprecationWarning, stacklevel=2) + self.log.warning(msg) if active is not None: if not isinstance(active, bool): From e2a413f20eb64001703c2ff5220d232fbdf0d0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Jun 2025 11:40:43 +0200 Subject: [PATCH 025/386] :dog: remove unneeded f-string --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a37fefc1f9..17a5dea7dc 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -733,7 +733,7 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.abstract_plugins: self.log.debug( - f"Skipping abstract Creator '%s'", str(creator_class) + "Skipping abstract Creator '%s'", str(creator_class) ) for creator_class in report.plugins: From 50045d71bd58466e4e1b94209fda8cedafa69d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 10 Jun 2025 12:16:49 +0200 Subject: [PATCH 026/386] :sparkles: support product base types in the integrator --- client/ayon_core/pipeline/publish/lib.py | 6 +++++- client/ayon_core/plugins/publish/integrate.py | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 49ecab2221..fba7f1d84b 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -106,7 +106,8 @@ def get_publish_template_name( task_type, project_settings=None, hero=False, - logger=None + logger=None, + product_base_type: Optional[str] = None ): """Get template name which should be used for passed context. @@ -126,6 +127,8 @@ def get_publish_template_name( hero (bool): Template is for hero version publishing. logger (logging.Logger): Custom logger used for 'filter_profiles' function. + product_base_type (Optional[str]): Product type for which should + be found template. Returns: str: Template name which should be used for integration. @@ -135,6 +138,7 @@ def get_publish_template_name( filter_criteria = { "hosts": host_name, "product_types": product_type, + "product_base_types": product_base_type, "task_names": task_name, "task_types": task_type, } diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index f1e066018c..41e71207e7 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -368,6 +368,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): folder_entity = instance.data["folderEntity"] product_name = instance.data["productName"] product_type = instance.data["productType"] + product_base_type = instance.data.get("productBaseType") + self.log.debug("Product: {}".format(product_name)) # Get existing product if it exists @@ -401,7 +403,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): folder_entity["id"], data=data, attribs=attributes, - entity_id=product_id + entity_id=product_id, + product_base_type=product_base_type ) if existing_product_entity is None: @@ -917,6 +920,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): host_name = context.data["hostName"] anatomy_data = instance.data["anatomyData"] product_type = instance.data["productType"] + product_base_type = instance.data.get("productBaseType") task_info = anatomy_data.get("task") or {} return get_publish_template_name( @@ -926,7 +930,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): task_name=task_info.get("name"), task_type=task_info.get("type"), project_settings=context.data["project_settings"], - logger=self.log + logger=self.log, + product_base_type=product_base_type ) def get_rootless_path(self, anatomy, path): From 3d612016089436d0742262e02862586b8410c5b4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Jul 2025 12:04:17 +0200 Subject: [PATCH 027/386] Collect Loaded Scene Versions: Enable for more hosts --- .../plugins/publish/collect_scene_loaded_versions.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1abb8e29d2..1c28c28f5b 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -13,15 +13,23 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): "aftereffects", "blender", "celaction", + "cinema4d", + "flame", "fusion", "harmony", "hiero", "houdini", + "max", "maya", + "motionbuilder", "nuke", "photoshop", + "silhouette", + "substancepainter", + "substancedesigner", "resolve", - "tvpaint" + "tvpaint", + "zbrush", ] def process(self, context): From 1e1828bbdc8cc9fb8c5f81d7a2f34a4e745d3285 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:21:40 +0200 Subject: [PATCH 028/386] moved current actions to subdir --- client/ayon_core/pipeline/actions/__init__.py | 33 ++++++ .../ayon_core/pipeline/actions/inventory.py | 108 ++++++++++++++++++ .../{actions.py => actions/launcher.py} | 104 ----------------- 3 files changed, 141 insertions(+), 104 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/__init__.py create mode 100644 client/ayon_core/pipeline/actions/inventory.py rename client/ayon_core/pipeline/{actions.py => actions/launcher.py} (76%) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py new file mode 100644 index 0000000000..bda9b50ede --- /dev/null +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -0,0 +1,33 @@ +from .launcher import ( + LauncherAction, + LauncherActionSelection, + discover_launcher_actions, + register_launcher_action, + register_launcher_action_path, +) + +from .inventory import ( + InventoryAction, + discover_inventory_actions, + register_inventory_action, + register_inventory_action_path, + + deregister_inventory_action, + deregister_inventory_action_path, +) + + +__all__= ( + "LauncherAction", + "LauncherActionSelection", + "discover_launcher_actions", + "register_launcher_action", + "register_launcher_action_path", + + "InventoryAction", + "discover_inventory_actions", + "register_inventory_action", + "register_inventory_action_path", + "deregister_inventory_action", + "deregister_inventory_action_path", +) diff --git a/client/ayon_core/pipeline/actions/inventory.py b/client/ayon_core/pipeline/actions/inventory.py new file mode 100644 index 0000000000..2300119336 --- /dev/null +++ b/client/ayon_core/pipeline/actions/inventory.py @@ -0,0 +1,108 @@ +import logging + +from ayon_core.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) +from ayon_core.pipeline.load.utils import get_representation_path_from_context + + +class InventoryAction: + """A custom action for the scene inventory tool + + If registered the action will be visible in the Right Mouse Button menu + under the submenu "Actions". + + """ + + label = None + icon = None + color = None + order = 0 + + log = logging.getLogger("InventoryAction") + log.propagate = True + + @staticmethod + def is_compatible(container): + """Override function in a custom class + + This method is specifically used to ensure the action can operate on + the container. + + Args: + container(dict): the data of a loaded asset, see host.ls() + + Returns: + bool + """ + return bool(container.get("objectName")) + + def process(self, containers): + """Override function in a custom class + + This method will receive all containers even those which are + incompatible. It is advised to create a small filter along the lines + of this example: + + valid_containers = filter(self.is_compatible(c) for c in containers) + + The return value will need to be a True-ish value to trigger + the data_changed signal in order to refresh the view. + + You can return a list of container names to trigger GUI to select + treeview items. + + You can return a dict to carry extra GUI options. For example: + { + "objectNames": [container names...], + "options": {"mode": "toggle", + "clear": False} + } + Currently workable GUI options are: + - clear (bool): Clear current selection before selecting by action. + Default `True`. + - mode (str): selection mode, use one of these: + "select", "deselect", "toggle". Default is "select". + + Args: + containers (list): list of dictionaries + + Return: + bool, list or dict + + """ + return True + + @classmethod + def filepath_from_context(cls, context): + return get_representation_path_from_context(context) + + +def discover_inventory_actions(): + actions = discover(InventoryAction) + filtered_actions = [] + for action in actions: + if action is not InventoryAction: + filtered_actions.append(action) + + return filtered_actions + + +def register_inventory_action(plugin): + return register_plugin(InventoryAction, plugin) + + +def deregister_inventory_action(plugin): + deregister_plugin(InventoryAction, plugin) + + +def register_inventory_action_path(path): + return register_plugin_path(InventoryAction, path) + + +def deregister_inventory_action_path(path): + return deregister_plugin_path(InventoryAction, path) diff --git a/client/ayon_core/pipeline/actions.py b/client/ayon_core/pipeline/actions/launcher.py similarity index 76% rename from client/ayon_core/pipeline/actions.py rename to client/ayon_core/pipeline/actions/launcher.py index 860fed5e8b..d47123cf20 100644 --- a/client/ayon_core/pipeline/actions.py +++ b/client/ayon_core/pipeline/actions/launcher.py @@ -8,12 +8,8 @@ from ayon_core.pipeline.plugin_discover import ( discover, register_plugin, register_plugin_path, - deregister_plugin, - deregister_plugin_path ) -from .load.utils import get_representation_path_from_context - class LauncherActionSelection: """Object helper to pass selection to actions. @@ -347,79 +343,6 @@ class LauncherAction(object): pass -class InventoryAction(object): - """A custom action for the scene inventory tool - - If registered the action will be visible in the Right Mouse Button menu - under the submenu "Actions". - - """ - - label = None - icon = None - color = None - order = 0 - - log = logging.getLogger("InventoryAction") - log.propagate = True - - @staticmethod - def is_compatible(container): - """Override function in a custom class - - This method is specifically used to ensure the action can operate on - the container. - - Args: - container(dict): the data of a loaded asset, see host.ls() - - Returns: - bool - """ - return bool(container.get("objectName")) - - def process(self, containers): - """Override function in a custom class - - This method will receive all containers even those which are - incompatible. It is advised to create a small filter along the lines - of this example: - - valid_containers = filter(self.is_compatible(c) for c in containers) - - The return value will need to be a True-ish value to trigger - the data_changed signal in order to refresh the view. - - You can return a list of container names to trigger GUI to select - treeview items. - - You can return a dict to carry extra GUI options. For example: - { - "objectNames": [container names...], - "options": {"mode": "toggle", - "clear": False} - } - Currently workable GUI options are: - - clear (bool): Clear current selection before selecting by action. - Default `True`. - - mode (str): selection mode, use one of these: - "select", "deselect", "toggle". Default is "select". - - Args: - containers (list): list of dictionaries - - Return: - bool, list or dict - - """ - return True - - @classmethod - def filepath_from_context(cls, context): - return get_representation_path_from_context(context) - - -# Launcher action def discover_launcher_actions(): return discover(LauncherAction) @@ -430,30 +353,3 @@ def register_launcher_action(plugin): def register_launcher_action_path(path): return register_plugin_path(LauncherAction, path) - - -# Inventory action -def discover_inventory_actions(): - actions = discover(InventoryAction) - filtered_actions = [] - for action in actions: - if action is not InventoryAction: - filtered_actions.append(action) - - return filtered_actions - - -def register_inventory_action(plugin): - return register_plugin(InventoryAction, plugin) - - -def deregister_inventory_action(plugin): - deregister_plugin(InventoryAction, plugin) - - -def register_inventory_action_path(path): - return register_plugin_path(InventoryAction, path) - - -def deregister_inventory_action_path(path): - return deregister_plugin_path(InventoryAction, path) From bd94d7ede6d2cf4806e817aa2b93d7d6d2160408 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:36:39 +0200 Subject: [PATCH 029/386] move 'StrEnum' to lib --- client/ayon_core/host/constants.py | 9 +-------- client/ayon_core/lib/__init__.py | 3 +++ client/ayon_core/lib/_compatibility.py | 8 ++++++++ 3 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 client/ayon_core/lib/_compatibility.py diff --git a/client/ayon_core/host/constants.py b/client/ayon_core/host/constants.py index 2564c5d54d..1ca33728d8 100644 --- a/client/ayon_core/host/constants.py +++ b/client/ayon_core/host/constants.py @@ -1,11 +1,4 @@ -from enum import Enum - - -class StrEnum(str, Enum): - """A string-based Enum class that allows for string comparison.""" - - def __str__(self) -> str: - return self.value +from ayon_core.lib import StrEnum class ContextChangeReason(StrEnum): diff --git a/client/ayon_core/lib/__init__.py b/client/ayon_core/lib/__init__.py index 5ccc8d03e5..1097cf701a 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -2,6 +2,7 @@ # flake8: noqa E402 """AYON lib functions.""" +from ._compatibility import StrEnum from .local_settings import ( IniSettingRegistry, JSONSettingRegistry, @@ -140,6 +141,8 @@ from .ayon_info import ( terminal = Terminal __all__ = [ + "StrEnum", + "IniSettingRegistry", "JSONSettingRegistry", "AYONSecureRegistry", diff --git a/client/ayon_core/lib/_compatibility.py b/client/ayon_core/lib/_compatibility.py new file mode 100644 index 0000000000..299ed5e233 --- /dev/null +++ b/client/ayon_core/lib/_compatibility.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class StrEnum(str, Enum): + """A string-based Enum class that allows for string comparison.""" + + def __str__(self) -> str: + return self.value From 5e3b38376c6e5ed5f4bc0450a08632fb19e45f9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:37:16 +0200 Subject: [PATCH 030/386] separated discover logic from 'PluginDiscoverContext' --- client/ayon_core/pipeline/plugin_discover.py | 124 +++++++++++-------- 1 file changed, 75 insertions(+), 49 deletions(-) diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index 03da7fce79..dddd6847ec 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import os import inspect import traceback +from typing import Optional from ayon_core.lib import Logger from ayon_core.lib.python_module_tools import ( @@ -96,6 +99,70 @@ class DiscoverResult: log.info(report) +def discover_plugins( + base_class: type, + paths: Optional[list[str]] = None, + classes: Optional[list[type]] = None, + ignored_classes: Optional[list[type]] = None, + allow_duplicates: bool = True, +): + """Find and return subclasses of `superclass` + + Args: + base_class (type): Class which determines discovered subclasses. + paths (Optional[list[str]]): List of paths to look for plug-ins. + classes (Optional[list[str]]): List of classes to filter. + ignored_classes (list[type]): List of classes that won't be added to + the output plugins. + allow_duplicates (bool): Validate class name duplications. + + Returns: + DiscoverResult: Object holding successfully + discovered plugins, ignored plugins, plugins with missing + abstract implementation and duplicated plugin. + + """ + ignored_classes = ignored_classes or [] + paths = paths or [] + classes = classes or [] + + result = DiscoverResult(base_class) + + all_plugins = list(classes) + + for path in paths: + modules, crashed = modules_from_path(path) + for (filepath, exc_info) in crashed: + result.crashed_file_paths[filepath] = exc_info + + for item in modules: + filepath, module = item + result.add_module(module) + all_plugins.extend(classes_from_module(base_class, module)) + + if base_class not in ignored_classes: + ignored_classes.append(base_class) + + plugin_names = set() + for cls in all_plugins: + if cls in ignored_classes: + result.ignored_plugins.add(cls) + continue + + if inspect.isabstract(cls): + result.abstract_plugins.append(cls) + continue + + if not allow_duplicates: + class_name = cls.__name__ + if class_name in plugin_names: + result.duplicated_plugins.append(cls) + continue + plugin_names.add(class_name) + result.plugins.append(cls) + return result + + class PluginDiscoverContext(object): """Store and discover registered types nad registered paths to types. @@ -141,58 +208,17 @@ class PluginDiscoverContext(object): Union[DiscoverResult, list[Any]]: Object holding successfully discovered plugins, ignored plugins, plugins with missing abstract implementation and duplicated plugin. + """ - - if not ignore_classes: - ignore_classes = [] - - result = DiscoverResult(superclass) - plugin_names = set() registered_classes = self._registered_plugins.get(superclass) or [] registered_paths = self._registered_plugin_paths.get(superclass) or [] - for cls in registered_classes: - if cls is superclass or cls in ignore_classes: - result.ignored_plugins.add(cls) - continue - - if inspect.isabstract(cls): - result.abstract_plugins.append(cls) - continue - - class_name = cls.__name__ - if class_name in plugin_names: - result.duplicated_plugins.append(cls) - continue - plugin_names.add(class_name) - result.plugins.append(cls) - - # Include plug-ins from registered paths - for path in registered_paths: - modules, crashed = modules_from_path(path) - for item in crashed: - filepath, exc_info = item - result.crashed_file_paths[filepath] = exc_info - - for item in modules: - filepath, module = item - result.add_module(module) - for cls in classes_from_module(superclass, module): - if cls is superclass or cls in ignore_classes: - result.ignored_plugins.add(cls) - continue - - if inspect.isabstract(cls): - result.abstract_plugins.append(cls) - continue - - if not allow_duplicates: - class_name = cls.__name__ - if class_name in plugin_names: - result.duplicated_plugins.append(cls) - continue - plugin_names.add(class_name) - - result.plugins.append(cls) + result = discover_plugins( + superclass, + paths=registered_paths, + classes=registered_classes, + ignored_classes=ignore_classes, + allow_duplicates=allow_duplicates, + ) # Store in memory last result to keep in memory loaded modules self._last_discovered_results[superclass] = result From 723932cfac04cfb114b949898fe0a07a851f9f9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:38:56 +0200 Subject: [PATCH 031/386] reduced information that is used in loader for action item --- client/ayon_core/tools/loader/abstract.py | 36 ++++---------- client/ayon_core/tools/loader/control.py | 10 ++-- .../ayon_core/tools/loader/models/actions.py | 47 ++++++------------- .../tools/loader/ui/products_widget.py | 6 +-- .../tools/loader/ui/repres_widget.py | 6 +-- 5 files changed, 33 insertions(+), 72 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5ab7e78212..04cf0c6037 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -324,11 +324,6 @@ class ActionItem: options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. order (int): Action order. - project_name (str): Project name. - folder_ids (list[str]): Folder ids. - product_ids (list[str]): Product ids. - version_ids (list[str]): Version ids. - representation_ids (list[str]): Representation ids. """ def __init__( @@ -339,11 +334,6 @@ class ActionItem: tooltip, options, order, - project_name, - folder_ids, - product_ids, - version_ids, - representation_ids, ): self.identifier = identifier self.label = label @@ -351,11 +341,6 @@ class ActionItem: self.tooltip = tooltip self.options = options self.order = order - self.project_name = project_name - self.folder_ids = folder_ids - self.product_ids = product_ids - self.version_ids = version_ids - self.representation_ids = representation_ids def _options_to_data(self): options = self.options @@ -382,11 +367,6 @@ class ActionItem: "tooltip": self.tooltip, "options": options, "order": self.order, - "project_name": self.project_name, - "folder_ids": self.folder_ids, - "product_ids": self.product_ids, - "version_ids": self.version_ids, - "representation_ids": self.representation_ids, } @classmethod @@ -1013,11 +993,11 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, - identifier, - options, - project_name, - version_ids, - representation_ids + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, ): """Trigger action item. @@ -1038,10 +1018,10 @@ class FrontendLoaderController(_BaseLoaderController): identifier (str): Action identifier. options (dict[str, Any]): Action option values from UI. project_name (str): Project name. - version_ids (Iterable[str]): Version ids. - representation_ids (Iterable[str]): Representation ids. - """ + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. + """ pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7ba42a0981..a48fa7b853 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -309,14 +309,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): identifier, options, project_name, - version_ids, - representation_ids + entity_ids, + entity_type, ): if self._sitesync_model.is_sitesync_action(identifier): self._sitesync_model.trigger_action_item( identifier, project_name, - representation_ids + entity_ids, ) return @@ -324,8 +324,8 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): identifier, options, project_name, - version_ids, - representation_ids + entity_ids, + entity_type, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index b792f92dfd..ec0997685f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -113,11 +113,11 @@ class LoaderActionsModel: def trigger_action_item( self, - identifier, - options, - project_name, - version_ids, - representation_ids + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, ): """Trigger action by identifier. @@ -131,10 +131,10 @@ class LoaderActionsModel: identifier (str): Loader identifier. options (dict[str, Any]): Loader option values. project_name (str): Project name. - version_ids (Iterable[str]): Version ids. - representation_ids (Iterable[str]): Representation ids. - """ + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. + """ event_data = { "identifier": identifier, "id": uuid.uuid4().hex, @@ -145,23 +145,24 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - if representation_ids is not None: + if entity_type == "representation": error_info = self._trigger_representation_loader( loader, options, project_name, - representation_ids, + entity_ids, ) - elif version_ids is not None: + elif entity_type == "version": error_info = self._trigger_version_loader( loader, options, project_name, - version_ids, + entity_ids, ) else: raise NotImplementedError( - "Invalid arguments to trigger action item") + f"Invalid entity type '{entity_type}' to trigger action item" + ) event_data["error_info"] = error_info self._controller.emit_event( @@ -276,11 +277,6 @@ class LoaderActionsModel: self, loader, contexts, - project_name, - folder_ids=None, - product_ids=None, - version_ids=None, - representation_ids=None, repre_name=None, ): label = self._get_action_label(loader) @@ -293,11 +289,6 @@ class LoaderActionsModel: tooltip=self._get_action_tooltip(loader), options=loader.get_options(contexts), order=loader.order, - project_name=project_name, - folder_ids=folder_ids, - product_ids=product_ids, - version_ids=version_ids, - representation_ids=representation_ids, ) def _get_loaders(self, project_name): @@ -570,17 +561,11 @@ class LoaderActionsModel: item = self._create_loader_action_item( loader, repre_contexts, - project_name=project_name, - folder_ids=repre_folder_ids, - product_ids=repre_product_ids, - version_ids=repre_version_ids, - representation_ids=repre_ids, repre_name=repre_name, ) action_items.append(item) # Product Loaders. - version_ids = set(version_context_by_id.keys()) product_folder_ids = set() product_ids = set() for product_context in version_context_by_id.values(): @@ -592,10 +577,6 @@ class LoaderActionsModel: item = self._create_loader_action_item( loader, version_contexts, - project_name=project_name, - folder_ids=product_folder_ids, - product_ids=product_ids, - version_ids=version_ids, ) action_items.append(item) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index e5bb75a208..caa2ee82d0 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -439,9 +439,9 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.identifier, options, - action_item.project_name, - version_ids=action_item.version_ids, - representation_ids=action_item.representation_ids, + project_name, + version_ids, + "version", ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index d19ad306a3..17c429cb53 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -401,7 +401,7 @@ class RepresentationsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.identifier, options, - action_item.project_name, - version_ids=action_item.version_ids, - representation_ids=action_item.representation_ids, + self._selected_project_name, + repre_ids, + "representation", ) From 53848ad366ca2451091223ca7871482ecaa75d2d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:53:03 +0200 Subject: [PATCH 032/386] keep entity ids and entity type on action item --- client/ayon_core/tools/loader/abstract.py | 11 ++++++- .../ayon_core/tools/loader/models/actions.py | 31 +++++++++++-------- .../tools/loader/ui/products_widget.py | 9 +++--- .../tools/loader/ui/repres_widget.py | 8 ++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 04cf0c6037..55898e460f 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -318,17 +318,21 @@ class ActionItem: Args: identifier (str): Action identifier. + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. label (str): Action label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. order (int): Action order. - """ + """ def __init__( self, identifier, + entity_ids, + entity_type, label, icon, tooltip, @@ -336,6 +340,8 @@ class ActionItem: order, ): self.identifier = identifier + self.entity_ids = entity_ids + self.entity_type = entity_type self.label = label self.icon = icon self.tooltip = tooltip @@ -362,6 +368,8 @@ class ActionItem: options = self._options_to_data() return { "identifier": self.identifier, + "entity_ids": list(self.entity_ids), + "entity_type": self.entity_type, "label": self.label, "icon": self.icon, "tooltip": self.tooltip, @@ -375,6 +383,7 @@ class ActionItem: if options: options = deserialize_attr_defs(options) data["options"] = options + data["entity_ids"] = set(data["entity_ids"]) return cls(**data) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index ec0997685f..d8fd67234c 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -145,15 +145,16 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) loader = self._get_loader_by_identifier(project_name, identifier) - if entity_type == "representation": - error_info = self._trigger_representation_loader( + + if entity_type == "version": + error_info = self._trigger_version_loader( loader, options, project_name, entity_ids, ) - elif entity_type == "version": - error_info = self._trigger_version_loader( + elif entity_type == "representation": + error_info = self._trigger_representation_loader( loader, options, project_name, @@ -277,6 +278,8 @@ class LoaderActionsModel: self, loader, contexts, + entity_ids, + entity_type, repre_name=None, ): label = self._get_action_label(loader) @@ -284,6 +287,8 @@ class LoaderActionsModel: label = "{} ({})".format(label, repre_name) return ActionItem( get_loader_identifier(loader), + entity_ids=entity_ids, + entity_type=entity_type, label=label, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), @@ -548,19 +553,16 @@ class LoaderActionsModel: if not filtered_repre_contexts: continue - repre_ids = set() - repre_version_ids = set() - repre_product_ids = set() - repre_folder_ids = set() - for repre_context in filtered_repre_contexts: - repre_ids.add(repre_context["representation"]["id"]) - repre_product_ids.add(repre_context["product"]["id"]) - repre_version_ids.add(repre_context["version"]["id"]) - repre_folder_ids.add(repre_context["folder"]["id"]) + repre_ids = { + repre_context["representation"]["id"] + for repre_context in filtered_repre_contexts + } item = self._create_loader_action_item( loader, repre_contexts, + repre_ids, + "representation", repre_name=repre_name, ) action_items.append(item) @@ -572,11 +574,14 @@ class LoaderActionsModel: product_ids.add(product_context["product"]["id"]) product_folder_ids.add(product_context["folder"]["id"]) + version_ids = set(version_context_by_id.keys()) version_contexts = list(version_context_by_id.values()) for loader in product_loaders: item = self._create_loader_action_item( loader, version_contexts, + version_ids, + "version", ) action_items.append(item) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index caa2ee82d0..4ed4368ab4 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -420,8 +420,9 @@ class ProductsWidget(QtWidgets.QWidget): if version_id is not None: version_ids.add(version_id) - action_items = self._controller.get_versions_action_items( - project_name, version_ids) + action_items = self._controller.get_action_items( + project_name, version_ids, "version" + ) # Prepare global point where to show the menu global_point = self._products_view.mapToGlobal(point) @@ -440,8 +441,8 @@ class ProductsWidget(QtWidgets.QWidget): action_item.identifier, options, project_name, - version_ids, - "version", + action_item.entity_ids, + action_item.entity_type, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index 17c429cb53..c0957d186c 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -384,8 +384,8 @@ class RepresentationsWidget(QtWidgets.QWidget): def _on_context_menu(self, point): repre_ids = self._get_selected_repre_ids() - action_items = self._controller.get_representations_action_items( - self._selected_project_name, repre_ids + action_items = self._controller.get_action_items( + self._selected_project_name, repre_ids, "representation" ) global_point = self._repre_view.mapToGlobal(point) result = show_actions_menu( @@ -402,6 +402,6 @@ class RepresentationsWidget(QtWidgets.QWidget): action_item.identifier, options, self._selected_project_name, - repre_ids, - "representation", + action_item.entity_ids, + action_item.entity_type, ) From 29b3794dd8625d547ee52fe51e632f9726f1717f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 17:59:56 +0200 Subject: [PATCH 033/386] only one method to get actions --- client/ayon_core/tools/loader/abstract.py | 28 +++------ client/ayon_core/tools/loader/control.py | 30 +++++----- .../ayon_core/tools/loader/models/actions.py | 59 ++++++------------- 3 files changed, 42 insertions(+), 75 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 55898e460f..baf6aabb69 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -970,33 +970,23 @@ class FrontendLoaderController(_BaseLoaderController): # Load action items @abstractmethod - def get_versions_action_items(self, project_name, version_ids): + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: """Action items for versions selection. Args: project_name (str): Project name. - version_ids (Iterable[str]): Version ids. + entity_ids (set[str]): Entity ids. + entity_type (str): Entity type. Returns: list[ActionItem]: List of action items. + """ - - pass - - @abstractmethod - def get_representations_action_items( - self, project_name, representation_ids - ): - """Action items for representations selection. - - Args: - project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. - - Returns: - list[ActionItem]: List of action items. - """ - pass @abstractmethod diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index a48fa7b853..f05914da17 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -21,7 +21,8 @@ from ayon_core.tools.common_models import ( from .abstract import ( BackendLoaderController, FrontendLoaderController, - ProductTypesFilter + ProductTypesFilter, + ActionItem, ) from .models import ( SelectionModel, @@ -287,21 +288,20 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): project_name, product_ids, group_name ) - def get_versions_action_items(self, project_name, version_ids): - return self._loader_actions_model.get_versions_action_items( - project_name, version_ids) - - def get_representations_action_items( - self, project_name, representation_ids): - action_items = ( - self._loader_actions_model.get_representations_action_items( - project_name, representation_ids) + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + action_items = self._loader_actions_model.get_action_items( + project_name, entity_ids, entity_type ) - - action_items.extend(self._sitesync_model.get_sitesync_action_items( - project_name, representation_ids) - ) - + if entity_type == "representation": + site_sync_items = self._sitesync_model.get_sitesync_action_items( + project_name, entity_ids + ) + action_items.extend(site_sync_items) return action_items def trigger_action_item( diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index d8fd67234c..2ef20a7921 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -61,56 +61,33 @@ class LoaderActionsModel: self._product_loaders.reset() self._repre_loaders.reset() - def get_versions_action_items(self, project_name, version_ids): - """Get action items for given version ids. - Args: - project_name (str): Project name. - version_ids (Iterable[str]): Version ids. + def get_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + version_context_by_id = {} + repre_context_by_id = {} + if entity_type == "representation": + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_representations(project_name, entity_ids) - Returns: - list[ActionItem]: List of action items. - """ + if entity_type == "version": + ( + version_context_by_id, + repre_context_by_id + ) = self._contexts_for_versions(project_name, entity_ids) - ( - version_context_by_id, - repre_context_by_id - ) = self._contexts_for_versions( - project_name, - version_ids - ) return self._get_action_items_for_contexts( project_name, version_context_by_id, repre_context_by_id ) - def get_representations_action_items( - self, project_name, representation_ids - ): - """Get action items for given representation ids. - - Args: - project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. - - Returns: - list[ActionItem]: List of action items. - """ - - ( - product_context_by_id, - repre_context_by_id - ) = self._contexts_for_representations( - project_name, - representation_ids - ) - return self._get_action_items_for_contexts( - project_name, - product_context_by_id, - repre_context_by_id - ) - def trigger_action_item( self, identifier: str, From b3c5933042a6a50372810df15b78a61ca55a5ebf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:12:27 +0200 Subject: [PATCH 034/386] use version contexts instead of product contexts --- client/ayon_core/tools/loader/models/actions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 2ef20a7921..c41119ac45 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -434,10 +434,10 @@ class LoaderActionsModel: representation contexts. """ - product_context_by_id = {} + version_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id repre_entities = list(ayon_api.get_representations( project_name, representation_ids=repre_ids @@ -468,13 +468,17 @@ class LoaderActionsModel: project_entity = ayon_api.get_project(project_name) - for product_id, product_entity in product_entities_by_id.items(): + version_context_by_id = {} + for version_id, version_entity in version_entities_by_id.items(): + product_id = version_entity["productId"] + product_entity = product_entities_by_id[product_id] folder_id = product_entity["folderId"] folder_entity = folder_entities_by_id[folder_id] - product_context_by_id[product_id] = { + version_context_by_id[version_id] = { "project": project_entity, "folder": folder_entity, "product": product_entity, + "version": version_entity, } for repre_entity in repre_entities: @@ -492,7 +496,7 @@ class LoaderActionsModel: "version": version_entity, "representation": repre_entity, } - return product_context_by_id, repre_context_by_id + return version_context_by_id, repre_context_by_id def _get_action_items_for_contexts( self, From dee1d51640decb7e14b84a041a2e38389316499c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:13:30 +0200 Subject: [PATCH 035/386] cache entities --- .../ayon_core/tools/loader/models/actions.py | 185 +++++++++++++++--- 1 file changed, 163 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index c41119ac45..1e8bfe7ae1 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -5,6 +5,7 @@ import traceback import inspect import collections import uuid +from typing import Callable, Any import ayon_api @@ -53,6 +54,14 @@ class LoaderActionsModel: self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._projects_cache = NestedCacheItem(levels=1, lifetime=60) + self._folders_cache = NestedCacheItem(levels=2, lifetime=300) + self._tasks_cache = NestedCacheItem(levels=2, lifetime=300) + self._products_cache = NestedCacheItem(levels=2, lifetime=300) + self._versions_cache = NestedCacheItem(levels=2, lifetime=1200) + self._representations_cache = NestedCacheItem(levels=2, lifetime=1200) + self._repre_parents_cache = NestedCacheItem(levels=2, lifetime=1200) + def reset(self): """Reset the model with all cached items.""" @@ -61,6 +70,12 @@ class LoaderActionsModel: self._product_loaders.reset() self._repre_loaders.reset() + self._folders_cache.reset() + self._tasks_cache.reset() + self._products_cache.reset() + self._versions_cache.reset() + self._representations_cache.reset() + self._repre_parents_cache.reset() def get_action_items( self, @@ -358,8 +373,8 @@ class LoaderActionsModel: if not project_name and not version_ids: return version_context_by_id, repre_context_by_id - version_entities = ayon_api.get_versions( - project_name, version_ids=version_ids + version_entities = self._get_versions( + project_name, version_ids ) version_entities_by_id = {} version_entities_by_product_id = collections.defaultdict(list) @@ -370,18 +385,18 @@ class LoaderActionsModel: version_entities_by_product_id[product_id].append(version_entity) _product_ids = set(version_entities_by_product_id.keys()) - _product_entities = ayon_api.get_products( - project_name, product_ids=_product_ids + _product_entities = self._get_products( + project_name, _product_ids ) product_entities_by_id = {p["id"]: p for p in _product_entities} _folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - _folder_entities = ayon_api.get_folders( - project_name, folder_ids=_folder_ids + _folder_entities = self._get_folders( + project_name, _folder_ids ) folder_entities_by_id = {f["id"]: f for f in _folder_entities} - project_entity = ayon_api.get_project(project_name) + project_entity = self._get_project(project_name) for version_id, version_entity in version_entities_by_id.items(): product_id = version_entity["productId"] @@ -395,8 +410,15 @@ class LoaderActionsModel: "version": version_entity, } - repre_entities = ayon_api.get_representations( - project_name, version_ids=version_ids) + all_repre_ids = set() + for repre_ids in self._get_repre_ids_by_version_ids( + project_name, version_ids + ).values(): + all_repre_ids |= repre_ids + + repre_entities = self._get_representations( + project_name, all_repre_ids + ) for repre_entity in repre_entities: version_id = repre_entity["versionId"] version_entity = version_entities_by_id[version_id] @@ -439,34 +461,35 @@ class LoaderActionsModel: if not project_name and not repre_ids: return version_context_by_id, repre_context_by_id - repre_entities = list(ayon_api.get_representations( - project_name, representation_ids=repre_ids - )) + repre_entities = self._get_representations( + project_name, repre_ids + ) version_ids = {r["versionId"] for r in repre_entities} - version_entities = ayon_api.get_versions( - project_name, version_ids=version_ids + version_entities = self._get_versions( + project_name, version_ids ) version_entities_by_id = { v["id"]: v for v in version_entities } product_ids = {v["productId"] for v in version_entities_by_id.values()} - product_entities = ayon_api.get_products( - project_name, product_ids=product_ids + product_entities = self._get_products( + project_name, product_ids + ) product_entities_by_id = { p["id"]: p for p in product_entities } folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - folder_entities = ayon_api.get_folders( - project_name, folder_ids=folder_ids + folder_entities = self._get_folders( + project_name, folder_ids ) folder_entities_by_id = { f["id"]: f for f in folder_entities } - project_entity = ayon_api.get_project(project_name) + project_entity = self._get_project(project_name) version_context_by_id = {} for version_id, version_entity in version_entities_by_id.items(): @@ -498,6 +521,124 @@ class LoaderActionsModel: } return version_context_by_id, repre_context_by_id + def _get_project(self, project_name: str) -> dict[str, Any]: + cache = self._projects_cache[project_name] + if not cache.is_valid: + cache.update_data(ayon_api.get_project(project_name)) + return cache.get_data() + + def _get_folders( + self, project_name: str, folder_ids: set[str] + ) -> list[dict[str, Any]]: + """Get folders by ids.""" + return self._get_entities( + project_name, + folder_ids, + self._folders_cache, + ayon_api.get_folders, + "folder_ids", + ) + + def _get_products( + self, project_name: str, product_ids: set[str] + ) -> list[dict[str, Any]]: + """Get products by ids.""" + return self._get_entities( + project_name, + product_ids, + self._products_cache, + ayon_api.get_products, + "product_ids", + ) + + def _get_versions( + self, project_name: str, version_ids: set[str] + ) -> list[dict[str, Any]]: + """Get versions by ids.""" + return self._get_entities( + project_name, + version_ids, + self._versions_cache, + ayon_api.get_versions, + "version_ids", + ) + + def _get_representations( + self, project_name: str, representation_ids: set[str] + ) -> list[dict[str, Any]]: + """Get representations by ids.""" + return self._get_entities( + project_name, + representation_ids, + self._representations_cache, + ayon_api.get_representations, + "representation_ids", + ) + + def _get_repre_ids_by_version_ids( + self, project_name: str, version_ids: set[str] + ) -> dict[str, set[str]]: + output = {} + if not version_ids: + return output + + project_cache = self._repre_parents_cache[project_name] + missing_ids = set() + for version_id in version_ids: + cache = project_cache[version_id] + if cache.is_valid: + output[version_id] = cache.get_data() + else: + missing_ids.add(version_id) + + if missing_ids: + repre_cache = self._representations_cache[project_name] + repres_by_parent_id = collections.defaultdict(list) + for repre in ayon_api.get_representations( + project_name, version_ids=missing_ids + ): + version_id = repre["versionId"] + repre_cache[repre["id"]].update_data(repre) + repres_by_parent_id[version_id].append(repre) + + for version_id, repres in repres_by_parent_id.items(): + repre_ids = { + repre["id"] + for repre in repres + } + output[version_id] = set(repre_ids) + project_cache[version_id].update_data(repre_ids) + + return output + + def _get_entities( + self, + project_name: str, + entity_ids: set[str], + cache: NestedCacheItem, + getter: Callable, + filter_arg: str, + ) -> list[dict[str, Any]]: + entities = [] + if not entity_ids: + return entities + + missing_ids = set() + project_cache = cache[project_name] + for entity_id in entity_ids: + entity_cache = project_cache[entity_id] + if entity_cache.is_valid: + entities.append(entity_cache.get_data()) + else: + missing_ids.add(entity_id) + + if missing_ids: + for entity in getter(project_name, **{filter_arg: missing_ids}): + entities.append(entity) + entity_id = entity["id"] + project_cache[entity_id].update_data(entity) + return entities + def _get_action_items_for_contexts( self, project_name, @@ -601,12 +742,12 @@ class LoaderActionsModel: project_name, version_ids=version_ids )) product_ids = {v["productId"] for v in version_entities} - product_entities = ayon_api.get_products( - project_name, product_ids=product_ids + product_entities = self._get_products( + project_name, product_ids ) product_entities_by_id = {p["id"]: p for p in product_entities} folder_ids = {p["folderId"] for p in product_entities_by_id.values()} - folder_entities = ayon_api.get_folders( + folder_entities = self._get_folders( project_name, folder_ids=folder_ids ) folder_entities_by_id = {f["id"]: f for f in folder_entities} From 599716fe942952649d4bd66f99de712690199f59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:23:10 +0200 Subject: [PATCH 036/386] base of loader action --- client/ayon_core/pipeline/actions/__init__.py | 18 + client/ayon_core/pipeline/actions/loader.py | 546 ++++++++++++++++++ 2 files changed, 564 insertions(+) create mode 100644 client/ayon_core/pipeline/actions/loader.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index bda9b50ede..188414bdbe 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,3 +1,13 @@ +from .loader import ( + LoaderActionForm, + LoaderActionResult, + LoaderActionItem, + LoaderActionPlugin, + LoaderActionSelection, + LoaderActionsContext, + SelectionEntitiesCache, +) + from .launcher import ( LauncherAction, LauncherActionSelection, @@ -18,6 +28,14 @@ from .inventory import ( __all__= ( + "LoaderActionForm", + "LoaderActionResult", + "LoaderActionItem", + "LoaderActionPlugin", + "LoaderActionSelection", + "LoaderActionsContext", + "SelectionEntitiesCache", + "LauncherAction", "LauncherActionSelection", "discover_launcher_actions", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py new file mode 100644 index 0000000000..33f48b195c --- /dev/null +++ b/client/ayon_core/pipeline/actions/loader.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +import os +import collections +import copy +from abc import ABC, abstractmethod +from typing import Optional, Any, Callable +from dataclasses import dataclass + +import ayon_api + +from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import StrEnum, Logger, AbstractAttrDef +from ayon_core.addon import AddonsManager, IPluginPaths +from ayon_core.settings import get_studio_settings, get_project_settings +from ayon_core.pipeline.plugin_discover import discover_plugins + + +class EntityType(StrEnum): + """Selected entity type.""" + # folder = "folder" + # task = "task" + version = "version" + representation = "representation" + + +class SelectionEntitiesCache: + def __init__( + self, + project_name: str, + project_entity: Optional[dict[str, Any]] = None, + folders_by_id: Optional[dict[str, dict[str, Any]]] = None, + tasks_by_id: Optional[dict[str, dict[str, Any]]] = None, + products_by_id: Optional[dict[str, dict[str, Any]]] = None, + versions_by_id: Optional[dict[str, dict[str, Any]]] = None, + representations_by_id: Optional[dict[str, dict[str, Any]]] = None, + task_ids_by_folder_id: Optional[dict[str, str]] = None, + product_ids_by_folder_id: Optional[dict[str, str]] = None, + version_ids_by_product_id: Optional[dict[str, str]] = None, + version_id_by_task_id: Optional[dict[str, str]] = None, + representation_id_by_version_id: Optional[dict[str, str]] = None, + ): + self._project_name = project_name + self._project_entity = project_entity + self._folders_by_id = folders_by_id or {} + self._tasks_by_id = tasks_by_id or {} + self._products_by_id = products_by_id or {} + self._versions_by_id = versions_by_id or {} + self._representations_by_id = representations_by_id or {} + + self._task_ids_by_folder_id = task_ids_by_folder_id or {} + self._product_ids_by_folder_id = product_ids_by_folder_id or {} + self._version_ids_by_product_id = version_ids_by_product_id or {} + self._version_id_by_task_id = version_id_by_task_id or {} + self._representation_id_by_version_id = ( + representation_id_by_version_id or {} + ) + + def get_project(self) -> dict[str, Any]: + if self._project_entity is None: + self._project_entity = ayon_api.get_project(self._project_name) + return copy.deepcopy(self._project_entity) + + def get_folders( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + folder_ids, + self._folders_by_id, + "folder_ids", + ayon_api.get_folders, + ) + + def get_tasks( + self, task_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + task_ids, + self._tasks_by_id, + "task_ids", + ayon_api.get_tasks, + ) + + def get_products( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + product_ids, + self._products_by_id, + "product_ids", + ayon_api.get_products, + ) + + def get_versions( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + version_ids, + self._versions_by_id, + "version_ids", + ayon_api.get_versions, + ) + + def get_representations( + self, representation_ids: set[str] + ) -> list[dict[str, Any]]: + return self._get_entities( + representation_ids, + self._representations_by_id, + "representation_ids", + ayon_api.get_representations, + ) + + def get_folders_tasks( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + task_ids = self._fill_parent_children_ids( + folder_ids, + "folderId", + "folder_ids", + self._task_ids_by_folder_id, + ayon_api.get_tasks, + ) + return self.get_tasks(task_ids) + + def get_folders_products( + self, folder_ids: set[str] + ) -> list[dict[str, Any]]: + product_ids = self._get_folders_products_ids(folder_ids) + return self.get_products(product_ids) + + def get_tasks_versions( + self, task_ids: set[str] + ) -> list[dict[str, Any]]: + folder_ids = { + task["folderId"] + for task in self.get_tasks(task_ids) + } + product_ids = self._get_folders_products_ids(folder_ids) + output = [] + for version in self.get_products_versions(product_ids): + task_id = version["taskId"] + if task_id in task_ids: + output.append(version) + return output + + def get_products_versions( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + version_ids = self._fill_parent_children_ids( + product_ids, + "productId", + "product_ids", + self._version_ids_by_product_id, + ayon_api.get_versions, + ) + return self.get_versions(version_ids) + + def get_versions_representations( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + repre_ids = self._fill_parent_children_ids( + version_ids, + "versionId", + "version_ids", + self._representation_id_by_version_id, + ayon_api.get_representations, + ) + return self.get_representations(repre_ids) + + def get_tasks_folders(self, task_ids: set[str]) -> list[dict[str, Any]]: + folder_ids = { + task["folderId"] + for task in self.get_tasks(task_ids) + } + return self.get_folders(folder_ids) + + def get_products_folders( + self, product_ids: set[str] + ) -> list[dict[str, Any]]: + folder_ids = { + product["folderId"] + for product in self.get_products(product_ids) + } + return self.get_folders(folder_ids) + + def get_versions_products( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + product_ids = { + version["productId"] + for version in self.get_versions(version_ids) + } + return self.get_products(product_ids) + + def get_versions_tasks( + self, version_ids: set[str] + ) -> list[dict[str, Any]]: + task_ids = { + version["taskId"] + for version in self.get_versions(version_ids) + if version["taskId"] + } + return self.get_tasks(task_ids) + + def get_representations_versions( + self, representation_ids: set[str] + ) -> list[dict[str, Any]]: + version_ids = { + repre["versionId"] + for repre in self.get_representations(representation_ids) + } + return self.get_versions(version_ids) + + def _get_folders_products_ids(self, folder_ids: set[str]) -> set[str]: + return self._fill_parent_children_ids( + folder_ids, + "folderId", + "folder_ids", + self._product_ids_by_folder_id, + ayon_api.get_products, + ) + + def _fill_parent_children_ids( + self, + entity_ids: set[str], + parent_key: str, + filter_attr: str, + parent_mapping: dict[str, set[str]], + getter: Callable, + ) -> set[str]: + if not entity_ids: + return set() + children_ids = set() + missing_ids = set() + for entity_id in entity_ids: + _children_ids = parent_mapping.get(entity_id) + if _children_ids is None: + missing_ids.add(entity_id) + else: + children_ids.update(_children_ids) + if missing_ids: + entities_by_parent_id = collections.defaultdict(set) + for entity in getter( + self._project_name, + fields={"id", parent_key}, + **{filter_attr: missing_ids}, + ): + child_id = entity["id"] + children_ids.add(child_id) + entities_by_parent_id[entity[parent_key]].add(child_id) + + for entity_id in missing_ids: + parent_mapping[entity_id] = entities_by_parent_id[entity_id] + + return children_ids + + def _get_entities( + self, + entity_ids: set[str], + cache_var: dict[str, Any], + filter_arg: str, + getter: Callable, + ) -> list[dict[str, Any]]: + if not entity_ids: + return [] + + output = [] + missing_ids: set[str] = set() + for entity_id in entity_ids: + entity = cache_var.get(entity_id) + if entity_id not in cache_var: + missing_ids.add(entity_id) + cache_var[entity_id] = None + elif entity: + output.append(entity) + + if missing_ids: + for entity in getter( + self._project_name, + **{filter_arg: missing_ids} + ): + output.append(entity) + cache_var[entity["id"]] = entity + return output + + +class LoaderActionSelection: + def __init__( + self, + project_name: str, + selected_ids: set[str], + selected_type: EntityType, + *, + project_anatomy: Optional["Anatomy"] = None, + project_settings: Optional[dict[str, Any]] = None, + entities_cache: Optional[SelectionEntitiesCache] = None, + ): + self._project_name = project_name + self._selected_ids = selected_ids + self._selected_type = selected_type + + self._project_anatomy = project_anatomy + self._project_settings = project_settings + + if entities_cache is None: + entities_cache = SelectionEntitiesCache(project_name) + self._entities_cache = entities_cache + + def get_entities_cache(self) -> SelectionEntitiesCache: + return self._entities_cache + + def get_project_name(self) -> str: + return self._project_name + + def get_selected_ids(self) -> set[str]: + return set(self._selected_ids) + + def get_selected_type(self) -> str: + return self._selected_type + + def get_project_settings(self) -> dict[str, Any]: + if self._project_settings is None: + self._project_settings = get_project_settings(self._project_name) + return copy.deepcopy(self._project_settings) + + def get_project_anatomy(self) -> dict[str, Any]: + if self._project_anatomy is None: + from ayon_core.pipeline import Anatomy + + self._project_anatomy = Anatomy( + self._project_name, + project_entity=self.get_entities_cache().get_project(), + ) + return self._project_anatomy + + project_name = property(get_project_name) + selected_ids = property(get_selected_ids) + selected_type = property(get_selected_type) + project_settings = property(get_project_settings) + project_anatomy = property(get_project_anatomy) + entities = property(get_entities_cache) + + +@dataclass +class LoaderActionItem: + identifier: str + entity_ids: set[str] + entity_type: EntityType + label: str + group_label: Optional[str] = None + # Is filled automatically + plugin_identifier: str = None + + +@dataclass +class LoaderActionForm: + title: str + fields: list[AbstractAttrDef] + submit_label: Optional[str] = "Submit" + submit_icon: Optional[str] = None + cancel_label: Optional[str] = "Cancel" + cancel_icon: Optional[str] = None + + +@dataclass +class LoaderActionResult: + message: Optional[str] = None + success: bool = True + form: Optional[LoaderActionForm] = None + + +class LoaderActionPlugin(ABC): + """Plugin for loader actions. + + Plugin is responsible for getting action items and executing actions. + + + """ + def __init__(self, studio_settings: dict[str, Any]): + self.apply_settings(studio_settings) + + def apply_settings(self, studio_settings: dict[str, Any]) -> None: + """Apply studio settings to the plugin. + + Args: + studio_settings (dict[str, Any]): Studio settings. + + """ + pass + + @property + def identifier(self) -> str: + """Identifier of the plugin. + + Returns: + str: Plugin identifier. + + """ + return self.__class__.__name__ + + @abstractmethod + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + """Action items for the selection. + + Args: + selection (LoaderActionSelection): Selection. + + Returns: + list[LoaderActionItem]: Action items. + + """ + pass + + @abstractmethod + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + """Execute an action. + + Args: + identifier (str): Action identifier. + entity_ids: (set[str]): Entity ids stored on action item. + entity_type: (str): Entity type stored on action item. + selection (LoaderActionSelection): Selection wrapper. Can be used + to get entities or get context of original selection. + form_values (dict[str, Any]): Attribute values. + + Returns: + Optional[LoaderActionResult]: Result of the action execution. + + """ + pass + + +class LoaderActionsContext: + def __init__( + self, + studio_settings: Optional[dict[str, Any]] = None, + addons_manager: Optional[AddonsManager] = None, + ) -> None: + self._log = Logger.get_logger(self.__class__.__name__) + + self._addons_manager = addons_manager + + self._studio_settings = studio_settings + self._plugins = None + + def reset( + self, studio_settings: Optional[dict[str, Any]] = None + ) -> None: + self._studio_settings = studio_settings + self._plugins = None + + def get_addons_manager(self) -> AddonsManager: + if self._addons_manager is None: + self._addons_manager = AddonsManager( + settings=self._get_studio_settings() + ) + return self._addons_manager + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + output = [] + for plugin in self._get_plugins().values(): + try: + for action_item in plugin.get_action_items(selection): + action_item.identifier = plugin.identifier + output.append(action_item) + + except Exception: + self._log.warning( + "Failed to get action items for" + f" plugin '{plugin.identifier}'", + exc_info=True, + ) + return output + + def execute_action( + self, + plugin_identifier: str, + action_identifier: str, + entity_ids: set[str], + entity_type: EntityType, + selection: LoaderActionSelection, + attribute_values: dict[str, Any], + ) -> None: + plugins_by_id = self._get_plugins() + plugin = plugins_by_id[plugin_identifier] + plugin.execute_action( + action_identifier, + entity_ids, + entity_type, + selection, + attribute_values, + ) + + def _get_studio_settings(self) -> dict[str, Any]: + if self._studio_settings is None: + self._studio_settings = get_studio_settings() + return copy.deepcopy(self._studio_settings) + + def _get_plugins(self) -> dict[str, LoaderActionPlugin]: + if self._plugins is None: + addons_manager = self.get_addons_manager() + all_paths = [ + os.path.join(AYON_CORE_ROOT, "plugins", "loader") + ] + for addon in addons_manager.addons: + if not isinstance(addon, IPluginPaths): + continue + paths = addon.get_loader_action_plugin_paths() + if paths: + all_paths.extend(paths) + + studio_settings = self._get_studio_settings() + result = discover_plugins(LoaderActionPlugin, all_paths) + result.log_report() + plugins = {} + for cls in result.plugins: + try: + plugin = cls(studio_settings) + plugin_id = plugin.identifier + if plugin_id not in plugins: + plugins[plugin_id] = plugin + continue + + self._log.warning( + f"Duplicated plugins identifier found '{plugin_id}'." + ) + + except Exception: + self._log.warning( + f"Failed to initialize plugin '{cls.__name__}'", + exc_info=True, + ) + self._plugins = plugins + return self._plugins From 7b81cb1215e900637593c26aa4213db11e6dc038 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 13:54:38 +0200 Subject: [PATCH 037/386] added logger to action plugin --- client/ayon_core/pipeline/actions/loader.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 33f48b195c..b81b89a56a 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -3,6 +3,7 @@ from __future__ import annotations import os import collections import copy +import logging from abc import ABC, abstractmethod from typing import Optional, Any, Callable from dataclasses import dataclass @@ -377,6 +378,8 @@ class LoaderActionPlugin(ABC): """ + _log: Optional[logging.Logger] = None + def __init__(self, studio_settings: dict[str, Any]): self.apply_settings(studio_settings) @@ -389,6 +392,12 @@ class LoaderActionPlugin(ABC): """ pass + @property + def log(self) -> logging.Logger: + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + @property def identifier(self) -> str: """Identifier of the plugin. From 0f65fe34a78a9f1e6c51677d03bdcf4c4f401b73 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:02:19 +0200 Subject: [PATCH 038/386] change entity type to str --- client/ayon_core/pipeline/actions/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index b81b89a56a..a6cceabd76 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -347,7 +347,7 @@ class LoaderActionSelection: class LoaderActionItem: identifier: str entity_ids: set[str] - entity_type: EntityType + entity_type: str label: str group_label: Optional[str] = None # Is filled automatically From 700006692a27793e8295b212bdea06e650d3078f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:15:02 +0200 Subject: [PATCH 039/386] added order and icon to --- client/ayon_core/pipeline/actions/loader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index a6cceabd76..ccc81a2d73 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -349,7 +349,9 @@ class LoaderActionItem: entity_ids: set[str] entity_type: str label: str + order: int = 0 group_label: Optional[str] = None + icon: Optional[dict[str, Any]] = None # Is filled automatically plugin_identifier: str = None From e05ffe0263848aee9db112901202b99c45530bfc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:08:04 +0200 Subject: [PATCH 040/386] converted copy file action --- client/ayon_core/plugins/load/copy_file.py | 34 ------ .../ayon_core/plugins/load/copy_file_path.py | 29 ----- client/ayon_core/plugins/loader/copy_file.py | 115 ++++++++++++++++++ 3 files changed, 115 insertions(+), 63 deletions(-) delete mode 100644 client/ayon_core/plugins/load/copy_file.py delete mode 100644 client/ayon_core/plugins/load/copy_file_path.py create mode 100644 client/ayon_core/plugins/loader/copy_file.py diff --git a/client/ayon_core/plugins/load/copy_file.py b/client/ayon_core/plugins/load/copy_file.py deleted file mode 100644 index 08dad03be3..0000000000 --- a/client/ayon_core/plugins/load/copy_file.py +++ /dev/null @@ -1,34 +0,0 @@ -from ayon_core.style import get_default_entity_icon_color -from ayon_core.pipeline import load - - -class CopyFile(load.LoaderPlugin): - """Copy the published file to be pasted at the desired location""" - - representations = {"*"} - product_types = {"*"} - - label = "Copy File" - order = 10 - icon = "copy" - color = get_default_entity_icon_color() - - def load(self, context, name=None, namespace=None, data=None): - path = self.filepath_from_context(context) - self.log.info("Added copy to clipboard: {0}".format(path)) - self.copy_file_to_clipboard(path) - - @staticmethod - def copy_file_to_clipboard(path): - from qtpy import QtCore, QtWidgets - - clipboard = QtWidgets.QApplication.clipboard() - assert clipboard, "Must have running QApplication instance" - - # Build mime data for clipboard - data = QtCore.QMimeData() - url = QtCore.QUrl.fromLocalFile(path) - data.setUrls([url]) - - # Set to Clipboard - clipboard.setMimeData(data) diff --git a/client/ayon_core/plugins/load/copy_file_path.py b/client/ayon_core/plugins/load/copy_file_path.py deleted file mode 100644 index fdf31b5e02..0000000000 --- a/client/ayon_core/plugins/load/copy_file_path.py +++ /dev/null @@ -1,29 +0,0 @@ -import os - -from ayon_core.pipeline import load - - -class CopyFilePath(load.LoaderPlugin): - """Copy published file path to clipboard""" - representations = {"*"} - product_types = {"*"} - - label = "Copy File Path" - order = 20 - icon = "clipboard" - color = "#999999" - - def load(self, context, name=None, namespace=None, data=None): - path = self.filepath_from_context(context) - self.log.info("Added file path to clipboard: {0}".format(path)) - self.copy_path_to_clipboard(path) - - @staticmethod - def copy_path_to_clipboard(path): - from qtpy import QtWidgets - - clipboard = QtWidgets.QApplication.clipboard() - assert clipboard, "Must have running QApplication instance" - - # Set to Clipboard - clipboard.setText(os.path.normpath(path)) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py new file mode 100644 index 0000000000..54e92b0ab9 --- /dev/null +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -0,0 +1,115 @@ +import os +import collections + +from typing import Optional, Any + +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +class CopyFileActionPlugin(LoaderActionPlugin): + """Copy published file path to clipboard""" + identifier = "core.copy-action" + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + repres = [] + if selection.selected_type in "representations": + repres = selection.entities.get_representations( + selection.selected_ids + ) + + if selection.selected_type in "version": + repres = selection.entities.get_versions_representations( + selection.selected_ids + ) + + output = [] + if not repres: + return output + + repre_ids_by_name = collections.defaultdict(set) + for repre in repres: + repre_ids_by_name[repre["name"]].add(repre["id"]) + + for repre_name, repre_ids in repre_ids_by_name.items(): + output.append( + LoaderActionItem( + identifier="copy-path", + label=repre_name, + group_label="Copy file path", + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "content_copy", + "color": "#999999", + } + ) + ) + output.append( + LoaderActionItem( + identifier="copy-file", + label=repre_name, + group_label="Copy file", + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "file_copy", + "color": "#999999", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + from qtpy import QtWidgets, QtCore + + repre = next(iter(selection.entities.get_representations(entity_ids))) + path = get_representation_path_with_anatomy( + repre, selection.get_project_anatomy() + ) + self.log.info(f"Added file path to clipboard: {path}") + + clipboard = QtWidgets.QApplication.clipboard() + if not clipboard: + return LoaderActionResult( + "Failed to copy file path to clipboard", + success=False, + ) + + if identifier == "copy-path": + # Set to Clipboard + clipboard.setText(os.path.normpath(path)) + + return LoaderActionResult( + "Path stored to clipboard", + success=True, + ) + + # Build mime data for clipboard + data = QtCore.QMimeData() + url = QtCore.QUrl.fromLocalFile(path) + data.setUrls([url]) + + # Set to Clipboard + clipboard.setMimeData(data) + + return LoaderActionResult( + "File added to clipboard", + success=True, + ) From e7439a2d7fe093c7181fccfecd5ef170f62e945f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:08:33 +0200 Subject: [PATCH 041/386] fix fill of plugin identifier --- client/ayon_core/pipeline/actions/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ccc81a2d73..96806809bd 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -484,7 +484,7 @@ class LoaderActionsContext: for plugin in self._get_plugins().values(): try: for action_item in plugin.get_action_items(selection): - action_item.identifier = plugin.identifier + action_item.plugin_identifier = plugin.identifier output.append(action_item) except Exception: From 422968315ebe7c0509612c6760138bce445e587b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:01 +0200 Subject: [PATCH 042/386] do not hard force plugin identifier --- client/ayon_core/pipeline/actions/loader.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 96806809bd..7822522496 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -484,7 +484,8 @@ class LoaderActionsContext: for plugin in self._get_plugins().values(): try: for action_item in plugin.get_action_items(selection): - action_item.plugin_identifier = plugin.identifier + if action_item.plugin_identifier is None: + action_item.plugin_identifier = plugin.identifier output.append(action_item) except Exception: From 3a65c56123936041f4fb8bfba2994f7ce6f1311e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:09:11 +0200 Subject: [PATCH 043/386] import Anatomy directly --- client/ayon_core/pipeline/actions/loader.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 7822522496..e2628da43c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -14,6 +14,7 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins @@ -293,7 +294,7 @@ class LoaderActionSelection: selected_ids: set[str], selected_type: EntityType, *, - project_anatomy: Optional["Anatomy"] = None, + project_anatomy: Optional[Anatomy] = None, project_settings: Optional[dict[str, Any]] = None, entities_cache: Optional[SelectionEntitiesCache] = None, ): @@ -325,10 +326,8 @@ class LoaderActionSelection: self._project_settings = get_project_settings(self._project_name) return copy.deepcopy(self._project_settings) - def get_project_anatomy(self) -> dict[str, Any]: + def get_project_anatomy(self) -> Anatomy: if self._project_anatomy is None: - from ayon_core.pipeline import Anatomy - self._project_anatomy = Anatomy( self._project_name, project_entity=self.get_entities_cache().get_project(), From a22f378ed51dcd019531780524e58f531822c7dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:10:03 +0200 Subject: [PATCH 044/386] added 'get_loader_action_plugin_paths' to 'IPluginPaths' --- client/ayon_core/addon/interfaces.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index bf08ccd48c..cc7e39218e 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -185,6 +185,10 @@ class IPluginPaths(AYONInterface): """ return self._get_plugin_paths_by_type("inventory") + def get_loader_action_plugin_paths(self) -> list[str]: + """Receive loader action plugin paths.""" + return [] + class ITrayAddon(AYONInterface): """Addon has special procedures when used in Tray tool. From db764619fc55fb7f0d4fd1c62c350d62ff4918e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:06:39 +0200 Subject: [PATCH 045/386] sort actions at different place --- client/ayon_core/tools/loader/models/actions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 1e8bfe7ae1..5dda2ef51f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -97,11 +97,13 @@ class LoaderActionsModel: repre_context_by_id ) = self._contexts_for_versions(project_name, entity_ids) - return self._get_action_items_for_contexts( + action_items = self._get_action_items_for_contexts( project_name, version_context_by_id, repre_context_by_id ) + action_items.sort(key=self._actions_sorter) + return action_items def trigger_action_item( self, @@ -706,8 +708,6 @@ class LoaderActionsModel: "version", ) action_items.append(item) - - action_items.sort(key=self._actions_sorter) return action_items def _trigger_version_loader( From b5ab3d3380e68c1f968189e65dac0332cbae701e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:01 +0200 Subject: [PATCH 046/386] different way how to set plugin id --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e2628da43c..c14c4bd0cb 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -480,11 +480,11 @@ class LoaderActionsContext: self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: output = [] - for plugin in self._get_plugins().values(): + for plugin_id, plugin in self._get_plugins().items(): try: for action_item in plugin.get_action_items(selection): if action_item.plugin_identifier is None: - action_item.plugin_identifier = plugin.identifier + action_item.plugin_identifier = plugin_id output.append(action_item) except Exception: From 39dc54b09e0aacd2f117fb1a996d407439de923a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:18 +0200 Subject: [PATCH 047/386] return output of execute action --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c14c4bd0cb..ed6a47502c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -503,10 +503,10 @@ class LoaderActionsContext: entity_type: EntityType, selection: LoaderActionSelection, attribute_values: dict[str, Any], - ) -> None: + ) -> Optional[LoaderActionResult]: plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] - plugin.execute_action( + return plugin.execute_action( action_identifier, entity_ids, entity_type, From 234ac09f42dc08715bc43a5228dfb3b1a4a84a80 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:17:29 +0200 Subject: [PATCH 048/386] added enabled option to plugin --- client/ayon_core/pipeline/actions/loader.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ed6a47502c..be311dbdff 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -380,6 +380,7 @@ class LoaderActionPlugin(ABC): """ _log: Optional[logging.Logger] = None + enabled: bool = True def __init__(self, studio_settings: dict[str, Any]): self.apply_settings(studio_settings) @@ -539,6 +540,9 @@ class LoaderActionsContext: for cls in result.plugins: try: plugin = cls(studio_settings) + if not plugin.enabled: + continue + plugin_id = plugin.identifier if plugin_id not in plugins: plugins[plugin_id] = plugin From 12d4905b39e23e9a0eb11f78410e468111ea4201 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:41:30 +0200 Subject: [PATCH 049/386] base implementation in loader tool --- client/ayon_core/tools/loader/abstract.py | 16 ++- client/ayon_core/tools/loader/control.py | 29 ++-- .../ayon_core/tools/loader/models/actions.py | 127 ++++++++++++++---- .../ayon_core/tools/loader/models/sitesync.py | 42 +++--- .../tools/loader/ui/products_widget.py | 3 + .../tools/loader/ui/repres_widget.py | 3 + 6 files changed, 162 insertions(+), 58 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index baf6aabb69..9bff8dbb2d 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -317,6 +317,7 @@ class ActionItem: use 'identifier' and context, it necessary also use 'options'. Args: + plugin_identifier (str): Action identifier. identifier (str): Action identifier. entity_ids (set[str]): Entity ids. entity_type (str): Entity type. @@ -330,6 +331,7 @@ class ActionItem: """ def __init__( self, + plugin_identifier, identifier, entity_ids, entity_type, @@ -339,6 +341,7 @@ class ActionItem: options, order, ): + self.plugin_identifier = plugin_identifier self.identifier = identifier self.entity_ids = entity_ids self.entity_type = entity_type @@ -367,6 +370,7 @@ class ActionItem: def to_data(self): options = self._options_to_data() return { + "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, "entity_ids": list(self.entity_ids), "entity_type": self.entity_type, @@ -992,11 +996,14 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, + plugin_identifier: str, identifier: str, options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): """Trigger action item. @@ -1014,11 +1021,14 @@ class FrontendLoaderController(_BaseLoaderController): } Args: - identifier (str): Action identifier. + plugin_identifier (sttr): Plugin identifier. + identifier (sttr): Action identifier. options (dict[str, Any]): Action option values from UI. project_name (str): Project name. - entity_ids (set[str]): Selected entity ids. - entity_type (str): Selected entity type. + entity_ids (set[str]): Entity ids stored on action item. + entity_type (str): Entity type stored on action item. + selected_ids (set[str]): Selected entity ids. + selected_entity_type (str): Selected entity type. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index f05914da17..900eaf7656 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import uuid +from typing import Any import ayon_api @@ -297,22 +298,25 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): action_items = self._loader_actions_model.get_action_items( project_name, entity_ids, entity_type ) - if entity_type == "representation": - site_sync_items = self._sitesync_model.get_sitesync_action_items( - project_name, entity_ids - ) - action_items.extend(site_sync_items) + + site_sync_items = self._sitesync_model.get_sitesync_action_items( + project_name, entity_ids, entity_type + ) + action_items.extend(site_sync_items) return action_items def trigger_action_item( self, - identifier, - options, - project_name, - entity_ids, - entity_type, + plugin_identifier: str, + identifier: str, + options: dict[str, Any], + project_name: str, + entity_ids: set[str], + entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): - if self._sitesync_model.is_sitesync_action(identifier): + if self._sitesync_model.is_sitesync_action(plugin_identifier): self._sitesync_model.trigger_action_item( identifier, project_name, @@ -321,11 +325,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): return self._loader_actions_model.trigger_action_item( + plugin_identifier, identifier, options, project_name, entity_ids, entity_type, + selected_ids, + selected_entity_type, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 5dda2ef51f..e6ac328f92 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -9,7 +9,12 @@ from typing import Callable, Any import ayon_api -from ayon_core.lib import NestedCacheItem +from ayon_core.lib import NestedCacheItem, Logger +from ayon_core.pipeline.actions import ( + LoaderActionsContext, + LoaderActionSelection, + SelectionEntitiesCache, +) from ayon_core.pipeline.load import ( discover_loader_plugins, ProductLoaderPlugin, @@ -24,6 +29,7 @@ from ayon_core.pipeline.load import ( from ayon_core.tools.loader.abstract import ActionItem ACTIONS_MODEL_SENDER = "actions.model" +LOADER_PLUGIN_ID = "__loader_plugin__" NOT_SET = object() @@ -45,6 +51,7 @@ class LoaderActionsModel: loaders_cache_lifetime = 30 def __init__(self, controller): + self._log = Logger.get_logger(self.__class__.__name__) self._controller = controller self._current_context_project = NOT_SET self._loaders_by_identifier = NestedCacheItem( @@ -53,6 +60,7 @@ class LoaderActionsModel: levels=1, lifetime=self.loaders_cache_lifetime) self._repre_loaders = NestedCacheItem( levels=1, lifetime=self.loaders_cache_lifetime) + self._loader_actions = LoaderActionsContext() self._projects_cache = NestedCacheItem(levels=1, lifetime=60) self._folders_cache = NestedCacheItem(levels=2, lifetime=300) @@ -69,6 +77,7 @@ class LoaderActionsModel: self._loaders_by_identifier.reset() self._product_loaders.reset() self._repre_loaders.reset() + self._loader_actions.reset() self._folders_cache.reset() self._tasks_cache.reset() @@ -102,16 +111,25 @@ class LoaderActionsModel: version_context_by_id, repre_context_by_id ) + action_items.extend(self._get_loader_action_items( + project_name, + entity_ids, + entity_type, + )) + action_items.sort(key=self._actions_sorter) return action_items def trigger_action_item( self, + plugin_identifier: str, identifier: str, options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, + selected_ids: set[str], + selected_entity_type: str, ): """Trigger action by identifier. @@ -122,14 +140,18 @@ class LoaderActionsModel: happened. Args: - identifier (str): Loader identifier. + plugin_identifier (str): Plugin identifier. + identifier (str): Action identifier. options (dict[str, Any]): Loader option values. project_name (str): Project name. - entity_ids (set[str]): Entity ids. - entity_type (str): Entity type. + entity_ids (set[str]): Entity ids on action item. + entity_type (str): Entity type on action item. + selected_ids (set[str]): Selected entity ids. + selected_entity_type (str): Selected entity type. """ event_data = { + "plugin_identifier": plugin_identifier, "identifier": identifier, "id": uuid.uuid4().hex, } @@ -138,27 +160,52 @@ class LoaderActionsModel: event_data, ACTIONS_MODEL_SENDER, ) - loader = self._get_loader_by_identifier(project_name, identifier) + if plugin_identifier != LOADER_PLUGIN_ID: + # TODO fill error infor if any happens + error_info = [] + try: + self._loader_actions.execute_action( + plugin_identifier, + identifier, + entity_ids, + entity_type, + LoaderActionSelection( + project_name, + selected_ids, + selected_entity_type, + ), + {}, + ) - if entity_type == "version": - error_info = self._trigger_version_loader( - loader, - options, - project_name, - entity_ids, - ) - elif entity_type == "representation": - error_info = self._trigger_representation_loader( - loader, - options, - project_name, - entity_ids, - ) + except Exception: + self._log.warning( + f"Failed to execute action '{identifier}'", + exc_info=True, + ) else: - raise NotImplementedError( - f"Invalid entity type '{entity_type}' to trigger action item" + loader = self._get_loader_by_identifier( + project_name, identifier ) + if entity_type == "version": + error_info = self._trigger_version_loader( + loader, + options, + project_name, + entity_ids, + ) + elif entity_type == "representation": + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + entity_ids, + ) + else: + raise NotImplementedError( + f"Invalid entity type '{entity_type}' to trigger action item" + ) + event_data["error_info"] = error_info self._controller.emit_event( "load.finished", @@ -278,8 +325,9 @@ class LoaderActionsModel: ): label = self._get_action_label(loader) if repre_name: - label = "{} ({})".format(label, repre_name) + label = f"{label} ({repre_name})" return ActionItem( + LOADER_PLUGIN_ID, get_loader_identifier(loader), entity_ids=entity_ids, entity_type=entity_type, @@ -456,8 +504,8 @@ class LoaderActionsModel: Returns: tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and representation contexts. - """ + """ version_context_by_id = {} repre_context_by_id = {} if not project_name and not repre_ids: @@ -710,6 +758,39 @@ class LoaderActionsModel: action_items.append(item) return action_items + + def _get_loader_action_items( + self, + project_name: str, + entity_ids: set[str], + entity_type: str, + ) -> list[ActionItem]: + # TODO prepare cached entities + # entities_cache = SelectionEntitiesCache(project_name) + selection = LoaderActionSelection( + project_name, + entity_ids, + entity_type, + # entities_cache=entities_cache + ) + items = [] + for action in self._loader_actions.get_action_items(selection): + label = action.label + if action.group_label: + label = f"{action.group_label} ({label})" + items.append(ActionItem( + action.plugin_identifier, + action.identifier, + action.entity_ids, + action.entity_type, + label, + action.icon, + None, # action.tooltip, + None, # action.options, + action.order, + )) + return items + def _trigger_version_loader( self, loader, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 3a54a1b5f8..bab8a68132 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -246,26 +246,32 @@ class SiteSyncModel: output[repre_id] = repre_cache.get_data() return output - def get_sitesync_action_items(self, project_name, representation_ids): + def get_sitesync_action_items( + self, project_name, entity_ids, entity_type + ): """ Args: project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. Returns: list[ActionItem]: Actions that can be shown in loader. + """ + if entity_type != "representation": + return [] if not self.is_sitesync_enabled(project_name): return [] repres_status = self.get_representations_sync_status( - project_name, representation_ids + project_name, entity_ids ) repre_ids_per_identifier = collections.defaultdict(set) - for repre_id in representation_ids: + for repre_id in entity_ids: repre_status = repres_status[repre_id] local_status, remote_status = repre_status @@ -293,27 +299,23 @@ class SiteSyncModel: return action_items - def is_sitesync_action(self, identifier): + def is_sitesync_action(self, plugin_identifier: str) -> bool: """Should be `identifier` handled by SiteSync. Args: - identifier (str): Action identifier. + plugin_identifier (str): Plugin identifier. Returns: bool: Should action be handled by SiteSync. - """ - return identifier in { - UPLOAD_IDENTIFIER, - DOWNLOAD_IDENTIFIER, - REMOVE_IDENTIFIER, - } + """ + return plugin_identifier == "sitesync.loader.action" def trigger_action_item( self, - identifier, - project_name, - representation_ids + identifier: str, + project_name: str, + representation_ids: set[str], ): """Resets status for site_name or remove local files. @@ -321,8 +323,8 @@ class SiteSyncModel: identifier (str): Action identifier. project_name (str): Project name. representation_ids (Iterable[str]): Representation ids. - """ + """ active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -482,6 +484,7 @@ class SiteSyncModel: icon_name ): return ActionItem( + "sitesync.loader.action", identifier, label, icon={ @@ -492,11 +495,8 @@ class SiteSyncModel: tooltip=tooltip, options={}, order=1, - project_name=project_name, - folder_ids=[], - product_ids=[], - version_ids=[], - representation_ids=representation_ids, + entity_ids=representation_ids, + entity_type="representation", ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 4ed4368ab4..29bab7d0c5 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,11 +438,14 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( + action_item.plugin_identifier, action_item.identifier, options, project_name, action_item.entity_ids, action_item.entity_type, + version_ids, + "version", ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index c0957d186c..d1d9c73a2b 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,9 +399,12 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( + action_item.plugin_identifier, action_item.identifier, options, self._selected_project_name, action_item.entity_ids, action_item.entity_type, + repre_ids, + "representation", ) From afc1af7e9579e7304a2b8e47da2db72bde5c7499 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 21 Aug 2025 16:42:32 +0200 Subject: [PATCH 050/386] use kwargs --- client/ayon_core/tools/loader/models/sitesync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index bab8a68132..67da36cd53 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -485,8 +485,8 @@ class SiteSyncModel: ): return ActionItem( "sitesync.loader.action", - identifier, - label, + identifier=identifier, + label=label, icon={ "type": "awesome-font", "name": icon_name, From d0cb16a1558a8bfeffe525b47aba4945b45d6a20 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:44:55 +0200 Subject: [PATCH 051/386] pass context to loader action plugins --- client/ayon_core/pipeline/actions/loader.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index be311dbdff..e62f10e7f2 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -382,8 +382,9 @@ class LoaderActionPlugin(ABC): _log: Optional[logging.Logger] = None enabled: bool = True - def __init__(self, studio_settings: dict[str, Any]): - self.apply_settings(studio_settings) + def __init__(self, context: "LoaderActionsContext") -> None: + self._context = context + self.apply_settings(context.get_studio_settings()) def apply_settings(self, studio_settings: dict[str, Any]) -> None: """Apply studio settings to the plugin. @@ -473,10 +474,15 @@ class LoaderActionsContext: def get_addons_manager(self) -> AddonsManager: if self._addons_manager is None: self._addons_manager = AddonsManager( - settings=self._get_studio_settings() + settings=self.get_studio_settings() ) return self._addons_manager + def get_studio_settings(self) -> dict[str, Any]: + if self._studio_settings is None: + self._studio_settings = get_studio_settings() + return copy.deepcopy(self._studio_settings) + def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -515,11 +521,6 @@ class LoaderActionsContext: attribute_values, ) - def _get_studio_settings(self) -> dict[str, Any]: - if self._studio_settings is None: - self._studio_settings = get_studio_settings() - return copy.deepcopy(self._studio_settings) - def _get_plugins(self) -> dict[str, LoaderActionPlugin]: if self._plugins is None: addons_manager = self.get_addons_manager() @@ -533,13 +534,12 @@ class LoaderActionsContext: if paths: all_paths.extend(paths) - studio_settings = self._get_studio_settings() result = discover_plugins(LoaderActionPlugin, all_paths) result.log_report() plugins = {} for cls in result.plugins: try: - plugin = cls(studio_settings) + plugin = cls(self) if not plugin.enabled: continue From 8da213c5660b19a0b72536df37703ce06a21ffb7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:26 +0200 Subject: [PATCH 052/386] added host to the context --- client/ayon_core/pipeline/actions/loader.py | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e62f10e7f2..c3216064e3 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -11,12 +11,14 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT +from ayon_core.host import AbstractHost from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings -from ayon_core.pipeline import Anatomy +from ayon_core.pipeline import Anatomy, registered_host from ayon_core.pipeline.plugin_discover import discover_plugins +_PLACEHOLDER = object() class EntityType(StrEnum): """Selected entity type.""" @@ -411,6 +413,11 @@ class LoaderActionPlugin(ABC): """ return self.__class__.__name__ + @property + def host_name(self) -> Optional[str]: + """Name of the current host.""" + return self._context.get_host_name() + @abstractmethod def get_action_items( self, selection: LoaderActionSelection @@ -457,11 +464,14 @@ class LoaderActionsContext: self, studio_settings: Optional[dict[str, Any]] = None, addons_manager: Optional[AddonsManager] = None, + host: Optional[AbstractHost] = _PLACEHOLDER, ) -> None: self._log = Logger.get_logger(self.__class__.__name__) self._addons_manager = addons_manager + self._host = host + # Attributes that are re-cached on reset self._studio_settings = studio_settings self._plugins = None @@ -478,6 +488,17 @@ class LoaderActionsContext: ) return self._addons_manager + def get_host(self) -> Optional[AbstractHost]: + if self._host is _PLACEHOLDER: + self._host = registered_host() + return self._host + + def get_host_name(self) -> Optional[str]: + host = self.get_host() + if host is None: + return None + return host.name + def get_studio_settings(self) -> dict[str, Any]: if self._studio_settings is None: self._studio_settings = get_studio_settings() From e30738d79b7b9fec3cf9b75f440b6f41fae9fe3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:45:48 +0200 Subject: [PATCH 053/386] LoaderSelectedType is public --- client/ayon_core/pipeline/actions/__init__.py | 4 +++- client/ayon_core/pipeline/actions/loader.py | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 188414bdbe..247f64e890 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,4 +1,5 @@ from .loader import ( + LoaderSelectedType, LoaderActionForm, LoaderActionResult, LoaderActionItem, @@ -27,7 +28,8 @@ from .inventory import ( ) -__all__= ( +__all__ = ( + "LoaderSelectedType", "LoaderActionForm", "LoaderActionResult", "LoaderActionItem", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c3216064e3..726ee6dcff 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -20,7 +20,8 @@ from ayon_core.pipeline.plugin_discover import discover_plugins _PLACEHOLDER = object() -class EntityType(StrEnum): + +class LoaderSelectedType(StrEnum): """Selected entity type.""" # folder = "folder" # task = "task" @@ -294,7 +295,7 @@ class LoaderActionSelection: self, project_name: str, selected_ids: set[str], - selected_type: EntityType, + selected_type: LoaderSelectedType, *, project_anatomy: Optional[Anatomy] = None, project_settings: Optional[dict[str, Any]] = None, @@ -528,7 +529,7 @@ class LoaderActionsContext: plugin_identifier: str, action_identifier: str, entity_ids: set[str], - entity_type: EntityType, + entity_type: LoaderSelectedType, selection: LoaderActionSelection, attribute_values: dict[str, Any], ) -> Optional[LoaderActionResult]: From 8bdfe806e0a896785b5f7f404aaaffc9b376274e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:46:27 +0200 Subject: [PATCH 054/386] result can contain form values This allows to re-open the same dialog having the same default values but with values already filled from user --- client/ayon_core/pipeline/actions/loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 726ee6dcff..9f0653a7f1 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -373,6 +373,7 @@ class LoaderActionResult: message: Optional[str] = None success: bool = True form: Optional[LoaderActionForm] = None + form_values: Optional[dict[str, Any]] = None class LoaderActionPlugin(ABC): From 2be5d3b72b9b0fac9e97cf581a3bd299fb0f1100 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 22 Aug 2025 11:46:49 +0200 Subject: [PATCH 055/386] fix type comparison --- client/ayon_core/plugins/loader/copy_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 54e92b0ab9..d8424761e9 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -20,12 +20,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type in "representations": + if selection.selected_type == "representations": repres = selection.entities.get_representations( selection.selected_ids ) - if selection.selected_type in "version": + if selection.selected_type == "version": repres = selection.entities.get_versions_representations( selection.selected_ids ) From c6c642f37af190ffdbf5da8f88393fd3fff2c3fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:47:20 +0200 Subject: [PATCH 056/386] added json conversions --- client/ayon_core/pipeline/actions/loader.py | 57 ++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 9f0653a7f1..b537655ada 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -11,11 +11,16 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import StrEnum, Logger +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) from ayon_core.host import AbstractHost -from ayon_core.lib import StrEnum, Logger, AbstractAttrDef from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings -from ayon_core.pipeline import Anatomy, registered_host +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins _PLACEHOLDER = object() @@ -363,9 +368,29 @@ class LoaderActionForm: title: str fields: list[AbstractAttrDef] submit_label: Optional[str] = "Submit" - submit_icon: Optional[str] = None + submit_icon: Optional[dict[str, Any]] = None cancel_label: Optional[str] = "Cancel" - cancel_icon: Optional[str] = None + cancel_icon: Optional[dict[str, Any]] = None + + def to_json_data(self) -> dict[str, Any]: + fields = self.fields + if fields is not None: + fields = serialize_attr_defs(fields) + return { + "title": self.title, + "fields": fields, + "submit_label": self.submit_label, + "submit_icon": self.submit_icon, + "cancel_label": self.cancel_label, + "cancel_icon": self.cancel_icon, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionForm": + fields = data["fields"] + if fields is not None: + data["fields"] = deserialize_attr_defs(fields) + return cls(**data) @dataclass @@ -375,6 +400,24 @@ class LoaderActionResult: form: Optional[LoaderActionForm] = None form_values: Optional[dict[str, Any]] = None + def to_json_data(self) -> dict[str, Any]: + form = self.form + if form is not None: + form = form.to_json_data() + return { + "message": self.message, + "success": self.success, + "form": form, + "form_values": self.form_values, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult": + form = data["form"] + if form is not None: + data["form"] = LoaderActionForm.from_json_data(form) + return LoaderActionResult(**data) + class LoaderActionPlugin(ABC): """Plugin for loader actions. @@ -492,6 +535,8 @@ class LoaderActionsContext: def get_host(self) -> Optional[AbstractHost]: if self._host is _PLACEHOLDER: + from ayon_core.pipeline import registered_host + self._host = registered_host() return self._host @@ -532,7 +577,7 @@ class LoaderActionsContext: entity_ids: set[str], entity_type: LoaderSelectedType, selection: LoaderActionSelection, - attribute_values: dict[str, Any], + form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] @@ -541,7 +586,7 @@ class LoaderActionsContext: entity_ids, entity_type, selection, - attribute_values, + form_values, ) def _get_plugins(self) -> dict[str, LoaderActionPlugin]: From 856aa31231c364d1957e6e2e07e53cbc45532faf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:57:40 +0200 Subject: [PATCH 057/386] change order of arguments --- client/ayon_core/tools/loader/abstract.py | 14 ++++++++------ client/ayon_core/tools/loader/control.py | 6 ++++-- .../ayon_core/tools/loader/ui/products_widget.py | 3 ++- client/ayon_core/tools/loader/ui/repres_widget.py | 3 ++- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 9bff8dbb2d..a58ddf11d7 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -324,9 +324,9 @@ class ActionItem: label (str): Action label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. + order (int): Action order. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. - order (int): Action order. """ def __init__( @@ -338,8 +338,8 @@ class ActionItem: label, icon, tooltip, - options, order, + options, ): self.plugin_identifier = plugin_identifier self.identifier = identifier @@ -348,8 +348,8 @@ class ActionItem: self.label = label self.icon = icon self.tooltip = tooltip - self.options = options self.order = order + self.options = options def _options_to_data(self): options = self.options @@ -377,8 +377,8 @@ class ActionItem: "label": self.label, "icon": self.icon, "tooltip": self.tooltip, - "options": options, "order": self.order, + "options": options, } @classmethod @@ -998,12 +998,13 @@ class FrontendLoaderController(_BaseLoaderController): self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): """Trigger action item. @@ -1023,12 +1024,13 @@ class FrontendLoaderController(_BaseLoaderController): Args: plugin_identifier (sttr): Plugin identifier. identifier (sttr): Action identifier. - options (dict[str, Any]): Action option values from UI. project_name (str): Project name. entity_ids (set[str]): Entity ids stored on action item. entity_type (str): Entity type stored on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + options (dict[str, Any]): Action option values from UI. + form_values (dict[str, Any]): Action form values from UI. """ pass diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 900eaf7656..7ca25976b9 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -309,12 +309,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): if self._sitesync_model.is_sitesync_action(plugin_identifier): self._sitesync_model.trigger_action_item( @@ -327,12 +328,13 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._loader_actions_model.trigger_action_item( plugin_identifier, identifier, - options, project_name, entity_ids, entity_type, selected_ids, selected_entity_type, + options, + form_values, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 29bab7d0c5..319108e8ea 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -440,12 +440,13 @@ class ProductsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.plugin_identifier, action_item.identifier, - options, project_name, action_item.entity_ids, action_item.entity_type, version_ids, "version", + options, + {}, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index d1d9c73a2b..bfbcc73503 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -401,10 +401,11 @@ class RepresentationsWidget(QtWidgets.QWidget): self._controller.trigger_action_item( action_item.plugin_identifier, action_item.identifier, - options, self._selected_project_name, action_item.entity_ids, action_item.entity_type, repre_ids, "representation", + options, + {}, ) From 51beef8192a435edcd2e0c4b29802f161ab755f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:22:22 +0200 Subject: [PATCH 058/386] handle the actions --- .../ayon_core/tools/loader/models/actions.py | 84 ++++++++------ .../ayon_core/tools/loader/models/sitesync.py | 2 +- client/ayon_core/tools/loader/ui/window.py | 103 +++++++++++++++++- 3 files changed, 154 insertions(+), 35 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index e6ac328f92..d3d053ae85 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -124,12 +124,13 @@ class LoaderActionsModel: self, plugin_identifier: str, identifier: str, - options: dict[str, Any], project_name: str, entity_ids: set[str], entity_type: str, selected_ids: set[str], selected_entity_type: str, + options: dict[str, Any], + form_values: dict[str, Any], ): """Trigger action by identifier. @@ -142,17 +143,23 @@ class LoaderActionsModel: Args: plugin_identifier (str): Plugin identifier. identifier (str): Action identifier. - options (dict[str, Any]): Loader option values. project_name (str): Project name. entity_ids (set[str]): Entity ids on action item. entity_type (str): Entity type on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + options (dict[str, Any]): Loader option values. + form_values (dict[str, Any]): Form values. """ event_data = { "plugin_identifier": plugin_identifier, "identifier": identifier, + "project_name": project_name, + "entity_ids": list(entity_ids), + "entity_type": entity_type, + "selected_ids": list(selected_ids), + "selected_entity_type": selected_entity_type, "id": uuid.uuid4().hex, } self._controller.emit_event( @@ -162,9 +169,10 @@ class LoaderActionsModel: ) if plugin_identifier != LOADER_PLUGIN_ID: # TODO fill error infor if any happens - error_info = [] + result = None + crashed = False try: - self._loader_actions.execute_action( + result = self._loader_actions.execute_action( plugin_identifier, identifier, entity_ids, @@ -174,37 +182,47 @@ class LoaderActionsModel: selected_ids, selected_entity_type, ), - {}, + form_values, ) except Exception: + crashed = True self._log.warning( f"Failed to execute action '{identifier}'", exc_info=True, ) - else: - loader = self._get_loader_by_identifier( - project_name, identifier - ) - if entity_type == "version": - error_info = self._trigger_version_loader( - loader, - options, - project_name, - entity_ids, - ) - elif entity_type == "representation": - error_info = self._trigger_representation_loader( - loader, - options, - project_name, - entity_ids, - ) - else: - raise NotImplementedError( - f"Invalid entity type '{entity_type}' to trigger action item" - ) + event_data["result"] = result + event_data["crashed"] = crashed + self._controller.emit_event( + "loader.action.finished", + event_data, + ACTIONS_MODEL_SENDER, + ) + return + + loader = self._get_loader_by_identifier( + project_name, identifier + ) + + if entity_type == "version": + error_info = self._trigger_version_loader( + loader, + options, + project_name, + entity_ids, + ) + elif entity_type == "representation": + error_info = self._trigger_representation_loader( + loader, + options, + project_name, + entity_ids, + ) + else: + raise NotImplementedError( + f"Invalid entity type '{entity_type}' to trigger action item" + ) event_data["error_info"] = error_info self._controller.emit_event( @@ -334,8 +352,8 @@ class LoaderActionsModel: label=label, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), - options=loader.get_options(contexts), order=loader.order, + options=loader.get_options(contexts), ) def _get_loaders(self, project_name): @@ -783,11 +801,11 @@ class LoaderActionsModel: action.identifier, action.entity_ids, action.entity_type, - label, - action.icon, - None, # action.tooltip, - None, # action.options, - action.order, + label=label, + icon=action.icon, + tooltip=None, # action.tooltip, + order=action.order, + options=None, # action.options, )) return items diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 67da36cd53..ced4ac5d05 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -493,10 +493,10 @@ class SiteSyncModel: "color": "#999999" }, tooltip=tooltip, - options={}, order=1, entity_ids=representation_ids, entity_type="representation", + options={}, ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index df5beb708f..71679213e5 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -1,18 +1,24 @@ from __future__ import annotations +from typing import Optional + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.resources import get_ayon_icon_filepath from ayon_core.style import load_stylesheet +from ayon_core.pipeline.actions import LoaderActionResult from ayon_core.tools.utils import ( PlaceholderLineEdit, + MessageOverlayObject, ErrorMessageBox, ThumbnailPainterWidget, RefreshButton, GoToCurrentButton, + ProjectsCombobox, + get_qt_icon, ) +from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.utils.lib import center_window -from ayon_core.tools.utils import ProjectsCombobox from ayon_core.tools.common_models import StatusItem from ayon_core.tools.loader.abstract import ProductTypeItem from ayon_core.tools.loader.control import LoaderController @@ -141,6 +147,8 @@ class LoaderWindow(QtWidgets.QWidget): if controller is None: controller = LoaderController() + overlay_object = MessageOverlayObject(self) + main_splitter = QtWidgets.QSplitter(self) context_splitter = QtWidgets.QSplitter(main_splitter) @@ -294,6 +302,12 @@ class LoaderWindow(QtWidgets.QWidget): "controller.reset.finished", self._on_controller_reset_finish, ) + controller.register_event_callback( + "loader.action.finished", + self._on_loader_action_finished, + ) + + self._overlay_object = overlay_object self._group_dialog = ProductGroupDialog(controller, self) @@ -406,6 +420,20 @@ class LoaderWindow(QtWidgets.QWidget): if self._reset_on_show: self.refresh() + def _show_toast_message( + self, + message: str, + success: bool = True, + message_id: Optional[str] = None, + ): + message_type = None + if not success: + message_type = "error" + + self._overlay_object.add_message( + message, message_type, message_id=message_id + ) + def _show_group_dialog(self): project_name = self._projects_combobox.get_selected_project_name() if not project_name: @@ -494,6 +522,79 @@ class LoaderWindow(QtWidgets.QWidget): box = LoadErrorMessageBox(error_info, self) box.show() + def _on_loader_action_finished(self, event): + crashed = event["crashed"] + if crashed: + self._show_toast_message( + "Action failed", + success=False, + ) + return + + result: Optional[LoaderActionResult] = event["result"] + if result is None: + return + + if result.message: + self._show_toast_message( + result.message, result.success + ) + + if result.form is None: + return + + form = result.form + dialog = AttributeDefinitionsDialog( + form.fields, + title=form.title, + parent=self, + ) + if result.form_values: + dialog.set_values(result.form_values) + submit_label = form.submit_label + submit_icon = form.submit_icon + cancel_label = form.cancel_label + cancel_icon = form.cancel_icon + + if submit_icon: + submit_icon = get_qt_icon(submit_icon) + if cancel_icon: + cancel_icon = get_qt_icon(cancel_icon) + + if submit_label: + dialog.set_submit_label(submit_label) + else: + dialog.set_submit_visible(False) + + if submit_icon: + dialog.set_submit_icon(submit_icon) + + if cancel_label: + dialog.set_cancel_label(cancel_label) + else: + dialog.set_cancel_visible(False) + + if cancel_icon: + dialog.set_cancel_icon(cancel_icon) + + dialog.setMinimumSize(300, 140) + result = dialog.exec_() + if result != QtWidgets.QDialog.Accepted: + return + + form_data = dialog.get_values() + self._controller.trigger_action_item( + event["plugin_identifier"], + event["identifier"], + event["project_name"], + event["entity_ids"], + event["entity_type"], + event["selected_ids"], + event["selected_entity_type"], + {}, + form_data, + ) + def _on_project_selection_changed(self, event): self._selected_project_name = event["project_name"] self._update_filters() From c2cdd4130edaaa78c3ef9eaf9cd99ea510f78c34 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 11:25:09 +0200 Subject: [PATCH 059/386] better stretch, margins and spacing --- client/ayon_core/tools/attribute_defs/dialog.py | 1 + client/ayon_core/tools/attribute_defs/widgets.py | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/attribute_defs/dialog.py b/client/ayon_core/tools/attribute_defs/dialog.py index 7423d58475..4d8e41199e 100644 --- a/client/ayon_core/tools/attribute_defs/dialog.py +++ b/client/ayon_core/tools/attribute_defs/dialog.py @@ -56,6 +56,7 @@ class AttributeDefinitionsDialog(QtWidgets.QDialog): btns_layout.addWidget(cancel_btn, 0) main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) main_layout.addWidget(attrs_widget, 0) main_layout.addStretch(1) main_layout.addWidget(btns_widget, 0) diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 1e948b2d28..f7766f50ac 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -182,6 +182,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): layout.deleteLater() new_layout = QtWidgets.QGridLayout() + new_layout.setContentsMargins(0, 0, 0, 0) new_layout.setColumnStretch(0, 0) new_layout.setColumnStretch(1, 1) self.setLayout(new_layout) @@ -210,12 +211,8 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): if not attr_def.visible: continue + col_num = 0 expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - if attr_def.is_value_def and attr_def.label: label_widget = AttributeDefinitionsLabel( attr_def.id, attr_def.label, self @@ -233,9 +230,12 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): | QtCore.Qt.AlignVCenter ) layout.addWidget( - label_widget, row, 0, 1, expand_cols + label_widget, row, col_num, 1, 1 ) - if not attr_def.is_label_horizontal: + if attr_def.is_label_horizontal: + col_num += 1 + expand_cols = 1 + else: row += 1 if attr_def.is_value_def: From 270d7cbff9679bb434d586728191d5cae8613447 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:44:16 +0200 Subject: [PATCH 060/386] convert delete old versions actions --- .../plugins/load/delete_old_versions.py | 477 ------------------ .../plugins/loader/delete_old_versions.py | 393 +++++++++++++++ 2 files changed, 393 insertions(+), 477 deletions(-) delete mode 100644 client/ayon_core/plugins/load/delete_old_versions.py create mode 100644 client/ayon_core/plugins/loader/delete_old_versions.py diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py deleted file mode 100644 index 3a42ccba7e..0000000000 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ /dev/null @@ -1,477 +0,0 @@ -import collections -import os -import uuid -from typing import List, Dict, Any - -import clique -import ayon_api -from ayon_api.operations import OperationsSession -import qargparse -from qtpy import QtWidgets, QtCore - -from ayon_core import style -from ayon_core.lib import format_file_size -from ayon_core.pipeline import load, Anatomy -from ayon_core.pipeline.load import ( - get_representation_path_with_anatomy, - InvalidRepresentationContext, -) - - -class DeleteOldVersions(load.ProductLoaderPlugin): - """Deletes specific number of old version""" - - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" - - representations = ["*"] - product_types = {"*"} - tool_names = ["library_loader"] - - label = "Delete Old Versions" - order = 35 - icon = "trash" - color = "#d8d8d8" - - options = [ - qargparse.Integer( - "versions_to_keep", default=2, min=0, help="Versions to keep:" - ), - qargparse.Boolean( - "remove_publish_folder", help="Remove publish folder:" - ) - ] - - requires_confirmation = True - - def delete_whole_dir_paths(self, dir_paths, delete=True): - size = 0 - - for dir_path in dir_paths: - # Delete all files and folders in dir path - for root, dirs, files in os.walk(dir_path, topdown=False): - for name in files: - file_path = os.path.join(root, name) - size += os.path.getsize(file_path) - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - - for name in dirs: - if delete: - os.rmdir(os.path.join(root, name)) - - if not delete: - continue - - # Delete even the folder and it's parents folders if they are empty - while True: - if not os.path.exists(dir_path): - dir_path = os.path.dirname(dir_path) - continue - - if len(os.listdir(dir_path)) != 0: - break - - os.rmdir(os.path.join(dir_path)) - - return size - - def path_from_representation(self, representation, anatomy): - try: - context = representation["context"] - except KeyError: - return (None, None) - - try: - path = get_representation_path_with_anatomy( - representation, anatomy - ) - except InvalidRepresentationContext: - return (None, None) - - sequence_path = None - if "frame" in context: - context["frame"] = self.sequence_splitter - sequence_path = get_representation_path_with_anatomy( - representation, anatomy - ) - - if sequence_path: - sequence_path = sequence_path.normalized() - - return (path.normalized(), sequence_path) - - def delete_only_repre_files(self, dir_paths, file_paths, delete=True): - size = 0 - - for dir_id, dir_path in dir_paths.items(): - dir_files = os.listdir(dir_path) - collections, remainders = clique.assemble(dir_files) - for file_path, seq_path in file_paths[dir_id]: - file_path_base = os.path.split(file_path)[1] - # Just remove file if `frame` key was not in context or - # filled path is in remainders (single file sequence) - if not seq_path or file_path_base in remainders: - if not os.path.exists(file_path): - self.log.debug( - "File was not found: {}".format(file_path) - ) - continue - - size += os.path.getsize(file_path) - - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - - if file_path_base in remainders: - remainders.remove(file_path_base) - continue - - seq_path_base = os.path.split(seq_path)[1] - head, tail = seq_path_base.split(self.sequence_splitter) - - final_col = None - for collection in collections: - if head != collection.head or tail != collection.tail: - continue - final_col = collection - break - - if final_col is not None: - # Fill full path to head - final_col.head = os.path.join(dir_path, final_col.head) - for _file_path in final_col: - if os.path.exists(_file_path): - - size += os.path.getsize(_file_path) - - if delete: - os.remove(_file_path) - self.log.debug( - "Removed file: {}".format(_file_path) - ) - - _seq_path = final_col.format("{head}{padding}{tail}") - self.log.debug("Removed files: {}".format(_seq_path)) - collections.remove(final_col) - - elif os.path.exists(file_path): - size += os.path.getsize(file_path) - - if delete: - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) - else: - self.log.debug( - "File was not found: {}".format(file_path) - ) - - # Delete as much as possible parent folders - if not delete: - return size - - for dir_path in dir_paths.values(): - while True: - if not os.path.exists(dir_path): - dir_path = os.path.dirname(dir_path) - continue - - if len(os.listdir(dir_path)) != 0: - break - - self.log.debug("Removed folder: {}".format(dir_path)) - os.rmdir(dir_path) - - return size - - def message(self, text): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(text) - msgBox.setStyleSheet(style.load_stylesheet()) - msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) - msgBox.exec_() - - def _confirm_delete(self, - contexts: List[Dict[str, Any]], - versions_to_keep: int) -> bool: - """Prompt user for a deletion confirmation""" - - contexts_list = "\n".join(sorted( - "- {folder[name]} > {product[name]}".format_map(context) - for context in contexts - )) - num_contexts = len(contexts) - s = "s" if num_contexts > 1 else "" - text = ( - "Are you sure you want to delete versions?\n\n" - f"This will keep only the last {versions_to_keep} " - f"versions for the {num_contexts} selected product{s}." - ) - informative_text = "Warning: This will delete files from disk" - detailed_text = ( - f"Keep only {versions_to_keep} versions for:\n{contexts_list}" - ) - - messagebox = QtWidgets.QMessageBox() - messagebox.setIcon(QtWidgets.QMessageBox.Warning) - messagebox.setWindowTitle("Delete Old Versions") - messagebox.setText(text) - messagebox.setInformativeText(informative_text) - messagebox.setDetailedText(detailed_text) - messagebox.setStandardButtons( - QtWidgets.QMessageBox.Yes - | QtWidgets.QMessageBox.Cancel - ) - messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel) - messagebox.setStyleSheet(style.load_stylesheet()) - messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) - return messagebox.exec_() == QtWidgets.QMessageBox.Yes - - def get_data(self, context, versions_count): - product_entity = context["product"] - folder_entity = context["folder"] - project_name = context["project"]["name"] - anatomy = Anatomy(project_name, project_entity=context["project"]) - - version_fields = ayon_api.get_default_fields_for_type("version") - version_fields.add("tags") - versions = list(ayon_api.get_versions( - project_name, - product_ids=[product_entity["id"]], - active=None, - hero=False, - fields=version_fields - )) - self.log.debug( - "Version Number ({})".format(len(versions)) - ) - versions_by_parent = collections.defaultdict(list) - for ent in versions: - versions_by_parent[ent["productId"]].append(ent) - - def sort_func(ent): - return int(ent["version"]) - - all_last_versions = [] - for _parent_id, _versions in versions_by_parent.items(): - for idx, version in enumerate( - sorted(_versions, key=sort_func, reverse=True) - ): - if idx >= versions_count: - break - all_last_versions.append(version) - - self.log.debug("Collected versions ({})".format(len(versions))) - - # Filter latest versions - for version in all_last_versions: - versions.remove(version) - - # Update versions_by_parent without filtered versions - versions_by_parent = collections.defaultdict(list) - for ent in versions: - versions_by_parent[ent["productId"]].append(ent) - - # Filter already deleted versions - versions_to_pop = [] - for version in versions: - if "deleted" in version["tags"]: - versions_to_pop.append(version) - - for version in versions_to_pop: - msg = "Folder: \"{}\" | Product: \"{}\" | Version: \"{}\"".format( - folder_entity["path"], - product_entity["name"], - version["version"] - ) - self.log.debug(( - "Skipping version. Already tagged as inactive. < {} >" - ).format(msg)) - versions.remove(version) - - version_ids = [ent["id"] for ent in versions] - - self.log.debug( - "Filtered versions to delete ({})".format(len(version_ids)) - ) - - if not version_ids: - msg = "Skipping processing. Nothing to delete on {}/{}".format( - folder_entity["path"], product_entity["name"] - ) - self.log.info(msg) - print(msg) - return - - repres = list(ayon_api.get_representations( - project_name, version_ids=version_ids - )) - - self.log.debug( - "Collected representations to remove ({})".format(len(repres)) - ) - - dir_paths = {} - file_paths_by_dir = collections.defaultdict(list) - for repre in repres: - file_path, seq_path = self.path_from_representation( - repre, anatomy - ) - if file_path is None: - self.log.debug(( - "Could not format path for represenation \"{}\"" - ).format(str(repre))) - continue - - dir_path = os.path.dirname(file_path) - dir_id = None - for _dir_id, _dir_path in dir_paths.items(): - if _dir_path == dir_path: - dir_id = _dir_id - break - - if dir_id is None: - dir_id = uuid.uuid4() - dir_paths[dir_id] = dir_path - - file_paths_by_dir[dir_id].append([file_path, seq_path]) - - dir_ids_to_pop = [] - for dir_id, dir_path in dir_paths.items(): - if os.path.exists(dir_path): - continue - - dir_ids_to_pop.append(dir_id) - - # Pop dirs from both dictionaries - for dir_id in dir_ids_to_pop: - dir_paths.pop(dir_id) - paths = file_paths_by_dir.pop(dir_id) - # TODO report of missing directories? - paths_msg = ", ".join([ - "'{}'".format(path[0].replace("\\", "/")) for path in paths - ]) - self.log.debug(( - "Folder does not exist. Deleting its files skipped: {}" - ).format(paths_msg)) - - return { - "dir_paths": dir_paths, - "file_paths_by_dir": file_paths_by_dir, - "versions": versions, - "folder": folder_entity, - "product": product_entity, - "archive_product": versions_count == 0 - } - - def main(self, project_name, data, remove_publish_folder): - # Size of files. - size = 0 - if not data: - return size - - if remove_publish_folder: - size = self.delete_whole_dir_paths(data["dir_paths"].values()) - else: - size = self.delete_only_repre_files( - data["dir_paths"], data["file_paths_by_dir"] - ) - - op_session = OperationsSession() - for version in data["versions"]: - orig_version_tags = version["tags"] - version_tags = list(orig_version_tags) - changes = {} - if "deleted" not in version_tags: - version_tags.append("deleted") - changes["tags"] = version_tags - - if version["active"]: - changes["active"] = False - - if not changes: - continue - op_session.update_entity( - project_name, "version", version["id"], changes - ) - - op_session.commit() - - return size - - def load(self, contexts, name=None, namespace=None, options=None): - - # Get user options - versions_to_keep = 2 - remove_publish_folder = False - if options: - versions_to_keep = options.get( - "versions_to_keep", versions_to_keep - ) - remove_publish_folder = options.get( - "remove_publish_folder", remove_publish_folder - ) - - # Because we do not want this run by accident we will add an extra - # user confirmation - if ( - self.requires_confirmation - and not self._confirm_delete(contexts, versions_to_keep) - ): - return - - try: - size = 0 - for count, context in enumerate(contexts): - data = self.get_data(context, versions_to_keep) - if not data: - continue - project_name = context["project"]["name"] - size += self.main(project_name, data, remove_publish_folder) - print("Progressing {}/{}".format(count + 1, len(contexts))) - - msg = "Total size of files: {}".format(format_file_size(size)) - self.log.info(msg) - self.message(msg) - - except Exception: - self.log.error("Failed to delete versions.", exc_info=True) - - -class CalculateOldVersions(DeleteOldVersions): - """Calculate file size of old versions""" - label = "Calculate Old Versions" - order = 30 - tool_names = ["library_loader"] - - options = [ - qargparse.Integer( - "versions_to_keep", default=2, min=0, help="Versions to keep:" - ), - qargparse.Boolean( - "remove_publish_folder", help="Remove publish folder:" - ) - ] - - requires_confirmation = False - - def main(self, project_name, data, remove_publish_folder): - size = 0 - - if not data: - return size - - if remove_publish_folder: - size = self.delete_whole_dir_paths( - data["dir_paths"].values(), delete=False - ) - else: - size = self.delete_only_repre_files( - data["dir_paths"], data["file_paths_by_dir"], delete=False - ) - - return size diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py new file mode 100644 index 0000000000..31b0ff4bdf --- /dev/null +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import os +import collections +import json +import shutil +from typing import Optional, Any + +import clique +from ayon_api.operations import OperationsSession +from qtpy import QtWidgets, QtCore + +from ayon_core import style +from ayon_core.lib import ( + format_file_size, + AbstractAttrDef, + NumberDef, + BoolDef, + TextDef, + UILabelDef, +) +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.actions import ( + LoaderSelectedType, + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, + LoaderActionForm, +) + + +class DeleteOldVersions(LoaderActionPlugin): + """Deletes specific number of old version""" + + is_multiple_contexts_compatible = True + sequence_splitter = "__sequence_splitter__" + + requires_confirmation = True + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + # Do not show in hosts + if self.host_name is not None: + return [] + + versions = None + if selection.selected_type == LoaderSelectedType.version: + versions = selection.entities.get_versions( + selection.selected_ids + ) + + if not versions: + return [] + + product_ids = { + version["productId"] + for version in versions + } + + return [ + LoaderActionItem( + identifier="delete-versions", + label="Delete Versions", + order=35, + entity_ids=product_ids, + entity_type="product", + icon={ + "type": "material-symbols", + "name": "delete", + "color": "#d8d8d8", + } + ), + LoaderActionItem( + identifier="calculate-versions-size", + label="Calculate Versions size", + order=30, + entity_ids=product_ids, + entity_type="product", + icon={ + "type": "material-symbols", + "name": "auto_delete", + "color": "#d8d8d8", + } + ) + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + step = form_values.get("step") + versions_to_keep = form_values.get("versions_to_keep") + remove_publish_folder = form_values.get("remove_publish_folder") + if step is None: + return self._first_step( + identifier, + versions_to_keep, + remove_publish_folder, + ) + + if versions_to_keep is None: + versions_to_keep = 2 + if remove_publish_folder is None: + remove_publish_folder = False + + if step == "prepare-data": + return self._prepare_data_step( + identifier, + versions_to_keep, + remove_publish_folder, + entity_ids, + selection, + ) + + if step == "delete-versions": + return self._delete_versions_step( + selection.project_name, form_values + ) + return None + + def _first_step( + self, + identifier: str, + versions_to_keep: Optional[int], + remove_publish_folder: Optional[bool], + ) -> LoaderActionResult: + fields: list[AbstractAttrDef] = [ + TextDef( + "step", + visible=False, + ), + NumberDef( + "versions_to_keep", + label="Versions to keep", + minimum=0, + default=2, + ), + ] + if identifier == "delete-versions": + fields.append( + BoolDef( + "remove_publish_folder", + label="Remove publish folder", + default=False, + ) + ) + + form_values = { + key: value + for key, value in ( + ("remove_publish_folder", remove_publish_folder), + ("versions_to_keep", versions_to_keep), + ) + if value is not None + } + form_values["step"] = "prepare-data" + return LoaderActionResult( + form=LoaderActionForm( + title="Delete Old Versions", + fields=fields, + ), + form_values=form_values + ) + + def _prepare_data_step( + self, + identifier: str, + versions_to_keep: int, + remove_publish_folder: bool, + entity_ids: set[str], + selection: LoaderActionSelection, + ): + versions_by_product_id = collections.defaultdict(list) + for version in selection.entities.get_products_versions(entity_ids): + # Keep hero version + if versions_to_keep != 0 and version["version"] < 0: + continue + versions_by_product_id[version["productId"]].append(version) + + versions_to_delete = [] + for product_id, versions in versions_by_product_id.items(): + if versions_to_keep == 0: + versions_to_delete.extend(versions) + continue + + if len(versions) <= versions_to_keep: + continue + + versions.sort(key=lambda v: v["version"]) + for _ in range(versions_to_keep): + if not versions: + break + versions.pop(-1) + versions_to_delete.extend(versions) + + self.log.debug( + f"Collected versions to delete ({len(versions_to_delete)})" + ) + + version_ids = { + version["id"] + for version in versions_to_delete + } + if not version_ids: + return LoaderActionResult( + message="Skipping. Nothing to delete.", + success=False, + ) + + project = selection.entities.get_project() + anatomy = Anatomy(project["name"], project_entity=project) + + repres = selection.entities.get_versions_representations(version_ids) + + self.log.debug( + f"Collected representations to remove ({len(repres)})" + ) + + filepaths_by_repre_id = {} + repre_ids_by_version_id = { + version_id: [] + for version_id in version_ids + } + for repre in repres: + repre_ids_by_version_id[repre["versionId"]].append(repre["id"]) + filepaths_by_repre_id[repre["id"]] = [ + anatomy.fill_root(repre_file["path"]) + for repre_file in repre["files"] + ] + + size = 0 + for filepaths in filepaths_by_repre_id.values(): + for filepath in filepaths: + if os.path.exists(filepath): + size += os.path.getsize(filepath) + + if identifier == "calculate-versions-size": + return LoaderActionResult( + message="Calculated size", + success=True, + form=LoaderActionForm( + title="Calculated versions size", + fields=[ + UILabelDef( + f"Total size of files: {format_file_size(size)}" + ), + ], + submit_label=None, + cancel_label="Close", + ), + ) + + form, form_values = self._get_delete_form( + size, + remove_publish_folder, + list(version_ids), + repre_ids_by_version_id, + filepaths_by_repre_id, + ) + return LoaderActionResult( + form=form, + form_values=form_values + ) + + def _delete_versions_step( + self, project_name: str, form_values: dict[str, Any] + ) -> LoaderActionResult: + delete_data = json.loads(form_values["delete_data"]) + remove_publish_folder = form_values["remove_publish_folder"] + if form_values["delete_value"].lower() != "delete": + size = delete_data["size"] + form, form_values = self._get_delete_form( + size, + remove_publish_folder, + delete_data["version_ids"], + delete_data["repre_ids_by_version_id"], + delete_data["filepaths_by_repre_id"], + True, + ) + return LoaderActionResult( + form=form, + form_values=form_values, + ) + + version_ids = delete_data["version_ids"] + repre_ids_by_version_id = delete_data["repre_ids_by_version_id"] + filepaths_by_repre_id = delete_data["filepaths_by_repre_id"] + op_session = OperationsSession() + total_versions = len(version_ids) + try: + for version_idx, version_id in enumerate(version_ids): + self.log.info( + f"Progressing version {version_idx + 1}/{total_versions}" + ) + for repre_id in repre_ids_by_version_id[version_id]: + for filepath in filepaths_by_repre_id[repre_id]: + publish_folder = os.path.dirname(filepath) + if remove_publish_folder: + if os.path.exists(publish_folder): + shutil.rmtree(publish_folder, ignore_errors=True) + continue + + if os.path.exists(filepath): + os.remove(filepath) + + op_session.delete_entity( + project_name, "representation", repre_id + ) + op_session.delete_entity( + project_name, "version", version_id + ) + self.log.info("All done") + + except Exception: + self.log.error("Failed to delete versions.", exc_info=True) + return LoaderActionResult( + message="Failed to delete versions.", + success=False, + ) + + finally: + op_session.commit() + + return LoaderActionResult( + message="Deleted versions", + success=True, + ) + + def _get_delete_form( + self, + size: int, + remove_publish_folder: bool, + version_ids: list[str], + repre_ids_by_version_id: dict[str, list[str]], + filepaths_by_repre_id: dict[str, list[str]], + repeated: bool = False, + ) -> tuple[LoaderActionForm, dict[str, Any]]: + versions_len = len(repre_ids_by_version_id) + fields = [ + UILabelDef( + f"Going to delete {versions_len} versions
" + f"- total size of files: {format_file_size(size)}
" + ), + UILabelDef("Are you sure you want to continue?"), + TextDef( + "delete_value", + placeholder="Type 'delete' to confirm...", + ), + ] + if repeated: + fields.append(UILabelDef( + "*Please fill in '**delete**' to confirm deletion.*" + )) + fields.extend([ + TextDef( + "delete_data", + visible=False, + ), + TextDef( + "step", + visible=False, + ), + BoolDef( + "remove_publish_folder", + label="Remove publish folder", + default=False, + visible=False, + ) + ]) + + form = LoaderActionForm( + title="Delete versions", + submit_label="Delete", + cancel_label="Close", + fields=fields, + ) + form_values = { + "delete_data": json.dumps({ + "size": size, + "version_ids": version_ids, + "repre_ids_by_version_id": repre_ids_by_version_id, + "filepaths_by_repre_id": filepaths_by_repre_id, + }), + "step": "delete-versions", + "remove_publish_folder": remove_publish_folder, + } + return form, form_values From f06fbe159f4dcdf8b87586706a1a0d0f4810d17b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:00:10 +0200 Subject: [PATCH 061/386] added group label to 'ActionItem' --- client/ayon_core/tools/loader/abstract.py | 4 +++ .../ayon_core/tools/loader/models/actions.py | 18 ++-------- .../ayon_core/tools/loader/models/sitesync.py | 1 + .../tools/loader/ui/actions_utils.py | 34 ++++++++++++++++--- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index a58ddf11d7..d3de8fb7c2 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -322,6 +322,7 @@ class ActionItem: entity_ids (set[str]): Entity ids. entity_type (str): Entity type. label (str): Action label. + group_label (str): Group label. icon (dict[str, Any]): Action icon definition. tooltip (str): Action tooltip. order (int): Action order. @@ -336,6 +337,7 @@ class ActionItem: entity_ids, entity_type, label, + group_label, icon, tooltip, order, @@ -346,6 +348,7 @@ class ActionItem: self.entity_ids = entity_ids self.entity_type = entity_type self.label = label + self.group_label = group_label self.icon = icon self.tooltip = tooltip self.order = order @@ -375,6 +378,7 @@ class ActionItem: "entity_ids": list(self.entity_ids), "entity_type": self.entity_type, "label": self.label, + "group_label": self.group_label, "icon": self.icon, "tooltip": self.tooltip, "order": self.order, diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index d3d053ae85..684adf36a9 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -116,8 +116,6 @@ class LoaderActionsModel: entity_ids, entity_type, )) - - action_items.sort(key=self._actions_sorter) return action_items def trigger_action_item( @@ -350,6 +348,7 @@ class LoaderActionsModel: entity_ids=entity_ids, entity_type=entity_type, label=label, + group_label=None, icon=self._get_action_icon(loader), tooltip=self._get_action_tooltip(loader), order=loader.order, @@ -407,15 +406,6 @@ class LoaderActionsModel: loaders_by_identifier = loaders_by_identifier_c.get_data() return loaders_by_identifier.get(identifier) - def _actions_sorter(self, action_item): - """Sort the Loaders by their order and then their name. - - Returns: - tuple[int, str]: Sort keys. - """ - - return action_item.order, action_item.label - def _contexts_for_versions(self, project_name, version_ids): """Get contexts for given version ids. @@ -793,15 +783,13 @@ class LoaderActionsModel: ) items = [] for action in self._loader_actions.get_action_items(selection): - label = action.label - if action.group_label: - label = f"{action.group_label} ({label})" items.append(ActionItem( action.plugin_identifier, action.identifier, action.entity_ids, action.entity_type, - label=label, + label=action.label, + group_label=action.group_label, icon=action.icon, tooltip=None, # action.tooltip, order=action.order, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index ced4ac5d05..4d6ffcf9d4 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -487,6 +487,7 @@ class SiteSyncModel: "sitesync.loader.action", identifier=identifier, label=label, + group_label=None, icon={ "type": "awesome-font", "name": icon_name, diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index b601cd95bd..3281a170bd 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -1,6 +1,7 @@ import uuid +from typing import Optional, Any -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore import qtawesome from ayon_core.lib.attribute_definitions import AbstractAttrDef @@ -11,9 +12,26 @@ from ayon_core.tools.utils.widgets import ( OptionDialog, ) from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.loader.abstract import ActionItem -def show_actions_menu(action_items, global_point, one_item_selected, parent): +def _actions_sorter(item: tuple[str, ActionItem]): + """Sort the Loaders by their order and then their name. + + Returns: + tuple[int, str]: Sort keys. + + """ + label, action_item = item + return action_item.order, label + + +def show_actions_menu( + action_items: list[ActionItem], + global_point: QtCore.QPoint, + one_item_selected: bool, + parent: QtWidgets.QWidget, +) -> tuple[Optional[ActionItem], Optional[dict[str, Any]]]: selected_action_item = None selected_options = None @@ -26,15 +44,23 @@ def show_actions_menu(action_items, global_point, one_item_selected, parent): menu = OptionalMenu(parent) - action_items_by_id = {} + action_items_with_labels = [] for action_item in action_items: + label = action_item.label + if action_item.group_label: + label = f"{action_item.group_label} ({label})" + action_items_with_labels.append((label, action_item)) + + action_items_by_id = {} + for item in sorted(action_items_with_labels, key=_actions_sorter): + label, action_item = item item_id = uuid.uuid4().hex action_items_by_id[item_id] = action_item item_options = action_item.options icon = get_qt_icon(action_item.icon) use_option = bool(item_options) action = OptionalAction( - action_item.label, + label, icon, use_option, menu From 1768543b8bd05327168649036743c43c3c3323d0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:18:18 +0200 Subject: [PATCH 062/386] safe-guards for optional action and menu --- client/ayon_core/tools/utils/widgets.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index de2c42c91f..61a4886741 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -861,24 +861,26 @@ class OptionalMenu(QtWidgets.QMenu): def mouseReleaseEvent(self, event): """Emit option clicked signal if mouse released on it""" active = self.actionAt(event.pos()) - if active and active.use_option: + if isinstance(active, OptionalAction) and active.use_option: option = active.widget.option if option.is_hovered(event.globalPos()): option.clicked.emit() - super(OptionalMenu, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) def mouseMoveEvent(self, event): """Add highlight to active action""" active = self.actionAt(event.pos()) for action in self.actions(): - action.set_highlight(action is active, event.globalPos()) - super(OptionalMenu, self).mouseMoveEvent(event) + if isinstance(action, OptionalAction): + action.set_highlight(action is active, event.globalPos()) + super().mouseMoveEvent(event) def leaveEvent(self, event): """Remove highlight from all actions""" for action in self.actions(): - action.set_highlight(False) - super(OptionalMenu, self).leaveEvent(event) + if isinstance(action, OptionalAction): + action.set_highlight(False) + super().leaveEvent(event) class OptionalAction(QtWidgets.QWidgetAction): @@ -890,7 +892,7 @@ class OptionalAction(QtWidgets.QWidgetAction): """ def __init__(self, label, icon, use_option, parent): - super(OptionalAction, self).__init__(parent) + super().__init__(parent) self.label = label self.icon = icon self.use_option = use_option @@ -951,7 +953,7 @@ class OptionalActionWidget(QtWidgets.QWidget): """Main widget class for `OptionalAction`""" def __init__(self, label, parent=None): - super(OptionalActionWidget, self).__init__(parent) + super().__init__(parent) body_widget = QtWidgets.QWidget(self) body_widget.setObjectName("OptionalActionBody") From f100a6c563b8acea1242c8ca2cc7f102db18de89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:07 +0200 Subject: [PATCH 063/386] show grouped actions as menu --- client/ayon_core/tools/loader/abstract.py | 37 +++++++++---------- .../tools/loader/ui/actions_utils.py | 34 ++++++++++++----- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index d3de8fb7c2..2e90a51a5b 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -322,9 +322,9 @@ class ActionItem: entity_ids (set[str]): Entity ids. entity_type (str): Entity type. label (str): Action label. - group_label (str): Group label. - icon (dict[str, Any]): Action icon definition. - tooltip (str): Action tooltip. + group_label (Optional[str]): Group label. + icon (Optional[dict[str, Any]]): Action icon definition. + tooltip (Optional[str]): Action tooltip. order (int): Action order. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. @@ -332,16 +332,16 @@ class ActionItem: """ def __init__( self, - plugin_identifier, - identifier, - entity_ids, - entity_type, - label, - group_label, - icon, - tooltip, - order, - options, + plugin_identifier: str, + identifier: str, + entity_ids: set[str], + entity_type: str, + label: str, + group_label: Optional[str], + icon: Optional[dict[str, Any]], + tooltip: Optional[str], + order: int, + options: Optional[list], ): self.plugin_identifier = plugin_identifier self.identifier = identifier @@ -364,13 +364,12 @@ class ActionItem: # future development of detached UI tools it would be better to be # prepared for it. raise NotImplementedError( - "{}.to_data is not implemented. Use Attribute definitions" - " from 'ayon_core.lib' instead of 'qargparse'.".format( - self.__class__.__name__ - ) + f"{self.__class__.__name__}.to_data is not implemented." + " Use Attribute definitions from 'ayon_core.lib'" + " instead of 'qargparse'." ) - def to_data(self): + def to_data(self) -> dict[str, Any]: options = self._options_to_data() return { "plugin_identifier": self.plugin_identifier, @@ -386,7 +385,7 @@ class ActionItem: } @classmethod - def from_data(cls, data): + def from_data(cls, data) -> "ActionItem": options = data["options"] if options: options = deserialize_attr_defs(options) diff --git a/client/ayon_core/tools/loader/ui/actions_utils.py b/client/ayon_core/tools/loader/ui/actions_utils.py index 3281a170bd..cf39bc348c 100644 --- a/client/ayon_core/tools/loader/ui/actions_utils.py +++ b/client/ayon_core/tools/loader/ui/actions_utils.py @@ -15,15 +15,18 @@ from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.loader.abstract import ActionItem -def _actions_sorter(item: tuple[str, ActionItem]): +def _actions_sorter(item: tuple[ActionItem, str, str]): """Sort the Loaders by their order and then their name. Returns: tuple[int, str]: Sort keys. """ - label, action_item = item - return action_item.order, label + action_item, group_label, label = item + if group_label is None: + group_label = label + label = "" + return action_item.order, group_label, label def show_actions_menu( @@ -46,21 +49,21 @@ def show_actions_menu( action_items_with_labels = [] for action_item in action_items: - label = action_item.label - if action_item.group_label: - label = f"{action_item.group_label} ({label})" - action_items_with_labels.append((label, action_item)) + action_items_with_labels.append( + (action_item, action_item.group_label, action_item.label) + ) + group_menu_by_label = {} action_items_by_id = {} for item in sorted(action_items_with_labels, key=_actions_sorter): - label, action_item = item + action_item, _, _ = item item_id = uuid.uuid4().hex action_items_by_id[item_id] = action_item item_options = action_item.options icon = get_qt_icon(action_item.icon) use_option = bool(item_options) action = OptionalAction( - label, + action_item.label, icon, use_option, menu @@ -76,7 +79,18 @@ def show_actions_menu( action.setData(item_id) - menu.addAction(action) + group_label = action_item.group_label + if group_label: + group_menu = group_menu_by_label.get(group_label) + if group_menu is None: + group_menu = OptionalMenu(group_label, menu) + if icon is not None: + group_menu.setIcon(icon) + menu.addMenu(group_menu) + group_menu_by_label[group_label] = group_menu + group_menu.addAction(action) + else: + menu.addAction(action) action = menu.exec_(global_point) if action is not None: From 0ad0b3927ff70b0381974cc605242b5023f7e070 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:31:23 +0200 Subject: [PATCH 064/386] small enhancements of messages --- client/ayon_core/plugins/loader/copy_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index d8424761e9..716b4ab88f 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -88,7 +88,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard = QtWidgets.QApplication.clipboard() if not clipboard: return LoaderActionResult( - "Failed to copy file path to clipboard", + "Failed to copy file path to clipboard.", success=False, ) @@ -97,7 +97,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard.setText(os.path.normpath(path)) return LoaderActionResult( - "Path stored to clipboard", + "Path stored to clipboard...", success=True, ) @@ -110,6 +110,6 @@ class CopyFileActionPlugin(LoaderActionPlugin): clipboard.setMimeData(data) return LoaderActionResult( - "File added to clipboard", + "File added to clipboard...", success=True, ) From f784eeb17e4e78967bad1f6475d19e92c4aaba65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:43:10 +0200 Subject: [PATCH 065/386] remove unused imports --- client/ayon_core/plugins/loader/delete_old_versions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 31b0ff4bdf..b0905954f1 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -6,11 +6,8 @@ import json import shutil from typing import Optional, Any -import clique from ayon_api.operations import OperationsSession -from qtpy import QtWidgets, QtCore -from ayon_core import style from ayon_core.lib import ( format_file_size, AbstractAttrDef, From 2a13074e6bbda03b63725b5fc4cd9ef4b9491c3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:04:20 +0200 Subject: [PATCH 066/386] Converted push to project plugin --- .../ayon_core/plugins/load/push_to_project.py | 51 ------------- .../plugins/loader/push_to_project.py | 73 +++++++++++++++++++ 2 files changed, 73 insertions(+), 51 deletions(-) delete mode 100644 client/ayon_core/plugins/load/push_to_project.py create mode 100644 client/ayon_core/plugins/loader/push_to_project.py diff --git a/client/ayon_core/plugins/load/push_to_project.py b/client/ayon_core/plugins/load/push_to_project.py deleted file mode 100644 index dccac42444..0000000000 --- a/client/ayon_core/plugins/load/push_to_project.py +++ /dev/null @@ -1,51 +0,0 @@ -import os - -from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import get_ayon_launcher_args, run_detached_process -from ayon_core.pipeline import load -from ayon_core.pipeline.load import LoadError - - -class PushToProject(load.ProductLoaderPlugin): - """Export selected versions to different project""" - - is_multiple_contexts_compatible = True - - representations = {"*"} - product_types = {"*"} - - label = "Push to project" - order = 35 - icon = "send" - color = "#d8d8d8" - - def load(self, contexts, name=None, namespace=None, options=None): - filtered_contexts = [ - context - for context in contexts - if context.get("project") and context.get("version") - ] - if not filtered_contexts: - raise LoadError("Nothing to push for your selection") - - if len(filtered_contexts) > 1: - raise LoadError("Please select only one item") - - context = tuple(filtered_contexts)[0] - - push_tool_script_path = os.path.join( - AYON_CORE_ROOT, - "tools", - "push_to_project", - "main.py" - ) - - project_name = context["project"]["name"] - version_id = context["version"]["id"] - - args = get_ayon_launcher_args( - push_tool_script_path, - "--project", project_name, - "--version", version_id - ) - run_detached_process(args) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py new file mode 100644 index 0000000000..ef1908f19c --- /dev/null +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -0,0 +1,73 @@ +import os +from typing import Optional, Any + +from ayon_core import AYON_CORE_ROOT +from ayon_core.lib import get_ayon_launcher_args, run_detached_process + +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +class PushToProject(LoaderActionPlugin): + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + version_ids = set() + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) + + output = [] + if len(version_ids) == 1: + output.append( + LoaderActionItem( + identifier="core.push-to-project", + label="Push to project", + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "send", + "color": "#d8d8d8", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + if len(entity_ids) > 1: + return LoaderActionResult( + message="Please select only one version", + success=False, + ) + + push_tool_script_path = os.path.join( + AYON_CORE_ROOT, + "tools", + "push_to_project", + "main.py" + ) + + version_id = next(iter(entity_ids)) + + args = get_ayon_launcher_args( + push_tool_script_path, + "--project", selection.project_name, + "--version", version_id + ) + run_detached_process(args) + return LoaderActionResult( + message="Push to project tool opened...", + success=True, + ) From ed6247d23194f5cd1936b341418218651c49e9e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:32:00 +0200 Subject: [PATCH 067/386] converted otio export action --- .../plugins/{load => loader}/export_otio.py | 145 +++++++++++++----- 1 file changed, 109 insertions(+), 36 deletions(-) rename client/ayon_core/plugins/{load => loader}/export_otio.py (86%) diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py similarity index 86% rename from client/ayon_core/plugins/load/export_otio.py rename to client/ayon_core/plugins/loader/export_otio.py index 8094490246..bbbad3378f 100644 --- a/client/ayon_core/plugins/load/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -2,11 +2,10 @@ import logging import os from pathlib import Path from collections import defaultdict +from typing import Any, Optional from qtpy import QtWidgets, QtCore, QtGui -from ayon_api import get_representations -from ayon_core.pipeline import load, Anatomy from ayon_core import resources, style from ayon_core.lib.transcoding import ( IMAGE_EXTENSIONS, @@ -16,9 +15,17 @@ from ayon_core.lib import ( get_ffprobe_data, is_oiio_supported, ) +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.tools.utils import show_message_dialog +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + OTIO = None FRAME_SPLITTER = "__frame_splitter__" @@ -30,34 +37,116 @@ def _import_otio(): OTIO = opentimelineio -class ExportOTIO(load.ProductLoaderPlugin): - """Export selected versions to OpenTimelineIO.""" - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" +class ExportOTIO(LoaderActionPlugin): + """Copy published file path to clipboard""" + identifier = "core.export-otio" - representations = {"*"} - product_types = {"*"} - tool_names = ["library_loader"] + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + # Don't show in hosts + if self.host_name is None: + return [] - label = "Export OTIO" - order = 35 - icon = "save" - color = "#d8d8d8" + version_ids = set() + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) - def load(self, contexts, name=None, namespace=None, options=None): + output = [] + if version_ids: + output.append( + LoaderActionItem( + identifier="copy-path", + label="Export OTIO", + group_label=None, + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "save", + "color": "#d8d8d8", + } + ) + ) + return output + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: _import_otio() + + versions_by_id = { + version["id"]: version + for version in selection.entities.get_versions(entity_ids) + } + product_ids = { + version["productId"] + for version in versions_by_id.values() + } + products_by_id = { + product["id"]: product + for product in selection.entities.get_products(product_ids) + } + folder_ids = { + product["folderId"] + for product in products_by_id.values() + } + folder_by_id = { + folder["id"]: folder + for folder in selection.entities.get_folders(folder_ids) + } + repre_entities = selection.entities.get_versions_representations( + entity_ids + ) + + version_path_by_id = {} + for version in versions_by_id.values(): + version_id = version["id"] + product_id = version["productId"] + product = products_by_id[product_id] + folder_id = product["folderId"] + folder = folder_by_id[folder_id] + + version_path_by_id[version_id] = "/".join([ + folder["path"], + product["name"], + version["name"] + ]) + try: - dialog = ExportOTIOOptionsDialog(contexts, self.log) + # TODO this should probably trigger a subprocess? + dialog = ExportOTIOOptionsDialog( + selection.project_name, + versions_by_id, + repre_entities, + version_path_by_id, + self.log + ) dialog.exec_() except Exception: self.log.error("Failed to export OTIO.", exc_info=True) + return LoaderActionResult() class ExportOTIOOptionsDialog(QtWidgets.QDialog): """Dialog to select template where to deliver selected representations.""" - def __init__(self, contexts, log=None, parent=None): + def __init__( + self, + project_name, + versions_by_id, + repre_entities, + version_path_by_id, + log=None, + parent=None + ): # Not all hosts have OpenTimelineIO available. self.log = log @@ -73,30 +162,14 @@ class ExportOTIOOptionsDialog(QtWidgets.QDialog): | QtCore.Qt.WindowMinimizeButtonHint ) - project_name = contexts[0]["project"]["name"] - versions_by_id = { - context["version"]["id"]: context["version"] - for context in contexts - } - repre_entities = list(get_representations( - project_name, version_ids=set(versions_by_id) - )) version_by_representation_id = { repre_entity["id"]: versions_by_id[repre_entity["versionId"]] for repre_entity in repre_entities } - version_path_by_id = {} - representations_by_version_id = {} - for context in contexts: - version_id = context["version"]["id"] - if version_id in version_path_by_id: - continue - representations_by_version_id[version_id] = [] - version_path_by_id[version_id] = "/".join([ - context["folder"]["path"], - context["product"]["name"], - context["version"]["name"] - ]) + representations_by_version_id = { + version_id: [] + for version_id in versions_by_id + } for repre_entity in repre_entities: representations_by_version_id[repre_entity["versionId"]].append( From 79ca56f3adde7a1fad81e2ebd7eed6e55747d160 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:33:43 +0200 Subject: [PATCH 068/386] added identifier to push to project plugin --- client/ayon_core/plugins/loader/push_to_project.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index ef1908f19c..4435ecf0c6 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -13,6 +13,8 @@ from ayon_core.pipeline.actions import ( class PushToProject(LoaderActionPlugin): + identifier = "core.push-to-project" + def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: From fc0232b7449f888f54568cae4955e0238ff737dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:57:39 +0200 Subject: [PATCH 069/386] convert open file action --- client/ayon_core/plugins/load/open_file.py | 36 ----- client/ayon_core/plugins/loader/open_file.py | 133 +++++++++++++++++++ 2 files changed, 133 insertions(+), 36 deletions(-) delete mode 100644 client/ayon_core/plugins/load/open_file.py create mode 100644 client/ayon_core/plugins/loader/open_file.py diff --git a/client/ayon_core/plugins/load/open_file.py b/client/ayon_core/plugins/load/open_file.py deleted file mode 100644 index 3b5fbbc0c9..0000000000 --- a/client/ayon_core/plugins/load/open_file.py +++ /dev/null @@ -1,36 +0,0 @@ -import sys -import os -import subprocess - -from ayon_core.pipeline import load - - -def open(filepath): - """Open file with system default executable""" - if sys.platform.startswith('darwin'): - subprocess.call(('open', filepath)) - elif os.name == 'nt': - os.startfile(filepath) - elif os.name == 'posix': - subprocess.call(('xdg-open', filepath)) - - -class OpenFile(load.LoaderPlugin): - """Open Image Sequence or Video with system default""" - - product_types = {"render2d"} - representations = {"*"} - - label = "Open" - order = -10 - icon = "play-circle" - color = "orange" - - def load(self, context, name, namespace, data): - - path = self.filepath_from_context(context) - if not os.path.exists(path): - raise RuntimeError("File not found: {}".format(path)) - - self.log.info("Opening : {}".format(path)) - open(path) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py new file mode 100644 index 0000000000..a46bb31472 --- /dev/null +++ b/client/ayon_core/plugins/loader/open_file.py @@ -0,0 +1,133 @@ +import os +import sys +import subprocess +import collections +from typing import Optional, Any + +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionItem, + LoaderActionSelection, + LoaderActionResult, +) + + +def open_file(filepath: str) -> None: + """Open file with system default executable""" + if sys.platform.startswith("darwin"): + subprocess.call(("open", filepath)) + elif os.name == "nt": + os.startfile(filepath) + elif os.name == "posix": + subprocess.call(("xdg-open", filepath)) + + +class OpenFileAction(LoaderActionPlugin): + """Open Image Sequence or Video with system default""" + identifier = "core.open-file" + + product_types = {"render2d"} + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + repres = [] + if selection.selected_type == "representations": + repres = selection.entities.get_representations( + selection.selected_ids + ) + + if selection.selected_type == "version": + repres = selection.entities.get_versions_representations( + selection.selected_ids + ) + + if not repres: + return [] + + repre_ids = {repre["id"] for repre in repres} + versions = selection.entities.get_representations_versions( + repre_ids + ) + product_ids = {version["productId"] for version in versions} + products = selection.entities.get_products(product_ids) + fitlered_product_ids = { + product["id"] + for product in products + if product["productType"] in self.product_types + } + if not fitlered_product_ids: + return [] + + versions_by_product_id = collections.defaultdict(list) + for version in versions: + versions_by_product_id[version["productId"]].append(version) + + repres_by_version_ids = collections.defaultdict(list) + for repre in repres: + repres_by_version_ids[repre["versionId"]].append(repre) + + filtered_repres = [] + for product_id in fitlered_product_ids: + for version in versions_by_product_id[product_id]: + for repre in repres_by_version_ids[version["id"]]: + filtered_repres.append(repre) + + repre_ids_by_name = collections.defaultdict(set) + for repre in filtered_repres: + repre_ids_by_name[repre["name"]].add(repre["id"]) + + return [ + LoaderActionItem( + identifier="open-file", + label=repre_name, + group_label="Open file", + order=-10, + entity_ids=repre_ids, + entity_type="representation", + icon={ + "type": "material-symbols", + "name": "play_circle", + "color": "#FFA500", + } + ) + for repre_name, repre_ids in repre_ids_by_name.items() + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + path = None + repre_path = None + for repre in selection.entities.get_representations(entity_ids): + repre_path = get_representation_path_with_anatomy( + repre, selection.get_project_anatomy() + ) + if os.path.exists(repre_path): + path = repre_path + break + + if path is None: + if repre_path is None: + return LoaderActionResult( + "Failed to fill representation path...", + success=False, + ) + return LoaderActionResult( + "File to open was not found...", + success=False, + ) + + self.log.info(f"Opening: {path}") + open_file(path) + + return LoaderActionResult( + "File was opened...", + success=True, + ) From 062069028f4ae5dbe0925f8812b200b10987b81c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:28 +0200 Subject: [PATCH 070/386] convert delivery action --- .../plugins/{load => loader}/delivery.py | 93 +++++++++++++------ 1 file changed, 64 insertions(+), 29 deletions(-) rename client/ayon_core/plugins/{load => loader}/delivery.py (87%) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/loader/delivery.py similarity index 87% rename from client/ayon_core/plugins/load/delivery.py rename to client/ayon_core/plugins/loader/delivery.py index 406040d936..fb668e5b10 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -1,5 +1,6 @@ import platform from collections import defaultdict +from typing import Optional, Any import ayon_api from qtpy import QtWidgets, QtCore, QtGui @@ -10,7 +11,13 @@ from ayon_core.lib import ( collect_frames, get_datetime_data, ) -from ayon_core.pipeline import load, Anatomy +from ayon_core.pipeline import Anatomy +from ayon_core.pipeline.actions import ( + LoaderActionPlugin, + LoaderActionSelection, + LoaderActionItem, + LoaderActionResult, +) from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.delivery import ( get_format_dict, @@ -20,43 +27,74 @@ from ayon_core.pipeline.delivery import ( ) -class Delivery(load.ProductLoaderPlugin): - """Export selected versions to folder structure from Template""" +class DeliveryAction(LoaderActionPlugin): + identifier = "core.delivery" - is_multiple_contexts_compatible = True - sequence_splitter = "__sequence_splitter__" + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + if self.host_name is None: + return [] - representations = {"*"} - product_types = {"*"} - tool_names = ["library_loader"] + version_ids = set() + if selection.selected_type == "representations": + versions = selection.entities.get_representations_versions( + selection.selected_ids + ) + version_ids = {version["id"] for version in versions} - label = "Deliver Versions" - order = 35 - icon = "upload" - color = "#d8d8d8" + if selection.selected_type == "version": + version_ids = set(selection.selected_ids) - def message(self, text): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(text) - msgBox.setStyleSheet(style.load_stylesheet()) - msgBox.setWindowFlags( - msgBox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) - msgBox.exec_() + if not version_ids: + return [] - def load(self, contexts, name=None, namespace=None, options=None): + return [ + LoaderActionItem( + identifier="deliver-versions", + label="Deliver Versions", + order=35, + entity_ids=version_ids, + entity_type="version", + icon={ + "type": "material-symbols", + "name": "upload", + "color": "#d8d8d8", + } + ) + ] + + def execute_action( + self, + identifier: str, + entity_ids: set[str], + entity_type: str, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: try: - dialog = DeliveryOptionsDialog(contexts, self.log) + # TODO run the tool in subprocess + dialog = DeliveryOptionsDialog( + selection.project_name, entity_ids, self.log + ) dialog.exec_() except Exception: self.log.error("Failed to deliver versions.", exc_info=True) + return LoaderActionResult() + class DeliveryOptionsDialog(QtWidgets.QDialog): """Dialog to select template where to deliver selected representations.""" - def __init__(self, contexts, log=None, parent=None): - super(DeliveryOptionsDialog, self).__init__(parent=parent) + def __init__( + self, + project_name, + version_ids, + log=None, + parent=None, + ): + super().__init__(parent=parent) self.setWindowTitle("AYON - Deliver versions") icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) @@ -70,13 +108,12 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) - project_name = contexts[0]["project"]["name"] self.anatomy = Anatomy(project_name) self._representations = None self.log = log self.currently_uploaded = 0 - self._set_representations(project_name, contexts) + self._set_representations(project_name, version_ids) dropdown = QtWidgets.QComboBox() self.templates = self._get_templates(self.anatomy) @@ -316,9 +353,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): return templates - def _set_representations(self, project_name, contexts): - version_ids = {context["version"]["id"] for context in contexts} - + def _set_representations(self, project_name, version_ids): repres = list(ayon_api.get_representations( project_name, version_ids=version_ids )) From b1a4d5dfc54c5141ad7ab878ae5ab16d03481b18 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:05:34 +0200 Subject: [PATCH 071/386] remove docstring --- client/ayon_core/plugins/loader/export_otio.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index bbbad3378f..6a9acc9730 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -39,7 +39,6 @@ def _import_otio(): class ExportOTIO(LoaderActionPlugin): - """Copy published file path to clipboard""" identifier = "core.export-otio" def get_action_items( From c4b47950a8fd944dd4412e2ebb6fd13df2fc21e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 25 Aug 2025 18:07:12 +0200 Subject: [PATCH 072/386] formatting fixes --- client/ayon_core/plugins/loader/delete_old_versions.py | 4 +++- client/ayon_core/plugins/loader/export_otio.py | 1 - client/ayon_core/tools/loader/models/actions.py | 2 -- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index b0905954f1..69b93cbb32 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -300,7 +300,9 @@ class DeleteOldVersions(LoaderActionPlugin): publish_folder = os.path.dirname(filepath) if remove_publish_folder: if os.path.exists(publish_folder): - shutil.rmtree(publish_folder, ignore_errors=True) + shutil.rmtree( + publish_folder, ignore_errors=True + ) continue if os.path.exists(filepath): diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 6a9acc9730..b23021fc11 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -37,7 +37,6 @@ def _import_otio(): OTIO = opentimelineio - class ExportOTIO(LoaderActionPlugin): identifier = "core.export-otio" diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 684adf36a9..90f3613c24 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -13,7 +13,6 @@ from ayon_core.lib import NestedCacheItem, Logger from ayon_core.pipeline.actions import ( LoaderActionsContext, LoaderActionSelection, - SelectionEntitiesCache, ) from ayon_core.pipeline.load import ( discover_loader_plugins, @@ -766,7 +765,6 @@ class LoaderActionsModel: action_items.append(item) return action_items - def _get_loader_action_items( self, project_name: str, From a0f6a3f37971c30390f5a1b99d81f1a582ab5122 Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Mon, 25 Aug 2025 19:09:20 -0400 Subject: [PATCH 073/386] Implement upload retries for reviewable files and add user-friendly error handling in case of timeout. Update validation help documentation for upload failures. --- .../publish/help/validate_publish_dir.xml | 18 +++++ .../plugins/publish/integrate_review.py | 68 +++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml index 9f62b264bf..0449e61fa2 100644 --- a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml +++ b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml @@ -1,5 +1,23 @@ + +Review upload timed out + +## Review upload failed after retries + +The connection to the AYON server timed out while uploading a reviewable file. + +### How to repair? + +1. Try publishing again. Intermittent network hiccups often resolve on retry. +2. Ensure your network/VPN is stable and large uploads are allowed. +3. If it keeps failing, try again later or contact your admin. + +
File: {file}
+Error: {error}
+ +
+
Source directory not collected diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 0a6b24adb4..c7ac5038d3 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -1,11 +1,14 @@ import os +import time -import pyblish.api import ayon_api +import pyblish.api from ayon_api.server_api import RequestTypes - from ayon_core.lib import get_media_mime_type -from ayon_core.pipeline.publish import get_publish_repre_path +from ayon_core.pipeline.publish import ( + PublishXmlValidationError, + get_publish_repre_path, +) class IntegrateAYONReview(pyblish.api.InstancePlugin): @@ -82,11 +85,12 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): headers = ayon_con.get_headers(content_type) headers["x-file-name"] = filename self.log.info(f"Uploading reviewable {repre_path}") - ayon_con.upload_file( + # Upload with retries and clear help if it keeps failing + self._upload_with_retries( + ayon_con, endpoint, repre_path, - headers=headers, - request_type=RequestTypes.post, + headers, ) def _get_review_label(self, repre, uploaded_labels): @@ -100,3 +104,55 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): idx += 1 label = f"{orig_label}_{idx}" return label + + def _upload_with_retries( + self, + ayon_con, + endpoint, + repre_path, + headers, + max_retries: int = 3, + backoff_seconds: int = 2, + ): + """Upload file with simple exponential backoff retries. + + If all retries fail we raise a PublishXmlValidationError with a help key + to guide the user to retry publish. + """ + last_error = None + for attempt in range(1, max_retries + 1): + try: + ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) + return + except Exception as exc: # noqa: BLE001 - bubble after retries + last_error = exc + # Log and retry with backoff if attempts remain + if attempt < max_retries: + wait = backoff_seconds * (2 ** (attempt - 1)) + self.log.warning( + f"Review upload failed (attempt {attempt}/{max_retries}): {exc}. " + f"Retrying in {wait}s..." + ) + try: + time.sleep(wait) + except Exception: # Sleep errors are highly unlikely; continue + pass + else: + # Exhausted retries - raise a user-friendly validation error with help + raise PublishXmlValidationError( + self, + ( + "Upload of reviewable timed out or failed after multiple attempts. " + "Please try publishing again." + ), + key="upload_timeout", + formatting_data={ + "file": repre_path, + "error": str(last_error), + }, + ) From cf62eede8a2386a4531d979eaffb6d9d14f57ac5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:50:55 +0200 Subject: [PATCH 074/386] use already cached entities --- .../ayon_core/tools/loader/models/actions.py | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 90f3613c24..8aded40919 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -13,6 +13,7 @@ from ayon_core.lib import NestedCacheItem, Logger from ayon_core.pipeline.actions import ( LoaderActionsContext, LoaderActionSelection, + SelectionEntitiesCache, ) from ayon_core.pipeline.load import ( discover_loader_plugins, @@ -114,6 +115,8 @@ class LoaderActionsModel: project_name, entity_ids, entity_type, + version_context_by_id, + repre_context_by_id, )) return action_items @@ -165,7 +168,6 @@ class LoaderActionsModel: ACTIONS_MODEL_SENDER, ) if plugin_identifier != LOADER_PLUGIN_ID: - # TODO fill error infor if any happens result = None crashed = False try: @@ -770,14 +772,35 @@ class LoaderActionsModel: project_name: str, entity_ids: set[str], entity_type: str, + version_context_by_id: dict[str, dict[str, Any]], + repre_context_by_id: dict[str, dict[str, Any]], ) -> list[ActionItem]: - # TODO prepare cached entities - # entities_cache = SelectionEntitiesCache(project_name) + """ + + Args: + project_name (str): Project name. + entity_ids (set[str]): Selected entity ids. + entity_type (str): Selected entity type. + version_context_by_id (dict[str, dict[str, Any]]): Version context + by id. + repre_context_by_id (dict[str, dict[str, Any]]): Representation + context by id. + + Returns: + list[ActionItem]: List of action items. + + """ + entities_cache = self._prepare_entities_cache( + project_name, + entity_type, + version_context_by_id, + repre_context_by_id, + ) selection = LoaderActionSelection( project_name, entity_ids, entity_type, - # entities_cache=entities_cache + entities_cache=entities_cache ) items = [] for action in self._loader_actions.get_action_items(selection): @@ -795,6 +818,55 @@ class LoaderActionsModel: )) return items + def _prepare_entities_cache( + self, + project_name: str, + entity_type: str, + version_context_by_id: dict[str, dict[str, Any]], + repre_context_by_id: dict[str, dict[str, Any]], + ): + project_entity = None + folders_by_id = {} + products_by_id = {} + versions_by_id = {} + representations_by_id = {} + for context in version_context_by_id.values(): + if project_entity is None: + project_entity = context["project"] + folder_entity = context["folder"] + product_entity = context["product"] + version_entity = context["version"] + folders_by_id[folder_entity["id"]] = folder_entity + products_by_id[product_entity["id"]] = product_entity + versions_by_id[version_entity["id"]] = version_entity + + for context in repre_context_by_id.values(): + repre_entity = context["representation"] + representations_by_id[repre_entity["id"]] = repre_entity + + # Mapping has to be for all child entities which is available for + # representations only if version is selected + representation_ids_by_version_id = {} + if entity_type == "version": + representation_ids_by_version_id = { + version_id: set() + for version_id in versions_by_id + } + for context in repre_context_by_id.values(): + repre_entity = context["representation"] + v_id = repre_entity["versionId"] + representation_ids_by_version_id[v_id].add(repre_entity["id"]) + + return SelectionEntitiesCache( + project_name, + project_entity=project_entity, + folders_by_id=folders_by_id, + products_by_id=products_by_id, + versions_by_id=versions_by_id, + representations_by_id=representations_by_id, + representation_ids_by_version_id=representation_ids_by_version_id, + ) + def _trigger_version_loader( self, loader, From 751ad94343b8999873f1068c7c2492940c60162f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:51:19 +0200 Subject: [PATCH 075/386] few fixes in entities cache --- client/ayon_core/pipeline/actions/loader.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index b537655ada..e04a64b240 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -44,11 +44,10 @@ class SelectionEntitiesCache: products_by_id: Optional[dict[str, dict[str, Any]]] = None, versions_by_id: Optional[dict[str, dict[str, Any]]] = None, representations_by_id: Optional[dict[str, dict[str, Any]]] = None, - task_ids_by_folder_id: Optional[dict[str, str]] = None, - product_ids_by_folder_id: Optional[dict[str, str]] = None, - version_ids_by_product_id: Optional[dict[str, str]] = None, - version_id_by_task_id: Optional[dict[str, str]] = None, - representation_id_by_version_id: Optional[dict[str, str]] = None, + task_ids_by_folder_id: Optional[dict[str, set[str]]] = None, + product_ids_by_folder_id: Optional[dict[str, set[str]]] = None, + version_ids_by_product_id: Optional[dict[str, set[str]]] = None, + representation_ids_by_version_id: Optional[dict[str, set[str]]] = None, ): self._project_name = project_name self._project_entity = project_entity @@ -61,9 +60,8 @@ class SelectionEntitiesCache: self._task_ids_by_folder_id = task_ids_by_folder_id or {} self._product_ids_by_folder_id = product_ids_by_folder_id or {} self._version_ids_by_product_id = version_ids_by_product_id or {} - self._version_id_by_task_id = version_id_by_task_id or {} - self._representation_id_by_version_id = ( - representation_id_by_version_id or {} + self._representation_ids_by_version_id = ( + representation_ids_by_version_id or {} ) def get_project(self) -> dict[str, Any]: @@ -173,7 +171,7 @@ class SelectionEntitiesCache: version_ids, "versionId", "version_ids", - self._representation_id_by_version_id, + self._representation_ids_by_version_id, ayon_api.get_representations, ) return self.get_representations(repre_ids) From 32c022cd4daeb4027f88021a0a5ea2163734f9de Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Tue, 26 Aug 2025 09:55:47 -0400 Subject: [PATCH 076/386] Refactor upload retry logic to handle only transient network issues and improve error handling --- .../plugins/publish/integrate_review.py | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index c7ac5038d3..f9fa862320 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -9,6 +9,10 @@ from ayon_core.pipeline.publish import ( PublishXmlValidationError, get_publish_repre_path, ) +from requests import exceptions as req_exc + +# Narrow retryable failures to transient network issues +RETRYABLE_EXCEPTIONS = (req_exc.Timeout, req_exc.ConnectionError) class IntegrateAYONReview(pyblish.api.InstancePlugin): @@ -47,7 +51,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): if "webreview" not in repre_tags: continue - # exclude representations with are going to be published on farm + # exclude representations going to be published on farm if "publish_on_farm" in repre_tags: continue @@ -120,7 +124,8 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): to guide the user to retry publish. """ last_error = None - for attempt in range(1, max_retries + 1): + for attempt in range(max_retries): + attempt_num = attempt + 1 try: ayon_con.upload_file( endpoint, @@ -129,30 +134,36 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): request_type=RequestTypes.post, ) return - except Exception as exc: # noqa: BLE001 - bubble after retries + except RETRYABLE_EXCEPTIONS as exc: last_error = exc # Log and retry with backoff if attempts remain - if attempt < max_retries: - wait = backoff_seconds * (2 ** (attempt - 1)) + if attempt_num < max_retries: + wait = backoff_seconds * (2 ** attempt) self.log.warning( - f"Review upload failed (attempt {attempt}/{max_retries}): {exc}. " - f"Retrying in {wait}s..." + "Review upload failed (attempt %s/%s). Retrying in %ss...", + attempt_num, max_retries, wait, + exc_info=True, ) try: time.sleep(wait) - except Exception: # Sleep errors are highly unlikely; continue + except Exception: pass else: - # Exhausted retries - raise a user-friendly validation error with help - raise PublishXmlValidationError( - self, - ( - "Upload of reviewable timed out or failed after multiple attempts. " - "Please try publishing again." - ), - key="upload_timeout", - formatting_data={ - "file": repre_path, - "error": str(last_error), - }, - ) + break + except Exception: + # Non retryable failures bubble immediately + raise + + # Exhausted retries - raise a user-friendly validation error with help + raise PublishXmlValidationError( + self, + ( + "Upload of reviewable timed out or failed after multiple attempts." + " Please try publishing again." + ), + key="upload_timeout", + formatting_data={ + "file": repre_path, + "error": str(last_error), + }, + ) From 15a3f9d29aeea40419ece93b51925f3f38fd9066 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:12:32 +0200 Subject: [PATCH 077/386] fix 'representations' -> 'representation' --- client/ayon_core/plugins/loader/copy_file.py | 2 +- client/ayon_core/plugins/loader/delivery.py | 2 +- client/ayon_core/plugins/loader/open_file.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 716b4ab88f..09875698bd 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -20,7 +20,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type == "representations": + if selection.selected_type == "representation": repres = selection.entities.get_representations( selection.selected_ids ) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index fb668e5b10..3b39f2d3f6 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -37,7 +37,7 @@ class DeliveryAction(LoaderActionPlugin): return [] version_ids = set() - if selection.selected_type == "representations": + if selection.selected_type == "representation": versions = selection.entities.get_representations_versions( selection.selected_ids ) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index a46bb31472..f7a7167c9a 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -33,7 +33,7 @@ class OpenFileAction(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: repres = [] - if selection.selected_type == "representations": + if selection.selected_type == "representation": repres = selection.entities.get_representations( selection.selected_ids ) From b560bb356ed8fb3f687fddbec9628042a48f54f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 28 Aug 2025 12:19:56 +0200 Subject: [PATCH 078/386] fix host name checks --- client/ayon_core/plugins/loader/delivery.py | 2 +- client/ayon_core/plugins/loader/export_otio.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 3b39f2d3f6..d1fbb20afc 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -33,7 +33,7 @@ class DeliveryAction(LoaderActionPlugin): def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: - if self.host_name is None: + if self.host_name is not None: return [] version_ids = set() diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index b23021fc11..8a142afdb5 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -44,7 +44,7 @@ class ExportOTIO(LoaderActionPlugin): self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: # Don't show in hosts - if self.host_name is None: + if self.host_name is not None: return [] version_ids = set() From 2597469b30bfa1fc386218d0aa3a51154445ec52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 3 Sep 2025 15:16:27 +0200 Subject: [PATCH 079/386] :fire: remove deprecated code --- .../pipeline/create/creator_plugins.py | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 95db3f260f..26dbc5f3d3 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -977,52 +977,6 @@ def discover_convertor_plugins(*args, **kwargs): return discover(ProductConvertorPlugin, *args, **kwargs) -def discover_legacy_creator_plugins(): - from ayon_core.pipeline import get_current_project_name - - log = Logger.get_logger("CreatorDiscover") - - plugins = discover(LegacyCreator) - project_name = get_current_project_name() - project_settings = get_project_settings(project_name) - for plugin in plugins: - try: - plugin.apply_settings(project_settings) - except Exception: - log.warning( - "Failed to apply settings to creator %s", plugin.__name__, - exc_info=True - ) - return plugins - - -def get_legacy_creator_by_name(creator_name, case_sensitive=False): - """Find creator plugin by name. - - Args: - creator_name (str): Name of creator class that should be returned. - case_sensitive (bool): Match of creator plugin name is case sensitive. - Set to `False` by default. - - Returns: - Creator: Return first matching plugin or `None`. - """ - # Lower input creator name if is not case sensitive - if not case_sensitive: - creator_name = creator_name.lower() - - for creator_plugin in discover_legacy_creator_plugins(): - _creator_name = creator_plugin.__name__ - - # Lower creator plugin name if is not case sensitive - if not case_sensitive: - _creator_name = _creator_name.lower() - - if _creator_name == creator_name: - return creator_plugin - return None - - def register_creator_plugin(plugin): if issubclass(plugin, BaseCreator): register_plugin(BaseCreator, plugin) From 51965a9de160caf8fa334043b5f62adfaab1d374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 3 Sep 2025 15:18:50 +0200 Subject: [PATCH 080/386] :fire: remove unused import --- client/ayon_core/pipeline/create/creator_plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 26dbc5f3d3..480ef28432 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -16,7 +16,6 @@ from ayon_core.pipeline.plugin_discover import ( register_plugin_path, ) from ayon_core.pipeline.staging_dir import StagingDir, get_staging_dir_info -from ayon_core.settings import get_project_settings from .constants import DEFAULT_VARIANT_VALUE from .product_name import get_product_name From 8bbd15c48244ceb9e11c4fae4afb018620026519 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:11:46 +0200 Subject: [PATCH 081/386] added some docstrings --- client/ayon_core/pipeline/actions/loader.py | 124 +++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index e04a64b240..2c3ad39c48 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -35,6 +35,17 @@ class LoaderSelectedType(StrEnum): class SelectionEntitiesCache: + """Cache of entities used as helper in the selection wrapper. + + It is possible to get entities based on ids with helper methods to get + entities, their parents or their children's entities. + + The goal is to avoid multiple API calls for the same entity in multiple + action plugins. + + The cache is based on the selected project. Entities are fetched + if are not in cache yet. + """ def __init__( self, project_name: str, @@ -65,6 +76,7 @@ class SelectionEntitiesCache: ) def get_project(self) -> dict[str, Any]: + """Get project entity""" if self._project_entity is None: self._project_entity = ayon_api.get_project(self._project_name) return copy.deepcopy(self._project_entity) @@ -294,6 +306,15 @@ class SelectionEntitiesCache: class LoaderActionSelection: + """Selection of entities for loader actions. + + Selection tells action plugins what exactly is selected in the tool and + which ids. + + Contains entity cache which can be used to get entities by their ids. Or + to get project settings and anatomy. + + """ def __init__( self, project_name: str, @@ -350,9 +371,33 @@ class LoaderActionSelection: @dataclass class LoaderActionItem: + """Item of loader action. + + Action plugins return these items as possible actions to run for a given + context. + + Because the action item can be related to a specific entity + and not the whole selection, they also have to define the entity type + and ids to be executed on. + + Attributes: + identifier (str): Unique action identifier. What is sent to action + plugin when the action is executed. + entity_type (str): Entity type to which the action belongs. + entity_ids (set[str]): Entity ids to which the action belongs. + label (str): Text shown in UI. + order (int): Order of the action in UI. + group_label (Optional[str]): Label of the group to which the action + belongs. + icon (Optional[dict[str, Any]]): Icon definition. + plugin_identifier (Optional[str]): Identifier of the plugin which + created the action item. Is filled automatically. Is not changed + if is filled -> can lead to different plugin. + + """ identifier: str - entity_ids: set[str] entity_type: str + entity_ids: set[str] label: str order: int = 0 group_label: Optional[str] = None @@ -363,6 +408,25 @@ class LoaderActionItem: @dataclass class LoaderActionForm: + """Form for loader action. + + If an action needs to collect information from a user before or during of + the action execution, it can return a response with a form. When the + form is confirmed, a new execution of the action is triggered. + + Attributes: + title (str): Title of the form -> title of the window. + fields (list[AbstractAttrDef]): Fields of the form. + submit_label (Optional[str]): Label of the submit button. Is hidden + if is set to None. + submit_icon (Optional[dict[str, Any]]): Icon definition of the submit + button. + cancel_label (Optional[str]): Label of the cancel button. Is hidden + if is set to None. User can still close the window tho. + cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel + button. + + """ title: str fields: list[AbstractAttrDef] submit_label: Optional[str] = "Submit" @@ -393,6 +457,18 @@ class LoaderActionForm: @dataclass class LoaderActionResult: + """Result of loader action execution. + + Attributes: + message (Optional[str]): Message to show in UI. + success (bool): If the action was successful. Affects color of + the message. + form (Optional[LoaderActionForm]): Form to show in UI. + form_values (Optional[dict[str, Any]]): Values for the form. Can be + used if the same form is re-shown e.g. because a user forgot to + fill a required field. + + """ message: Optional[str] = None success: bool = True form: Optional[LoaderActionForm] = None @@ -422,7 +498,6 @@ class LoaderActionPlugin(ABC): Plugin is responsible for getting action items and executing actions. - """ _log: Optional[logging.Logger] = None enabled: bool = True @@ -503,6 +578,12 @@ class LoaderActionPlugin(ABC): class LoaderActionsContext: + """Wrapper for loader actions and their logic. + + Takes care about the public api of loader actions and internal logic like + discovery and initialization of plugins. + + """ def __init__( self, studio_settings: Optional[dict[str, Any]] = None, @@ -521,6 +602,15 @@ class LoaderActionsContext: def reset( self, studio_settings: Optional[dict[str, Any]] = None ) -> None: + """Reset context cache. + + Reset plugins and studio settings to reload them. + + Notes: + Does not reset the cache of AddonsManger because there should not + be a reason to do so. + + """ self._studio_settings = studio_settings self._plugins = None @@ -532,6 +622,14 @@ class LoaderActionsContext: return self._addons_manager def get_host(self) -> Optional[AbstractHost]: + """Get current host integration. + + Returns: + Optional[AbstractHost]: Host integration. Can be None if host + integration is not registered -> probably not used in the + host integration process. + + """ if self._host is _PLACEHOLDER: from ayon_core.pipeline import registered_host @@ -552,6 +650,12 @@ class LoaderActionsContext: def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: + """Collect action items from all plugins for given selection. + + Args: + selection (LoaderActionSelection): Selection wrapper. + + """ output = [] for plugin_id, plugin in self._get_plugins().items(): try: @@ -572,11 +676,25 @@ class LoaderActionsContext: self, plugin_identifier: str, action_identifier: str, + entity_type: str, entity_ids: set[str], - entity_type: LoaderSelectedType, selection: LoaderActionSelection, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: + """Trigger action execution. + + Args: + plugin_identifier (str): Identifier of the plugin. + action_identifier (str): Identifier of the action. + entity_type (str): Entity type defined on the action item. + entity_ids (set[str]): Entity ids defined on the action item. + selection (LoaderActionSelection): Selection wrapper. Can be used + to get what is selected in UI and to get access to entity + cache. + form_values (dict[str, Any]): Form values related to action. + Usually filled if action returned response with form. + + """ plugins_by_id = self._get_plugins() plugin = plugins_by_id[plugin_identifier] return plugin.execute_action( From a7b379059fdba2282ce1c9ccec50c98078f1bc23 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:43:59 +0200 Subject: [PATCH 082/386] allow to pass data into action items --- client/ayon_core/pipeline/actions/loader.py | 30 +++++++++---------- client/ayon_core/plugins/loader/copy_file.py | 12 ++++---- .../plugins/loader/delete_old_versions.py | 12 ++++---- client/ayon_core/plugins/loader/delivery.py | 8 ++--- .../ayon_core/plugins/loader/export_otio.py | 11 ++++--- client/ayon_core/plugins/loader/open_file.py | 9 +++--- .../plugins/loader/push_to_project.py | 11 ++++--- 7 files changed, 42 insertions(+), 51 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 2c3ad39c48..94e30c5114 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -5,6 +5,7 @@ import collections import copy import logging from abc import ABC, abstractmethod +import typing from typing import Optional, Any, Callable from dataclasses import dataclass @@ -23,6 +24,12 @@ from ayon_core.settings import get_studio_settings, get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins +if typing.TYPE_CHECKING: + from typing import Union + + DataBaseType = Union[str, int, float, bool] + DataType = dict[str, Union[DataBaseType, list[DataBaseType]]] + _PLACEHOLDER = object() @@ -383,25 +390,23 @@ class LoaderActionItem: Attributes: identifier (str): Unique action identifier. What is sent to action plugin when the action is executed. - entity_type (str): Entity type to which the action belongs. - entity_ids (set[str]): Entity ids to which the action belongs. label (str): Text shown in UI. order (int): Order of the action in UI. group_label (Optional[str]): Label of the group to which the action belongs. - icon (Optional[dict[str, Any]]): Icon definition. + icon (Optional[dict[str, Any]): Icon definition. + data (Optional[DataType]): Action item data. plugin_identifier (Optional[str]): Identifier of the plugin which created the action item. Is filled automatically. Is not changed if is filled -> can lead to different plugin. """ identifier: str - entity_type: str - entity_ids: set[str] label: str order: int = 0 group_label: Optional[str] = None icon: Optional[dict[str, Any]] = None + data: Optional[DataType] = None # Is filled automatically plugin_identifier: str = None @@ -555,19 +560,17 @@ class LoaderActionPlugin(ABC): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: """Execute an action. Args: identifier (str): Action identifier. - entity_ids: (set[str]): Entity ids stored on action item. - entity_type: (str): Entity type stored on action item. selection (LoaderActionSelection): Selection wrapper. Can be used to get entities or get context of original selection. + data (Optional[DataType]): Additional action item data. form_values (dict[str, Any]): Attribute values. Returns: @@ -676,9 +679,8 @@ class LoaderActionsContext: self, plugin_identifier: str, action_identifier: str, - entity_type: str, - entity_ids: set[str], selection: LoaderActionSelection, + data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: """Trigger action execution. @@ -686,11 +688,10 @@ class LoaderActionsContext: Args: plugin_identifier (str): Identifier of the plugin. action_identifier (str): Identifier of the action. - entity_type (str): Entity type defined on the action item. - entity_ids (set[str]): Entity ids defined on the action item. selection (LoaderActionSelection): Selection wrapper. Can be used to get what is selected in UI and to get access to entity cache. + data (Optional[DataType]): Additional action item data. form_values (dict[str, Any]): Form values related to action. Usually filled if action returned response with form. @@ -699,9 +700,8 @@ class LoaderActionsContext: plugin = plugins_by_id[plugin_identifier] return plugin.execute_action( action_identifier, - entity_ids, - entity_type, selection, + data, form_values, ) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 09875698bd..8253a772eb 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -44,8 +44,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): identifier="copy-path", label=repre_name, group_label="Copy file path", - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "content_copy", @@ -58,8 +57,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): identifier="copy-file", label=repre_name, group_label="Copy file", - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "file_copy", @@ -72,14 +70,14 @@ class CopyFileActionPlugin(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore - repre = next(iter(selection.entities.get_representations(entity_ids))) + repre_ids = data["representation_ids"] + repre = next(iter(selection.entities.get_representations(repre_ids))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 69b93cbb32..cc7d4d3fa6 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -61,8 +61,7 @@ class DeleteOldVersions(LoaderActionPlugin): identifier="delete-versions", label="Delete Versions", order=35, - entity_ids=product_ids, - entity_type="product", + data={"product_ids": list(product_ids)}, icon={ "type": "material-symbols", "name": "delete", @@ -73,8 +72,7 @@ class DeleteOldVersions(LoaderActionPlugin): identifier="calculate-versions-size", label="Calculate Versions size", order=30, - entity_ids=product_ids, - entity_type="product", + data={"product_ids": list(product_ids)}, icon={ "type": "material-symbols", "name": "auto_delete", @@ -86,9 +84,8 @@ class DeleteOldVersions(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: step = form_values.get("step") @@ -106,12 +103,13 @@ class DeleteOldVersions(LoaderActionPlugin): if remove_publish_folder is None: remove_publish_folder = False + product_ids = data["product_ids"] if step == "prepare-data": return self._prepare_data_step( identifier, versions_to_keep, remove_publish_folder, - entity_ids, + product_ids, selection, ) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index d1fbb20afc..538bdec414 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -54,8 +54,7 @@ class DeliveryAction(LoaderActionPlugin): identifier="deliver-versions", label="Deliver Versions", order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "upload", @@ -67,15 +66,14 @@ class DeliveryAction(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: try: # TODO run the tool in subprocess dialog = DeliveryOptionsDialog( - selection.project_name, entity_ids, self.log + selection.project_name, data["version_ids"], self.log ) dialog.exec_() except Exception: diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 8a142afdb5..1ad9038c5e 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -59,8 +59,7 @@ class ExportOTIO(LoaderActionPlugin): label="Export OTIO", group_label=None, order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "save", @@ -73,16 +72,16 @@ class ExportOTIO(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: _import_otio() + version_ids = data["version_ids"] versions_by_id = { version["id"]: version - for version in selection.entities.get_versions(entity_ids) + for version in selection.entities.get_versions(version_ids) } product_ids = { version["productId"] @@ -101,7 +100,7 @@ class ExportOTIO(LoaderActionPlugin): for folder in selection.entities.get_folders(folder_ids) } repre_entities = selection.entities.get_versions_representations( - entity_ids + version_ids ) version_path_by_id = {} diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index f7a7167c9a..1ed470c06e 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -84,8 +84,7 @@ class OpenFileAction(LoaderActionPlugin): label=repre_name, group_label="Open file", order=-10, - entity_ids=repre_ids, - entity_type="representation", + data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", "name": "play_circle", @@ -98,14 +97,14 @@ class OpenFileAction(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: path = None repre_path = None - for repre in selection.entities.get_representations(entity_ids): + repre_ids = data["representation_ids"] + for repre in selection.entities.get_representations(repre_ids): repre_path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index bd0da71c0e..275f5de88d 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -42,8 +42,7 @@ class PushToProject(LoaderActionPlugin): identifier="core.push-to-project", label="Push to project", order=35, - entity_ids=version_ids, - entity_type="version", + data={"version_ids": list(version_ids)}, icon={ "type": "material-symbols", "name": "send", @@ -56,12 +55,12 @@ class PushToProject(LoaderActionPlugin): def execute_action( self, identifier: str, - entity_ids: set[str], - entity_type: str, selection: LoaderActionSelection, + data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - if len(entity_ids) > 1: + version_ids = data["version_ids"] + if len(version_ids) > 1: return LoaderActionResult( message="Please select only one version", success=False, @@ -77,7 +76,7 @@ class PushToProject(LoaderActionPlugin): args = get_ayon_launcher_args( push_tool_script_path, "--project", selection.project_name, - "--versions", ",".join(entity_ids) + "--versions", ",".join(version_ids) ) run_detached_process(args) return LoaderActionResult( From 8fdbda78ee6b0b3b8e27aa87d6b8907d86d88222 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:37:07 +0200 Subject: [PATCH 083/386] modify loader tool to match changes in backend --- client/ayon_core/tools/loader/abstract.py | 19 ++++------- client/ayon_core/tools/loader/control.py | 22 ++++++------ .../ayon_core/tools/loader/models/actions.py | 34 +++++++++---------- .../ayon_core/tools/loader/models/sitesync.py | 13 ++++--- .../tools/loader/ui/products_widget.py | 17 +++++----- .../tools/loader/ui/repres_widget.py | 17 +++++----- client/ayon_core/tools/loader/ui/window.py | 19 +++++------ 7 files changed, 65 insertions(+), 76 deletions(-) diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 5de4560d3e..90371204f9 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -316,13 +316,12 @@ class ActionItem: Args: plugin_identifier (str): Action identifier. identifier (str): Action identifier. - entity_ids (set[str]): Entity ids. - entity_type (str): Entity type. label (str): Action label. group_label (Optional[str]): Group label. icon (Optional[dict[str, Any]]): Action icon definition. tooltip (Optional[str]): Action tooltip. order (int): Action order. + data (Optional[dict[str, Any]]): Additional action data. options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]): Action options. Note: 'qargparse' is considered as deprecated. @@ -331,23 +330,21 @@ class ActionItem: self, plugin_identifier: str, identifier: str, - entity_ids: set[str], - entity_type: str, label: str, group_label: Optional[str], icon: Optional[dict[str, Any]], tooltip: Optional[str], order: int, + data: Optional[dict[str, Any]], options: Optional[list], ): self.plugin_identifier = plugin_identifier self.identifier = identifier - self.entity_ids = entity_ids - self.entity_type = entity_type self.label = label self.group_label = group_label self.icon = icon self.tooltip = tooltip + self.data = data self.order = order self.options = options @@ -371,13 +368,12 @@ class ActionItem: return { "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, - "entity_ids": list(self.entity_ids), - "entity_type": self.entity_type, "label": self.label, "group_label": self.group_label, "icon": self.icon, "tooltip": self.tooltip, "order": self.order, + "data": self.data, "options": options, } @@ -387,7 +383,6 @@ class ActionItem: if options: options = deserialize_attr_defs(options) data["options"] = options - data["entity_ids"] = set(data["entity_ids"]) return cls(**data) @@ -1011,10 +1006,9 @@ class FrontendLoaderController(_BaseLoaderController): plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -1037,10 +1031,9 @@ class FrontendLoaderController(_BaseLoaderController): plugin_identifier (sttr): Plugin identifier. identifier (sttr): Action identifier. project_name (str): Project name. - entity_ids (set[str]): Entity ids stored on action item. - entity_type (str): Entity type stored on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + data (Optional[dict[str, Any]]): Additional action item data. options (dict[str, Any]): Action option values from UI. form_values (dict[str, Any]): Action form values from UI. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index 7a406fd2a3..e406b30fe0 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -318,10 +318,9 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -329,20 +328,19 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): self._sitesync_model.trigger_action_item( identifier, project_name, - entity_ids, + data, ) return self._loader_actions_model.trigger_action_item( - plugin_identifier, - identifier, - project_name, - entity_ids, - entity_type, - selected_ids, - selected_entity_type, - options, - form_values, + plugin_identifier=plugin_identifier, + identifier=identifier, + project_name=project_name, + selected_ids=selected_ids, + selected_entity_type=selected_entity_type, + data=data, + options=options, + form_values=form_values, ) # Selection model wrappers diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 8aded40919..772befc22f 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -5,7 +5,7 @@ import traceback import inspect import collections import uuid -from typing import Callable, Any +from typing import Optional, Callable, Any import ayon_api @@ -125,10 +125,9 @@ class LoaderActionsModel: plugin_identifier: str, identifier: str, project_name: str, - entity_ids: set[str], - entity_type: str, selected_ids: set[str], selected_entity_type: str, + data: Optional[dict[str, Any]], options: dict[str, Any], form_values: dict[str, Any], ): @@ -144,10 +143,9 @@ class LoaderActionsModel: plugin_identifier (str): Plugin identifier. identifier (str): Action identifier. project_name (str): Project name. - entity_ids (set[str]): Entity ids on action item. - entity_type (str): Entity type on action item. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. + data (Optional[dict[str, Any]]): Additional action item data. options (dict[str, Any]): Loader option values. form_values (dict[str, Any]): Form values. @@ -156,10 +154,9 @@ class LoaderActionsModel: "plugin_identifier": plugin_identifier, "identifier": identifier, "project_name": project_name, - "entity_ids": list(entity_ids), - "entity_type": entity_type, "selected_ids": list(selected_ids), "selected_entity_type": selected_entity_type, + "data": data, "id": uuid.uuid4().hex, } self._controller.emit_event( @@ -172,16 +169,15 @@ class LoaderActionsModel: crashed = False try: result = self._loader_actions.execute_action( - plugin_identifier, - identifier, - entity_ids, - entity_type, - LoaderActionSelection( + plugin_identifier=plugin_identifier, + action_identifier=identifier, + selection=LoaderActionSelection( project_name, selected_ids, selected_entity_type, ), - form_values, + data=data, + form_values=form_values, ) except Exception: @@ -203,7 +199,8 @@ class LoaderActionsModel: loader = self._get_loader_by_identifier( project_name, identifier ) - + entity_type = data["entity_type"] + entity_ids = data["entity_ids"] if entity_type == "version": error_info = self._trigger_version_loader( loader, @@ -346,8 +343,10 @@ class LoaderActionsModel: return ActionItem( LOADER_PLUGIN_ID, get_loader_identifier(loader), - entity_ids=entity_ids, - entity_type=entity_type, + data={ + "entity_ids": entity_ids, + "entity_type": entity_type, + }, label=label, group_label=None, icon=self._get_action_icon(loader), @@ -807,13 +806,12 @@ class LoaderActionsModel: items.append(ActionItem( action.plugin_identifier, action.identifier, - action.entity_ids, - action.entity_type, label=action.label, group_label=action.group_label, icon=action.icon, tooltip=None, # action.tooltip, order=action.order, + data=action.data, options=None, # action.options, )) return items diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 4d6ffcf9d4..2d0dcea5bf 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -1,6 +1,7 @@ from __future__ import annotations import collections +from typing import Any from ayon_api import ( get_representations, @@ -315,16 +316,17 @@ class SiteSyncModel: self, identifier: str, project_name: str, - representation_ids: set[str], + data: dict[str, Any], ): """Resets status for site_name or remove local files. Args: identifier (str): Action identifier. project_name (str): Project name. - representation_ids (Iterable[str]): Representation ids. + data (dict[str, Any]): Action item data. """ + representation_ids = data["representation_ids"] active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -495,9 +497,10 @@ class SiteSyncModel: }, tooltip=tooltip, order=1, - entity_ids=representation_ids, - entity_type="representation", - options={}, + data={ + "representation_ids": representation_ids, + }, + options=None, ) def _add_site(self, project_name, repre_entity, site_name, product_type): diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 319108e8ea..384fed2ee9 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,15 +438,14 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - action_item.plugin_identifier, - action_item.identifier, - project_name, - action_item.entity_ids, - action_item.entity_type, - version_ids, - "version", - options, - {}, + plugin_identifier=action_item.plugin_identifier, + identifier=action_item.identifier, + project_name=project_name, + selected_ids=version_ids, + selected_entity_type="version", + data=action_item.data, + options=options, + form_values={}, ) def _on_selection_change(self): diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index bfbcc73503..dcfcfea81b 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,13 +399,12 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - action_item.plugin_identifier, - action_item.identifier, - self._selected_project_name, - action_item.entity_ids, - action_item.entity_type, - repre_ids, - "representation", - options, - {}, + plugin_identifier=action_item.plugin_identifier, + identifier=action_item.identifier, + project_name=self._selected_project_name, + selected_ids=repre_ids, + selected_entity_type="representation", + data=action_item.data, + options=options, + form_values={}, ) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 71679213e5..d2a4145707 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -582,17 +582,16 @@ class LoaderWindow(QtWidgets.QWidget): if result != QtWidgets.QDialog.Accepted: return - form_data = dialog.get_values() + form_values = dialog.get_values() self._controller.trigger_action_item( - event["plugin_identifier"], - event["identifier"], - event["project_name"], - event["entity_ids"], - event["entity_type"], - event["selected_ids"], - event["selected_entity_type"], - {}, - form_data, + plugin_identifier=event["plugin_identifier"], + identifier=event["identifier"], + project_name=event["project_name"], + selected_ids=event["selected_ids"], + selected_entity_type=event["selected_entity_type"], + options={}, + data=event["data"], + form_values=form_values, ) def _on_project_selection_changed(self, event): From 3945655f217fc7703c3d145520a7f35071c34318 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:37:22 +0200 Subject: [PATCH 084/386] return type in docstring --- client/ayon_core/addon/interfaces.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index cc7e39218e..bc44fd2d2e 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -186,7 +186,12 @@ class IPluginPaths(AYONInterface): return self._get_plugin_paths_by_type("inventory") def get_loader_action_plugin_paths(self) -> list[str]: - """Receive loader action plugin paths.""" + """Receive loader action plugin paths. + + Returns: + list[str]: Paths to loader action plugins. + + """ return [] From 66b1a6e8adab48a299d5e52c396358dbdcc65e0b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:48:07 +0200 Subject: [PATCH 085/386] add small explanation to the code --- client/ayon_core/pipeline/actions/loader.py | 65 ++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 94e30c5114..bb903b7c54 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -1,3 +1,61 @@ +"""API for actions for loader tool. + +Even though the api is meant for the loader tool, the api should be possible + to use in a standalone way out of the loader tool. + +To use add actions, make sure your addon does inherit from + 'IPluginPaths' and implements 'get_loader_action_plugin_paths' which + returns paths to python files with loader actions. + +The plugin is used to collect available actions for the given context and to + execute them. Selection is defined with 'LoaderActionSelection' object + that also contains a cache of entities and project anatomy. + +Implementing 'get_action_items' allows the plugin to define what actions + are shown and available for the selection. Because for a single selection + can be shown multiple actions with the same action identifier, the action + items also have 'data' attribute which can be used to store additional + data for the action (they have to be json-serializable). + +The action is triggered by calling the 'execute_action' method. Which takes + the action identifier, the selection, the additional data from the action + item and form values from the form if any. + +Using 'LoaderActionResult' as the output of 'execute_action' can trigger to + show a message in UI or to show an additional form ('LoaderActionForm') + which would retrigger the action with the values from the form on + submitting. That allows handling of multistep actions. + +It is also recommended that the plugin does override the 'identifier' + attribute. The identifier has to be unique across all plugins. + Class name is used by default. + +The selection wrapper currently supports the following types of entity types: + - version + - representation +It is planned to add 'folder' and 'task' selection in the future. + +NOTE: It is possible to trigger 'execute_action' without ever calling + 'get_action_items', that can be handy in automations. + +The whole logic is wrapped into 'LoaderActionsContext'. It takes care of + the discovery of plugins and wraps the collection and execution of + action items. Method 'execute_action' on context also requires plugin + identifier. + +The flow of the logic is (in the loader tool): + 1. User selects entities in the UI. + 2. Right-click the selected entities. + 3. Use 'LoaderActionsContext' to collect items using 'get_action_items'. + 4. Show a menu (with submenus) in the UI. + 5. If a user selects an action, the action is triggered using + 'execute_action'. + 5a. If the action returns 'LoaderActionResult', show a 'message' if it is + filled and show a form dialog if 'form' is filled. + 5b. If the user submitted the form, trigger the action again with the + values from the form and repeat from 5a. + +""" from __future__ import annotations import os @@ -388,7 +446,7 @@ class LoaderActionItem: and ids to be executed on. Attributes: - identifier (str): Unique action identifier. What is sent to action + identifier (str): Unique action identifier. What is sent to the action plugin when the action is executed. label (str): Text shown in UI. order (int): Order of the action in UI. @@ -417,7 +475,10 @@ class LoaderActionForm: If an action needs to collect information from a user before or during of the action execution, it can return a response with a form. When the - form is confirmed, a new execution of the action is triggered. + form is submitted, a new execution of the action is triggered. + + It is also possible to just show a label message without the submit + button to make sure the user has seen the message. Attributes: title (str): Title of the form -> title of the window. From 4c492b6d4bce55f79c60fd84d850e7d54a77c4ce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:54:58 +0200 Subject: [PATCH 086/386] fetch only first representation --- client/ayon_core/plugins/loader/copy_file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 8253a772eb..2c4a99dc4f 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -76,8 +76,8 @@ class CopyFileActionPlugin(LoaderActionPlugin): ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore - repre_ids = data["representation_ids"] - repre = next(iter(selection.entities.get_representations(repre_ids))) + repre_id = next(iter(data["representation_ids"])) + repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() ) From 76be69c4b2fb396600fc67a9c6f76ab7751e9b88 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:01:48 +0200 Subject: [PATCH 087/386] add simple action plugin --- client/ayon_core/pipeline/actions/__init__.py | 2 + client/ayon_core/pipeline/actions/loader.py | 76 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 247f64e890..6120fd6ac5 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -7,6 +7,7 @@ from .loader import ( LoaderActionSelection, LoaderActionsContext, SelectionEntitiesCache, + LoaderSimpleActionPlugin, ) from .launcher import ( @@ -37,6 +38,7 @@ __all__ = ( "LoaderActionSelection", "LoaderActionsContext", "SelectionEntitiesCache", + "LoaderSimpleActionPlugin", "LauncherAction", "LauncherActionSelection", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index bb903b7c54..a77eee82c7 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -804,3 +804,79 @@ class LoaderActionsContext: ) self._plugins = plugins return self._plugins + + +class LoaderSimpleActionPlugin(LoaderActionPlugin): + """Simple action plugin. + + This action will show exactly one action item defined by attributes + on the class. + + Attributes: + label: Label of the action item. + order: Order of the action item. + group_label: Label of the group to which the action belongs. + icon: Icon definition shown next to label. + + """ + + label: Optional[str] = None + order: int = 0 + group_label: Optional[str] = None + icon: Optional[dict[str, Any]] = None + + @abstractmethod + def is_compatible(self, selection: LoaderActionSelection) -> bool: + """Check if plugin is compatible with selection. + + Args: + selection (LoaderActionSelection): Selection information. + + Returns: + bool: True if plugin is compatible with selection. + + """ + pass + + @abstractmethod + def process( + self, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + """Process action based on selection. + + Args: + selection (LoaderActionSelection): Selection information. + form_values (dict[str, Any]): Values from a form if there are any. + + Returns: + Optional[LoaderActionResult]: Result of the action. + + """ + pass + + def get_action_items( + self, selection: LoaderActionSelection + ) -> list[LoaderActionItem]: + if self.is_compatible(selection): + label = self.label or self.__class__.__name__ + return [ + LoaderActionItem( + identifier=self.identifier, + label=label, + order=self.order, + group_label=self.group_label, + icon=self.icon, + ) + ] + return [] + + def execute_action( + self, + identifier: str, + selection: LoaderActionSelection, + data: Optional[DataType], + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: + return self.process(selection, form_values) From af196dd049855dd1d0cf95ca2f11fffebbe62687 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:02:04 +0200 Subject: [PATCH 088/386] use simple plugin in export otio action --- .../ayon_core/plugins/loader/export_otio.py | 47 +++++++------------ 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index 1ad9038c5e..f8cdbed0a5 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -20,8 +20,7 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.tools.utils import show_message_dialog from ayon_core.pipeline.actions import ( - LoaderActionPlugin, - LoaderActionItem, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionResult, ) @@ -37,47 +36,33 @@ def _import_otio(): OTIO = opentimelineio -class ExportOTIO(LoaderActionPlugin): +class ExportOTIO(LoaderSimpleActionPlugin): identifier = "core.export-otio" + label = "Export OTIO" + group_label = None + order = 35 + icon = { + "type": "material-symbols", + "name": "save", + "color": "#d8d8d8", + } - def get_action_items( + def is_compatible( self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: + ) -> bool: # Don't show in hosts if self.host_name is not None: - return [] + return False - version_ids = set() - if selection.selected_type == "version": - version_ids = set(selection.selected_ids) + return selection.versions_selected() - output = [] - if version_ids: - output.append( - LoaderActionItem( - identifier="copy-path", - label="Export OTIO", - group_label=None, - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "save", - "color": "#d8d8d8", - } - ) - ) - return output - - def execute_action( + def process( self, - identifier: str, selection: LoaderActionSelection, - data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: _import_otio() - version_ids = data["version_ids"] + version_ids = set(selection.selected_ids) versions_by_id = { version["id"]: version From 90497bdd5924ce94a7d04cb35142567cf4b40985 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:14:07 +0200 Subject: [PATCH 089/386] added some helper methods --- client/ayon_core/pipeline/actions/loader.py | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index a77eee82c7..7a5956160c 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -433,6 +433,53 @@ class LoaderActionSelection: project_anatomy = property(get_project_anatomy) entities = property(get_entities_cache) + # --- Helper methods --- + def versions_selected(self) -> bool: + """Selected entity type is version. + + Returns: + bool: True if selected entity type is version. + + """ + return self._selected_type == LoaderSelectedType.version + + def representations_selected(self) -> bool: + """Selected entity type is representation. + + Returns: + bool: True if selected entity type is representation. + + """ + return self._selected_type == LoaderSelectedType.representation + + def get_selected_version_entities(self) -> list[dict[str, Any]]: + """Retrieve selected version entities. + + An empty list is returned if 'version' is not the selected + entity type. + + Returns: + list[dict[str, Any]]: List of selected version entities. + + """ + if self.versions_selected(): + return self.entities.get_versions(self.selected_ids) + return [] + + def get_selected_representation_entities(self) -> list[dict[str, Any]]: + """Retrieve selected representation entities. + + An empty list is returned if 'representation' is not the selected + entity type. + + Returns: + list[dict[str, Any]]: List of selected representation entities. + + """ + if self.representations_selected(): + return self.entities.get_representations(self.selected_ids) + return [] + @dataclass class LoaderActionItem: From 365d0a95e032d3612560fe4473b759cb332c2dc8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:20:14 +0200 Subject: [PATCH 090/386] fix typo --- client/ayon_core/plugins/loader/open_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 1ed470c06e..5b21a359f8 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -52,12 +52,12 @@ class OpenFileAction(LoaderActionPlugin): ) product_ids = {version["productId"] for version in versions} products = selection.entities.get_products(product_ids) - fitlered_product_ids = { + filtered_product_ids = { product["id"] for product in products if product["productType"] in self.product_types } - if not fitlered_product_ids: + if not filtered_product_ids: return [] versions_by_product_id = collections.defaultdict(list) @@ -69,7 +69,7 @@ class OpenFileAction(LoaderActionPlugin): repres_by_version_ids[repre["versionId"]].append(repre) filtered_repres = [] - for product_id in fitlered_product_ids: + for product_id in filtered_product_ids: for version in versions_by_product_id[product_id]: for repre in repres_by_version_ids[version["id"]]: filtered_repres.append(repre) From 81a0b6764028024ee40966c51866b0d59fe22de8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 16:43:32 +0200 Subject: [PATCH 091/386] remove action identifier --- client/ayon_core/pipeline/actions/loader.py | 23 +++++----------- client/ayon_core/plugins/loader/copy_file.py | 16 +++++++----- .../plugins/loader/delete_old_versions.py | 26 +++++++++++-------- client/ayon_core/plugins/loader/delivery.py | 2 -- client/ayon_core/plugins/loader/open_file.py | 2 -- .../plugins/loader/push_to_project.py | 2 -- client/ayon_core/tools/loader/abstract.py | 8 +----- client/ayon_core/tools/loader/control.py | 5 +--- .../ayon_core/tools/loader/models/actions.py | 15 ++++------- .../ayon_core/tools/loader/models/sitesync.py | 19 +++++++------- .../tools/loader/ui/products_widget.py | 1 - .../tools/loader/ui/repres_widget.py | 1 - client/ayon_core/tools/loader/ui/window.py | 1 - 13 files changed, 48 insertions(+), 73 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 7a5956160c..ccdae302b9 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -493,27 +493,24 @@ class LoaderActionItem: and ids to be executed on. Attributes: - identifier (str): Unique action identifier. What is sent to the action - plugin when the action is executed. label (str): Text shown in UI. order (int): Order of the action in UI. group_label (Optional[str]): Label of the group to which the action belongs. icon (Optional[dict[str, Any]): Icon definition. data (Optional[DataType]): Action item data. - plugin_identifier (Optional[str]): Identifier of the plugin which + identifier (Optional[str]): Identifier of the plugin which created the action item. Is filled automatically. Is not changed if is filled -> can lead to different plugin. """ - identifier: str label: str order: int = 0 group_label: Optional[str] = None icon: Optional[dict[str, Any]] = None data: Optional[DataType] = None # Is filled automatically - plugin_identifier: str = None + identifier: str = None @dataclass @@ -667,7 +664,6 @@ class LoaderActionPlugin(ABC): @abstractmethod def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], @@ -675,7 +671,6 @@ class LoaderActionPlugin(ABC): """Execute an action. Args: - identifier (str): Action identifier. selection (LoaderActionSelection): Selection wrapper. Can be used to get entities or get context of original selection. data (Optional[DataType]): Additional action item data. @@ -771,8 +766,8 @@ class LoaderActionsContext: for plugin_id, plugin in self._get_plugins().items(): try: for action_item in plugin.get_action_items(selection): - if action_item.plugin_identifier is None: - action_item.plugin_identifier = plugin_id + if action_item.identifier is None: + action_item.identifier = plugin_id output.append(action_item) except Exception: @@ -785,8 +780,7 @@ class LoaderActionsContext: def execute_action( self, - plugin_identifier: str, - action_identifier: str, + identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], @@ -794,8 +788,7 @@ class LoaderActionsContext: """Trigger action execution. Args: - plugin_identifier (str): Identifier of the plugin. - action_identifier (str): Identifier of the action. + identifier (str): Identifier of the plugin. selection (LoaderActionSelection): Selection wrapper. Can be used to get what is selected in UI and to get access to entity cache. @@ -805,9 +798,8 @@ class LoaderActionsContext: """ plugins_by_id = self._get_plugins() - plugin = plugins_by_id[plugin_identifier] + plugin = plugins_by_id[identifier] return plugin.execute_action( - action_identifier, selection, data, form_values, @@ -910,7 +902,6 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): label = self.label or self.__class__.__name__ return [ LoaderActionItem( - identifier=self.identifier, label=label, order=self.order, group_label=self.group_label, diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2c4a99dc4f..dd263383e4 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -41,10 +41,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): for repre_name, repre_ids in repre_ids_by_name.items(): output.append( LoaderActionItem( - identifier="copy-path", label=repre_name, group_label="Copy file path", - data={"representation_ids": list(repre_ids)}, + data={ + "representation_ids": list(repre_ids), + "action": "copy-path", + }, icon={ "type": "material-symbols", "name": "content_copy", @@ -54,10 +56,12 @@ class CopyFileActionPlugin(LoaderActionPlugin): ) output.append( LoaderActionItem( - identifier="copy-file", label=repre_name, group_label="Copy file", - data={"representation_ids": list(repre_ids)}, + data={ + "representation_ids": list(repre_ids), + "action": "copy-file", + }, icon={ "type": "material-symbols", "name": "file_copy", @@ -69,13 +73,13 @@ class CopyFileActionPlugin(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict, form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: from qtpy import QtWidgets, QtCore + action = data["action"] repre_id = next(iter(data["representation_ids"])) repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( @@ -90,7 +94,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): success=False, ) - if identifier == "copy-path": + if action == "copy-path": # Set to Clipboard clipboard.setText(os.path.normpath(path)) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index cc7d4d3fa6..f7f20fefef 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -58,10 +58,12 @@ class DeleteOldVersions(LoaderActionPlugin): return [ LoaderActionItem( - identifier="delete-versions", label="Delete Versions", order=35, - data={"product_ids": list(product_ids)}, + data={ + "product_ids": list(product_ids), + "action": "delete-versions", + }, icon={ "type": "material-symbols", "name": "delete", @@ -69,10 +71,12 @@ class DeleteOldVersions(LoaderActionPlugin): } ), LoaderActionItem( - identifier="calculate-versions-size", label="Calculate Versions size", order=30, - data={"product_ids": list(product_ids)}, + data={ + "product_ids": list(product_ids), + "action": "calculate-versions-size", + }, icon={ "type": "material-symbols", "name": "auto_delete", @@ -83,17 +87,17 @@ class DeleteOldVersions(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: step = form_values.get("step") + action = data["action"] versions_to_keep = form_values.get("versions_to_keep") remove_publish_folder = form_values.get("remove_publish_folder") if step is None: return self._first_step( - identifier, + action, versions_to_keep, remove_publish_folder, ) @@ -106,7 +110,7 @@ class DeleteOldVersions(LoaderActionPlugin): product_ids = data["product_ids"] if step == "prepare-data": return self._prepare_data_step( - identifier, + action, versions_to_keep, remove_publish_folder, product_ids, @@ -121,7 +125,7 @@ class DeleteOldVersions(LoaderActionPlugin): def _first_step( self, - identifier: str, + action: str, versions_to_keep: Optional[int], remove_publish_folder: Optional[bool], ) -> LoaderActionResult: @@ -137,7 +141,7 @@ class DeleteOldVersions(LoaderActionPlugin): default=2, ), ] - if identifier == "delete-versions": + if action == "delete-versions": fields.append( BoolDef( "remove_publish_folder", @@ -165,7 +169,7 @@ class DeleteOldVersions(LoaderActionPlugin): def _prepare_data_step( self, - identifier: str, + action: str, versions_to_keep: int, remove_publish_folder: bool, entity_ids: set[str], @@ -235,7 +239,7 @@ class DeleteOldVersions(LoaderActionPlugin): if os.path.exists(filepath): size += os.path.getsize(filepath) - if identifier == "calculate-versions-size": + if action == "calculate-versions-size": return LoaderActionResult( message="Calculated size", success=True, diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 538bdec414..c39b791dbb 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -51,7 +51,6 @@ class DeliveryAction(LoaderActionPlugin): return [ LoaderActionItem( - identifier="deliver-versions", label="Deliver Versions", order=35, data={"version_ids": list(version_ids)}, @@ -65,7 +64,6 @@ class DeliveryAction(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 5b21a359f8..9b5a6fec20 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -80,7 +80,6 @@ class OpenFileAction(LoaderActionPlugin): return [ LoaderActionItem( - identifier="open-file", label=repre_name, group_label="Open file", order=-10, @@ -96,7 +95,6 @@ class OpenFileAction(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index 275f5de88d..215e63be86 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -39,7 +39,6 @@ class PushToProject(LoaderActionPlugin): if version_ids and len(folder_ids) == 1: output.append( LoaderActionItem( - identifier="core.push-to-project", label="Push to project", order=35, data={"version_ids": list(version_ids)}, @@ -54,7 +53,6 @@ class PushToProject(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: dict[str, Any], form_values: dict[str, Any], diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 90371204f9..3f86317e90 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -314,7 +314,6 @@ class ActionItem: use 'identifier' and context, it necessary also use 'options'. Args: - plugin_identifier (str): Action identifier. identifier (str): Action identifier. label (str): Action label. group_label (Optional[str]): Group label. @@ -328,7 +327,6 @@ class ActionItem: """ def __init__( self, - plugin_identifier: str, identifier: str, label: str, group_label: Optional[str], @@ -338,7 +336,6 @@ class ActionItem: data: Optional[dict[str, Any]], options: Optional[list], ): - self.plugin_identifier = plugin_identifier self.identifier = identifier self.label = label self.group_label = group_label @@ -366,7 +363,6 @@ class ActionItem: def to_data(self) -> dict[str, Any]: options = self._options_to_data() return { - "plugin_identifier": self.plugin_identifier, "identifier": self.identifier, "label": self.label, "group_label": self.group_label, @@ -1003,7 +999,6 @@ class FrontendLoaderController(_BaseLoaderController): @abstractmethod def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -1028,8 +1023,7 @@ class FrontendLoaderController(_BaseLoaderController): } Args: - plugin_identifier (sttr): Plugin identifier. - identifier (sttr): Action identifier. + identifier (sttr): Plugin identifier. project_name (str): Project name. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. diff --git a/client/ayon_core/tools/loader/control.py b/client/ayon_core/tools/loader/control.py index e406b30fe0..722cdf9653 100644 --- a/client/ayon_core/tools/loader/control.py +++ b/client/ayon_core/tools/loader/control.py @@ -315,7 +315,6 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -324,16 +323,14 @@ class LoaderController(BackendLoaderController, FrontendLoaderController): options: dict[str, Any], form_values: dict[str, Any], ): - if self._sitesync_model.is_sitesync_action(plugin_identifier): + if self._sitesync_model.is_sitesync_action(identifier): self._sitesync_model.trigger_action_item( - identifier, project_name, data, ) return self._loader_actions_model.trigger_action_item( - plugin_identifier=plugin_identifier, identifier=identifier, project_name=project_name, selected_ids=selected_ids, diff --git a/client/ayon_core/tools/loader/models/actions.py b/client/ayon_core/tools/loader/models/actions.py index 772befc22f..3db1792247 100644 --- a/client/ayon_core/tools/loader/models/actions.py +++ b/client/ayon_core/tools/loader/models/actions.py @@ -122,7 +122,6 @@ class LoaderActionsModel: def trigger_action_item( self, - plugin_identifier: str, identifier: str, project_name: str, selected_ids: set[str], @@ -140,8 +139,7 @@ class LoaderActionsModel: happened. Args: - plugin_identifier (str): Plugin identifier. - identifier (str): Action identifier. + identifier (str): Plugin identifier. project_name (str): Project name. selected_ids (set[str]): Selected entity ids. selected_entity_type (str): Selected entity type. @@ -151,7 +149,6 @@ class LoaderActionsModel: """ event_data = { - "plugin_identifier": plugin_identifier, "identifier": identifier, "project_name": project_name, "selected_ids": list(selected_ids), @@ -164,13 +161,12 @@ class LoaderActionsModel: event_data, ACTIONS_MODEL_SENDER, ) - if plugin_identifier != LOADER_PLUGIN_ID: + if identifier != LOADER_PLUGIN_ID: result = None crashed = False try: result = self._loader_actions.execute_action( - plugin_identifier=plugin_identifier, - action_identifier=identifier, + identifier=identifier, selection=LoaderActionSelection( project_name, selected_ids, @@ -197,7 +193,7 @@ class LoaderActionsModel: return loader = self._get_loader_by_identifier( - project_name, identifier + project_name, data["loader"] ) entity_type = data["entity_type"] entity_ids = data["entity_ids"] @@ -342,10 +338,10 @@ class LoaderActionsModel: label = f"{label} ({repre_name})" return ActionItem( LOADER_PLUGIN_ID, - get_loader_identifier(loader), data={ "entity_ids": entity_ids, "entity_type": entity_type, + "loader": get_loader_identifier(loader), }, label=label, group_label=None, @@ -804,7 +800,6 @@ class LoaderActionsModel: items = [] for action in self._loader_actions.get_action_items(selection): items.append(ActionItem( - action.plugin_identifier, action.identifier, label=action.label, group_label=action.group_label, diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 2d0dcea5bf..a7bbda18a3 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -300,33 +300,32 @@ class SiteSyncModel: return action_items - def is_sitesync_action(self, plugin_identifier: str) -> bool: + def is_sitesync_action(self, identifier: str) -> bool: """Should be `identifier` handled by SiteSync. Args: - plugin_identifier (str): Plugin identifier. + identifier (str): Plugin identifier. Returns: bool: Should action be handled by SiteSync. """ - return plugin_identifier == "sitesync.loader.action" + return identifier == "sitesync.loader.action" def trigger_action_item( self, - identifier: str, project_name: str, data: dict[str, Any], ): """Resets status for site_name or remove local files. Args: - identifier (str): Action identifier. project_name (str): Project name. data (dict[str, Any]): Action item data. """ representation_ids = data["representation_ids"] + action_identifier = data["action_identifier"] active_site = self.get_active_site(project_name) remote_site = self.get_remote_site(project_name) @@ -350,17 +349,17 @@ class SiteSyncModel: for repre_id in representation_ids: repre_entity = repre_entities_by_id.get(repre_id) product_type = product_type_by_repre_id[repre_id] - if identifier == DOWNLOAD_IDENTIFIER: + if action_identifier == DOWNLOAD_IDENTIFIER: self._add_site( project_name, repre_entity, active_site, product_type ) - elif identifier == UPLOAD_IDENTIFIER: + elif action_identifier == UPLOAD_IDENTIFIER: self._add_site( project_name, repre_entity, remote_site, product_type ) - elif identifier == REMOVE_IDENTIFIER: + elif action_identifier == REMOVE_IDENTIFIER: self._sitesync_addon.remove_site( project_name, repre_id, @@ -480,14 +479,13 @@ class SiteSyncModel: self, project_name, representation_ids, - identifier, + action_identifier, label, tooltip, icon_name ): return ActionItem( "sitesync.loader.action", - identifier=identifier, label=label, group_label=None, icon={ @@ -499,6 +497,7 @@ class SiteSyncModel: order=1, data={ "representation_ids": representation_ids, + "action_identifier": action_identifier, }, options=None, ) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 384fed2ee9..ddd6ce8554 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -438,7 +438,6 @@ class ProductsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - plugin_identifier=action_item.plugin_identifier, identifier=action_item.identifier, project_name=project_name, selected_ids=version_ids, diff --git a/client/ayon_core/tools/loader/ui/repres_widget.py b/client/ayon_core/tools/loader/ui/repres_widget.py index dcfcfea81b..33bbf46b34 100644 --- a/client/ayon_core/tools/loader/ui/repres_widget.py +++ b/client/ayon_core/tools/loader/ui/repres_widget.py @@ -399,7 +399,6 @@ class RepresentationsWidget(QtWidgets.QWidget): return self._controller.trigger_action_item( - plugin_identifier=action_item.plugin_identifier, identifier=action_item.identifier, project_name=self._selected_project_name, selected_ids=repre_ids, diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index d2a4145707..1c8b56f0c0 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -584,7 +584,6 @@ class LoaderWindow(QtWidgets.QWidget): form_values = dialog.get_values() self._controller.trigger_action_item( - plugin_identifier=event["plugin_identifier"], identifier=event["identifier"], project_name=event["project_name"], selected_ids=event["selected_ids"], From 0dfaa001655103b5690a0424a0ca987bac914242 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 1 Oct 2025 18:06:39 +0200 Subject: [PATCH 092/386] remove unnecessary argument --- client/ayon_core/pipeline/actions/loader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index ccdae302b9..c8b579614a 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -912,7 +912,6 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): def execute_action( self, - identifier: str, selection: LoaderActionSelection, data: Optional[DataType], form_values: dict[str, Any], From 55828c73414f999d9280af9309f4aaeb24bb7936 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 16:58:21 +0200 Subject: [PATCH 093/386] move LoaderActionForm as ActionForm to structures --- client/ayon_core/pipeline/actions/__init__.py | 7 +- client/ayon_core/pipeline/actions/loader.py | 67 ++----------------- .../ayon_core/pipeline/actions/structures.py | 60 +++++++++++++++++ 3 files changed, 71 insertions(+), 63 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/structures.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 6120fd6ac5..569047438c 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,6 +1,8 @@ +from .structures import ( + ActionForm, +) from .loader import ( LoaderSelectedType, - LoaderActionForm, LoaderActionResult, LoaderActionItem, LoaderActionPlugin, @@ -30,8 +32,9 @@ from .inventory import ( __all__ = ( + "ActionForm", + "LoaderSelectedType", - "LoaderActionForm", "LoaderActionResult", "LoaderActionItem", "LoaderActionPlugin", diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index c8b579614a..13f243bf66 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -22,7 +22,7 @@ The action is triggered by calling the 'execute_action' method. Which takes item and form values from the form if any. Using 'LoaderActionResult' as the output of 'execute_action' can trigger to - show a message in UI or to show an additional form ('LoaderActionForm') + show a message in UI or to show an additional form ('ActionForm') which would retrigger the action with the values from the form on submitting. That allows handling of multistep actions. @@ -71,17 +71,14 @@ import ayon_api from ayon_core import AYON_CORE_ROOT from ayon_core.lib import StrEnum, Logger -from ayon_core.lib.attribute_definitions import ( - AbstractAttrDef, - serialize_attr_defs, - deserialize_attr_defs, -) from ayon_core.host import AbstractHost from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import discover_plugins +from .structures import ActionForm + if typing.TYPE_CHECKING: from typing import Union @@ -513,58 +510,6 @@ class LoaderActionItem: identifier: str = None -@dataclass -class LoaderActionForm: - """Form for loader action. - - If an action needs to collect information from a user before or during of - the action execution, it can return a response with a form. When the - form is submitted, a new execution of the action is triggered. - - It is also possible to just show a label message without the submit - button to make sure the user has seen the message. - - Attributes: - title (str): Title of the form -> title of the window. - fields (list[AbstractAttrDef]): Fields of the form. - submit_label (Optional[str]): Label of the submit button. Is hidden - if is set to None. - submit_icon (Optional[dict[str, Any]]): Icon definition of the submit - button. - cancel_label (Optional[str]): Label of the cancel button. Is hidden - if is set to None. User can still close the window tho. - cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel - button. - - """ - title: str - fields: list[AbstractAttrDef] - submit_label: Optional[str] = "Submit" - submit_icon: Optional[dict[str, Any]] = None - cancel_label: Optional[str] = "Cancel" - cancel_icon: Optional[dict[str, Any]] = None - - def to_json_data(self) -> dict[str, Any]: - fields = self.fields - if fields is not None: - fields = serialize_attr_defs(fields) - return { - "title": self.title, - "fields": fields, - "submit_label": self.submit_label, - "submit_icon": self.submit_icon, - "cancel_label": self.cancel_label, - "cancel_icon": self.cancel_icon, - } - - @classmethod - def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionForm": - fields = data["fields"] - if fields is not None: - data["fields"] = deserialize_attr_defs(fields) - return cls(**data) - - @dataclass class LoaderActionResult: """Result of loader action execution. @@ -573,7 +518,7 @@ class LoaderActionResult: message (Optional[str]): Message to show in UI. success (bool): If the action was successful. Affects color of the message. - form (Optional[LoaderActionForm]): Form to show in UI. + form (Optional[ActionForm]): Form to show in UI. form_values (Optional[dict[str, Any]]): Values for the form. Can be used if the same form is re-shown e.g. because a user forgot to fill a required field. @@ -581,7 +526,7 @@ class LoaderActionResult: """ message: Optional[str] = None success: bool = True - form: Optional[LoaderActionForm] = None + form: Optional[ActionForm] = None form_values: Optional[dict[str, Any]] = None def to_json_data(self) -> dict[str, Any]: @@ -599,7 +544,7 @@ class LoaderActionResult: def from_json_data(cls, data: dict[str, Any]) -> "LoaderActionResult": form = data["form"] if form is not None: - data["form"] = LoaderActionForm.from_json_data(form) + data["form"] = ActionForm.from_json_data(form) return LoaderActionResult(**data) diff --git a/client/ayon_core/pipeline/actions/structures.py b/client/ayon_core/pipeline/actions/structures.py new file mode 100644 index 0000000000..0283a7a272 --- /dev/null +++ b/client/ayon_core/pipeline/actions/structures.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Optional, Any + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + serialize_attr_defs, + deserialize_attr_defs, +) + + +@dataclass +class ActionForm: + """Form for loader action. + + If an action needs to collect information from a user before or during of + the action execution, it can return a response with a form. When the + form is submitted, a new execution of the action is triggered. + + It is also possible to just show a label message without the submit + button to make sure the user has seen the message. + + Attributes: + title (str): Title of the form -> title of the window. + fields (list[AbstractAttrDef]): Fields of the form. + submit_label (Optional[str]): Label of the submit button. Is hidden + if is set to None. + submit_icon (Optional[dict[str, Any]]): Icon definition of the submit + button. + cancel_label (Optional[str]): Label of the cancel button. Is hidden + if is set to None. User can still close the window tho. + cancel_icon (Optional[dict[str, Any]]): Icon definition of the cancel + button. + + """ + title: str + fields: list[AbstractAttrDef] + submit_label: Optional[str] = "Submit" + submit_icon: Optional[dict[str, Any]] = None + cancel_label: Optional[str] = "Cancel" + cancel_icon: Optional[dict[str, Any]] = None + + def to_json_data(self) -> dict[str, Any]: + fields = self.fields + if fields is not None: + fields = serialize_attr_defs(fields) + return { + "title": self.title, + "fields": fields, + "submit_label": self.submit_label, + "submit_icon": self.submit_icon, + "cancel_label": self.cancel_label, + "cancel_icon": self.cancel_icon, + } + + @classmethod + def from_json_data(cls, data: dict[str, Any]) -> "ActionForm": + fields = data["fields"] + if fields is not None: + data["fields"] = deserialize_attr_defs(fields) + return cls(**data) From e9958811d44a46c832be8920587452c688386dc5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:15:53 +0200 Subject: [PATCH 094/386] added helper conversion function for webaction fields --- client/ayon_core/pipeline/actions/__init__.py | 4 + client/ayon_core/pipeline/actions/utils.py | 83 +++++++++++++++++++ .../tools/launcher/ui/actions_widget.py | 83 +------------------ 3 files changed, 90 insertions(+), 80 deletions(-) create mode 100644 client/ayon_core/pipeline/actions/utils.py diff --git a/client/ayon_core/pipeline/actions/__init__.py b/client/ayon_core/pipeline/actions/__init__.py index 569047438c..7af3ac1130 100644 --- a/client/ayon_core/pipeline/actions/__init__.py +++ b/client/ayon_core/pipeline/actions/__init__.py @@ -1,6 +1,9 @@ from .structures import ( ActionForm, ) +from .utils import ( + webaction_fields_to_attribute_defs, +) from .loader import ( LoaderSelectedType, LoaderActionResult, @@ -33,6 +36,7 @@ from .inventory import ( __all__ = ( "ActionForm", + "webaction_fields_to_attribute_defs", "LoaderSelectedType", "LoaderActionResult", diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py new file mode 100644 index 0000000000..00a8e91d68 --- /dev/null +++ b/client/ayon_core/pipeline/actions/utils.py @@ -0,0 +1,83 @@ +import uuid + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + UILabelDef, + BoolDef, + TextDef, + NumberDef, + EnumDef, + HiddenDef, +) + + +def webaction_fields_to_attribute_defs(fields) -> list[AbstractAttrDef]: + attr_defs = [] + for field in fields: + field_type = field["type"] + attr_def = None + if field_type == "label": + label = field.get("value") + if label is None: + label = field.get("text") + attr_def = UILabelDef( + label, key=uuid.uuid4().hex + ) + elif field_type == "boolean": + value = field["value"] + if isinstance(value, str): + value = value.lower() == "true" + + attr_def = BoolDef( + field["name"], + default=value, + label=field.get("label"), + ) + elif field_type == "text": + attr_def = TextDef( + field["name"], + default=field.get("value"), + label=field.get("label"), + placeholder=field.get("placeholder"), + multiline=field.get("multiline", False), + regex=field.get("regex"), + # syntax=field["syntax"], + ) + elif field_type in ("integer", "float"): + value = field.get("value") + if isinstance(value, str): + if field_type == "integer": + value = int(value) + else: + value = float(value) + attr_def = NumberDef( + field["name"], + default=value, + label=field.get("label"), + decimals=0 if field_type == "integer" else 5, + # placeholder=field.get("placeholder"), + minimum=field.get("min"), + maximum=field.get("max"), + ) + elif field_type in ("select", "multiselect"): + attr_def = EnumDef( + field["name"], + items=field["options"], + default=field.get("value"), + label=field.get("label"), + multiselection=field_type == "multiselect", + ) + elif field_type == "hidden": + attr_def = HiddenDef( + field["name"], + default=field.get("value"), + ) + + if attr_def is None: + print(f"Unknown config field type: {field_type}") + attr_def = UILabelDef( + f"Unknown field type '{field_type}", + key=uuid.uuid4().hex + ) + attr_defs.append(attr_def) + return attr_defs diff --git a/client/ayon_core/tools/launcher/ui/actions_widget.py b/client/ayon_core/tools/launcher/ui/actions_widget.py index 31b303ca2b..0e763a208a 100644 --- a/client/ayon_core/tools/launcher/ui/actions_widget.py +++ b/client/ayon_core/tools/launcher/ui/actions_widget.py @@ -1,22 +1,12 @@ import time -import uuid import collections from qtpy import QtWidgets, QtCore, QtGui from ayon_core.lib import Logger -from ayon_core.lib.attribute_definitions import ( - UILabelDef, - EnumDef, - TextDef, - BoolDef, - NumberDef, - HiddenDef, -) +from ayon_core.pipeline.actions import webaction_fields_to_attribute_defs from ayon_core.tools.flickcharm import FlickCharm -from ayon_core.tools.utils import ( - get_qt_icon, -) +from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.attribute_defs import AttributeDefinitionsDialog from ayon_core.tools.launcher.abstract import WebactionContext @@ -1173,74 +1163,7 @@ class ActionsWidget(QtWidgets.QWidget): float - 'label', 'value', 'placeholder', 'min', 'max' """ - attr_defs = [] - for config_field in config_fields: - field_type = config_field["type"] - attr_def = None - if field_type == "label": - label = config_field.get("value") - if label is None: - label = config_field.get("text") - attr_def = UILabelDef( - label, key=uuid.uuid4().hex - ) - elif field_type == "boolean": - value = config_field["value"] - if isinstance(value, str): - value = value.lower() == "true" - - attr_def = BoolDef( - config_field["name"], - default=value, - label=config_field.get("label"), - ) - elif field_type == "text": - attr_def = TextDef( - config_field["name"], - default=config_field.get("value"), - label=config_field.get("label"), - placeholder=config_field.get("placeholder"), - multiline=config_field.get("multiline", False), - regex=config_field.get("regex"), - # syntax=config_field["syntax"], - ) - elif field_type in ("integer", "float"): - value = config_field.get("value") - if isinstance(value, str): - if field_type == "integer": - value = int(value) - else: - value = float(value) - attr_def = NumberDef( - config_field["name"], - default=value, - label=config_field.get("label"), - decimals=0 if field_type == "integer" else 5, - # placeholder=config_field.get("placeholder"), - minimum=config_field.get("min"), - maximum=config_field.get("max"), - ) - elif field_type in ("select", "multiselect"): - attr_def = EnumDef( - config_field["name"], - items=config_field["options"], - default=config_field.get("value"), - label=config_field.get("label"), - multiselection=field_type == "multiselect", - ) - elif field_type == "hidden": - attr_def = HiddenDef( - config_field["name"], - default=config_field.get("value"), - ) - - if attr_def is None: - print(f"Unknown config field type: {field_type}") - attr_def = UILabelDef( - f"Unknown field type '{field_type}", - key=uuid.uuid4().hex - ) - attr_defs.append(attr_def) + attr_defs = webaction_fields_to_attribute_defs(config_fields) dialog = AttributeDefinitionsDialog( attr_defs, From 917c4e317cb9c36e1703857660864a2c7ca0e5e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 2 Oct 2025 17:16:14 +0200 Subject: [PATCH 095/386] use ActionForm in delete old versions --- client/ayon_core/plugins/loader/delete_old_versions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index f7f20fefef..d6ddacf146 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -18,12 +18,12 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( + ActionForm, LoaderSelectedType, LoaderActionPlugin, LoaderActionItem, LoaderActionSelection, LoaderActionResult, - LoaderActionForm, ) @@ -160,7 +160,7 @@ class DeleteOldVersions(LoaderActionPlugin): } form_values["step"] = "prepare-data" return LoaderActionResult( - form=LoaderActionForm( + form=ActionForm( title="Delete Old Versions", fields=fields, ), @@ -243,7 +243,7 @@ class DeleteOldVersions(LoaderActionPlugin): return LoaderActionResult( message="Calculated size", success=True, - form=LoaderActionForm( + form=ActionForm( title="Calculated versions size", fields=[ UILabelDef( @@ -341,7 +341,7 @@ class DeleteOldVersions(LoaderActionPlugin): repre_ids_by_version_id: dict[str, list[str]], filepaths_by_repre_id: dict[str, list[str]], repeated: bool = False, - ) -> tuple[LoaderActionForm, dict[str, Any]]: + ) -> tuple[ActionForm, dict[str, Any]]: versions_len = len(repre_ids_by_version_id) fields = [ UILabelDef( @@ -375,7 +375,7 @@ class DeleteOldVersions(LoaderActionPlugin): ) ]) - form = LoaderActionForm( + form = ActionForm( title="Delete versions", submit_label="Delete", cancel_label="Close", From eedd982a84c76169b746a64288e51aea1bf89fa5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:04:07 +0200 Subject: [PATCH 096/386] use first representation in action item collection --- client/ayon_core/plugins/loader/copy_file.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index dd263383e4..2380b465ed 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -39,12 +39,15 @@ class CopyFileActionPlugin(LoaderActionPlugin): repre_ids_by_name[repre["name"]].add(repre["id"]) for repre_name, repre_ids in repre_ids_by_name.items(): + repre_id = next(iter(repre_ids), None) + if not repre_id: + continue output.append( LoaderActionItem( label=repre_name, group_label="Copy file path", data={ - "representation_ids": list(repre_ids), + "representation_id": repre_id, "action": "copy-path", }, icon={ @@ -59,7 +62,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): label=repre_name, group_label="Copy file", data={ - "representation_ids": list(repre_ids), + "representation_id": repre_id, "action": "copy-file", }, icon={ @@ -80,7 +83,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): from qtpy import QtWidgets, QtCore action = data["action"] - repre_id = next(iter(data["representation_ids"])) + repre_id = data["representation_id"] repre = next(iter(selection.entities.get_representations({repre_id}))) path = get_representation_path_with_anatomy( repre, selection.get_project_anatomy() From 6d1d1e01d486c020c4fd5227bb6a23606cd22880 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:04:34 +0200 Subject: [PATCH 097/386] use 'get_selected_version_entities' in delete old versions --- client/ayon_core/plugins/loader/delete_old_versions.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index d6ddacf146..97e9d43628 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -42,12 +42,7 @@ class DeleteOldVersions(LoaderActionPlugin): if self.host_name is not None: return [] - versions = None - if selection.selected_type == LoaderSelectedType.version: - versions = selection.entities.get_versions( - selection.selected_ids - ) - + versions = selection.get_selected_version_entities() if not versions: return [] From d465e4a9b3a97c75e721012609cc904c86bcdfc7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:05:54 +0200 Subject: [PATCH 098/386] rename 'process' to 'execute_simple_action' --- client/ayon_core/pipeline/actions/loader.py | 4 ++-- client/ayon_core/plugins/loader/export_otio.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 13f243bf66..92de9c6cf8 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -823,7 +823,7 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): pass @abstractmethod - def process( + def execute_simple_action( self, selection: LoaderActionSelection, form_values: dict[str, Any], @@ -861,4 +861,4 @@ class LoaderSimpleActionPlugin(LoaderActionPlugin): data: Optional[DataType], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - return self.process(selection, form_values) + return self.execute_simple_action(selection, form_values) diff --git a/client/ayon_core/plugins/loader/export_otio.py b/client/ayon_core/plugins/loader/export_otio.py index f8cdbed0a5..c86a72700e 100644 --- a/client/ayon_core/plugins/loader/export_otio.py +++ b/client/ayon_core/plugins/loader/export_otio.py @@ -56,7 +56,7 @@ class ExportOTIO(LoaderSimpleActionPlugin): return selection.versions_selected() - def process( + def execute_simple_action( self, selection: LoaderActionSelection, form_values: dict[str, Any], From 48cc1719e30c22de00d8c3c6e59850c4e8c1fffe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:07:17 +0200 Subject: [PATCH 099/386] delivery action uses simple action --- client/ayon_core/plugins/loader/delivery.py | 54 +++++++++++---------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index c39b791dbb..1ac1c465dc 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -13,7 +13,7 @@ from ayon_core.lib import ( ) from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( - LoaderActionPlugin, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionItem, LoaderActionResult, @@ -27,15 +27,33 @@ from ayon_core.pipeline.delivery import ( ) -class DeliveryAction(LoaderActionPlugin): +class DeliveryAction(LoaderSimpleActionPlugin): identifier = "core.delivery" + label = "Deliver Versions" + order = 35 + icon = { + "type": "material-symbols", + "name": "upload", + "color": "#d8d8d8", + } - def get_action_items( - self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: + def is_compatible(self, selection: LoaderActionSelection) -> bool: if self.host_name is not None: - return [] + return False + if not selection.selected_ids: + return False + + return ( + selection.versions_selected() + or selection.representations_selected() + ) + + def execute_simple_action( + self, + selection: LoaderActionSelection, + form_values: dict[str, Any], + ) -> Optional[LoaderActionResult]: version_ids = set() if selection.selected_type == "representation": versions = selection.entities.get_representations_versions( @@ -47,31 +65,15 @@ class DeliveryAction(LoaderActionPlugin): version_ids = set(selection.selected_ids) if not version_ids: - return [] - - return [ - LoaderActionItem( - label="Deliver Versions", - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "upload", - "color": "#d8d8d8", - } + return LoaderActionResult( + message="No versions found in your selection", + success=False, ) - ] - def execute_action( - self, - selection: LoaderActionSelection, - data: dict[str, Any], - form_values: dict[str, Any], - ) -> Optional[LoaderActionResult]: try: # TODO run the tool in subprocess dialog = DeliveryOptionsDialog( - selection.project_name, data["version_ids"], self.log + selection.project_name, version_ids, self.log ) dialog.exec_() except Exception: From bc5c162a000fa928be1055156f6fd0ed75eba90a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:07:40 +0200 Subject: [PATCH 100/386] push to project uses simple action --- .../plugins/loader/push_to_project.py | 76 ++++++++----------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/client/ayon_core/plugins/loader/push_to_project.py b/client/ayon_core/plugins/loader/push_to_project.py index 215e63be86..d2ade736fd 100644 --- a/client/ayon_core/plugins/loader/push_to_project.py +++ b/client/ayon_core/plugins/loader/push_to_project.py @@ -5,65 +5,51 @@ from ayon_core import AYON_CORE_ROOT from ayon_core.lib import get_ayon_launcher_args, run_detached_process from ayon_core.pipeline.actions import ( - LoaderActionPlugin, - LoaderActionItem, + LoaderSimpleActionPlugin, LoaderActionSelection, LoaderActionResult, ) -class PushToProject(LoaderActionPlugin): +class PushToProject(LoaderSimpleActionPlugin): identifier = "core.push-to-project" + label = "Push to project" + order = 35 + icon = { + "type": "material-symbols", + "name": "send", + "color": "#d8d8d8", + } - def get_action_items( + def is_compatible( self, selection: LoaderActionSelection - ) -> list[LoaderActionItem]: - folder_ids = set() - version_ids = set() - if selection.selected_type == "version": - version_ids = set(selection.selected_ids) - product_ids = { - product["id"] - for product in selection.entities.get_versions_products( - version_ids - ) - } - folder_ids = { - folder["id"] - for folder in selection.entities.get_products_folders( - product_ids - ) - } + ) -> bool: + if not selection.versions_selected(): + return False - output = [] - if version_ids and len(folder_ids) == 1: - output.append( - LoaderActionItem( - label="Push to project", - order=35, - data={"version_ids": list(version_ids)}, - icon={ - "type": "material-symbols", - "name": "send", - "color": "#d8d8d8", - } - ) + version_ids = set(selection.selected_ids) + product_ids = { + product["id"] + for product in selection.entities.get_versions_products( + version_ids ) - return output + } + folder_ids = { + folder["id"] + for folder in selection.entities.get_products_folders( + product_ids + ) + } - def execute_action( + if len(folder_ids) == 1: + return True + return False + + def execute_simple_action( self, selection: LoaderActionSelection, - data: dict[str, Any], form_values: dict[str, Any], ) -> Optional[LoaderActionResult]: - version_ids = data["version_ids"] - if len(version_ids) > 1: - return LoaderActionResult( - message="Please select only one version", - success=False, - ) - push_tool_script_path = os.path.join( AYON_CORE_ROOT, "tools", @@ -74,7 +60,7 @@ class PushToProject(LoaderActionPlugin): args = get_ayon_launcher_args( push_tool_script_path, "--project", selection.project_name, - "--versions", ",".join(version_ids) + "--versions", ",".join(selection.selected_ids) ) run_detached_process(args) return LoaderActionResult( From d81f6eaa3e7fe4504e0f7684cb0347c8993e8387 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:08:42 +0200 Subject: [PATCH 101/386] remove unused import --- client/ayon_core/plugins/loader/delivery.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/delivery.py b/client/ayon_core/plugins/loader/delivery.py index 1ac1c465dc..5141bb1d3b 100644 --- a/client/ayon_core/plugins/loader/delivery.py +++ b/client/ayon_core/plugins/loader/delivery.py @@ -15,7 +15,6 @@ from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( LoaderSimpleActionPlugin, LoaderActionSelection, - LoaderActionItem, LoaderActionResult, ) from ayon_core.pipeline.load import get_representation_path_with_anatomy From 07650130c601d9cbb5d3370bc5faaff54333bfbd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:11:24 +0200 Subject: [PATCH 102/386] initial support to use folder in product name template --- .../ayon_core/pipeline/create/product_name.py | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ecffa4a340..58cf251f9d 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,14 +1,19 @@ +import warnings + import ayon_api from ayon_core.lib import ( StringTemplate, filter_profiles, prepare_template_data, + Logger, ) from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE from .exceptions import TaskNotSetError, TemplateFillError +log = Logger.get_logger(__name__) + def get_product_name_template( project_name, @@ -81,6 +86,8 @@ def get_product_name( project_settings=None, product_type_filter=None, project_entity=None, + folder_entity=None, + task_entity=None, ): """Calculate product name based on passed context and AYON settings. @@ -98,8 +105,8 @@ def get_product_name( Args: project_name (str): Project name. - task_name (Union[str, None]): Task name. - task_type (Union[str, None]): Task type. + task_name (Union[str, None]): Task name. Deprecated use 'task_entity'. + task_type (Union[str, None]): Task type. Deprecated use 'task_entity'. host_name (str): Host name. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. @@ -115,6 +122,8 @@ def get_product_name( not passed. project_entity (Optional[Dict[str, Any]]): Project entity used when task short name is required by template. + folder_entity (Optional[Dict[str, Any]]): Folder entity. + task_entity (Optional[Dict[str, Any]]): Task entity. Returns: str: Product name. @@ -139,17 +148,36 @@ def get_product_name( ) # Simple check of task name existence for template with {task} in # - missing task should be possible only in Standalone publisher - if not task_name and "{task" in template.lower(): + if task_name and not task_entity: + warnings.warn( + "Used deprecated 'task' argument. Please use" + " 'task_entity' instead.", + DeprecationWarning, + stacklevel=2 + ) + + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template_low = template.lower() + if not task_name and "{task" in template_low: raise TaskNotSetError() task_value = { "name": task_name, "type": task_type, } - if "{task}" in template.lower(): + if "{task}" in template_low: task_value = task_name + # NOTE this is message for TDs and Admins -> not really for users + # TODO validate this in settings and not allow it + log.warning( + "Found deprecated task key '{task}' in product name template." + " Please use '{task[name]}' instead." + ) - elif "{task[short]}" in template.lower(): + elif "{task[short]}" in template_low: if project_entity is None: project_entity = ayon_api.get_project(project_name) task_types_by_name = { @@ -167,6 +195,12 @@ def get_product_name( "type": product_type } } + if folder_entity: + fill_pairs["folder"] = { + "name": folder_entity["name"], + "type": folder_entity["folderType"], + } + if dynamic_data: # Dynamic data may override default values for key, value in dynamic_data.items(): From fc7ca39f39465f5c70be3ac7a3f38b75c2e2967b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:13:19 +0200 Subject: [PATCH 103/386] move comment to correct place --- client/ayon_core/pipeline/create/product_name.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 58cf251f9d..d2d161a789 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -146,8 +146,6 @@ def get_product_name( default_template=default_template, project_settings=project_settings ) - # Simple check of task name existence for template with {task} in - # - missing task should be possible only in Standalone publisher if task_name and not task_entity: warnings.warn( "Used deprecated 'task' argument. Please use" @@ -161,6 +159,7 @@ def get_product_name( task_type = task_entity["taskType"] template_low = template.lower() + # Simple check of task name existence for template with {task[name]} in if not task_name and "{task" in template_low: raise TaskNotSetError() From f7e9f6e7c9f7914d623bdbb874b73f4818f0e7bf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:17:51 +0200 Subject: [PATCH 104/386] use kwargs in default implementation --- client/ayon_core/pipeline/create/creator_plugins.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 7573589b82..56fa431090 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -566,14 +566,16 @@ class BaseCreator(ABC): return get_product_name( project_name, - task_name, - task_type, - host_name, - self.product_type, - variant, + folder_entity=folder_entity, + task_entity=task_entity, + host_name=host_name, + product_type=self.product_type, + variant=variant, dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, + task_name=task_name, + task_type=task_type, ) def get_instance_attr_defs(self): From 14fb34e4b64907a4fc2de6b2c7c5e3ef24c74ea8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:22:18 +0200 Subject: [PATCH 105/386] remove unused import --- client/ayon_core/plugins/loader/delete_old_versions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 97e9d43628..7499650cbe 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -19,7 +19,6 @@ from ayon_core.lib import ( from ayon_core.pipeline import Anatomy from ayon_core.pipeline.actions import ( ActionForm, - LoaderSelectedType, LoaderActionPlugin, LoaderActionItem, LoaderActionSelection, From 348e11f9680bd7c754ac04854c9d162471f48bca Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:40:12 +0200 Subject: [PATCH 106/386] wrap get_product_name function --- .../ayon_core/pipeline/create/product_name.py | 309 +++++++++++++----- 1 file changed, 235 insertions(+), 74 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index d2d161a789..1b22ff4523 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,4 +1,8 @@ +from __future__ import annotations + import warnings +from functools import wraps +from typing import Optional, Any import ayon_api from ayon_core.lib import ( @@ -6,7 +10,9 @@ from ayon_core.lib import ( filter_profiles, prepare_template_data, Logger, + is_func_signature_supported, ) +from ayon_core.lib.path_templates import TemplateResult from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -74,68 +80,27 @@ def get_product_name_template( return template -def get_product_name( - project_name, - task_name, - task_type, - host_name, - product_type, - variant, - default_template=None, - dynamic_data=None, - project_settings=None, - product_type_filter=None, - project_entity=None, - folder_entity=None, - task_entity=None, -): - """Calculate product name based on passed context and AYON settings. - - Subst name templates are defined in `project_settings/global/tools/creator - /product_name_profiles` where are profiles with host name, product type, - task name and task type filters. If context does not match any profile - then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. - - That's main reason why so many arguments are required to calculate product - name. - - Todos: - Find better filtering options to avoid requirement of - argument 'family_filter'. - - Args: - project_name (str): Project name. - task_name (Union[str, None]): Task name. Deprecated use 'task_entity'. - task_type (Union[str, None]): Task type. Deprecated use 'task_entity'. - host_name (str): Host name. - product_type (str): Product type. - variant (str): In most of the cases it is user input during creation. - default_template (Optional[str]): Default template if any profile does - not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' - is used if is not passed. - dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for - a creator which creates instance. - project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings - for project. Settings are queried if not passed. - product_type_filter (Optional[str]): Use different product type for - product template filtering. Value of `product_type` is used when - not passed. - project_entity (Optional[Dict[str, Any]]): Project entity used when - task short name is required by template. - folder_entity (Optional[Dict[str, Any]]): Folder entity. - task_entity (Optional[Dict[str, Any]]): Task entity. - - Returns: - str: Product name. - - Raises: - TaskNotSetError: If template requires task which is not provided. - TemplateFillError: If filled template contains placeholder key which - is not collected. - - """ +def _get_product_name_old( + project_name: str, + task_name: Optional[str], + task_type: Optional[str], + host_name: str, + product_type: str, + variant: str, + default_template: Optional[str] = None, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + product_type_filter: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, +) -> TemplateResult: + warnings.warn( + "Used deprecated 'task_name' and 'task_type' arguments." + " Please use new signature with 'folder_entity' and 'task_entity'.", + DeprecationWarning, + stacklevel=2 + ) if not product_type: - return "" + return StringTemplate("").format({}) template = get_product_name_template( project_name, @@ -146,17 +111,6 @@ def get_product_name( default_template=default_template, project_settings=project_settings ) - if task_name and not task_entity: - warnings.warn( - "Used deprecated 'task' argument. Please use" - " 'task_entity' instead.", - DeprecationWarning, - stacklevel=2 - ) - - if task_entity: - task_name = task_entity["name"] - task_type = task_entity["taskType"] template_low = template.lower() # Simple check of task name existence for template with {task[name]} in @@ -194,6 +148,106 @@ def get_product_name( "type": product_type } } + + if dynamic_data: + # Dynamic data may override default values + for key, value in dynamic_data.items(): + fill_pairs[key] = value + + try: + return StringTemplate.format_strict_template( + template=template, + data=prepare_template_data(fill_pairs) + ) + except KeyError as exp: + raise TemplateFillError( + "Value for {} key is missing in template '{}'." + " Available values are {}".format(str(exp), template, fill_pairs) + ) + + +def _get_product_name( + project_name: str, + folder_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], + host_name: str, + product_type: str, + variant: str, + *, + default_template: Optional[str] = None, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + product_type_filter: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, + # Ignore unused kwargs passed to 'get_product_name' + task_name: Optional[str] = None, + task_type: Optional[str] = None, +) -> TemplateResult: + """Future replacement of 'get_product_name' function.""" + # Future warning when 'task_name' and 'task_type' are deprecated + # if task_name is None: + # warnings.warn( + # "Still using deprecated 'task_name' argument. Please use" + # " 'task_entity' only.", + # DeprecationWarning, + # stacklevel=2 + # ) + + if not product_type: + return StringTemplate("").format({}) + + task_name = task_type = None + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + + template = get_product_name_template( + project_name, + product_type_filter or product_type, + task_name, + task_type, + host_name, + default_template=default_template, + project_settings=project_settings + ) + + template_low = template.lower() + # Simple check of task name existence for template with {task[name]} in + if not task_name and "{task" in template_low: + raise TaskNotSetError() + + task_value = { + "name": task_name, + "type": task_type, + } + if "{task}" in template_low: + task_value = task_name + # NOTE this is message for TDs and Admins -> not really for users + # TODO validate this in settings and not allow it + log.warning( + "Found deprecated task key '{task}' in product name template." + " Please use '{task[name]}' instead." + ) + + elif "{task[short]}" in template_low: + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + task_types_by_name = { + task["name"]: task for task in + project_entity["taskTypes"] + } + task_short = task_types_by_name.get(task_type, {}).get("shortName") + task_value["short"] = task_short + + fill_pairs = { + "variant": variant, + # TODO We should stop support 'family' key. + "family": product_type, + "task": task_value, + "product": { + "type": product_type + } + } if folder_entity: fill_pairs["folder"] = { "name": folder_entity["name"], @@ -212,6 +266,113 @@ def get_product_name( ) except KeyError as exp: raise TemplateFillError( - "Value for {} key is missing in template '{}'." - " Available values are {}".format(str(exp), template, fill_pairs) + f"Value for {exp} key is missing in template '{template}'." + f" Available values are {fill_pairs}" ) + + +def _get_product_name_decorator(func): + """Helper to decide which variant of 'get_product_name' to use. + + The old version expected 'task_name' and 'task_type' arguments. The new + version expects 'folder_entity' and 'task_entity' arguments instead. + """ + @wraps(_get_product_name) + def inner(*args, **kwargs): + # --- + # Decide which variant of the function is used based on + # passed arguments. + # --- + + # Entities in key-word arguments mean that the new function is used + if "folder_entity" in kwargs or "task_entity" in kwargs: + return func(*args, **kwargs) + + # Using more than 6 positional arguments is not allowed + # in the new function + if len(args) > 6: + return func(*args, **kwargs) + + if len(args) > 1: + arg_2 = args[1] + # Second argument is dictionary -> folder entity + if isinstance(arg_2, dict): + return func(*args, **kwargs) + + if is_func_signature_supported(func, *args, **kwargs): + return func(*args, **kwargs) + return _get_product_name_old(*args, **kwargs) + + return inner + + +def get_product_name( + project_name: str, + folder_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], + host_name: str, + product_type: str, + variant: str, + *, + default_template: Optional[str] = None, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + product_type_filter: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, +) -> TemplateResult: + """Calculate product name based on passed context and AYON settings. + + Subst name templates are defined in `project_settings/global/tools/creator + /product_name_profiles` where are profiles with host name, product type, + task name and task type filters. If context does not match any profile + then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate product + name. + + Todos: + Find better filtering options to avoid requirement of + argument 'family_filter'. + + Args: + project_name (str): Project name. + folder_entity (Optional[Dict[str, Any]]): Folder entity. + task_entity (Optional[Dict[str, Any]]): Task entity. + host_name (str): Host name. + product_type (str): Product type. + variant (str): In most of the cases it is user input during creation. + default_template (Optional[str]): Default template if any profile does + not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' + is used if is not passed. + dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for + a creator which creates instance. + project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings + for project. Settings are queried if not passed. + product_type_filter (Optional[str]): Use different product type for + product template filtering. Value of `product_type` is used when + not passed. + project_entity (Optional[Dict[str, Any]]): Project entity used when + task short name is required by template. + + Returns: + TemplateResult: Product name. + + Raises: + TaskNotSetError: If template requires task which is not provided. + TemplateFillError: If filled template contains placeholder key which + is not collected. + + """ + return _get_product_name( + project_name, + folder_entity, + task_entity, + host_name, + product_type, + variant, + default_template=default_template, + dynamic_data=dynamic_data, + project_settings=project_settings, + product_type_filter=product_type_filter, + project_entity=project_entity, + ) From 31b023b0fac2452af6bd3bc78d977d03ec802441 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:47:14 +0200 Subject: [PATCH 107/386] use only new signature --- client/ayon_core/pipeline/create/creator_plugins.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 56fa431090..931b33afd4 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -546,11 +546,6 @@ class BaseCreator(ABC): if host_name is None: host_name = self.create_context.host_name - task_name = task_type = None - if task_entity: - task_name = task_entity["name"] - task_type = task_entity["taskType"] - dynamic_data = self.get_dynamic_data( project_name, folder_entity, @@ -574,8 +569,6 @@ class BaseCreator(ABC): dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, - task_name=task_name, - task_type=task_type, ) def get_instance_attr_defs(self): From 16b45846094c5616f90412d7c4130f3767839d59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:50:57 +0200 Subject: [PATCH 108/386] mark the function with an attribute to know if entities are expected in arguments --- client/ayon_core/pipeline/create/product_name.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 1b22ff4523..f4ec4199d5 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -277,6 +277,11 @@ def _get_product_name_decorator(func): The old version expected 'task_name' and 'task_type' arguments. The new version expects 'folder_entity' and 'task_entity' arguments instead. """ + # Add attribute to function to identify it as the new function + # so other addons can easily identify it. + # >>> geattr(get_product_name, "use_entities", False) + func.use_entities = True + @wraps(_get_product_name) def inner(*args, **kwargs): # --- From a35b179ed1122f435bb5c83b509c0f957f2a4bcf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:59:46 +0200 Subject: [PATCH 109/386] remove the private variant of the function --- .../ayon_core/pipeline/create/product_name.py | 209 +++++++----------- 1 file changed, 84 insertions(+), 125 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f4ec4199d5..687d152e89 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -166,7 +166,48 @@ def _get_product_name_old( ) -def _get_product_name( +def _get_product_name_decorator(func): + """Helper to decide which variant of 'get_product_name' to use. + + The old version expected 'task_name' and 'task_type' arguments. The new + version expects 'folder_entity' and 'task_entity' arguments instead. + """ + # Add attribute to function to identify it as the new function + # so other addons can easily identify it. + # >>> geattr(get_product_name, "use_entities", False) + func.use_entities = True + + @wraps(func) + def inner(*args, **kwargs): + # --- + # Decide which variant of the function is used based on + # passed arguments. + # --- + + # Entities in key-word arguments mean that the new function is used + if "folder_entity" in kwargs or "task_entity" in kwargs: + return func(*args, **kwargs) + + # Using more than 6 positional arguments is not allowed + # in the new function + if len(args) > 6: + return _get_product_name_old(*args, **kwargs) + + if len(args) > 1: + arg_2 = args[1] + # The second argument is a string -> task name + if isinstance(arg_2, str): + return _get_product_name_old(*args, **kwargs) + + if is_func_signature_supported(func, *args, **kwargs): + return func(*args, **kwargs) + return _get_product_name_old(*args, **kwargs) + + return inner + + +@_get_product_name_decorator +def get_product_name( project_name: str, folder_entity: dict[str, Any], task_entity: Optional[dict[str, Any]], @@ -179,20 +220,50 @@ def _get_product_name( project_settings: Optional[dict[str, Any]] = None, product_type_filter: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, - # Ignore unused kwargs passed to 'get_product_name' - task_name: Optional[str] = None, - task_type: Optional[str] = None, ) -> TemplateResult: - """Future replacement of 'get_product_name' function.""" - # Future warning when 'task_name' and 'task_type' are deprecated - # if task_name is None: - # warnings.warn( - # "Still using deprecated 'task_name' argument. Please use" - # " 'task_entity' only.", - # DeprecationWarning, - # stacklevel=2 - # ) + """Calculate product name based on passed context and AYON settings. + Subst name templates are defined in `project_settings/global/tools/creator + /product_name_profiles` where are profiles with host name, product type, + task name and task type filters. If context does not match any profile + then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate product + name. + + Todos: + Find better filtering options to avoid requirement of + argument 'family_filter'. + + Args: + project_name (str): Project name. + folder_entity (Optional[Dict[str, Any]]): Folder entity. + task_entity (Optional[Dict[str, Any]]): Task entity. + host_name (str): Host name. + product_type (str): Product type. + variant (str): In most of the cases it is user input during creation. + default_template (Optional[str]): Default template if any profile does + not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' + is used if is not passed. + dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for + a creator which creates instance. + project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings + for project. Settings are queried if not passed. + product_type_filter (Optional[str]): Use different product type for + product template filtering. Value of `product_type` is used when + not passed. + project_entity (Optional[Dict[str, Any]]): Project entity used when + task short name is required by template. + + Returns: + TemplateResult: Product name. + + Raises: + TaskNotSetError: If template requires task which is not provided. + TemplateFillError: If filled template contains placeholder key which + is not collected. + + """ if not product_type: return StringTemplate("").format({}) @@ -269,115 +340,3 @@ def _get_product_name( f"Value for {exp} key is missing in template '{template}'." f" Available values are {fill_pairs}" ) - - -def _get_product_name_decorator(func): - """Helper to decide which variant of 'get_product_name' to use. - - The old version expected 'task_name' and 'task_type' arguments. The new - version expects 'folder_entity' and 'task_entity' arguments instead. - """ - # Add attribute to function to identify it as the new function - # so other addons can easily identify it. - # >>> geattr(get_product_name, "use_entities", False) - func.use_entities = True - - @wraps(_get_product_name) - def inner(*args, **kwargs): - # --- - # Decide which variant of the function is used based on - # passed arguments. - # --- - - # Entities in key-word arguments mean that the new function is used - if "folder_entity" in kwargs or "task_entity" in kwargs: - return func(*args, **kwargs) - - # Using more than 6 positional arguments is not allowed - # in the new function - if len(args) > 6: - return func(*args, **kwargs) - - if len(args) > 1: - arg_2 = args[1] - # Second argument is dictionary -> folder entity - if isinstance(arg_2, dict): - return func(*args, **kwargs) - - if is_func_signature_supported(func, *args, **kwargs): - return func(*args, **kwargs) - return _get_product_name_old(*args, **kwargs) - - return inner - - -def get_product_name( - project_name: str, - folder_entity: dict[str, Any], - task_entity: Optional[dict[str, Any]], - host_name: str, - product_type: str, - variant: str, - *, - default_template: Optional[str] = None, - dynamic_data: Optional[dict[str, Any]] = None, - project_settings: Optional[dict[str, Any]] = None, - product_type_filter: Optional[str] = None, - project_entity: Optional[dict[str, Any]] = None, -) -> TemplateResult: - """Calculate product name based on passed context and AYON settings. - - Subst name templates are defined in `project_settings/global/tools/creator - /product_name_profiles` where are profiles with host name, product type, - task name and task type filters. If context does not match any profile - then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. - - That's main reason why so many arguments are required to calculate product - name. - - Todos: - Find better filtering options to avoid requirement of - argument 'family_filter'. - - Args: - project_name (str): Project name. - folder_entity (Optional[Dict[str, Any]]): Folder entity. - task_entity (Optional[Dict[str, Any]]): Task entity. - host_name (str): Host name. - product_type (str): Product type. - variant (str): In most of the cases it is user input during creation. - default_template (Optional[str]): Default template if any profile does - not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' - is used if is not passed. - dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for - a creator which creates instance. - project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings - for project. Settings are queried if not passed. - product_type_filter (Optional[str]): Use different product type for - product template filtering. Value of `product_type` is used when - not passed. - project_entity (Optional[Dict[str, Any]]): Project entity used when - task short name is required by template. - - Returns: - TemplateResult: Product name. - - Raises: - TaskNotSetError: If template requires task which is not provided. - TemplateFillError: If filled template contains placeholder key which - is not collected. - - """ - return _get_product_name( - project_name, - folder_entity, - task_entity, - host_name, - product_type, - variant, - default_template=default_template, - dynamic_data=dynamic_data, - project_settings=project_settings, - product_type_filter=product_type_filter, - project_entity=project_entity, - ) From 5fd5b73e913eb45b2810b3ba7d63531d20758362 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:05:19 +0200 Subject: [PATCH 110/386] fix type hints --- client/ayon_core/pipeline/create/product_name.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 687d152e89..ede3141537 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -39,10 +39,10 @@ def get_product_name_template( host_name (str): Name of host in which the product name is calculated. task_name (str): Name of task in which context the product is created. task_type (str): Type of task in which context the product is created. - default_template (Union[str, None]): Default template which is used if + default_template (Optional[str]): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. - project_settings (Union[Dict[str, Any], None]): Prepared settings for + project_settings (Optional[dict[str, Any]]): Prepared settings for project. Settings are queried if not passed. """ @@ -237,22 +237,22 @@ def get_product_name( Args: project_name (str): Project name. - folder_entity (Optional[Dict[str, Any]]): Folder entity. - task_entity (Optional[Dict[str, Any]]): Task entity. + folder_entity (Optional[dict[str, Any]]): Folder entity. + task_entity (Optional[dict[str, Any]]): Task entity. host_name (str): Host name. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. default_template (Optional[str]): Default template if any profile does not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if is not passed. - dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for + dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for a creator which creates instance. - project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings + project_settings (Optional[dict[str, Any]]): Prepared settings for project. Settings are queried if not passed. product_type_filter (Optional[str]): Use different product type for product template filtering. Value of `product_type` is used when not passed. - project_entity (Optional[Dict[str, Any]]): Project entity used when + project_entity (Optional[dict[str, Any]]): Project entity used when task short name is required by template. Returns: From e59975fe95eebd0db025145a43c2ca46f9cce4e0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 3 Oct 2025 17:10:17 +0200 Subject: [PATCH 111/386] add docstring --- client/ayon_core/pipeline/actions/utils.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/actions/utils.py b/client/ayon_core/pipeline/actions/utils.py index 00a8e91d68..3502300ead 100644 --- a/client/ayon_core/pipeline/actions/utils.py +++ b/client/ayon_core/pipeline/actions/utils.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import uuid +from typing import Any from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -11,7 +14,21 @@ from ayon_core.lib.attribute_definitions import ( ) -def webaction_fields_to_attribute_defs(fields) -> list[AbstractAttrDef]: +def webaction_fields_to_attribute_defs( + fields: list[dict[str, Any]] +) -> list[AbstractAttrDef]: + """Helper function to convert fields definition from webactions form. + + Convert form fields to attribute definitions to be able to display them + using attribute definitions. + + Args: + fields (list[dict[str, Any]]): Fields from webaction form. + + Returns: + list[AbstractAttrDef]: Converted attribute definitions. + + """ attr_defs = [] for field in fields: field_type = field["type"] From 862049d995087d163ac02cb2c2538dcc53dffe38 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 13:00:09 +0300 Subject: [PATCH 112/386] Refactor color conversion logic in transcoding.py --- client/ayon_core/lib/transcoding.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 127bd3bac4..12a64c7e06 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1170,6 +1170,14 @@ def oiio_color_convert( # Handle the different conversion cases # Source view and display are known if source_view and source_display: + color_convert_args = None + ocio_display_args = None + oiio_cmd.extend([ + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view + ]) + if target_colorspace: # This is a two-step conversion process since there's no direct # display/view to colorspace command @@ -1179,22 +1187,28 @@ def oiio_color_convert( elif source_display != target_display or source_view != target_view: # Complete display/view pair conversion # - go through a reference space - color_convert_args = (target_display, target_view) + ocio_display_args = (target_display, target_view) else: color_convert_args = None + ocio_display_args = None logger.debug( "Source and target display/view pairs are identical." " No color conversion needed." ) + if color_convert_args: + # Use colorconvert for colorspace target oiio_cmd.extend([ - "--ociodisplay:inverse=1:subimages=0", - source_display, - source_view, "--colorconvert:subimages=0", *color_convert_args ]) + elif ocio_display_args: + # Use ociodisplay for display/view target + oiio_cmd.extend([ + "--ociodisplay:subimages=0", + *ocio_display_args + ]) elif target_colorspace: # Standard color space to color space conversion From 0db3f67eb3bdb04b84b8818d52971f251af1cc8e Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:06:51 +0300 Subject: [PATCH 113/386] Remove unnecessary blank lines in transcoding.py --- client/ayon_core/lib/transcoding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 12a64c7e06..9216c88ed2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1196,7 +1196,6 @@ def oiio_color_convert( " No color conversion needed." ) - if color_convert_args: # Use colorconvert for colorspace target oiio_cmd.extend([ @@ -1232,7 +1231,6 @@ def oiio_color_convert( logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) - def split_cmd_args(in_args): """Makes sure all entered arguments are separated in individual items. From 7ef330c3f4fb0e59af9d2a58a2470a2230bd23c2 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:55:12 +0300 Subject: [PATCH 114/386] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 9216c88ed2..fcf7fdece2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1173,10 +1173,10 @@ def oiio_color_convert( color_convert_args = None ocio_display_args = None oiio_cmd.extend([ - "--ociodisplay:inverse=1:subimages=0", - source_display, - source_view - ]) + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view, + ]) if target_colorspace: # This is a two-step conversion process since there's no direct From 2541f8909e6625e710e376e4dc4c10a21a7db082 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:55:18 +0300 Subject: [PATCH 115/386] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index fcf7fdece2..70a8f26cf2 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1189,8 +1189,6 @@ def oiio_color_convert( # - go through a reference space ocio_display_args = (target_display, target_view) else: - color_convert_args = None - ocio_display_args = None logger.debug( "Source and target display/view pairs are identical." " No color conversion needed." From aabd9f7f505ddd2972bf2fa9afd5d28318b36299 Mon Sep 17 00:00:00 2001 From: timsergeeff <38128238+timsergeeff@users.noreply.github.com> Date: Fri, 10 Oct 2025 21:55:23 +0300 Subject: [PATCH 116/386] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 70a8f26cf2..37fcb59ab3 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1229,6 +1229,7 @@ def oiio_color_convert( logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) + def split_cmd_args(in_args): """Makes sure all entered arguments are separated in individual items. From f147d28c528f67635c9aafe1d03fa7f8a51cbafd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Oct 2025 17:36:47 +0200 Subject: [PATCH 117/386] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20add=20tests=20for?= =?UTF-8?q?=20product=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/create/test_product_name.py | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 tests/client/ayon_core/pipeline/create/test_product_name.py diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py new file mode 100644 index 0000000000..b4507e39f1 --- /dev/null +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -0,0 +1,372 @@ +"""Tests for product_name helpers.""" +import pytest +from unittest.mock import patch + +from ayon_core.pipeline.create.product_name import ( + get_product_name_template, + get_product_name, +) +from ayon_core.pipeline.create.constants import DEFAULT_PRODUCT_TEMPLATE +from ayon_core.pipeline.create.exceptions import ( + TaskNotSetError, + TemplateFillError, +) + + +class TestGetProductNameTemplate: + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_matching_profile_with_replacements( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + """Matching profile applies legacy replacement tokens.""" + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + # The function should replace {task}/{family}/{asset} variants + mock_filter_profiles.return_value = { + "template": ("{task}-{Task}-{TASK}-{family}-{Family}" + "-{FAMILY}-{asset}-{Asset}-{ASSET}") + } + mock_is_supported.return_value = False + + result = get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + ) + assert result == ( + "{task[name]}-{Task[name]}-{TASK[NAME]}-" + "{product[type]}-{Product[type]}-{PRODUCT[TYPE]}-" + "{folder[name]}-{Folder[name]}-{FOLDER[NAME]}" + ) + + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_no_matching_profile_uses_default( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = False + + assert ( + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + ) + == DEFAULT_PRODUCT_TEMPLATE + ) + + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_custom_default_template_used( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = False + + custom_default = "{variant}_{family}" + assert ( + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + default_template=custom_default, + ) + == custom_default + ) + + @patch("ayon_core.pipeline.create.product_name.warn") + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_product_base_type_warns_when_supported_and_missing( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + mock_warn, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = True + + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + ) + mock_warn.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_project_settings") + @patch("ayon_core.pipeline.create.product_name.filter_profiles") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + def test_product_base_type_added_to_filtering_when_provided( + self, + mock_is_supported, + mock_filter_profiles, + mock_get_settings, + ): + mock_get_settings.return_value = { + "core": {"tools": {"creator": {"product_name_profiles": []}}} + } + mock_filter_profiles.return_value = None + mock_is_supported.return_value = True + + get_product_name_template( + project_name="proj", + product_type="model", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_base_type="asset", + ) + args, kwargs = mock_filter_profiles.call_args + # args[1] is filtering_criteria + assert args[1]["product_base_types"] == "asset" + + +class TestGetProductName: + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_empty_product_type_returns_empty( + self, mock_prepare, mock_format, mock_get_tmpl + ): + assert ( + get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="", + variant="Main", + ) + == "" + ) + mock_get_tmpl.assert_not_called() + mock_format.assert_not_called() + mock_prepare.assert_not_called() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_happy_path( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{task[name]}_{product[type]}_{variant}" + mock_prepare.return_value = { + "task": {"name": "modeling"}, + "product": {"type": "model"}, + "variant": "Main", + "family": "model", + } + mock_format.return_value = "modeling_model_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + assert result == "modeling_model_Main" + mock_get_tmpl.assert_called_once() + mock_prepare.assert_called_once() + mock_format.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_product_name_with_base_type( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{task[name]}_{product[basetype]}_{variant}" + mock_prepare.return_value = { + "task": {"name": "modeling"}, + "product": {"type": "model"}, + "variant": "Main", + "family": "model", + } + mock_format.return_value = "modeling_modelBase_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + product_base_type="modelBase", + variant="Main", + ) + assert result == "modeling_modelBase_Main" + mock_get_tmpl.assert_called_once() + mock_prepare.assert_called_once() + mock_format.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + def test_task_required_but_missing_raises(self, mock_get_tmpl): + mock_get_tmpl.return_value = "{task[name]}_{variant}" + with pytest.raises(TaskNotSetError): + get_product_name( + project_name="proj", + task_name="", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name.ayon_api.get_project") + @patch("ayon_core.pipeline.create.product_name.StringTemplate." + "format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_task_short_name_is_used( + self, mock_prepare, mock_format, mock_get_project, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{task[short]}_{variant}" + mock_get_project.return_value = { + "taskTypes": [{"name": "Modeling", "shortName": "mdl"}] + } + mock_prepare.return_value = {"task": {"short": "mdl"}, "variant": "Main"} + mock_format.return_value = "mdl_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + assert result == "mdl_Main" + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name.StringTemplate." + "format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_template_fill_error_translated( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{missing_key}_{variant}" + mock_prepare.return_value = {"variant": "Main"} + mock_format.side_effect = KeyError("missing_key") + with pytest.raises(TemplateFillError): + get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + + @patch("ayon_core.pipeline.create.product_name.warn") + @patch("ayon_core.pipeline.create.product_name." + "is_product_base_type_supported") + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_warns_when_template_needs_base_type_but_missing( + self, + mock_prepare, + mock_format, + mock_get_tmpl, + mock_is_supported, + mock_warn, + ): + mock_get_tmpl.return_value = "{product[basetype]}_{variant}" + mock_is_supported.return_value = True + mock_prepare.return_value = { + "product": {"type": "model"}, + "variant": "Main", + "family": "model", + } + mock_format.return_value = "asset_Main" + + _ = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + ) + mock_warn.assert_called_once() + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + @patch("ayon_core.pipeline.create.product_name." + "StringTemplate.format_strict_template") + @patch("ayon_core.pipeline.create.product_name.prepare_template_data") + def test_dynamic_data_overrides_defaults( + self, mock_prepare, mock_format, mock_get_tmpl + ): + mock_get_tmpl.return_value = "{custom}_{variant}" + mock_prepare.return_value = {"custom": "overridden", "variant": "Main"} + mock_format.return_value = "overridden_Main" + + result = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + dynamic_data={"custom": "overridden"}, + ) + assert result == "overridden_Main" + + @patch("ayon_core.pipeline.create.product_name.get_product_name_template") + def test_product_type_filter_is_used(self, mock_get_tmpl): + mock_get_tmpl.return_value = DEFAULT_PRODUCT_TEMPLATE + _ = get_product_name( + project_name="proj", + task_name="modeling", + task_type="Modeling", + host_name="maya", + product_type="model", + variant="Main", + product_type_filter="look", + ) + args, kwargs = mock_get_tmpl.call_args + assert kwargs["product_type"] == "look" From 0ca2d25ef651775b7f27b4d6cacc18a40c41bdb1 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Oct 2025 17:41:50 +0200 Subject: [PATCH 118/386] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20fix=20linting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ayon_core/pipeline/create/test_product_name.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py index b4507e39f1..a8a8566aa8 100644 --- a/tests/client/ayon_core/pipeline/create/test_product_name.py +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -219,7 +219,9 @@ class TestGetProductName: def test_product_name_with_base_type( self, mock_prepare, mock_format, mock_get_tmpl ): - mock_get_tmpl.return_value = "{task[name]}_{product[basetype]}_{variant}" + mock_get_tmpl.return_value = ( + "{task[name]}_{product[basetype]}_{variant}" + ) mock_prepare.return_value = { "task": {"name": "modeling"}, "product": {"type": "model"}, @@ -267,7 +269,12 @@ class TestGetProductName: mock_get_project.return_value = { "taskTypes": [{"name": "Modeling", "shortName": "mdl"}] } - mock_prepare.return_value = {"task": {"short": "mdl"}, "variant": "Main"} + mock_prepare.return_value = { + "task": { + "short": "mdl" + }, + "variant": "Main" + } mock_format.return_value = "mdl_Main" result = get_product_name( From 882c0bcc6aed066026e67bfe1b4c211038c08576 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:58:26 +0200 Subject: [PATCH 119/386] rename decorator and add more information to the example --- client/ayon_core/pipeline/create/product_name.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ede3141537..45b77d1a95 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -166,11 +166,21 @@ def _get_product_name_old( ) -def _get_product_name_decorator(func): +def _backwards_compatibility_product_name(func): """Helper to decide which variant of 'get_product_name' to use. The old version expected 'task_name' and 'task_type' arguments. The new version expects 'folder_entity' and 'task_entity' arguments instead. + + 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_product_name, "use_entities", None): + >>> # New signature is used + >>> path = get_product_name(project_name, folder_entity, ...) + >>> else: + >>> # Old signature is used + >>> path = get_product_name(project_name, taks_name, ...) """ # Add attribute to function to identify it as the new function # so other addons can easily identify it. @@ -206,7 +216,7 @@ def _get_product_name_decorator(func): return inner -@_get_product_name_decorator +@_backwards_compatibility_product_name def get_product_name( project_name: str, folder_entity: dict[str, Any], From d7433f84d796abb04d0a0aed5fa3eee134ecaf02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:58:34 +0200 Subject: [PATCH 120/386] use setattr --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 45b77d1a95..ee07f939bc 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -185,7 +185,7 @@ def _backwards_compatibility_product_name(func): # Add attribute to function to identify it as the new function # so other addons can easily identify it. # >>> geattr(get_product_name, "use_entities", False) - func.use_entities = True + setattr(func, "use_entities", True) @wraps(func) def inner(*args, **kwargs): From 2fe89c4b4619d01b53f0bcdbc5ef2c20b5d05c5c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 3 Nov 2025 22:09:25 +0800 Subject: [PATCH 121/386] add substance painter as host and adjust some instance data so that it can be used to review for image product --- client/ayon_core/plugins/publish/extract_review.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 580aa27eef..665d031d5a 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -163,7 +163,8 @@ class ExtractReview(pyblish.api.InstancePlugin): "flame", "unreal", "batchdelivery", - "photoshop" + "photoshop", + "substancepainter", ] settings_category = "core" @@ -571,7 +572,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # review output files "timecode": frame_to_timecode( frame=temp_data.frame_start_handle, - fps=float(instance.data["fps"]) + fps=float(instance.data.get("fps", 25.0)) ) }) @@ -664,8 +665,8 @@ class ExtractReview(pyblish.api.InstancePlugin): with values may be added. """ - frame_start = instance.data["frameStart"] - frame_end = instance.data["frameEnd"] + frame_start = instance.data.get("frameStart", 1) + frame_end = instance.data.get("frameEnd", 1) # Try to get handles from instance handle_start = instance.data.get("handleStart") @@ -725,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"])[1].replace(".", "") return TempData( - fps=float(instance.data["fps"]), + fps=float(instance.data.get("fps", 25.0)), frame_start=frame_start, frame_end=frame_end, handle_start=handle_start, From cfed4afaaf7e22741b463fe77fb2eb3affaf7156 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 4 Nov 2025 15:30:33 +0800 Subject: [PATCH 122/386] use the frame range from context data if it cannot find one --- client/ayon_core/plugins/publish/extract_review.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 665d031d5a..e519a4a97d 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -572,7 +572,9 @@ class ExtractReview(pyblish.api.InstancePlugin): # review output files "timecode": frame_to_timecode( frame=temp_data.frame_start_handle, - fps=float(instance.data.get("fps", 25.0)) + fps=float(instance.data.get( + "fps", instance.context.data["fps"] + )) ) }) @@ -665,8 +667,12 @@ class ExtractReview(pyblish.api.InstancePlugin): with values may be added. """ - frame_start = instance.data.get("frameStart", 1) - frame_end = instance.data.get("frameEnd", 1) + frame_start = instance.data.get( + "frameStart", instance.context.data["frameStart"] + ) + frame_end = instance.data.get( + "frameEnd", instance.context.data["frameEnd"] + ) # Try to get handles from instance handle_start = instance.data.get("handleStart") @@ -726,7 +732,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"])[1].replace(".", "") return TempData( - fps=float(instance.data.get("fps", 25.0)), + fps=float(instance.data.get("fps", instance.context.data["fps"])), frame_start=frame_start, frame_end=frame_end, handle_start=handle_start, From 5ab274aa503cecc07a7289175b5c61f755c0aede Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 4 Nov 2025 17:47:49 +0800 Subject: [PATCH 123/386] restore the instance data and adjust them into textureset collector in substance instead --- client/ayon_core/plugins/publish/extract_review.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index e519a4a97d..3f2a0dcd3e 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -572,9 +572,7 @@ class ExtractReview(pyblish.api.InstancePlugin): # review output files "timecode": frame_to_timecode( frame=temp_data.frame_start_handle, - fps=float(instance.data.get( - "fps", instance.context.data["fps"] - )) + fps=float(instance.data["fps"]) ) }) @@ -667,12 +665,8 @@ class ExtractReview(pyblish.api.InstancePlugin): with values may be added. """ - frame_start = instance.data.get( - "frameStart", instance.context.data["frameStart"] - ) - frame_end = instance.data.get( - "frameEnd", instance.context.data["frameEnd"] - ) + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] # Try to get handles from instance handle_start = instance.data.get("handleStart") @@ -732,7 +726,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ext = os.path.splitext(repre["files"])[1].replace(".", "") return TempData( - fps=float(instance.data.get("fps", instance.context.data["fps"])), + fps=float(instance.data["fps"]), frame_start=frame_start, frame_end=frame_end, handle_start=handle_start, From c0fd2aa8c57b5ef17cfe38245f74f0b889243e51 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 4 Nov 2025 18:10:52 +0800 Subject: [PATCH 124/386] add additional default settings into ExtractReview for substance painter --- server/settings/publish_plugins.py | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..311b4672cf 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1448,6 +1448,105 @@ DEFAULT_PUBLISH_VALUES = { "fill_missing_frames": "closest_existing" } ] + }, + { + "product_types": [], + "hosts": ["substancepainter"], + "task_types": [], + "outputs": [ + { + "name": "png", + "ext": "png", + "tags": [ + "ftrackreview", + "kitsureview", + "webreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [], + "output": [] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "product_names": [], + "custom_tags": [], + "single_frame_filter": "single_frame" + }, + "overscan_crop": "", + # "overscan_color": [0, 0, 0], + "overscan_color": [0, 0, 0, 0.0], + "width": 1920, + "height": 1080, + "scale_pixel_aspect": True, + "bg_color": [0, 0, 0, 0.0], + "letter_box": { + "enabled": False, + "ratio": 0.0, + "fill_color": [0, 0, 0, 1.0], + "line_thickness": 0, + "line_color": [255, 0, 0, 1.0] + }, + "fill_missing_frames": "only_rendered" + }, + { + "name": "h264", + "ext": "mp4", + "tags": [ + "burnin", + "ftrackreview", + "kitsureview", + "webreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [ + "-apply_trc gamma22" + ], + "output": [ + "-pix_fmt yuv420p", + "-crf 18", + "-c:a aac", + "-b:a 192k", + "-g 1", + "-movflags faststart" + ] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "product_names": [], + "custom_tags": [], + "single_frame_filter": "multi_frame" + }, + "overscan_crop": "", + # "overscan_color": [0, 0, 0], + "overscan_color": [0, 0, 0, 0.0], + "width": 0, + "height": 0, + "scale_pixel_aspect": True, + "bg_color": [0, 0, 0, 0.0], + "letter_box": { + "enabled": False, + "ratio": 0.0, + "fill_color": [0, 0, 0, 1.0], + "line_thickness": 0, + "line_color": [255, 0, 0, 1.0] + }, + "fill_missing_frames": "only_rendered" + } + ] } ] }, From 67d5422c94584a100a1d3818137c4e16e06465ab Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 8 Nov 2025 22:45:42 +0100 Subject: [PATCH 125/386] If folder name starts with a digit we now prefix it with `_` to avoid invalid USD data to be authored. --- client/ayon_core/pipeline/usdlib.py | 17 +++++++++++++++++ .../publish/extract_usd_layer_contributions.py | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/usdlib.py b/client/ayon_core/pipeline/usdlib.py index 2ff98c5e45..095f6fdc57 100644 --- a/client/ayon_core/pipeline/usdlib.py +++ b/client/ayon_core/pipeline/usdlib.py @@ -684,3 +684,20 @@ def get_sdf_format_args(path): """Return SDF_FORMAT_ARGS parsed to `dict`""" _raw_path, data = Sdf.Layer.SplitIdentifier(path) return data + + +def get_standard_default_prim_name(folder_path: str) -> str: + """Return the AYON-specified default prim name for a folder path. + + This is used e.g. for the default prim in AYON USD Contribution workflows. + """ + folder_name: str = folder_path.rsplit("/", 1)[-1] + + # Prim names are not allowed to start with a digit in USD. Authoring them + # would mean generating essentially garbage data and may result in + # unexpected behavior in certain USD or DCC versions, like failure to + # refresh in usdview or crashes in Houdini 21. + if folder_name and folder_name[0].isdigit(): + folder_name = f"_{folder_name}" + + return folder_name 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 9db8c49a02..c73d1aa447 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -25,7 +25,8 @@ try: variant_nested_prim_path, setup_asset_layer, add_ordered_sublayer, - set_layer_defaults + set_layer_defaults, + get_standard_default_prim_name ) except ImportError: pass @@ -652,7 +653,7 @@ class ExtractUSDLayerContribution(publish.Extractor): sdf_layer = Sdf.Layer.OpenAsAnonymous(path) default_prim = sdf_layer.defaultPrim else: - default_prim = folder_path.rsplit("/", 1)[-1] # use folder name + default_prim = get_standard_default_prim_name(folder_path) sdf_layer = Sdf.Layer.CreateAnonymous() set_layer_defaults(sdf_layer, default_prim=default_prim) From 113d01ce9952762a7dafca4960f80d899bdbc395 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 8 Nov 2025 22:54:24 +0100 Subject: [PATCH 126/386] Add setting to always enforce the default prim value --- .../extract_usd_layer_contributions.py | 10 +++++++++ server/settings/publish_plugins.py | 22 ++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) 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 c73d1aa447..ff1c4fec8a 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -641,6 +641,7 @@ class ExtractUSDLayerContribution(publish.Extractor): settings_category = "core" use_ayon_entity_uri = False + enforce_default_prim = False def process(self, instance): @@ -651,6 +652,15 @@ class ExtractUSDLayerContribution(publish.Extractor): path = get_last_publish(instance) if path and BUILD_INTO_LAST_VERSIONS: sdf_layer = Sdf.Layer.OpenAsAnonymous(path) + + # If enabled in settings, ignore any default prim specified on + # older publish versions and always publish with the AYON + # standard default prim + if self.enforce_default_prim: + sdf_layer.defaultPrim = get_standard_default_prim_name( + folder_path + ) + default_prim = sdf_layer.defaultPrim else: default_prim = get_standard_default_prim_name(folder_path) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..60bd933344 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -251,6 +251,19 @@ class AyonEntityURIModel(BaseSettingsModel): ) +class ExtractUSDLayerContributionModel(AyonEntityURIModel): + enforce_default_prim: bool = SettingsField( + title="Always set default prim to folder name.", + description=( + "When enabled ignore any default prim specified on older " + "published versions of a layer and always override it to the " + "AYON standard default prim. When disabled, preserve default prim " + "on the layer and then only the initial version would be setting " + "the AYON standard default prim." + ) + ) + + class PluginStateByHostModelProfile(BaseSettingsModel): _layout = "expanded" # Filtering @@ -1134,9 +1147,11 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=AyonEntityURIModel, title="Extract USD Asset Contribution", ) - ExtractUSDLayerContribution: AyonEntityURIModel = SettingsField( - default_factory=AyonEntityURIModel, - title="Extract USD Layer Contribution", + ExtractUSDLayerContribution: ExtractUSDLayerContributionModel = ( + SettingsField( + default_factory=ExtractUSDLayerContributionModel, + title="Extract USD Layer Contribution", + ) ) PreIntegrateThumbnails: PreIntegrateThumbnailsModel = SettingsField( default_factory=PreIntegrateThumbnailsModel, @@ -1526,6 +1541,7 @@ DEFAULT_PUBLISH_VALUES = { }, "ExtractUSDLayerContribution": { "use_ayon_entity_uri": False, + "enforce_default_prim": False, }, "PreIntegrateThumbnails": { "enabled": True, From e7896c66f3b513ae1f024259943ad4cf3f60fb09 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 9 Nov 2025 11:33:33 +0100 Subject: [PATCH 127/386] Update server/settings/publish_plugins.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 60bd933344..e939a6518b 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -256,7 +256,7 @@ class ExtractUSDLayerContributionModel(AyonEntityURIModel): title="Always set default prim to folder name.", description=( "When enabled ignore any default prim specified on older " - "published versions of a layer and always override it to the " + "published versions of a layer and always override it to the " "AYON standard default prim. When disabled, preserve default prim " "on the layer and then only the initial version would be setting " "the AYON standard default prim." From 0dfaed53cba2a76f2463bc2fbf6c5ef585dd6dfa Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 12:43:00 +0100 Subject: [PATCH 128/386] Fix setting display/view based on collected scene display/view if left empty in ExtractOIIOTranscode settings. `instance.data["sceneDisplay"]` and `instance.data["sceneView"]` are now intended to be set to describe the user's configured display/view inside the DCC and can still be used as fallback for the `ExtractOIIOTrancode` transcoding. For the time being the legacy `colorspaceDisplay` and `colorspaceView` instance.data keys will act as fallback for backwards compatibility to represent the scene display and view. Also see: https://github.com/ynput/ayon-core/issues/1430#issuecomment-3516459205 --- .../pipeline/farm/pyblish_functions.py | 4 +- .../publish/extract_color_transcode.py | 44 +++++++++---------- server/settings/publish_plugins.py | 4 +- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 2193e96cb1..45c9eb22dc 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -594,8 +594,8 @@ def create_instances_for_aov( additional_color_data = { "renderProducts": instance.data["renderProducts"], "colorspaceConfig": instance.data["colorspaceConfig"], - "display": instance.data["colorspaceDisplay"], - "view": instance.data["colorspaceView"] + "display": instance.data.get("sourceDisplay"), + "view": instance.data.get("sourceView") } # Get templated path from absolute config path. diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 1a2c85e597..b293bd29c3 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -87,15 +87,19 @@ class ExtractOIIOTranscode(publish.Extractor): profile_output_defs = profile["outputs"] new_representations = [] repres = instance.data["representations"] - for idx, repre in enumerate(list(repres)): - # target space, display and view might be defined upstream - # TODO: address https://github.com/ynput/ayon-core/pull/1268#discussion_r2156555474 - # Implement upstream logic to handle target_colorspace, - # target_display, target_view in other DCCs - target_colorspace = False - target_display = instance.data.get("colorspaceDisplay") - target_view = instance.data.get("colorspaceView") + scene_display = instance.data.get( + "sceneDisplay", + # Backward compatibility + instance.data.get("colorspaceDisplay") + ) + scene_view = instance.data.get( + "sceneView", + # Backward compatibility + instance.data.get("colorspaceView") + ) + + for idx, repre in enumerate(list(repres)): self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) if not self._repre_is_valid(repre): continue @@ -142,24 +146,18 @@ class ExtractOIIOTranscode(publish.Extractor): transcoding_type = output_def["transcoding_type"] - # NOTE: we use colorspace_data as the fallback values for - # the target colorspace. + # Set target colorspace/display/view based on transcoding type + target_colorspace = None + target_view = None + target_display = None if transcoding_type == "colorspace": - # TODO: Should we fallback to the colorspace - # (which used as source above) ? - # or should we compute the target colorspace from - # current view and display ? - target_colorspace = (output_def["colorspace"] or - colorspace_data.get("colorspace")) + target_colorspace = output_def["colorspace"] elif transcoding_type == "display_view": display_view = output_def["display_view"] - target_view = ( - display_view["view"] - or colorspace_data.get("view")) - target_display = ( - display_view["display"] - or colorspace_data.get("display") - ) + # If empty values are provided in output definition, + # fallback to scene display/view that is collected from DCC + target_view = display_view["view"] or scene_view + target_display = display_view["display"] or scene_display # both could be already collected by DCC, # but could be overwritten when transcoding diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..7311115966 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -443,7 +443,7 @@ class UseDisplayViewModel(BaseSettingsModel): title="Target Display", description=( "Display of the target transform. If left empty, the" - " source Display value will be used." + " scene Display value will be used." ) ) view: str = SettingsField( @@ -451,7 +451,7 @@ class UseDisplayViewModel(BaseSettingsModel): title="Target View", description=( "View of the target transform. If left empty, the" - " source View value will be used." + " scene View value will be used." ) ) From 8fdc943553687c4d43dea9d855e906df1d8dbb4e Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 11 Nov 2025 14:05:58 +0000 Subject: [PATCH 129/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 83a7d0a51d..e481e81356 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.8" +__version__ = "1.6.8+dev" diff --git a/package.py b/package.py index b3e41b2e81..eb30148176 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.8" +version = "1.6.8+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 212fe505b9..6708432e85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.8" +version = "1.6.8+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From ccd54e16cc8be451c08b8b0da296f8ed10d43730 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Nov 2025 14:07:19 +0000 Subject: [PATCH 130/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c79ca69fca..513e088fef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.8 - 1.6.7 - 1.6.6 - 1.6.5 From f38a6dffba44e264aa06f9b424943378bbf4dd98 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 16:40:59 +0100 Subject: [PATCH 131/386] Avoid repeating input channel names if e.g. R, G and B are reading from Y channel --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 22396a5324..0255b5a9d4 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1545,7 +1545,7 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): channels_arg += ",A={}".format(float(alpha_default)) input_channels.append("A") - input_channels_str = ",".join(input_channels) + input_channels_str = ",".join(set(input_channels)) subimages = oiio_input_info.get("subimages") input_arg = "-i" From e2c668769032b7a68753f31164887032a6b0ce2e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 11 Nov 2025 16:46:04 +0100 Subject: [PATCH 132/386] Preserve order when making unique to avoid error on `R,G,B` becoming `B,G,R` but the channels being using in `R,G,B` order in `--ch` argument --- client/ayon_core/lib/transcoding.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 0255b5a9d4..076ee79665 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1545,7 +1545,8 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): channels_arg += ",A={}".format(float(alpha_default)) input_channels.append("A") - input_channels_str = ",".join(set(input_channels)) + # Make sure channels are unique, but preserve order to avoid oiiotool crash + input_channels_str = ",".join(list(dict.fromkeys(input_channels))) subimages = oiio_input_info.get("subimages") input_arg = "-i" From 42642ebd34f5b35bc42cf6e5ace0c7a6866a2426 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:41:55 +0100 Subject: [PATCH 133/386] use graphql to get projects --- .../ayon_core/tools/common_models/projects.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 250c3b020d..3e090e18b8 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import contextlib from abc import ABC, abstractmethod from typing import Any, Optional from dataclasses import dataclass import ayon_api +from ayon_api.graphql_queries import projects_graphql_query from ayon_core.style import get_default_entity_icon_color from ayon_core.lib import CacheItem, NestedCacheItem @@ -290,6 +292,7 @@ def _get_project_items_from_entitiy( return [ ProjectItem.from_entity(project) for project in projects + if project["active"] ] @@ -538,8 +541,32 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() + def _fetch_projects_bc(self) -> list[dict[str, Any]]: + """Fetch projects using GraphQl. + + This method was added because ayon_api had a bug in 'get_projects'. + + Returns: + list[dict[str, Any]]: List of projects. + + """ + api = ayon_api.get_server_api_connection() + query = projects_graphql_query({"name", "active", "library", "data"}) + + projects = [] + for parsed_data in query.continuous_query(api): + for project in parsed_data["projects"]: + project_data = project["data"] + if project_data is None: + project["data"] = {} + elif isinstance(project_data, str): + project["data"] = json.loads(project_data) + projects.append(project) + return projects + def _query_projects(self) -> list[ProjectItem]: - projects = ayon_api.get_projects(fields=["name", "active", "library"]) + projects = self._fetch_projects_bc() + user = ayon_api.get_user() pinned_projects = ( user From f4824cdc426c47f5db65d4ac417d1328259d0cb4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 12 Nov 2025 14:41:24 +0100 Subject: [PATCH 134/386] Allow creation of farm instances without colorspace data --- .../pipeline/farm/pyblish_functions.py | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 2193e96cb1..5e632c3599 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -591,22 +591,6 @@ def create_instances_for_aov( # AOV product of its own. log = Logger.get_logger("farm_publishing") - additional_color_data = { - "renderProducts": instance.data["renderProducts"], - "colorspaceConfig": instance.data["colorspaceConfig"], - "display": instance.data["colorspaceDisplay"], - "view": instance.data["colorspaceView"] - } - - # Get templated path from absolute config path. - anatomy = instance.context.data["anatomy"] - colorspace_template = instance.data["colorspaceConfig"] - try: - additional_color_data["colorspaceTemplate"] = remap_source( - colorspace_template, anatomy) - except ValueError as e: - log.warning(e) - additional_color_data["colorspaceTemplate"] = colorspace_template # if there are product to attach to and more than one AOV, # we cannot proceed. @@ -618,6 +602,29 @@ def create_instances_for_aov( "attaching multiple AOVs or renderable cameras to " "product is not supported yet.") + additional_data = { + "renderProducts": instance.data["renderProducts"], + } + + # Collect color management data if present + if "colorspaceConfig" in instance.data: + additional_data.update({ + "colorspaceConfig": instance.data["colorspaceConfig"], + # Display/View are optional + "display": instance.data.get("colorspaceDisplay"), + "view": instance.data.get("colorspaceView") + }) + + # Get templated path from absolute config path. + anatomy = instance.context.data["anatomy"] + colorspace_template = instance.data["colorspaceConfig"] + try: + additional_data["colorspaceTemplate"] = remap_source( + colorspace_template, anatomy) + except ValueError as e: + log.warning(e) + additional_data["colorspaceTemplate"] = colorspace_template + # create instances for every AOV we found in expected files. # NOTE: this is done for every AOV and every render camera (if # there are multiple renderable cameras in scene) @@ -625,7 +632,7 @@ def create_instances_for_aov( instance, skeleton, aov_filter, - additional_color_data, + additional_data, skip_integration_repre_list, do_not_add_review, frames_to_render @@ -936,16 +943,28 @@ def _create_instances_for_aov( "stagingDir": staging_dir, "fps": new_instance.get("fps"), "tags": ["review"] if preview else [], - "colorspaceData": { + } + + if colorspace and additional_data["colorspaceConfig"]: + # Only apply colorspace data if the image has a colorspace + colorspace_data: dict = { "colorspace": colorspace, "config": { "path": additional_data["colorspaceConfig"], "template": additional_data["colorspaceTemplate"] }, - "display": additional_data["display"], - "view": additional_data["view"] } - } + # Display/View are optional + display = additional_data.get("display") + if display: + additional_data["display"] = display + view = additional_data.get("view") + if view: + additional_data["view"] = view + + rep["colorspaceData"] = colorspace_data + else: + log.debug("No colorspace data for representation: {}".format(rep)) # support conversion from tiled to scanline if instance.data.get("convertToScanline"): From 5ede9cb091504f0ff2c3d0f730039862b102fc66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:21:34 +0100 Subject: [PATCH 135/386] add less/greater than to allowed chars --- server/settings/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index f40c7c3627..3b75a9ba23 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -34,7 +34,7 @@ class ProductNameProfile(BaseSettingsModel): enum_resolver=task_types_enum ) tasks: list[str] = SettingsField(default_factory=list, title="Task names") - template: str = SettingsField("", title="Template") + template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") class FilterCreatorProfile(BaseSettingsModel): From ca8b776ce12f86dc9ce9709ac59f14d34be9718c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:23:26 +0100 Subject: [PATCH 136/386] added conversion function --- server/settings/conversion.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 34820b5b32..4620202346 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -1,8 +1,26 @@ +import re import copy from typing import Any from .publish_plugins import DEFAULT_PUBLISH_VALUES +PRODUCT_NAME_REPL_REGEX = re.compile(r"[^<>{}\[\]a-zA-Z0-9_.]") + + +def _convert_imageio_configs_1_6_5(overrides): + product_name_profiles = ( + overrides + .get("tools", {}) + .get("creator", {}) + .get("product_name_profiles") + ) + if isinstance(product_name_profiles, list): + for item in product_name_profiles: + # Remove unsupported product name characters + template = item.get("template") + if isinstance(template, str): + item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template) + def _convert_imageio_configs_0_4_5(overrides): """Imageio config settings did change to profiles since 0.4.5.""" From 2f893574f4ac4b4f67e39d11ba4f2a0f50a97cfa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:24:02 +0100 Subject: [PATCH 137/386] change 'tasks' and 'hosts' to full attr names --- server/settings/conversion.py | 7 ++++++ server/settings/tools.py | 44 +++++++++++++++++------------------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 4620202346..846b91edab 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -21,6 +21,13 @@ def _convert_imageio_configs_1_6_5(overrides): if isinstance(template, str): item["template"] = PRODUCT_NAME_REPL_REGEX.sub("", template) + for new_key, old_key in ( + ("host_names", "hosts"), + ("task_names", "tasks"), + ): + if old_key in item: + item[new_key] = item.get(old_key) + def _convert_imageio_configs_0_4_5(overrides): """Imageio config settings did change to profiles since 0.4.5.""" diff --git a/server/settings/tools.py b/server/settings/tools.py index 3b75a9ba23..7e397d4874 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -27,13 +27,13 @@ class ProductNameProfile(BaseSettingsModel): product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField(default_factory=list, title="Hosts") + host_names: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", enum_resolver=task_types_enum ) - tasks: list[str] = SettingsField(default_factory=list, title="Task names") + task_names: list[str] = SettingsField(default_factory=list, title="Task names") template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") @@ -433,27 +433,27 @@ DEFAULT_TOOLS_VALUES = { "product_name_profiles": [ { "product_types": [], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{variant}" }, { "product_types": [ "workfile" ], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}" }, { "product_types": [ "render" ], - "hosts": [], + "host_names": [], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}{Variant}<_{Aov}>" }, { @@ -461,11 +461,11 @@ DEFAULT_TOOLS_VALUES = { "renderLayer", "renderPass" ], - "hosts": [ + "host_names": [ "tvpaint" ], "task_types": [], - "tasks": [], + "task_names": [], "template": ( "{product[type]}{Task[name]}_{Renderlayer}_{Renderpass}" ) @@ -475,65 +475,65 @@ DEFAULT_TOOLS_VALUES = { "review", "workfile" ], - "hosts": [ + "host_names": [ "aftereffects", "tvpaint" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}" }, { "product_types": ["render"], - "hosts": [ + "host_names": [ "aftereffects" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{product[type]}{Task[name]}{Composition}{Variant}" }, { "product_types": [ "staticMesh" ], - "hosts": [ + "host_names": [ "maya" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "S_{folder[name]}{variant}" }, { "product_types": [ "skeletalMesh" ], - "hosts": [ + "host_names": [ "maya" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "SK_{folder[name]}{variant}" }, { "product_types": [ "hda" ], - "hosts": [ + "host_names": [ "houdini" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "{folder[name]}_{variant}" }, { "product_types": [ "textureSet" ], - "hosts": [ + "host_names": [ "substancedesigner" ], "task_types": [], - "tasks": [], + "task_names": [], "template": "T_{folder[name]}{variant}" } ], From 7622c150cf5c33a81b830cafce5330d2ddf2caed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:49:48 +0100 Subject: [PATCH 138/386] fix formatting --- server/settings/tools.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index 7e397d4874..da3b4ebff8 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -25,16 +25,27 @@ class ProductNameProfile(BaseSettingsModel): _layout = "expanded" product_types: list[str] = SettingsField( - default_factory=list, title="Product types" + default_factory=list, + title="Product types", + ) + host_names: list[str] = SettingsField( + default_factory=list, + title="Host names", ) - host_names: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", - enum_resolver=task_types_enum + enum_resolver=task_types_enum, + ) + task_names: list[str] = SettingsField( + default_factory=list, + title="Task names", + ) + template: str = SettingsField( + "", + title="Template", + regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$", ) - task_names: list[str] = SettingsField(default_factory=list, title="Task names") - template: str = SettingsField("", title="Template", regex=r"^[<>{}\[\]a-zA-Z0-9_.]+$") class FilterCreatorProfile(BaseSettingsModel): From 1cdde6d7779785deafd4996000e025af6dfa4bce Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:03:23 +0100 Subject: [PATCH 139/386] fix typo Thanks @BigRoy --- client/ayon_core/tools/common_models/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index 3e090e18b8..d81b581894 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -277,7 +277,7 @@ class ProductTypeIconMapping: return self._definitions_by_name -def _get_project_items_from_entitiy( +def _get_project_items_from_entity( projects: list[dict[str, Any]] ) -> list[ProjectItem]: """ @@ -575,7 +575,7 @@ class ProjectsModel(object): .get("pinnedProjects") ) or [] pinned_projects = set(pinned_projects) - project_items = _get_project_items_from_entitiy(list(projects)) + project_items = _get_project_items_from_entity(list(projects)) for project in project_items: project.is_pinned = project.name in pinned_projects return project_items From be9b476151b455408d8d076ac944ebbe7bc1e3a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:03:31 +0100 Subject: [PATCH 140/386] use better method name --- client/ayon_core/tools/common_models/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/common_models/projects.py b/client/ayon_core/tools/common_models/projects.py index d81b581894..0c1f912fd1 100644 --- a/client/ayon_core/tools/common_models/projects.py +++ b/client/ayon_core/tools/common_models/projects.py @@ -541,7 +541,7 @@ class ProjectsModel(object): self._projects_cache.update_data(project_items) return self._projects_cache.get_data() - def _fetch_projects_bc(self) -> list[dict[str, Any]]: + def _fetch_graphql_projects(self) -> list[dict[str, Any]]: """Fetch projects using GraphQl. This method was added because ayon_api had a bug in 'get_projects'. @@ -565,7 +565,7 @@ class ProjectsModel(object): return projects def _query_projects(self) -> list[ProjectItem]: - projects = self._fetch_projects_bc() + projects = self._fetch_graphql_projects() user = ayon_api.get_user() pinned_projects = ( From 26839fa5c1b52ac4535eb0fbf19283991d05b411 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 12 Nov 2025 17:07:05 +0000 Subject: [PATCH 141/386] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e481e81356..869831b3ab 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.8+dev" +__version__ = "1.6.9" diff --git a/package.py b/package.py index eb30148176..cbfae1a4b3 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.8+dev" +version = "1.6.9" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 6708432e85..92c336770d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.8+dev" +version = "1.6.9" description = "" authors = ["Ynput Team "] readme = "README.md" From ea81e643f2d7158a50d4a6feebd6ee8ef3113ec4 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 12 Nov 2025 17:07:38 +0000 Subject: [PATCH 142/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 869831b3ab..da0cbff11d 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.9" +__version__ = "1.6.9+dev" diff --git a/package.py b/package.py index cbfae1a4b3..99524be8aa 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.9" +version = "1.6.9+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 92c336770d..f69f4f843a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.9" +version = "1.6.9+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 2ce5ba257502e279e9f5474ee88b27623fed75b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Nov 2025 17:08:36 +0000 Subject: [PATCH 143/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 513e088fef..e48e4b3b29 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.9 - 1.6.8 - 1.6.7 - 1.6.6 From 2cdcfa3f22278623c2dcdbb171359484a781f22d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 10:49:55 +0100 Subject: [PATCH 144/386] store host name to version entity data --- client/ayon_core/plugins/publish/integrate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index d18e546392..6182598e14 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -457,6 +457,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): else: version_data[key] = value + host_name = instance.context.data["hostName"] + version_data["host_name"] = host_name + version_entity = new_version_entity( version_number, product_entity["id"], From 4d90d35fc7204e97e4588c9f7969d58e7037630a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:01:08 +0100 Subject: [PATCH 145/386] Extended open file possibilities --- client/ayon_core/plugins/loader/open_file.py | 286 ++++++++++++++++--- 1 file changed, 254 insertions(+), 32 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 9b5a6fec20..80ddf925d3 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -1,8 +1,10 @@ import os import sys import subprocess +import platform import collections -from typing import Optional, Any +import ctypes +from typing import Optional, Any, Callable from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.actions import ( @@ -13,6 +15,232 @@ from ayon_core.pipeline.actions import ( ) +WINDOWS_USER_REG_PATH = ( + r"Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts" + r"\{ext}\UserChoice" +) + + +class _Cache: + supported_exts: set[str] = set() + unsupported_exts: set[str] = set() + + @classmethod + def is_supported(cls, ext: str) -> bool: + return ext in cls.supported_exts + + @classmethod + def already_checked(cls, ext: str) -> bool: + return ( + ext in cls.supported_exts + or ext in cls.unsupported_exts + ) + + @classmethod + def set_ext_support(cls, ext: str, supported: bool) -> None: + if supported: + cls.supported_exts.add(ext) + else: + cls.unsupported_exts.add(ext) + + +def _extension_has_assigned_app_windows(ext: str) -> bool: + import winreg + progid = None + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + WINDOWS_USER_REG_PATH.format(ext=ext), + ) as k: + progid, _ = winreg.QueryValueEx(k, "ProgId") + except OSError: + pass + + if progid: + return True + + try: + with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, ext) as k: + progid = winreg.QueryValueEx(k, None)[0] + except OSError: + pass + return bool(progid) + + +def _linux_find_desktop_file(desktop: str) -> Optional[str]: + for p in ( + os.path.join(os.path.expanduser("~/.local/share/applications"), desktop), + os.path.join("/usr/share/applications", desktop), + os.path.join("/usr/local/share/applications", desktop), + ): + if os.path.isfile(p): + return p + return None + + +def _extension_has_assigned_app_linux(ext: str) -> bool: + import mimetypes + + mime, _ = mimetypes.guess_type(f"file{ext}") + if not mime: + return False + + try: + # xdg-mime query default + desktop = subprocess.check_output( + ["xdg-mime", "query", "default", mime], + text=True + ).strip() or None + except Exception: + desktop = None + + if not desktop: + return False + + desktop_path = _linux_find_desktop_file(desktop) + if not desktop_path: + return False + if desktop_path and os.path.isfile(desktop_path): + return True + return False + + +def _extension_has_assigned_app_macos(ext: str): + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via ctypes. + # Steps: ext -> UTI -> default handler bundle id for role 'all'. + cf = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" + ) + ls = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/Frameworks" + "/LaunchServices.framework/LaunchServices" + ) + + # CFType/CFString helpers + CFStringRef = ctypes.c_void_p + CFURLRef = ctypes.c_void_p + CFAllocatorRef = ctypes.c_void_p + CFIndex = ctypes.c_long + UniChar = ctypes.c_ushort + + kCFStringEncodingUTF8 = 0x08000100 + + cf.CFStringCreateWithCString.argtypes = [CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32] + cf.CFStringCreateWithCString.restype = CFStringRef + + cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] + cf.CFStringGetCStringPtr.restype = ctypes.c_char_p + + cf.CFStringGetCString.argtypes = [CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32] + cf.CFStringGetCString.restype = ctypes.c_bool + + cf.CFRelease.argtypes = [ctypes.c_void_p] + cf.CFRelease.restype = None + + try: + UTTypeCreatePreferredIdentifierForTag = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreServices.framework/CoreServices" + ).UTTypeCreatePreferredIdentifierForTag + except OSError: + # Fallback path (older systems) + UTTypeCreatePreferredIdentifierForTag = ( + ls.UTTypeCreatePreferredIdentifierForTag + ) + UTTypeCreatePreferredIdentifierForTag.argtypes = [ + CFStringRef, CFStringRef, CFStringRef + ] + UTTypeCreatePreferredIdentifierForTag.restype = CFStringRef + + LSRolesMask = ctypes.c_uint + kLSRolesAll = 0xFFFFFFFF + ls.LSCopyDefaultRoleHandlerForContentType.argtypes = [ + CFStringRef, LSRolesMask + ] + ls.LSCopyDefaultRoleHandlerForContentType.restype = CFStringRef + + def cfstr(py_s: str) -> CFStringRef: + return cf.CFStringCreateWithCString( + None, py_s.encode("utf-8"), kCFStringEncodingUTF8 + ) + + def to_pystr(cf_s: CFStringRef) -> Optional[str]: + if not cf_s: + return None + # Try fast pointer + ptr = cf.CFStringGetCStringPtr(cf_s, kCFStringEncodingUTF8) + if ptr: + return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8") + + # Fallback buffer + buf_size = 1024 + buf = ctypes.create_string_buffer(buf_size) + ok = cf.CFStringGetCString( + cf_s, buf, buf_size, kCFStringEncodingUTF8 + ) + if ok: + return buf.value.decode("utf-8") + return None + + # Convert extension (without dot) to UTI + tag_class = cfstr("public.filename-extension") + tag_value = cfstr(ext.lstrip(".")) + + uti_ref = UTTypeCreatePreferredIdentifierForTag( + tag_class, tag_value, None + ) + uti = to_pystr(uti_ref) + + # Clean up temporary CFStrings + for ref in (tag_class, tag_value): + if ref: + cf.CFRelease(ref) + + bundle_id = None + if uti_ref: + # Get default handler for the UTI + default_bundle_ref = ls.LSCopyDefaultRoleHandlerForContentType( + uti_ref, kLSRolesAll + ) + bundle_id = to_pystr(default_bundle_ref) + if default_bundle_ref: + cf.CFRelease(default_bundle_ref) + cf.CFRelease(uti_ref) + return bundle_id is not None + + +def _filter_supported_exts( + extensions: set[str], test_func: Callable +) -> set[str]: + filtered_exs: set[str] = set() + for ext in extensions: + if not _Cache.already_checked(ext): + r = test_func(ext) + print(ext, r) + _Cache.set_ext_support(ext, r) + if _Cache.is_supported(ext): + filtered_exs.add(ext) + return filtered_exs + + +def filter_supported_exts(extensions: set[str]) -> set[str]: + if not extensions: + return set() + platform_name = platform.system().lower() + if platform_name == "windows": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_windows + ) + if platform_name == "linux": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_linux + ) + if platform_name == "darwin": + return _filter_supported_exts( + extensions, _extension_has_assigned_app_macos + ) + return set() + + def open_file(filepath: str) -> None: """Open file with system default executable""" if sys.platform.startswith("darwin"): @@ -27,8 +255,6 @@ class OpenFileAction(LoaderActionPlugin): """Open Image Sequence or Video with system default""" identifier = "core.open-file" - product_types = {"render2d"} - def get_action_items( self, selection: LoaderActionSelection ) -> list[LoaderActionItem]: @@ -46,37 +272,32 @@ class OpenFileAction(LoaderActionPlugin): if not repres: return [] - repre_ids = {repre["id"] for repre in repres} - versions = selection.entities.get_representations_versions( - repre_ids - ) - product_ids = {version["productId"] for version in versions} - products = selection.entities.get_products(product_ids) - filtered_product_ids = { - product["id"] - for product in products - if product["productType"] in self.product_types - } - if not filtered_product_ids: + repres_by_ext = collections.defaultdict(list) + for repre in repres: + repre_context = repre.get("context") + if not repre_context: + continue + ext = repre_context.get("ext") + if not ext: + path = repre["attrib"].get("path") + if path: + ext = os.path.splitext(path)[1] + + if ext: + ext = ext.lower() + if not ext.startswith("."): + ext = f".{ext}" + repres_by_ext[ext.lower()].append(repre) + + if not repres_by_ext: return [] - versions_by_product_id = collections.defaultdict(list) - for version in versions: - versions_by_product_id[version["productId"]].append(version) - - repres_by_version_ids = collections.defaultdict(list) - for repre in repres: - repres_by_version_ids[repre["versionId"]].append(repre) - - filtered_repres = [] - for product_id in filtered_product_ids: - for version in versions_by_product_id[product_id]: - for repre in repres_by_version_ids[version["id"]]: - filtered_repres.append(repre) + filtered_exts = filter_supported_exts(set(repres_by_ext)) repre_ids_by_name = collections.defaultdict(set) - for repre in filtered_repres: - repre_ids_by_name[repre["name"]].add(repre["id"]) + for ext in filtered_exts: + for repre in repres_by_ext[ext]: + repre_ids_by_name[repre["name"]].add(repre["id"]) return [ LoaderActionItem( @@ -86,8 +307,8 @@ class OpenFileAction(LoaderActionPlugin): data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", - "name": "play_circle", - "color": "#FFA500", + "name": "file_open", + "color": "#ffffff", } ) for repre_name, repre_ids in repre_ids_by_name.items() @@ -122,6 +343,7 @@ class OpenFileAction(LoaderActionPlugin): ) self.log.info(f"Opening: {path}") + open_file(path) return LoaderActionResult( From 3936270266f67e5e4707a39a3ba845f9eda7d023 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:16:51 +0100 Subject: [PATCH 146/386] fix formatting --- client/ayon_core/plugins/loader/open_file.py | 24 ++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 80ddf925d3..b29dfd1710 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -68,13 +68,14 @@ def _extension_has_assigned_app_windows(ext: str) -> bool: def _linux_find_desktop_file(desktop: str) -> Optional[str]: - for p in ( - os.path.join(os.path.expanduser("~/.local/share/applications"), desktop), - os.path.join("/usr/share/applications", desktop), - os.path.join("/usr/local/share/applications", desktop), + for dirpath in ( + os.path.expanduser("~/.local/share/applications"), + "/usr/share/applications", + "/usr/local/share/applications", ): - if os.path.isfile(p): - return p + path = os.path.join(dirpath, desktop) + if os.path.isfile(path): + return path return None @@ -106,7 +107,8 @@ def _extension_has_assigned_app_linux(ext: str) -> bool: def _extension_has_assigned_app_macos(ext: str): - # Uses CoreServices/LaunchServices and Uniform Type Identifiers via ctypes. + # Uses CoreServices/LaunchServices and Uniform Type Identifiers via + # ctypes. # Steps: ext -> UTI -> default handler bundle id for role 'all'. cf = ctypes.cdll.LoadLibrary( "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" @@ -125,13 +127,17 @@ def _extension_has_assigned_app_macos(ext: str): kCFStringEncodingUTF8 = 0x08000100 - cf.CFStringCreateWithCString.argtypes = [CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32] + cf.CFStringCreateWithCString.argtypes = [ + CFAllocatorRef, ctypes.c_char_p, ctypes.c_uint32 + ] cf.CFStringCreateWithCString.restype = CFStringRef cf.CFStringGetCStringPtr.argtypes = [CFStringRef, ctypes.c_uint32] cf.CFStringGetCStringPtr.restype = ctypes.c_char_p - cf.CFStringGetCString.argtypes = [CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32] + cf.CFStringGetCString.argtypes = [ + CFStringRef, ctypes.c_char_p, CFIndex, ctypes.c_uint32 + ] cf.CFStringGetCString.restype = ctypes.c_bool cf.CFRelease.argtypes = [ctypes.c_void_p] From 84a40336065b93f057e616ddb7775640770b8687 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 15:21:14 +0100 Subject: [PATCH 147/386] remove unused variables --- client/ayon_core/plugins/loader/open_file.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index b29dfd1710..13d255a682 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -106,7 +106,7 @@ def _extension_has_assigned_app_linux(ext: str) -> bool: return False -def _extension_has_assigned_app_macos(ext: str): +def _extension_has_assigned_app_macos(ext: str) -> bool: # Uses CoreServices/LaunchServices and Uniform Type Identifiers via # ctypes. # Steps: ext -> UTI -> default handler bundle id for role 'all'. @@ -120,10 +120,8 @@ def _extension_has_assigned_app_macos(ext: str): # CFType/CFString helpers CFStringRef = ctypes.c_void_p - CFURLRef = ctypes.c_void_p CFAllocatorRef = ctypes.c_void_p CFIndex = ctypes.c_long - UniChar = ctypes.c_ushort kCFStringEncodingUTF8 = 0x08000100 @@ -194,7 +192,6 @@ def _extension_has_assigned_app_macos(ext: str): uti_ref = UTTypeCreatePreferredIdentifierForTag( tag_class, tag_value, None ) - uti = to_pystr(uti_ref) # Clean up temporary CFStrings for ref in (tag_class, tag_value): From 0262a8e7630080a5c4d8e5a64a729febb2dee3c5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 16:33:00 +0100 Subject: [PATCH 148/386] Apply suggestion from @BigRoy --- client/ayon_core/pipeline/farm/pyblish_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 5e632c3599..6d116dcece 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -957,10 +957,10 @@ def _create_instances_for_aov( # Display/View are optional display = additional_data.get("display") if display: - additional_data["display"] = display + colorspace_data["display"] = display view = additional_data.get("view") if view: - additional_data["view"] = view + colorspace_data["view"] = view rep["colorspaceData"] = colorspace_data else: From f29470a08ca34d467c1070e9b05ec08b6fcc1f26 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 16:34:08 +0100 Subject: [PATCH 149/386] Apply suggestion from @iLLiCiTiT Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/farm/pyblish_functions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 6d116dcece..265d79b53e 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -607,9 +607,10 @@ def create_instances_for_aov( } # Collect color management data if present - if "colorspaceConfig" in instance.data: + colorspace_config = instance.data.get("colorspaceConfig") + if colorspace_config: additional_data.update({ - "colorspaceConfig": instance.data["colorspaceConfig"], + "colorspaceConfig": colorspace_config, # Display/View are optional "display": instance.data.get("colorspaceDisplay"), "view": instance.data.get("colorspaceView") @@ -617,13 +618,12 @@ def create_instances_for_aov( # Get templated path from absolute config path. anatomy = instance.context.data["anatomy"] - colorspace_template = instance.data["colorspaceConfig"] try: additional_data["colorspaceTemplate"] = remap_source( - colorspace_template, anatomy) + colorspace_config, anatomy) except ValueError as e: log.warning(e) - additional_data["colorspaceTemplate"] = colorspace_template + additional_data["colorspaceTemplate"] = colorspace_config # create instances for every AOV we found in expected files. # NOTE: this is done for every AOV and every render camera (if From bab249a54a4f50e018d4f403abf5b6f9e04b2b4a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:11:02 +0100 Subject: [PATCH 150/386] remove debug print Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/loader/open_file.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 13d255a682..3118bfa3db 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -218,7 +218,6 @@ def _filter_supported_exts( for ext in extensions: if not _Cache.already_checked(ext): r = test_func(ext) - print(ext, r) _Cache.set_ext_support(ext, r) if _Cache.is_supported(ext): filtered_exs.add(ext) From 46b534cfcce245dd0a7231e86efdd9e2685629eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:11:38 +0100 Subject: [PATCH 151/386] merge two lines into one --- client/ayon_core/plugins/loader/open_file.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 3118bfa3db..8bc4913da5 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -217,8 +217,7 @@ def _filter_supported_exts( filtered_exs: set[str] = set() for ext in extensions: if not _Cache.already_checked(ext): - r = test_func(ext) - _Cache.set_ext_support(ext, r) + _Cache.set_ext_support(ext, test_func(ext)) if _Cache.is_supported(ext): filtered_exs.add(ext) return filtered_exs From efa702405c75d016a46f692e8f678598adf9c91c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:26:55 +0100 Subject: [PATCH 152/386] tune out orders --- client/ayon_core/plugins/loader/copy_file.py | 2 ++ client/ayon_core/plugins/loader/delete_old_versions.py | 2 +- client/ayon_core/plugins/loader/open_file.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/loader/copy_file.py b/client/ayon_core/plugins/loader/copy_file.py index 2380b465ed..a1a98a2bf0 100644 --- a/client/ayon_core/plugins/loader/copy_file.py +++ b/client/ayon_core/plugins/loader/copy_file.py @@ -45,6 +45,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=32, group_label="Copy file path", data={ "representation_id": repre_id, @@ -60,6 +61,7 @@ class CopyFileActionPlugin(LoaderActionPlugin): output.append( LoaderActionItem( label=repre_name, + order=33, group_label="Copy file", data={ "representation_id": repre_id, diff --git a/client/ayon_core/plugins/loader/delete_old_versions.py b/client/ayon_core/plugins/loader/delete_old_versions.py index 7499650cbe..ce67df1c0c 100644 --- a/client/ayon_core/plugins/loader/delete_old_versions.py +++ b/client/ayon_core/plugins/loader/delete_old_versions.py @@ -66,7 +66,7 @@ class DeleteOldVersions(LoaderActionPlugin): ), LoaderActionItem( label="Calculate Versions size", - order=30, + order=34, data={ "product_ids": list(product_ids), "action": "calculate-versions-size", diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 8bc4913da5..ef92990f57 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -304,7 +304,7 @@ class OpenFileAction(LoaderActionPlugin): LoaderActionItem( label=repre_name, group_label="Open file", - order=-10, + order=30, data={"representation_ids": list(repre_ids)}, icon={ "type": "material-symbols", From 42b249a6b3732bafa3557abc5462857fe03e855e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 13 Nov 2025 17:32:22 +0100 Subject: [PATCH 153/386] add note about caching --- client/ayon_core/plugins/loader/open_file.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index ef92990f57..018b9aeab0 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -22,6 +22,13 @@ WINDOWS_USER_REG_PATH = ( class _Cache: + """Cache extensions information. + + Notes: + The cache is cleared when loader tool is refreshed so it might be + moved to other place which is not cleared of refresh. + + """ supported_exts: set[str] = set() unsupported_exts: set[str] = set() From 8478899b67c7c3aeec4b62ee179ebaaba87bcc0a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 17:40:47 +0100 Subject: [PATCH 154/386] Apply suggestion from @BigRoy --- client/ayon_core/plugins/loader/open_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/loader/open_file.py b/client/ayon_core/plugins/loader/open_file.py index 018b9aeab0..d226786bc2 100644 --- a/client/ayon_core/plugins/loader/open_file.py +++ b/client/ayon_core/plugins/loader/open_file.py @@ -26,7 +26,7 @@ class _Cache: Notes: The cache is cleared when loader tool is refreshed so it might be - moved to other place which is not cleared of refresh. + moved to other place which is not cleared on refresh. """ supported_exts: set[str] = set() From 80a95c19f143b4593d9ca610c2e5d55fc7feda56 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 13 Nov 2025 23:24:08 +0100 Subject: [PATCH 155/386] Pass on `sceneDisplay` (legacy `colorspaceDisplay`) and `sceneView` (legacy `colorspaceView`) to metadata JSON. Also pass on `sourceDisplay` and `sourceView` --- client/ayon_core/pipeline/farm/pyblish_functions.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 45c9eb22dc..c220b75403 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -253,6 +253,19 @@ def create_skeleton_instance( "reuseLastVersion": data.get("reuseLastVersion", False), } + # Pass on the OCIO metadata of what the source display and view are + # so that the farm can correctly set up color management. + if "sceneDisplay" in data and "sceneView" in data: + instance_skeleton_data["sceneDisplay"] = data["sceneDisplay"] + instance_skeleton_data["sceneView"] = data["sceneView"] + elif "colorspaceDisplay" in data and "colorspaceView" in data: + # Backwards compatibility for sceneDisplay and sceneView + instance_skeleton_data["colorspaceDisplay"] = data["colorspaceDisplay"] + instance_skeleton_data["colorspaceView"] = data["colorspaceView"] + if "sourceDisplay" in data and "sourceView" in data: + instance_skeleton_data["sourceDisplay"] = data["sourceDisplay"] + instance_skeleton_data["sourceView"] = data["sourceView"] + if data.get("renderlayer"): instance_skeleton_data["renderlayer"] = data["renderlayer"] From aea231d64e6a07258ee106ec7043967630e4e21c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Nov 2025 22:20:57 +0100 Subject: [PATCH 156/386] Also prefix folder name with `_` for initializing the asset layer --- .../plugins/publish/extract_usd_layer_contributions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ff1c4fec8a..4d8a8005f2 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -821,7 +821,7 @@ class ExtractUSDAssetContribution(publish.Extractor): folder_path = instance.data["folderPath"] product_name = instance.data["productName"] self.log.debug(f"Building asset: {folder_path} > {product_name}") - folder_name = folder_path.rsplit("/", 1)[-1] + asset_name = get_standard_default_prim_name(folder_path) # Contribute layers to asset # Use existing asset and add to it, or initialize a new asset layer @@ -840,7 +840,7 @@ class ExtractUSDAssetContribution(publish.Extractor): # the layer as either a default asset or shot structure. init_type = instance.data["contribution_target_product_init"] asset_layer, payload_layer = self.init_layer( - asset_name=folder_name, init_type=init_type + asset_name=asset_name, init_type=init_type ) # Author timeCodesPerSecond and framesPerSecond if the asset layer From b307cc6227db88347d70bdb149076d9c1ae66e41 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 16 Nov 2025 22:40:23 +0100 Subject: [PATCH 157/386] Run plug-in for all hosts --- .../publish/collect_scene_loaded_versions.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1c28c28f5b..5370b52492 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -9,28 +9,6 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.0001 label = "Collect Versions Loaded in Scene" - hosts = [ - "aftereffects", - "blender", - "celaction", - "cinema4d", - "flame", - "fusion", - "harmony", - "hiero", - "houdini", - "max", - "maya", - "motionbuilder", - "nuke", - "photoshop", - "silhouette", - "substancepainter", - "substancedesigner", - "resolve", - "tvpaint", - "zbrush", - ] def process(self, context): host = registered_host() From 1c25e357776f0a3a04686ffc96439f6b567635e4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 12:18:07 +0100 Subject: [PATCH 158/386] Fix Context card being clickable in Nuke 14/15 only outside the Context label area. Previously you could only click on the far left or far right side of the context card to be able to select it and access the Context attributes. Cosmetically the removal of the `` doesn't do much to the Context card because it doesn't have a sublabel. --- client/ayon_core/tools/publisher/widgets/card_view_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index ca95b1ff1a..aef3f85e0c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -211,7 +211,7 @@ class ContextCardWidget(CardWidget): icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("ProductTypeIconLabel") - label_widget = QtWidgets.QLabel(f"{CONTEXT_LABEL}", self) + label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) icon_layout = QtWidgets.QHBoxLayout() icon_layout.setContentsMargins(5, 5, 5, 5) From 335f9cf21b7ccba2ca6674081c8b9eb6e11b9f3f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 14:39:27 +0100 Subject: [PATCH 159/386] Implement generic ExtractOIIOPostProcess plug-in. This can be used to take any image representation through `oiiotool` to process with settings-defined arguments, to e.g. resize an image, convert all layers to scanline, etc. --- .../publish/extract_oiio_postprocess.py | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 client/ayon_core/plugins/publish/extract_oiio_postprocess.py diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py new file mode 100644 index 0000000000..6163eb98d2 --- /dev/null +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -0,0 +1,322 @@ +import os +import copy +import clique +import pyblish.api + +from ayon_core.pipeline import ( + publish, + get_temp_dir +) +from ayon_core.lib import ( + is_oiio_supported, + get_oiio_tool_args, + run_subprocess +) +from ayon_core.lib.profiles_filtering import filter_profiles + + +class ExtractOIIOPostProcess(publish.Extractor): + """Process representations through `oiiotool` with profile defined + settings so that e.g. color space conversions can be applied or images + could be converted to scanline, resized, etc. regardless of colorspace + data. + """ + + label = "OIIO Post Process" + order = pyblish.api.ExtractorOrder + 0.020 + + settings_category = "core" + + optional = True + + # Supported extensions + supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} + + # Configurable by Settings + profiles = None + options = None + + def process(self, instance): + if not self.profiles: + self.log.debug("No profiles present for OIIO Post Process") + return + + if "representations" not in instance.data: + self.log.debug("No representations, skipping.") + return + + if not is_oiio_supported(): + self.log.warning("OIIO not supported, no transcoding possible.") + return + + profile, representations = self._get_profile( + instance + ) + if not profile: + return + + profile_output_defs = profile["outputs"] + new_representations = [] + for idx, repre in enumerate(list(instance.data["representations"])): + self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) + if not self._repre_is_valid(repre, profile): + 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"]] + + added_representations = False + added_review = False + + # 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) + + original_staging_dir = new_repre["stagingDir"] + new_staging_dir = get_temp_dir( + project_name=instance.context.data["projectName"], + use_local_temp=True, + ) + new_repre["stagingDir"] = new_staging_dir + + output_extension = output_def["extension"] + output_extension = output_extension.replace('.', '') + self._rename_in_representation(new_repre, + files_to_convert, + output_name, + output_extension) + + sequence_files = self._translate_to_sequence(files_to_convert) + self.log.debug("Files to convert: {}".format(sequence_files)) + for file_name in sequence_files: + if isinstance(file_name, clique.Collection): + # Convert to filepath that can be directly converted + # by oiio like `frame.1001-1025%04d.exr` + file_name: str = file_name.format( + "{head}{range}{padding}{tail}" + ) + + self.log.debug("Transcoding file: `{}`".format(file_name)) + input_path = os.path.join(original_staging_dir, + file_name) + output_path = self._get_output_file_path(input_path, + new_staging_dir, + output_extension) + + # TODO: Support formatting with dynamic keys from the + # representation, like e.g. colorspace config, display, + # view, etc. + input_arguments: list[str] = output_def.get( + "input_arguments", [] + ) + output_arguments: list[str] = output_def.get( + "output_arguments", [] + ) + + # Prepare subprocess arguments + oiio_cmd = get_oiio_tool_args( + "oiiotool", + *input_arguments, + input_path, + *output_arguments, + "-o", + output_path + ) + + self.log.debug( + "Conversion command: {}".format(" ".join(oiio_cmd))) + run_subprocess(oiio_cmd, logger=self.log) + + # cleanup temporary transcoded files + for file_name in new_repre["files"]: + transcoded_file_path = os.path.join(new_staging_dir, + file_name) + instance.context.data["cleanupFullPaths"].append( + transcoded_file_path) + + custom_tags = output_def.get("custom_tags") + if custom_tags: + if new_repre.get("custom_tags") is None: + new_repre["custom_tags"] = [] + new_repre["custom_tags"].extend(custom_tags) + + # Add additional tags from output definition to representation + if new_repre.get("tags") is None: + new_repre["tags"] = [] + for tag in output_def["tags"]: + if tag not in new_repre["tags"]: + new_repre["tags"].append(tag) + + if tag == "review": + added_review = True + + # If there is only 1 file outputted then convert list to + # 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 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. + if "review" in repre.get("tags", []): + added_review = True + + new_representations.append(new_repre) + added_representations = True + + if added_representations: + self._mark_original_repre_for_deletion( + repre, profile, added_review + ) + + tags = repre.get("tags") or [] + if "delete" in tags and "thumbnail" not in tags: + instance.data["representations"].remove(repre) + + instance.data["representations"].extend(new_representations) + + def _rename_in_representation(self, new_repre, files_to_convert, + output_name, output_extension): + """Replace old extension with new one everywhere in representation. + + Args: + new_repre (dict) + files_to_convert (list): of filenames from repre["files"], + standardized to always list + output_name (str): key of output definition from Settings, + if "" token used, keep original repre name + output_extension (str): extension from output definition + """ + if output_name != "passthrough": + new_repre["name"] = output_name + if not output_extension: + return + + new_repre["ext"] = output_extension + new_repre["outputName"] = output_name + + renamed_files = [] + for file_name in files_to_convert: + file_name, _ = os.path.splitext(file_name) + file_name = '{}.{}'.format(file_name, + output_extension) + renamed_files.append(file_name) + new_repre["files"] = renamed_files + + def _translate_to_sequence(self, files_to_convert): + """Returns original list or a clique.Collection of a sequence. + + Uses clique to find frame sequence Collection. + If sequence not found, it returns original list. + + Args: + files_to_convert (list): list of file names + Returns: + list[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] + # 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 + + def _get_output_file_path(self, input_path, output_dir, + output_extension): + """Create output file name path.""" + file_name = os.path.basename(input_path) + file_name, input_extension = os.path.splitext(file_name) + if not output_extension: + output_extension = input_extension.replace(".", "") + new_file_name = '{}.{}'.format(file_name, + output_extension) + return os.path.join(output_dir, new_file_name) + + def _get_profile(self, instance): + """Returns profile if it should process this instance.""" + host_name = instance.context.data["hostName"] + product_type = instance.data["productType"] + product_name = instance.data["productName"] + task_data = instance.data["anatomyData"].get("task", {}) + task_name = task_data.get("name") + task_type = task_data.get("type") + filtering_criteria = { + "hosts": host_name, + "product_types": product_type, + "product_names": product_name, + "task_names": task_name, + "task_types": task_type, + } + profile = filter_profiles(self.profiles, filtering_criteria, + logger=self.log) + + if not profile: + self.log.debug(( + "Skipped instance. None of profiles in presets are for" + " Host: \"{}\" | Product types: \"{}\" | Product names: \"{}\"" + " | Task name \"{}\" | Task type \"{}\"" + ).format( + host_name, product_type, product_name, task_name, task_type + )) + + return profile + + def _repre_is_valid(self, repre) -> bool: + """Validation if representation should be processed. + + Args: + repre (dict): Representation which should be checked. + + Returns: + bool: False if can't be processed else True. + """ + if repre.get("ext") not in self.supported_exts: + self.log.debug(( + "Representation '{}' has unsupported extension: '{}'. Skipped." + ).format(repre["name"], repre.get("ext"))) + return False + + if not repre.get("files"): + self.log.debug(( + "Representation '{}' has empty files. Skipped." + ).format(repre["name"])) + return False + + return True + + def _mark_original_repre_for_deletion(self, repre, profile, added_review): + """If new transcoded representation created, delete old.""" + if not repre.get("tags"): + repre["tags"] = [] + + delete_original = profile["delete_original"] + + if delete_original: + if "delete" not in repre["tags"]: + repre["tags"].append("delete") + + if added_review and "review" in repre["tags"]: + repre["tags"].remove("review") From a6ecea872efda602b2fe0b157924d893d6611192 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 14:47:25 +0100 Subject: [PATCH 160/386] Add missing changes --- .../publish/extract_oiio_postprocess.py | 4 +- server/settings/publish_plugins.py | 111 ++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 6163eb98d2..2e93c68283 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -49,7 +49,7 @@ class ExtractOIIOPostProcess(publish.Extractor): self.log.warning("OIIO not supported, no transcoding possible.") return - profile, representations = self._get_profile( + profile = self._get_profile( instance ) if not profile: @@ -59,7 +59,7 @@ class ExtractOIIOPostProcess(publish.Extractor): new_representations = [] for idx, repre in enumerate(list(instance.data["representations"])): self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) - if not self._repre_is_valid(repre, profile): + if not self._repre_is_valid(repre): continue # Get representation files to convert diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index ee422a0acf..173526e13f 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -565,12 +565,115 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): class ExtractOIIOTranscodeModel(BaseSettingsModel): + """Color conversion transcoding using OIIO for images mostly aimed at + transcoding for reviewables (it'll process and output only RGBA channels). + """ enabled: bool = SettingsField(True) profiles: list[ExtractOIIOTranscodeProfileModel] = SettingsField( default_factory=list, title="Profiles" ) +class ExtractOIIOPostProcessOutputModel(BaseSettingsModel): + _layout = "expanded" + name: str = SettingsField( + "", + title="Name", + description="Output name (no space)", + regex=r"[a-zA-Z0-9_]([a-zA-Z0-9_\.\-]*[a-zA-Z0-9_])?$", + ) + extension: str = SettingsField( + "", + title="Extension", + description=( + "Target extension. If left empty, original" + " extension is used." + ), + ) + input_arguments: list[str] = SettingsField( + default_factory=list, + title="Input arguments", + description="Arguments passed prior to the input file argument.", + ) + output_arguments: list[str] = SettingsField( + default_factory=list, + title="Output arguments", + description="Arguments passed prior to the -o argument.", + ) + tags: list[str] = SettingsField( + default_factory=list, + title="Tags", + description=( + "Additional tags that will be added to the created representation." + "\nAdd *review* tag to create review from the transcoded" + " representation instead of the original." + ) + ) + custom_tags: list[str] = SettingsField( + default_factory=list, + title="Custom Tags", + description=( + "Additional custom tags that will be added" + " to the created representation." + ) + ) + + +class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types" + ) + hosts: list[str] = SettingsField( + default_factory=list, + title="Host names" + ) + task_types: list[str] = SettingsField( + default_factory=list, + title="Task types", + enum_resolver=task_types_enum + ) + task_names: list[str] = SettingsField( + default_factory=list, + title="Task names" + ) + product_names: list[str] = SettingsField( + default_factory=list, + title="Product names" + ) + delete_original: bool = SettingsField( + True, + title="Delete Original Representation", + description=( + "Choose to preserve or remove the original representation.\n" + "Keep in mind that if the transcoded representation includes" + " a `review` tag, it will take precedence over" + " the original for creating reviews." + ), + ) + outputs: list[ExtractOIIOPostProcessOutputModel] = SettingsField( + default_factory=list, + title="Output Definitions", + ) + + @validator("outputs") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ExtractOIIOPostProcessModel(BaseSettingsModel): + """Process representation images with `oiiotool` on publish. + + This could be used to convert images to different formats, convert to + scanline images or flatten deep images. + """ + enabled: bool = SettingsField(True) + profiles: list[ExtractOIIOPostProcessProfileModel] = SettingsField( + default_factory=list, title="Profiles" + ) + + # --- [START] Extract Review --- class ExtractReviewFFmpegModel(BaseSettingsModel): video_filters: list[str] = SettingsField( @@ -1122,6 +1225,10 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractOIIOTranscodeModel, title="Extract OIIO Transcode" ) + ExtractOIIOPostProcess: ExtractOIIOPostProcessModel = SettingsField( + default_factory=ExtractOIIOPostProcessModel, + title="Extract OIIO Post Process" + ) ExtractReview: ExtractReviewModel = SettingsField( default_factory=ExtractReviewModel, title="Extract Review" @@ -1347,6 +1454,10 @@ DEFAULT_PUBLISH_VALUES = { "enabled": True, "profiles": [] }, + "ExtractOIIOPostProcess": { + "enabled": True, + "profiles": [] + }, "ExtractReview": { "enabled": True, "profiles": [ From 82128c30c5d8a1e12939ffd3db09002f3c87b9d6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 17 Nov 2025 14:55:23 +0100 Subject: [PATCH 161/386] Disable text interaction instead --- .../ayon_core/tools/publisher/widgets/card_view_widgets.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index aef3f85e0c..a9abd56584 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -211,7 +211,12 @@ class ContextCardWidget(CardWidget): icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("ProductTypeIconLabel") - label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) + label_widget = QtWidgets.QLabel(f"{CONTEXT_LABEL}", self) + # HTML text will cause that label start catch mouse clicks + # - disabling with changing interaction flag + label_widget.setTextInteractionFlags( + QtCore.Qt.NoTextInteraction + ) icon_layout = QtWidgets.QHBoxLayout() icon_layout.setContentsMargins(5, 5, 5, 5) From 3b86b3612823c4ab4cc75feabaf2843a21602359 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 18 Nov 2025 13:05:19 +0000 Subject: [PATCH 162/386] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index da0cbff11d..ebf7e34a32 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.9+dev" +__version__ = "1.6.10" diff --git a/package.py b/package.py index 99524be8aa..461e09c38a 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.9+dev" +version = "1.6.10" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f69f4f843a..798405d3a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.9+dev" +version = "1.6.10" description = "" authors = ["Ynput Team "] readme = "README.md" From 90eef3f6b7cf9485f8385b2c94bc1c7929ebdf82 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 18 Nov 2025 13:05:58 +0000 Subject: [PATCH 163/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index ebf7e34a32..a220d400d4 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.10" +__version__ = "1.6.10+dev" diff --git a/package.py b/package.py index 461e09c38a..bb9c1b0c7e 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.10" +version = "1.6.10+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 798405d3a8..e61c2708de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.10" +version = "1.6.10+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 55f7ff6a46c2fe49f9398582bc2fba3b7a42c685 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 18 Nov 2025 13:06:52 +0000 Subject: [PATCH 164/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e48e4b3b29..78e86f43e4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.10 - 1.6.9 - 1.6.8 - 1.6.7 From 2375dda43bb72ac8c5396423a8b76ec35c41878f Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 18 Nov 2025 17:10:38 +0200 Subject: [PATCH 165/386] Add `folderpaths` for template profile filtering --- .../pipeline/workfile/workfile_template_builder.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 52e27baa80..9c77d2f7f7 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -832,14 +832,18 @@ class AbstractTemplateBuilder(ABC): host_name = self.host_name task_name = self.current_task_name task_type = self.current_task_type + folder_path = self.current_folder_path build_profiles = self._get_build_profiles() + filter_data = { + "task_types": task_type, + "task_names": task_name, + "folder_paths": folder_path + } profile = filter_profiles( build_profiles, - { - "task_types": task_type, - "task_names": task_name - } + filter_data, + logger=self.log ) if not profile: raise TemplateProfileNotFound(( From 2af5e918a745075c814c9b4d8dec08b3bfe5862b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:11:50 +0100 Subject: [PATCH 166/386] safe guard downloading of image for ayon_url --- client/ayon_core/tools/utils/lib.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index e087112a04..3308b943f0 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -548,11 +548,17 @@ class _IconsCache: elif icon_type == "ayon_url": url = icon_def["url"].lstrip("/") url = f"{ayon_api.get_base_url()}/{url}" - stream = io.BytesIO() - ayon_api.download_file_to_stream(url, stream) - pix = QtGui.QPixmap() - pix.loadFromData(stream.getvalue()) - icon = QtGui.QIcon(pix) + try: + stream = io.BytesIO() + ayon_api.download_file_to_stream(url, stream) + pix = QtGui.QPixmap() + pix.loadFromData(stream.getvalue()) + icon = QtGui.QIcon(pix) + except Exception: + log.warning( + "Failed to download image '%s'", url, exc_info=True + ) + icon = None elif icon_type == "transparent": size = icon_def.get("size") From 17f5788e43e980d308ef7f3442d59a05cbe7a216 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Nov 2025 12:56:01 +0200 Subject: [PATCH 167/386] Template Builder: Add folder types to filter data --- .../pipeline/workfile/workfile_template_builder.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 9c77d2f7f7..25b1c6737b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -834,12 +834,17 @@ class AbstractTemplateBuilder(ABC): task_type = self.current_task_type folder_path = self.current_folder_path - build_profiles = self._get_build_profiles() filter_data = { "task_types": task_type, "task_names": task_name, "folder_paths": folder_path } + + folder_entity = self.current_folder_entity + if folder_entity: + filter_data.update({"folder_types": folder_entity["folderType"]}) + + build_profiles = self._get_build_profiles() profile = filter_profiles( build_profiles, filter_data, From 6125a7db803dd2bfe878fa0fe255822b9a0655c6 Mon Sep 17 00:00:00 2001 From: Mustafa Zaky Jafar Date: Wed, 19 Nov 2025 13:15:37 +0200 Subject: [PATCH 168/386] Update client/ayon_core/pipeline/workfile/workfile_template_builder.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../pipeline/workfile/workfile_template_builder.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index dfb17b850f..6c0bc4c602 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -841,16 +841,18 @@ class AbstractTemplateBuilder(ABC): task_name = self.current_task_name task_type = self.current_task_type folder_path = self.current_folder_path + folder_type = None + folder_entity = self.current_folder_entity + if folder_entity: + folder_type = folder_entity["folderType"] filter_data = { "task_types": task_type, "task_names": task_name, - "folder_paths": folder_path + "folder_types": folder_type, + "folder_paths": folder_path, } - folder_entity = self.current_folder_entity - if folder_entity: - filter_data.update({"folder_types": folder_entity["folderType"]}) build_profiles = self._get_build_profiles() profile = filter_profiles( From 42da0fb424a0b3c07b2da8b38897851ebc025b61 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 19 Nov 2025 13:17:12 +0200 Subject: [PATCH 169/386] Make Ruff Happy. --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 6c0bc4c602..cbe9f82a81 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -853,7 +853,6 @@ class AbstractTemplateBuilder(ABC): "folder_paths": folder_path, } - build_profiles = self._get_build_profiles() profile = filter_profiles( build_profiles, From 17925292670462c518664d7fc2fb41b20c50c9db Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 19 Nov 2025 16:27:42 +0100 Subject: [PATCH 170/386] Safe-guard workfile template builder for versionless products --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index cbe9f82a81..2f9e7250c0 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1687,6 +1687,8 @@ class PlaceholderLoadMixin(object): for version in get_last_versions( project_name, filtered_product_ids, fields={"id"} ).values() + # Version may be none if a product has no versions + if version is not None ) return list(get_representations( project_name, From 626f627f581793749b6b74ccd98f8d733a23ee56 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 20 Nov 2025 14:59:51 +0100 Subject: [PATCH 171/386] fix product types fetching in loader tool --- .../ayon_core/tools/loader/models/products.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/models/products.py b/client/ayon_core/tools/loader/models/products.py index 7915a75bcf..83a017613d 100644 --- a/client/ayon_core/tools/loader/models/products.py +++ b/client/ayon_core/tools/loader/models/products.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Iterable, Optional import arrow import ayon_api +from ayon_api.graphql_queries import project_graphql_query from ayon_api.operations import OperationsSession from ayon_core.lib import NestedCacheItem @@ -202,7 +203,7 @@ class ProductsModel: cache = self._product_type_items_cache[project_name] if not cache.is_valid: icons_mapping = self._get_product_type_icons(project_name) - product_types = ayon_api.get_project_product_types(project_name) + product_types = self._get_project_product_types(project_name) cache.update_data([ ProductTypeItem( product_type["name"], @@ -462,6 +463,24 @@ class ProductsModel: PRODUCTS_MODEL_SENDER ) + def _get_project_product_types(self, project_name: str) -> list[dict]: + """This is a temporary solution for product types fetching. + + There was a bug in ayon_api.get_project(...) which did not use GraphQl + but REST instead. That is fixed in ayon-python-api 1.2.6 that will + be as part of ayon launcher 1.4.3 release. + + """ + if not project_name: + return [] + query = project_graphql_query({"productTypes.name"}) + query.set_variable_value("projectName", project_name) + parsed_data = query.query(ayon_api.get_server_api_connection()) + project = parsed_data["project"] + if project is None: + return [] + return project["productTypes"] + def _get_product_type_icons( self, project_name: Optional[str] ) -> ProductTypeIconMapping: From cebf3be97fd2dc4f7f2192ad08e7001359a41917 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 20 Nov 2025 14:37:22 +0000 Subject: [PATCH 172/386] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index a220d400d4..5c9b1cdce8 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.10+dev" +__version__ = "1.6.11" diff --git a/package.py b/package.py index bb9c1b0c7e..9bdf309bab 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.10+dev" +version = "1.6.11" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index e61c2708de..e30eb1d5d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.10+dev" +version = "1.6.11" description = "" authors = ["Ynput Team "] readme = "README.md" From 1ac26453d5f926a6692ca4127dc6820899d0eecb Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 20 Nov 2025 14:38:01 +0000 Subject: [PATCH 173/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 5c9b1cdce8..a3e1a6c939 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.11" +__version__ = "1.6.11+dev" diff --git a/package.py b/package.py index 9bdf309bab..62231060f0 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.11" +version = "1.6.11+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index e30eb1d5d9..d568edefc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.11" +version = "1.6.11+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 5bccc7cf2bcdc51e446cf7794f09d6081ff7255a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Nov 2025 14:39:23 +0000 Subject: [PATCH 174/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 78e86f43e4..7fc253b1b8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.11 - 1.6.10 - 1.6.9 - 1.6.8 From 04527b00616908377547a6c1a71a88c1a2db7f76 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 21 Nov 2025 19:06:36 +0100 Subject: [PATCH 175/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20change=20usage=20o?= =?UTF-8?q?f=20product=5Fbase=5Ftypes=20in=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/create/creator_plugins.py | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 480ef28432..93dd763ed9 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,4 +1,6 @@ """Creator plugins for the create process.""" +from __future__ import annotations + import collections import copy import os @@ -7,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Dict, Optional from warnings import warn from ayon_core.lib import Logger, get_version_from_path -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path, @@ -274,7 +275,7 @@ class BaseCreator(ABC): # - those may be potential dangerous typos in settings if not hasattr(self, key): self.log.debug( - "Applying settings to unknown attribute '%' on '%'.", + "Applying settings to unknown attribute '%s' on '%s'.", key, cls_name ) setattr(self, key, value) @@ -293,11 +294,9 @@ class BaseCreator(ABC): Default implementation returns plugin's product type. """ - identifier = self.product_type - if is_product_base_type_supported(): - identifier = self.product_base_type - if self.product_type: - identifier = f"{identifier}.{self.product_type}" + identifier = self.product_base_type + if not identifier: + identifier = self.product_type return identifier @property @@ -399,19 +398,14 @@ class BaseCreator(ABC): if product_type is None: product_type = self.product_type - if ( - is_product_base_type_supported() - and not product_base_type - and not self.product_base_type - ): + if not product_base_type and not self.product_base_type: warn( f"Creator {self.identifier} does not support " "product base type. This will be required in future.", DeprecationWarning, stacklevel=2, ) - else: - product_base_type = self.product_base_type + product_base_type = product_type instance = CreatedInstance( product_type=product_type, @@ -534,7 +528,6 @@ class BaseCreator(ABC): host_name: Optional[str] = None, instance: Optional[CreatedInstance] = None, project_entity: Optional[dict[str, Any]] = None, - product_base_type: Optional[str] = None, ) -> str: """Return product name for passed context. @@ -552,11 +545,11 @@ class BaseCreator(ABC): for which is product name updated. Passed only on product name update. project_entity (Optional[dict[str, Any]]): Project entity. - product_base_type (Optional[str]): Product base type. """ - if is_product_base_type_supported() and (instance and hasattr(instance, "product_base_type")): # noqa: E501 - product_base_type = instance.product_base_type + product_base_type = None + if hasattr(self, "product_base_type"): # noqa: E501 + product_base_type = self.product_base_type if host_name is None: host_name = self.create_context.host_name @@ -589,7 +582,8 @@ class BaseCreator(ABC): dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, - product_base_type=product_base_type + # until we make product_base_type mandatory + product_base_type=self.product_base_type ) def get_instance_attr_defs(self): From 73dfff9191e4ba5d159d2d495c71950d60389236 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 22 Nov 2025 13:28:08 +0100 Subject: [PATCH 176/386] Fix call to `get_representation_path_by_names` --- .../plugins/publish/extract_usd_layer_contributions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 4d8a8005f2..ec6b2b9792 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -177,7 +177,10 @@ def get_instance_uri_path( # If for whatever reason we were unable to retrieve from the context # then get the path from an existing database entry - path = get_representation_path_by_names(**query) + path = get_representation_path_by_names( + anatomy=context.data["anatomy"], + **names + ) # Ensure `None` for now is also a string path = str(path) From 70bf746c7acac36d8cf3670843b862cf08d82537 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 22 Nov 2025 13:29:49 +0100 Subject: [PATCH 177/386] Fail clearly if the path can't be resolved instead of setting path to "None" --- .../plugins/publish/extract_usd_layer_contributions.py | 2 ++ 1 file changed, 2 insertions(+) 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 ec6b2b9792..d5c5aca0f2 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -181,6 +181,8 @@ def get_instance_uri_path( anatomy=context.data["anatomy"], **names ) + if not path: + raise RuntimeError(f"Unable to resolve publish path for: {names}") # Ensure `None` for now is also a string path = str(path) From 58432ff4dd3aad7b879e72a6dcf158dce381d874 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Nov 2025 23:55:12 +0100 Subject: [PATCH 178/386] Re-show 'initialize as' attribute for USD publish so it's clear what is going on with the initial layer. --- .../plugins/publish/extract_usd_layer_contributions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 9db8c49a02..4dec4d8b9b 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -494,7 +494,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "asset" if profile.get("contribution_target_product") == "usdAsset" else "shot") - init_as_visible = False + init_as_visible = True # Attributes logic publish_attributes = instance["publish_attributes"].get( @@ -828,6 +828,7 @@ class ExtractUSDAssetContribution(publish.Extractor): # If no existing publish of this product exists then we initialize # the layer as either a default asset or shot structure. init_type = instance.data["contribution_target_product_init"] + self.log.debug("Initializing layer as type: %s", init_type) asset_layer, payload_layer = self.init_layer( asset_name=folder_name, init_type=init_type ) From 2aa7e46c9c94e5224c74c043971749f9b3c8f671 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Nov 2025 23:58:09 +0100 Subject: [PATCH 179/386] Cosmetic type hints --- .../plugins/publish/extract_usd_layer_contributions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4dec4d8b9b..d73a417f16 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -910,7 +910,7 @@ class ExtractUSDAssetContribution(publish.Extractor): payload_layer.Export(payload_path, args={"format": "usda"}) self.add_relative_file(instance, payload_path) - def init_layer(self, asset_name, init_type): + def init_layer(self, asset_name: str, init_type: str): """Initialize layer if no previous version exists""" if init_type == "asset": From 344f91c983256d5a29641c5e809761ab39b60f64 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 26 Nov 2025 00:38:37 +0100 Subject: [PATCH 180/386] Make settings profiles more granular for OIIO post process --- .../publish/extract_oiio_postprocess.py | 46 +++++++++++++------ server/settings/publish_plugins.py | 10 ++++ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 2e93c68283..610f464989 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Any, Optional import os import copy import clique @@ -49,19 +51,21 @@ class ExtractOIIOPostProcess(publish.Extractor): self.log.warning("OIIO not supported, no transcoding possible.") return - profile = self._get_profile( - instance - ) - if not profile: - return - - profile_output_defs = profile["outputs"] new_representations = [] for idx, repre in enumerate(list(instance.data["representations"])): self.log.debug("repre ({}): `{}`".format(idx + 1, repre["name"])) if not self._repre_is_valid(repre): continue + # We check profile per representation name and extension because + # it's included in the profile check. As such, an instance may have + # a different profile applied per representation. + profile = self._get_profile( + instance + ) + if not profile: + continue + # Get representation files to convert if isinstance(repre["files"], list): repre_files_to_convert = copy.deepcopy(repre["files"]) @@ -72,7 +76,7 @@ class ExtractOIIOPostProcess(publish.Extractor): added_review = False # Process each output definition - for output_def in profile_output_defs: + for output_def in profile["outputs"]: # Local copy to avoid accidental mutable changes files_to_convert = list(repre_files_to_convert) @@ -255,7 +259,7 @@ class ExtractOIIOPostProcess(publish.Extractor): output_extension) return os.path.join(output_dir, new_file_name) - def _get_profile(self, instance): + def _get_profile(self, instance: pyblish.api.Instance, repre: dict) -> Optional[dict[str, Any]]: """Returns profile if it should process this instance.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] @@ -263,24 +267,30 @@ class ExtractOIIOPostProcess(publish.Extractor): task_data = instance.data["anatomyData"].get("task", {}) task_name = task_data.get("name") task_type = task_data.get("type") + repre_name: str = repre["name"] + repre_ext: str = repre["ext"] filtering_criteria = { "hosts": host_name, "product_types": product_type, "product_names": product_name, "task_names": task_name, "task_types": task_type, + "representation_names": repre_name, + "representation_exts": repre_ext, } profile = filter_profiles(self.profiles, filtering_criteria, logger=self.log) if not profile: - self.log.debug(( + self.log.debug( "Skipped instance. None of profiles in presets are for" - " Host: \"{}\" | Product types: \"{}\" | Product names: \"{}\"" - " | Task name \"{}\" | Task type \"{}\"" - ).format( - host_name, product_type, product_name, task_name, task_type - )) + f" Host: \"{host_name}\" |" + f" Product types: \"{product_type}\" |" + f" Product names: \"{product_name}\" |" + f" Task name \"{task_name}\" |" + f" Task type \"{task_type}\" |" + f" Representation: \"{repre_name}\" (.{repre_ext})" + ) return profile @@ -305,6 +315,12 @@ class ExtractOIIOPostProcess(publish.Extractor): ).format(repre["name"])) return False + if "delete" in repre.get("tags", []): + self.log.debug(( + "Representation '{}' has 'delete' tag. Skipped." + ).format(repre["name"])) + return False + return True def _mark_original_repre_for_deletion(self, repre, profile, added_review): diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 173526e13f..2c133ddbbf 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -621,6 +621,7 @@ class ExtractOIIOPostProcessOutputModel(BaseSettingsModel): class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): product_types: list[str] = SettingsField( + section="Profile", default_factory=list, title="Product types" ) @@ -641,6 +642,14 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): default_factory=list, title="Product names" ) + representation_names: list[str] = SettingsField( + default_factory=list, + title="Representation names", + ) + representation_exts: list[str] = SettingsField( + default_factory=list, + title="Representation extensions", + ) delete_original: bool = SettingsField( True, title="Delete Original Representation", @@ -650,6 +659,7 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): " a `review` tag, it will take precedence over" " the original for creating reviews." ), + section="Conversion Outputs", ) outputs: list[ExtractOIIOPostProcessOutputModel] = SettingsField( default_factory=list, From 4f332766f0ccc9071d6f189abce8ed9fe67e9e6a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 26 Nov 2025 10:22:17 +0100 Subject: [PATCH 181/386] Use `IMAGE_EXTENSIONS` --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 610f464989..33384e0185 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -14,6 +14,7 @@ from ayon_core.lib import ( get_oiio_tool_args, run_subprocess ) +from ayon_core.lib.transcoding import IMAGE_EXTENSIONS from ayon_core.lib.profiles_filtering import filter_profiles @@ -32,7 +33,7 @@ class ExtractOIIOPostProcess(publish.Extractor): optional = True # Supported extensions - supported_exts = {"exr", "jpg", "jpeg", "png", "dpx"} + supported_exts = {ext.lstrip(".") for ext in IMAGE_EXTENSIONS} # Configurable by Settings profiles = None From d6431a49908f3bc5bd14b39f2c0c18ce6f7e3137 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 26 Nov 2025 12:17:13 +0100 Subject: [PATCH 182/386] added overload functionality --- .../ayon_core/pipeline/create/product_name.py | 117 +++++++++++++++++- 1 file changed, 112 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ee07f939bc..a85b12f0df 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -2,7 +2,7 @@ from __future__ import annotations import warnings from functools import wraps -from typing import Optional, Any +from typing import Optional, Any, overload import ayon_api from ayon_core.lib import ( @@ -216,7 +216,7 @@ def _backwards_compatibility_product_name(func): return inner -@_backwards_compatibility_product_name +@overload def get_product_name( project_name: str, folder_entity: dict[str, Any], @@ -241,9 +241,116 @@ def get_product_name( That's main reason why so many arguments are required to calculate product name. - Todos: - Find better filtering options to avoid requirement of - argument 'family_filter'. + Args: + project_name (str): Project name. + folder_entity (Optional[dict[str, Any]]): Folder entity. + task_entity (Optional[dict[str, Any]]): Task entity. + host_name (str): Host name. + product_type (str): Product type. + variant (str): In most of the cases it is user input during creation. + default_template (Optional[str]): Default template if any profile does + not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' + is used if is not passed. + dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for + a creator which creates instance. + project_settings (Optional[dict[str, Any]]): Prepared settings + for project. Settings are queried if not passed. + product_type_filter (Optional[str]): Use different product type for + product template filtering. Value of `product_type` is used when + not passed. + project_entity (Optional[dict[str, Any]]): Project entity used when + task short name is required by template. + + Returns: + TemplateResult: Product name. + + Raises: + TaskNotSetError: If template requires task which is not provided. + TemplateFillError: If filled template contains placeholder key which + is not collected. + + """ + + +@overload +def get_product_name( + project_name, + task_name, + task_type, + host_name, + product_type, + variant, + default_template=None, + dynamic_data=None, + project_settings=None, + product_type_filter=None, + project_entity=None, +) -> TemplateResult: + """Calculate product name based on passed context and AYON settings. + + Product name templates are defined in `project_settings/global/tools + /creator/product_name_profiles` where are profiles with host name, + product type, task name and task type filters. If context does not match + any profile then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate product + name. + + Deprecated: + This function is using deprecate signature that does not support + folder entity data to be used. + + Args: + project_name (str): Project name. + task_name (Optional[str]): Task name. + task_type (Optional[str]): Task type. + host_name (str): Host name. + product_type (str): Product type. + variant (str): In most of the cases it is user input during creation. + default_template (Optional[str]): Default template if any profile does + not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' + is used if is not passed. + dynamic_data (Optional[Dict[str, Any]]): Dynamic data specific for + a creator which creates instance. + project_settings (Optional[Union[Dict[str, Any]]]): Prepared settings + for project. Settings are queried if not passed. + product_type_filter (Optional[str]): Use different product type for + product template filtering. Value of `product_type` is used when + not passed. + project_entity (Optional[Dict[str, Any]]): Project entity used when + task short name is required by template. + + Returns: + TemplateResult: Product name. + + """ + pass + + +@_backwards_compatibility_product_name +def get_product_name( + project_name: str, + folder_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], + host_name: str, + product_type: str, + variant: str, + *, + default_template: Optional[str] = None, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = None, + product_type_filter: Optional[str] = None, + project_entity: Optional[dict[str, Any]] = None, +) -> TemplateResult: + """Calculate product name based on passed context and AYON settings. + + Product name templates are defined in `project_settings/global/tools/creator + /product_name_profiles` where are profiles with host name, product type, + task name and task type filters. If context does not match any profile + then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate product + name. Args: project_name (str): Project name. From 2cf392633e24b4465e846dbd534bd7461730da44 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 14:08:50 +0100 Subject: [PATCH 183/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20unnecessa?= =?UTF-8?q?ry=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ayon_core/pipeline/create/product_name.py | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ab7de0c9e8..f1076e51b3 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -10,7 +10,6 @@ from ayon_core.lib import ( filter_profiles, prepare_template_data, ) -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.settings import get_project_settings from .constants import DEFAULT_PRODUCT_TEMPLATE @@ -36,10 +35,10 @@ def get_product_name_template( host_name (str): Name of host in which the product name is calculated. task_name (str): Name of task in which context the product is created. task_type (str): Type of task in which context the product is created. - default_template (Optional, str): Default template which is used if + default_template (Optional[str]): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. - project_settings (Union[Dict[str, Any], None]): Prepared settings for + project_settings (Optional[dict[str, Any]]): Prepared settings for project. Settings are queried if not passed. product_base_type (Optional[str]): Base type of product. @@ -58,14 +57,16 @@ def get_product_name_template( "task_types": task_type } - if is_product_base_type_supported(): - if product_base_type: - filtering_criteria["product_base_types"] = product_base_type - else: - warn( - "Product base type is not provided, please update your" - "creation code to include it. It will be required in " - "the future.", DeprecationWarning, stacklevel=2) + if not product_base_type: + warn( + "Product base type is not provided, please update your" + "creation code to include it. It will be required in " + "the future.", + DeprecationWarning, + stacklevel=2 + ) + filtering_criteria["product_base_types"] = product_base_type + matching_profile = filter_profiles(profiles, filtering_criteria) template = None @@ -192,16 +193,20 @@ def get_product_name( # look what we have to do to make mypy happy. We should stop using # those undefined dict based types. - product: dict[str, str] = {"type": product_type} - if is_product_base_type_supported(): - if product_base_type: - product["baseType"] = product_base_type - elif "{product[basetype]}" in template.lower(): - warn( - "You have Product base type in product name template," - "but it is not provided by the creator, please update your" - "creation code to include it. It will be required in " - "the future.", DeprecationWarning, stacklevel=2) + product: dict[str, str] = { + "type": product_type, + "baseType": product_base_type + } + if not product_base_type and "{product[basetype]}" in template.lower(): + product["baseType"] = product_type + warn( + "You have Product base type in product name template, " + "but it is not provided by the creator, please update your " + "creation code to include it. It will be required in " + "the future.", + DeprecationWarning, + stacklevel=2) + fill_pairs: dict[str, Union[str, dict[str, str]]] = { "variant": variant, "family": product_type, From 05547c752ee788ec42fcf6c0b3235fc6f981353a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 14:27:22 +0100 Subject: [PATCH 184/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20the=20che?= =?UTF-8?q?ck=20for=20product=20base=20type=20support=20-=20publisher=20mo?= =?UTF-8?q?del?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/tools/publisher/models/create.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index ed3fd04d5c..86f0cd2d07 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -35,7 +35,6 @@ from ayon_core.pipeline.create import ( ConvertorsOperationFailed, ConvertorItem, ) -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, @@ -666,18 +665,14 @@ class CreateModel: kwargs = { "instance": instance, "project_entity": project_entity, + "product_base_type": creator.product_base_type, } - - if is_product_base_type_supported() and hasattr(creator, "product_base_type"): # noqa: E501 - kwargs["product_base_type"] = creator.product_base_type - # Backwards compatibility for 'project_entity' argument # - 'get_product_name' signature changed 24/07/08 if not is_func_signature_supported( creator.get_product_name, *args, **kwargs ): kwargs.pop("project_entity") - kwargs.pop("product_base_type") return creator.get_product_name(*args, **kwargs) def create( From b967f8f818b74e15ce62e06374c6213d721ecf1f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:01:25 +0100 Subject: [PATCH 185/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20consolidate=20warn?= =?UTF-8?q?inings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/context.py | 27 +++++++++---------- .../pipeline/create/creator_plugins.py | 11 -------- .../ayon_core/pipeline/create/product_name.py | 13 ++------- .../ayon_core/pipeline/create/structures.py | 16 +++-------- 4 files changed, 17 insertions(+), 50 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6df437202e..0350c00977 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -29,7 +29,6 @@ from ayon_core.host import IWorkfileHost, IPublishHost from ayon_core.pipeline import Anatomy from ayon_core.pipeline.template_data import get_template_data from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline.compatibility import is_product_base_type_supported from .exceptions import ( CreatorError, @@ -1237,6 +1236,15 @@ class CreateContext: """ creator = self._get_creator_in_create(creator_identifier) + if not hasattr(creator, "product_base_type"): + warn( + f"Provided creator {creator!r} doesn't have " + "product base type attribute defined. This will be " + "required in future.", + DeprecationWarning, + stacklevel=2 + ) + project_name = self.project_name if folder_entity is None: folder_path = self.get_current_folder_path() @@ -1290,23 +1298,12 @@ class CreateContext: "folderPath": folder_entity["path"], "task": task_entity["name"] if task_entity else None, "productType": creator.product_type, + # Add product base type if supported. Fallback to product type + "productBaseType": ( + creator.product_base_type or creator.product_type), "variant": variant } - # Add product base type if supported. - # TODO (antirotor): Once all creators support product base type - # remove this check. - if is_product_base_type_supported(): - - instance_data["productBaseType"] = creator.product_base_type - if creator.product_base_type is None: - msg = ( - f"Creator {creator_identifier} does not set " - "product base type. This will be required in future." - ) - warn(msg, DeprecationWarning, stacklevel=2) - self.log.warning(msg) - if active is not None: if not isinstance(active, bool): self.log.warning( diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 93dd763ed9..21d8596dea 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -6,7 +6,6 @@ import copy import os from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Dict, Optional -from warnings import warn from ayon_core.lib import Logger, get_version_from_path from ayon_core.pipeline.plugin_discover import ( @@ -399,12 +398,6 @@ class BaseCreator(ABC): product_type = self.product_type if not product_base_type and not self.product_base_type: - warn( - f"Creator {self.identifier} does not support " - "product base type. This will be required in future.", - DeprecationWarning, - stacklevel=2, - ) product_base_type = product_type instance = CreatedInstance( @@ -547,10 +540,6 @@ class BaseCreator(ABC): project_entity (Optional[dict[str, Any]]): Project entity. """ - product_base_type = None - if hasattr(self, "product_base_type"): # noqa: E501 - product_base_type = self.product_base_type - if host_name is None: host_name = self.create_context.host_name diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f1076e51b3..c4ddb34652 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -57,16 +57,7 @@ def get_product_name_template( "task_types": task_type } - if not product_base_type: - warn( - "Product base type is not provided, please update your" - "creation code to include it. It will be required in " - "the future.", - DeprecationWarning, - stacklevel=2 - ) filtering_criteria["product_base_types"] = product_base_type - matching_profile = filter_profiles(profiles, filtering_criteria) template = None @@ -127,8 +118,8 @@ def get_product_name( Args: project_name (str): Project name. - task_name (str): Task name. - task_type (str): Task type. + task_name (Optional[str]): Task name. + task_type (Optional[str]): Task type. host_name (str): Host name. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index e93270b357..dfa9d69938 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -4,7 +4,6 @@ from uuid import uuid4 from enum import Enum import typing from typing import Optional, Dict, List, Any -from warnings import warn from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -521,17 +520,9 @@ class CreatedInstance: product_base_type: Optional[str] = None ): """Initialize CreatedInstance.""" - if is_product_base_type_supported(): - if not hasattr(creator, "product_base_type"): - warn( - f"Provided creator {creator!r} doesn't have " - "product base type attribute defined. This will be " - "required in future.", - DeprecationWarning, - stacklevel=2 - ) - elif not product_base_type: - product_base_type = creator.product_base_type + # fallback to product type for backward compatibility + if not product_base_type: + product_base_type = creator.product_base_type or product_type self._creator = creator creator_identifier = creator.identifier @@ -587,7 +578,6 @@ class CreatedInstance: self._data["productName"] = product_name if is_product_base_type_supported(): - data.pop("productBaseType", None) self._data["productBaseType"] = product_base_type self._data["active"] = data.get("active", True) From 1cddb86918fbd2806cba78c549c2a0ca971caa30 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:17:19 +0100 Subject: [PATCH 186/386] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20fix=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/create/test_product_name.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py index a8a8566aa8..03b13d2c25 100644 --- a/tests/client/ayon_core/pipeline/create/test_product_name.py +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -20,7 +20,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_matching_profile_with_replacements( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -33,7 +32,6 @@ class TestGetProductNameTemplate: "template": ("{task}-{Task}-{TASK}-{family}-{Family}" "-{FAMILY}-{asset}-{Asset}-{ASSET}") } - mock_is_supported.return_value = False result = get_product_name_template( project_name="proj", @@ -54,7 +52,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_no_matching_profile_uses_default( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -62,7 +59,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = False assert ( get_product_name_template( @@ -81,7 +77,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_custom_default_template_used( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -89,7 +84,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = False custom_default = "{variant}_{family}" assert ( @@ -111,7 +105,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_product_base_type_warns_when_supported_and_missing( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, mock_warn, @@ -120,7 +113,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = True get_product_name_template( project_name="proj", @@ -137,7 +129,6 @@ class TestGetProductNameTemplate: "is_product_base_type_supported") def test_product_base_type_added_to_filtering_when_provided( self, - mock_is_supported, mock_filter_profiles, mock_get_settings, ): @@ -145,7 +136,6 @@ class TestGetProductNameTemplate: "core": {"tools": {"creator": {"product_name_profiles": []}}} } mock_filter_profiles.return_value = None - mock_is_supported.return_value = True get_product_name_template( project_name="proj", @@ -308,8 +298,6 @@ class TestGetProductName: ) @patch("ayon_core.pipeline.create.product_name.warn") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") @patch("ayon_core.pipeline.create.product_name.get_product_name_template") @patch("ayon_core.pipeline.create.product_name." "StringTemplate.format_strict_template") @@ -319,11 +307,10 @@ class TestGetProductName: mock_prepare, mock_format, mock_get_tmpl, - mock_is_supported, mock_warn, ): mock_get_tmpl.return_value = "{product[basetype]}_{variant}" - mock_is_supported.return_value = True + mock_prepare.return_value = { "product": {"type": "model"}, "variant": "Main", From 794bb716b268a385dd978dab1099bba13ffb01de Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:25:54 +0100 Subject: [PATCH 187/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20small=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/product_name.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index c4ddb34652..bf3d3b0abc 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -54,11 +54,9 @@ def get_product_name_template( "product_types": product_type, "hosts": host_name, "tasks": task_name, - "task_types": task_type + "task_types": task_type, + "product_base_types": product_base_type, } - - filtering_criteria["product_base_types"] = product_base_type - matching_profile = filter_profiles(profiles, filtering_criteria) template = None if matching_profile: From 00e2e3c2ade7b46c81377f083f8a321aae90c3e0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 15:33:43 +0100 Subject: [PATCH 188/386] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20fix=20type=20hi?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/product_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index bf3d3b0abc..f5a7418b57 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -83,8 +83,8 @@ def get_product_name_template( def get_product_name( project_name: str, task_name: str, - task_type: str, - host_name: str, + task_type: Optional[str], + host_name: Optional[str], product_type: str, variant: str, default_template: Optional[str] = None, From e6007b2cee79e82521850ce5d3658eb5a8d7279d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:25:10 +0100 Subject: [PATCH 189/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit type hints, checks --- client/ayon_core/pipeline/create/context.py | 18 +++++++++--------- .../pipeline/create/creator_plugins.py | 4 ++-- .../ayon_core/pipeline/create/product_name.py | 16 ++++++---------- client/ayon_core/pipeline/create/structures.py | 4 +--- 4 files changed, 18 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0350c00977..a09f1924da 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -766,6 +766,15 @@ class CreateContext: "and skipping: %s", creator_identifier, creator_class ) continue + if not creator_class.product_base_type: + warn( + f"Provided creator {creator_class!r} doesn't have " + "product base type attribute defined. This will be " + "required in future.", + DeprecationWarning, + stacklevel=2 + ) + continue # Filter by host name if ( @@ -1236,15 +1245,6 @@ class CreateContext: """ creator = self._get_creator_in_create(creator_identifier) - if not hasattr(creator, "product_base_type"): - warn( - f"Provided creator {creator!r} doesn't have " - "product base type attribute defined. This will be " - "required in future.", - DeprecationWarning, - stacklevel=2 - ) - project_name = self.project_name if folder_entity is None: folder_path = self.get_current_folder_path() diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 21d8596dea..92eb3b6946 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -405,7 +405,7 @@ class BaseCreator(ABC): product_name=product_name, data=data, creator=self, - product_base_type=product_base_type + product_base_type=product_base_type, ) self._add_instance_to_context(instance) return instance @@ -516,7 +516,7 @@ class BaseCreator(ABC): self, project_name: str, folder_entity: dict[str, Any], - task_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], variant: str, host_name: Optional[str] = None, instance: Optional[CreatedInstance] = None, diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f5a7418b57..cc1014173c 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -82,9 +82,9 @@ def get_product_name_template( def get_product_name( project_name: str, - task_name: str, + task_name: Optional[str], task_type: Optional[str], - host_name: Optional[str], + host_name: str, product_type: str, variant: str, default_template: Optional[str] = None, @@ -180,14 +180,7 @@ def get_product_name( task_short = task_types_by_name.get(task_type, {}).get("shortName") task_value["short"] = task_short - # look what we have to do to make mypy happy. We should stop using - # those undefined dict based types. - product: dict[str, str] = { - "type": product_type, - "baseType": product_base_type - } if not product_base_type and "{product[basetype]}" in template.lower(): - product["baseType"] = product_type warn( "You have Product base type in product name template, " "but it is not provided by the creator, please update your " @@ -200,7 +193,10 @@ def get_product_name( "variant": variant, "family": product_type, "task": task_value, - "product": product, + "product": { + "type": product_type, + "baseType": product_base_type or product_type, + } } if dynamic_data: diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index dfa9d69938..6f53a61b25 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -12,7 +12,6 @@ from ayon_core.lib.attribute_definitions import ( deserialize_attr_defs, ) -from ayon_core.pipeline.compatibility import is_product_base_type_supported from ayon_core.pipeline import ( AYON_INSTANCE_ID, @@ -577,8 +576,7 @@ class CreatedInstance: self._data["productType"] = product_type self._data["productName"] = product_name - if is_product_base_type_supported(): - self._data["productBaseType"] = product_base_type + self._data["productBaseType"] = product_base_type self._data["active"] = data.get("active", True) self._data["creator_identifier"] = creator_identifier From 700b025024faf91a54a904d10b0bd7b45fa81f6e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:34:01 +0100 Subject: [PATCH 190/386] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20plugin=20ch?= =?UTF-8?q?eck=20earlier,=20fix=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/pipeline/create/context.py | 16 +++++++++------- client/ayon_core/pipeline/create/product_name.py | 8 ++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a09f1924da..f1a5b0e9f8 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -759,13 +759,6 @@ class CreateContext: ) for creator_class in report.plugins: - creator_identifier = creator_class.identifier - if creator_identifier in creators: - self.log.warning( - "Duplicate Creator identifier: '%s'. Using first Creator " - "and skipping: %s", creator_identifier, creator_class - ) - continue if not creator_class.product_base_type: warn( f"Provided creator {creator_class!r} doesn't have " @@ -776,6 +769,15 @@ class CreateContext: ) continue + creator_identifier = creator_class.identifier + if creator_identifier in creators: + self.log.warning( + "Duplicate Creator identifier: '%s'. Using first Creator " + "and skipping: %s", creator_identifier, creator_class + ) + continue + + # Filter by host name if ( creator_class.host_name diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index cc1014173c..7f87145595 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -19,8 +19,8 @@ from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( project_name: str, product_type: str, - task_name: str, - task_type: str, + task_name: Optional[str], + task_type: Optional[str], host_name: str, default_template: Optional[str] = None, project_settings: Optional[dict[str, Any]] = None, @@ -33,8 +33,8 @@ def get_product_name_template( product_type (str): Product type for which the product name is calculated. host_name (str): Name of host in which the product name is calculated. - task_name (str): Name of task in which context the product is created. - task_type (str): Type of task in which context the product is created. + task_name (Optional[str]): Name of task in which context the product is created. + task_type (Optional[str]): Type of task in which context the product is created. default_template (Optional[str]): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. From bb430342d8b7b7d395d8af99916297f52353d047 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:35:49 +0100 Subject: [PATCH 191/386] :dog: fix linter --- client/ayon_core/pipeline/create/context.py | 1 - client/ayon_core/pipeline/create/product_name.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index f1a5b0e9f8..2b9556d005 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -777,7 +777,6 @@ class CreateContext: ) continue - # Filter by host name if ( creator_class.host_name diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 7f87145595..d59e8f9b67 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -33,8 +33,10 @@ def get_product_name_template( product_type (str): Product type for which the product name is calculated. host_name (str): Name of host in which the product name is calculated. - task_name (Optional[str]): Name of task in which context the product is created. - task_type (Optional[str]): Type of task in which context the product is created. + task_name (Optional[str]): Name of task in which context the + product is created. + task_type (Optional[str]): Type of task in which context the + product is created. default_template (Optional[str]): Default template which is used if settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. From b0005180f269f6139ca355c897052e4b417116eb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:40:27 +0100 Subject: [PATCH 192/386] :alembic: fix tests --- .../pipeline/create/test_product_name.py | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/tests/client/ayon_core/pipeline/create/test_product_name.py b/tests/client/ayon_core/pipeline/create/test_product_name.py index 03b13d2c25..7181e18b43 100644 --- a/tests/client/ayon_core/pipeline/create/test_product_name.py +++ b/tests/client/ayon_core/pipeline/create/test_product_name.py @@ -16,8 +16,6 @@ from ayon_core.pipeline.create.exceptions import ( class TestGetProductNameTemplate: @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_matching_profile_with_replacements( self, mock_filter_profiles, @@ -48,8 +46,6 @@ class TestGetProductNameTemplate: @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_no_matching_profile_uses_default( self, mock_filter_profiles, @@ -73,8 +69,6 @@ class TestGetProductNameTemplate: @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_custom_default_template_used( self, mock_filter_profiles, @@ -98,35 +92,8 @@ class TestGetProductNameTemplate: == custom_default ) - @patch("ayon_core.pipeline.create.product_name.warn") @patch("ayon_core.pipeline.create.product_name.get_project_settings") @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") - def test_product_base_type_warns_when_supported_and_missing( - self, - mock_filter_profiles, - mock_get_settings, - mock_warn, - ): - mock_get_settings.return_value = { - "core": {"tools": {"creator": {"product_name_profiles": []}}} - } - mock_filter_profiles.return_value = None - - get_product_name_template( - project_name="proj", - product_type="model", - task_name="modeling", - task_type="Modeling", - host_name="maya", - ) - mock_warn.assert_called_once() - - @patch("ayon_core.pipeline.create.product_name.get_project_settings") - @patch("ayon_core.pipeline.create.product_name.filter_profiles") - @patch("ayon_core.pipeline.create.product_name." - "is_product_base_type_supported") def test_product_base_type_added_to_filtering_when_provided( self, mock_filter_profiles, From 3a24db94f51915440b0a963b1ef9402d1681f0c0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:45:17 +0100 Subject: [PATCH 193/386] :memo: log deprecation warning --- client/ayon_core/pipeline/create/context.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 2b9556d005..6495a9d6e9 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -760,13 +760,15 @@ class CreateContext: for creator_class in report.plugins: if not creator_class.product_base_type: - warn( - f"Provided creator {creator_class!r} doesn't have " + message = (f"Provided creator {creator_class!r} doesn't have " "product base type attribute defined. This will be " - "required in future.", + "required in future.") + warn( + message, DeprecationWarning, stacklevel=2 ) + self.log.warning(message) continue creator_identifier = creator_class.identifier From 64f549c4956376b2c8fc92fc80cecfeb136a6432 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:48:52 +0100 Subject: [PATCH 194/386] ugly thing in name of compatibility? --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index d59e8f9b67..f10e375fb1 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -170,7 +170,7 @@ def get_product_name( "type": task_type, } if "{task}" in template.lower(): - task_value["name"] = task_name + task_value = task_name elif "{task[short]}" in template.lower(): if project_entity is None: From 1f88b0031dcdd72507c966cfb1dec0352fdc5ed3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 26 Nov 2025 17:56:35 +0100 Subject: [PATCH 195/386] :recycle: fix discovery --- client/ayon_core/pipeline/create/context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6495a9d6e9..08b6574db8 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -769,7 +769,6 @@ class CreateContext: stacklevel=2 ) self.log.warning(message) - continue creator_identifier = creator_class.identifier if creator_identifier in creators: From ba6a9bdca4a8c301c8bf8122f4a0bd9afa620a46 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 00:41:34 +0100 Subject: [PATCH 196/386] Allow OCIO color management profiles that have no matching profile or do match a profile with "disabled" status to be considered as NOT color managed. So that you can specify a particular part of the project to NOT be OCIO color managed. --- client/ayon_core/pipeline/colorspace.py | 56 ++++++++++++++++--------- server/settings/main.py | 1 + 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 41241e17ca..db7d287cf1 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -7,6 +7,7 @@ import platform import tempfile import warnings from copy import deepcopy +from dataclasses import dataclass import ayon_api @@ -25,6 +26,17 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy log = Logger.get_logger(__name__) +@dataclass +class ConfigData: + """OCIO Config to use in a certain context. + + When enabled and no path/template are set, it will be considered invalid + and will error on OCIO path not found. Enabled must be False to explicitly + allow OCIO to be disabled.""" + path: str = "" + template: str = "" + enabled: bool = True + class CachedData: remapping = {} @@ -710,7 +722,7 @@ def _get_config_path_from_profile_data( template_data (dict[str, Any]): Template data. Returns: - dict[str, str]: Config data with path and template. + ConfigData: Config data with path and template. """ template = profile[profile_type] result = StringTemplate.format_strict_template( @@ -719,12 +731,12 @@ def _get_config_path_from_profile_data( normalized_path = str(result.normalized()) if not os.path.exists(normalized_path): log.warning(f"Path was not found '{normalized_path}'.") - return None + return ConfigData() # Return invalid config data - return { - "path": normalized_path, - "template": template - } + return ConfigData( + path=normalized_path, + template=template + ) def _get_global_config_data( @@ -735,7 +747,7 @@ def _get_global_config_data( imageio_global, folder_id, log, -): +) -> ConfigData: """Get global config data. Global config from core settings is using profiles that are based on @@ -759,8 +771,7 @@ def _get_global_config_data( log (logging.Logger): Logger object. Returns: - Union[dict[str, str], None]: Config data with path and template - or None. + ConfigData: Config data with path and template. """ task_name = task_type = None @@ -779,12 +790,14 @@ def _get_global_config_data( ) if profile is None: log.info(f"No config profile matched filters {str(filter_values)}") - return None + return ConfigData(enabled=False) profile_type = profile["type"] - if profile_type in ("builtin_path", "custom_path"): + if profile_type in {"builtin_path", "custom_path"}: return _get_config_path_from_profile_data( profile, profile_type, template_data) + elif profile_type == "disabled": + return ConfigData(enabled=False) # TODO decide if this is the right name for representation repre_name = "ocioconfig" @@ -798,7 +811,7 @@ def _get_global_config_data( "Colorspace OCIO config path cannot be set. " "Profile is set to published product but `Product name` is empty." ) - return None + return ConfigData() folder_info = template_data.get("folder") if not folder_info: @@ -819,7 +832,7 @@ def _get_global_config_data( ) if not folder_entity: log.warning(f"Folder entity '{folder_path}' was not found..") - return None + return ConfigData() folder_id = folder_entity["id"] product_entities_by_name = { @@ -855,7 +868,7 @@ def _get_global_config_data( log.info( f"Product '{product_name}' does not have available any versions." ) - return None + return ConfigData() # Find 'ocioconfig' representation entity repre_entity = ayon_api.get_representation_by_name( @@ -868,15 +881,15 @@ def _get_global_config_data( f"Representation '{repre_name}'" f" not found on product '{product_name}'." ) - return None + return ConfigData() path = get_representation_path_with_anatomy(repre_entity, anatomy) template = repre_entity["attrib"]["template"] - return { - "path": path, - "template": template, - } + return ConfigData( + path=path, + template=template + ) def get_imageio_config_preset( @@ -1015,7 +1028,10 @@ def get_imageio_config_preset( host_ocio_config["filepath"], template_data ) - if not config_data: + if not config_data.enabled: + return {} # OCIO management disabled + + if not config_data.path: raise FileExistsError( "No OCIO config found in settings. It is" " either missing or there is typo in path inputs" diff --git a/server/settings/main.py b/server/settings/main.py index cca885303f..3bd9549116 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -59,6 +59,7 @@ def _ocio_config_profile_types(): {"value": "builtin_path", "label": "AYON built-in OCIO config"}, {"value": "custom_path", "label": "Path to OCIO config"}, {"value": "published_product", "label": "Published product"}, + {"value": "disabled", "label": "Disable OCIO management"}, ] From ab8a93b4a4455ba1ecf639f00d325051bb8bf8de Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 00:57:46 +0100 Subject: [PATCH 197/386] Cosmetics --- client/ayon_core/pipeline/colorspace.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index db7d287cf1..a7d205d48e 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -26,6 +26,7 @@ from ayon_core.pipeline.load import get_representation_path_with_anatomy log = Logger.get_logger(__name__) + @dataclass class ConfigData: """OCIO Config to use in a certain context. From a73d8f947d25f6243f293204a1405f1e47065fef Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 01:01:51 +0100 Subject: [PATCH 198/386] Fix return value --- client/ayon_core/pipeline/colorspace.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index a7d205d48e..7a4d9dda50 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -1038,7 +1038,10 @@ def get_imageio_config_preset( " either missing or there is typo in path inputs" ) - return config_data + return { + "path": config_data.path, + "template": config_data.template, + } def _get_host_config_data(templates, template_data): From fd1b3b0e64ea1516139608659ee9b013f82d372f Mon Sep 17 00:00:00 2001 From: TobiasPharos Date: Thu, 2 Oct 2025 11:22:25 +0200 Subject: [PATCH 199/386] fix for "replace_with_published_scene_path" Published scene file has to be of productType/family "workfile" --- client/ayon_core/pipeline/publish/lib.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 1f983808b0..3756746ee9 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -812,7 +812,20 @@ def replace_with_published_scene_path(instance, replace_in_path=True): template_data["comment"] = None anatomy = instance.context.data["anatomy"] - template = anatomy.get_template_item("publish", "default", "path") + project_name = anatomy.project_name + task_entity = instance.data.get("taskEntity", {}) + project_settings = get_project_settings(project_name) + template_name = get_publish_template_name( + project_name=project_name, + host_name=instance.context.data.get("hostName", os.environ["AYON_HOST_NAME"]), + # publish template has to match productType "workfile", + # otherwise default template will be used: + product_type="workfile", + task_name=task_entity.get("name", os.environ["AYON_TASK_NAME"]), + task_type=task_entity.get("taskType"), + project_settings=project_settings, + ) + template = anatomy.get_template_item("publish", template_name, "path") template_filled = template.format_strict(template_data) file_path = os.path.normpath(template_filled) From 81fb1e73c4f5c4776ad133e7ad488afea54ea973 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Oct 2025 14:14:52 +0200 Subject: [PATCH 200/386] handle project settings and task entity --- client/ayon_core/pipeline/publish/lib.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 3756746ee9..555f4b1894 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -813,16 +813,20 @@ def replace_with_published_scene_path(instance, replace_in_path=True): anatomy = instance.context.data["anatomy"] project_name = anatomy.project_name - task_entity = instance.data.get("taskEntity", {}) - project_settings = get_project_settings(project_name) + task_name = task_type = None + task_entity = instance.data.get("taskEntity") + if task_entity: + task_name = task_entity["name"] + task_type = task_entity["taskType"] + project_settings = instance.context.data["project_settings"] template_name = get_publish_template_name( project_name=project_name, - host_name=instance.context.data.get("hostName", os.environ["AYON_HOST_NAME"]), + host_name=instance.context.data["hostName"], # publish template has to match productType "workfile", # otherwise default template will be used: product_type="workfile", - task_name=task_entity.get("name", os.environ["AYON_TASK_NAME"]), - task_type=task_entity.get("taskType"), + task_name=task_name, + task_type=task_type, project_settings=project_settings, ) template = anatomy.get_template_item("publish", template_name, "path") From 67364633f0d3f68278d1d292a83eebd5e6ffd925 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 10:56:02 +0100 Subject: [PATCH 201/386] :robot: implement copilot suggestions --- client/ayon_core/pipeline/create/context.py | 41 +++++++++---------- .../pipeline/create/creator_plugins.py | 5 ++- .../ayon_core/pipeline/create/product_name.py | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 08b6574db8..c379dd38f2 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -758,18 +758,6 @@ class CreateContext: "Skipping abstract Creator '%s'", str(creator_class) ) - for creator_class in report.plugins: - if not creator_class.product_base_type: - message = (f"Provided creator {creator_class!r} doesn't have " - "product base type attribute defined. This will be " - "required in future.") - warn( - message, - DeprecationWarning, - stacklevel=2 - ) - self.log.warning(message) - creator_identifier = creator_class.identifier if creator_identifier in creators: self.log.warning( @@ -783,19 +771,17 @@ class CreateContext: creator_class.host_name and creator_class.host_name != self.host_name ): - self.log.info(( - "Creator's host name \"{}\"" - " is not supported for current host \"{}\"" - ).format(creator_class.host_name, self.host_name)) + self.log.info( + ( + 'Creator\'s host name "{}"' + ' is not supported for current host "{}"' + ).format(creator_class.host_name, self.host_name) + ) continue # TODO report initialization error try: - creator = creator_class( - project_settings, - self, - self.headless - ) + creator = creator_class(project_settings, self, self.headless) except Exception: self.log.error( f"Failed to initialize plugin: {creator_class}", @@ -803,6 +789,19 @@ class CreateContext: ) continue + if not creator.product_base_type: + message = ( + f"Provided creator {creator!r} doesn't have " + "product base type attribute defined. This will be " + "required in future." + ) + warn( + message, + DeprecationWarning, + stacklevel=2 + ) + self.log.warning(message) + if not creator.enabled: disabled_creators[creator_identifier] = creator continue diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 92eb3b6946..8d1dbd0f2e 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -290,7 +290,8 @@ class BaseCreator(ABC): def identifier(self): """Identifier of creator (must be unique). - Default implementation returns plugin's product type. + Default implementation returns plugin's product base type, or falls back + to product type if product base type is not set. """ identifier = self.product_base_type @@ -388,7 +389,7 @@ class BaseCreator(ABC): product_type (Optional[str]): Product type, object attribute 'product_type' is used if not passed. product_base_type (Optional[str]): Product base type, object - attribute 'product_type' is used if not passed. + attribute 'product_base_type' is used if not passed. Returns: CreatedInstance: Created instance. diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index f10e375fb1..2bf84db0f4 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -197,7 +197,7 @@ def get_product_name( "task": task_value, "product": { "type": product_type, - "baseType": product_base_type or product_type, + "basetype": product_base_type or product_type, } } From c33795b68a6d6bd374428c07e623a23e99861a45 Mon Sep 17 00:00:00 2001 From: TobiasPharos Date: Tue, 25 Nov 2025 10:07:18 +0100 Subject: [PATCH 202/386] get product_type from workfile instance --- client/ayon_core/pipeline/publish/lib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 555f4b1894..2187ef0304 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -822,9 +822,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True): template_name = get_publish_template_name( project_name=project_name, host_name=instance.context.data["hostName"], - # publish template has to match productType "workfile", - # otherwise default template will be used: - product_type="workfile", + product_type=workfile_instance.data["productType"], task_name=task_name, task_type=task_type, project_settings=project_settings, From f8e8ab2b27f0619b98bca16069da99d033ea0ee0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 10:58:13 +0100 Subject: [PATCH 203/386] :dog: fix long line --- client/ayon_core/pipeline/create/creator_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 8d1dbd0f2e..a034451d83 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -290,8 +290,8 @@ class BaseCreator(ABC): def identifier(self): """Identifier of creator (must be unique). - Default implementation returns plugin's product base type, or falls back - to product type if product base type is not set. + Default implementation returns plugin's product base type, + or falls back to product type if product base type is not set. """ identifier = self.product_base_type From bb8f214e475ec657730f8bbb861e99896795005a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 11:59:20 +0100 Subject: [PATCH 204/386] :bug: fix the abstract plugin debug print --- client/ayon_core/pipeline/create/context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index c379dd38f2..d8cb9d1b9e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -755,9 +755,11 @@ class CreateContext: self.creator_discover_result = report for creator_class in report.abstract_plugins: self.log.debug( - "Skipping abstract Creator '%s'", str(creator_class) + "Skipping abstract Creator '%s'", + str(creator_class) ) + for creator_class in report.plugins: creator_identifier = creator_class.identifier if creator_identifier in creators: self.log.warning( From 2a5210ccc535e13ae4aec24415a108505982ba2b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:10:55 +0100 Subject: [PATCH 205/386] Update client/ayon_core/plugins/publish/extract_oiio_postprocess.py Co-authored-by: Mustafa Zaky Jafar --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 33384e0185..1130a86fb6 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -62,7 +62,8 @@ class ExtractOIIOPostProcess(publish.Extractor): # it's included in the profile check. As such, an instance may have # a different profile applied per representation. profile = self._get_profile( - instance + instance, + repre ) if not profile: continue From 877a9fdecd61d7935e0bedf62344d2cc14c785d8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:38:35 +0100 Subject: [PATCH 206/386] Refactor profile `hosts` -> `host_names` --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 2 +- server/settings/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 1130a86fb6..3228e418b5 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -272,7 +272,7 @@ class ExtractOIIOPostProcess(publish.Extractor): repre_name: str = repre["name"] repre_ext: str = repre["ext"] filtering_criteria = { - "hosts": host_name, + "host_names": host_name, "product_types": product_type, "product_names": product_name, "task_names": task_name, diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index aa86398582..9b490ab208 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -638,7 +638,7 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField( + host_names: list[str] = SettingsField( default_factory=list, title="Host names" ) From e2727ad15e7db7c5789ff2a67ffabbbec32f917a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:41:31 +0100 Subject: [PATCH 207/386] Cosmetics + type hints --- .../plugins/publish/extract_oiio_postprocess.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 3228e418b5..c7ae4c3910 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -261,7 +261,11 @@ class ExtractOIIOPostProcess(publish.Extractor): output_extension) return os.path.join(output_dir, new_file_name) - def _get_profile(self, instance: pyblish.api.Instance, repre: dict) -> Optional[dict[str, Any]]: + def _get_profile( + self, + instance: pyblish.api.Instance, + repre: dict + ) -> Optional[dict[str, Any]]: """Returns profile if it should process this instance.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] @@ -296,7 +300,7 @@ class ExtractOIIOPostProcess(publish.Extractor): return profile - def _repre_is_valid(self, repre) -> bool: + def _repre_is_valid(self, repre: dict) -> bool: """Validation if representation should be processed. Args: @@ -325,7 +329,12 @@ class ExtractOIIOPostProcess(publish.Extractor): return True - def _mark_original_repre_for_deletion(self, repre, profile, added_review): + def _mark_original_repre_for_deletion( + self, + repre: dict, + profile: dict, + added_review: bool + ): """If new transcoded representation created, delete old.""" if not repre.get("tags"): repre["tags"] = [] From c1210b297788cd35190edc300fe8ebccfc5bb2a0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 14:42:34 +0100 Subject: [PATCH 208/386] Also skip early if no representations in the data. --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index c7ae4c3910..7ea6ba50e3 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -44,7 +44,7 @@ class ExtractOIIOPostProcess(publish.Extractor): self.log.debug("No profiles present for OIIO Post Process") return - if "representations" not in instance.data: + if not instance.data.get("representations"): self.log.debug("No representations, skipping.") return From 596612cc99e6e0238d77ca4278f4e926fc41380f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 15:02:11 +0100 Subject: [PATCH 209/386] Move over product types in settings so order makes more sense --- server/settings/publish_plugins.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 9b490ab208..a5b84354bd 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -633,12 +633,8 @@ class ExtractOIIOPostProcessOutputModel(BaseSettingsModel): class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): - product_types: list[str] = SettingsField( - section="Profile", - default_factory=list, - title="Product types" - ) host_names: list[str] = SettingsField( + section="Profile", default_factory=list, title="Host names" ) @@ -651,6 +647,10 @@ class ExtractOIIOPostProcessProfileModel(BaseSettingsModel): default_factory=list, title="Task names" ) + product_types: list[str] = SettingsField( + default_factory=list, + title="Product types" + ) product_names: list[str] = SettingsField( default_factory=list, title="Product names" From a7e02c19e5c7443638283a82b3f7ecab613edf55 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 15:30:53 +0100 Subject: [PATCH 210/386] Update client/ayon_core/plugins/publish/extract_oiio_postprocess.py Co-authored-by: Mustafa Zaky Jafar --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 7ea6ba50e3..7f3ba38963 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -40,6 +40,9 @@ class ExtractOIIOPostProcess(publish.Extractor): options = None def process(self, instance): + if instance.data.get("farm"): + self.log.debug("Should be processed on farm, skipping.") + return if not self.profiles: self.log.debug("No profiles present for OIIO Post Process") return From 0b942a062f3255a43a3d2a1a1f830f473396860b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Nov 2025 15:31:39 +0100 Subject: [PATCH 211/386] Cosmetics --- client/ayon_core/plugins/publish/extract_oiio_postprocess.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py index 7f3ba38963..2b432f2a0a 100644 --- a/client/ayon_core/plugins/publish/extract_oiio_postprocess.py +++ b/client/ayon_core/plugins/publish/extract_oiio_postprocess.py @@ -43,6 +43,7 @@ class ExtractOIIOPostProcess(publish.Extractor): if instance.data.get("farm"): self.log.debug("Should be processed on farm, skipping.") return + if not self.profiles: self.log.debug("No profiles present for OIIO Post Process") return From feb16122009857c4192914b1b3b9d9ca4936d4f7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 17:18:35 +0100 Subject: [PATCH 212/386] =?UTF-8?q?=E2=9E=96=20remove=20argument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/ayon_core/tools/publisher/models/create.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 86f0cd2d07..b8518a7de6 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -665,7 +665,6 @@ class CreateModel: kwargs = { "instance": instance, "project_entity": project_entity, - "product_base_type": creator.product_base_type, } # Backwards compatibility for 'project_entity' argument # - 'get_product_name' signature changed 24/07/08 From 8a0e1afcb37ef0843e1ae9bf714dfa949eb3bab0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 28 Nov 2025 23:16:02 +0100 Subject: [PATCH 213/386] Remove unused function: `split_cmd_args` --- client/ayon_core/lib/transcoding.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 076ee79665..726ea62542 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1281,24 +1281,6 @@ def oiio_color_convert( run_subprocess(oiio_cmd, logger=logger) -def split_cmd_args(in_args): - """Makes sure all entered arguments are separated in individual items. - - Split each argument string with " -" to identify if string contains - one or more arguments. - Args: - in_args (list): of arguments ['-n', '-d uint10'] - Returns - (list): ['-n', '-d', 'unint10'] - """ - splitted_args = [] - for arg in in_args: - if not arg.strip(): - continue - splitted_args.extend(arg.split(" ")) - return splitted_args - - def get_rescaled_command_arguments( application, input_path, From 72249691801ac1784e1bf85a2b070c62a03ccfa5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Sat, 29 Nov 2025 16:13:55 +0100 Subject: [PATCH 214/386] fix product name template filtering --- client/ayon_core/pipeline/create/product_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index ecffa4a340..5596cec0ce 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -41,8 +41,8 @@ def get_product_name_template( profiles = tools_settings["creator"]["product_name_profiles"] filtering_criteria = { "product_types": product_type, - "hosts": host_name, - "tasks": task_name, + "host_names": host_name, + "task_names": task_name, "task_types": task_type } From 930454ad08832328e98c0b8d2473eafbb3623c75 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 30 Nov 2025 21:38:36 +0100 Subject: [PATCH 215/386] Fix missing settings conversion --- server/settings/conversion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 846b91edab..757818a9ff 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -164,5 +164,6 @@ def convert_settings_overrides( ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) _convert_imageio_configs_0_4_5(overrides) + _convert_imageio_configs_1_6_5(overrides) _convert_publish_plugins(overrides) return overrides From b0d153ce8745d304108468d78c5ab630f28b0255 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:30:22 +0100 Subject: [PATCH 216/386] remove python from pyproject toml --- client/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/client/pyproject.toml b/client/pyproject.toml index c98591b707..5ae71de18b 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -3,7 +3,6 @@ name="core" description="AYON core addon." [tool.poetry.dependencies] -python = ">=3.9.1,<3.10" markdown = "^3.4.1" clique = "1.6.*" jsonschema = "^2.6.0" From 79aa108da794214285c98eea05a6c8e43b6b71aa Mon Sep 17 00:00:00 2001 From: Ynbot Date: Mon, 1 Dec 2025 12:02:25 +0000 Subject: [PATCH 217/386] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index a3e1a6c939..168eaa7c21 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.11+dev" +__version__ = "1.6.12" diff --git a/package.py b/package.py index 62231060f0..0b1bb92a3a 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.11+dev" +version = "1.6.12" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index d568edefc0..7b2e3f9e7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.11+dev" +version = "1.6.12" description = "" authors = ["Ynput Team "] readme = "README.md" From 0e34fb6474d0f30f3b164ac0579c61edf7bcb41c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Mon, 1 Dec 2025 12:03:02 +0000 Subject: [PATCH 218/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 168eaa7c21..3e6b3794b1 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.12" +__version__ = "1.6.12+dev" diff --git a/package.py b/package.py index 0b1bb92a3a..fbf7021b8e 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.12" +version = "1.6.12+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 7b2e3f9e7b..208bbec85f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.12" +version = "1.6.12+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 31b65f22ae9860dbe02209e5914adbdc3c698097 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Dec 2025 12:03:58 +0000 Subject: [PATCH 219/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7fc253b1b8..98e4b46e07 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.12 - 1.6.11 - 1.6.10 - 1.6.9 From 215d077f3165956b422fd9477ccd364313f60be5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 1 Dec 2025 13:15:19 +0100 Subject: [PATCH 220/386] pass host_name to 'get_loader_action_plugin_paths' --- client/ayon_core/pipeline/actions/loader.py | 22 +++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/actions/loader.py b/client/ayon_core/pipeline/actions/loader.py index 92de9c6cf8..53cc52d39f 100644 --- a/client/ayon_core/pipeline/actions/loader.py +++ b/client/ayon_core/pipeline/actions/loader.py @@ -70,7 +70,7 @@ from dataclasses import dataclass import ayon_api from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import StrEnum, Logger +from ayon_core.lib import StrEnum, Logger, is_func_signature_supported from ayon_core.host import AbstractHost from ayon_core.addon import AddonsManager, IPluginPaths from ayon_core.settings import get_studio_settings, get_project_settings @@ -752,6 +752,7 @@ class LoaderActionsContext: def _get_plugins(self) -> dict[str, LoaderActionPlugin]: if self._plugins is None: + host_name = self.get_host_name() addons_manager = self.get_addons_manager() all_paths = [ os.path.join(AYON_CORE_ROOT, "plugins", "loader") @@ -759,7 +760,24 @@ class LoaderActionsContext: for addon in addons_manager.addons: if not isinstance(addon, IPluginPaths): continue - paths = addon.get_loader_action_plugin_paths() + + try: + if is_func_signature_supported( + addon.get_loader_action_plugin_paths, + host_name + ): + paths = addon.get_loader_action_plugin_paths( + host_name + ) + else: + paths = addon.get_loader_action_plugin_paths() + except Exception: + self._log.warning( + "Failed to get plugin paths for addon", + exc_info=True + ) + continue + if paths: all_paths.extend(paths) From cd499f495136599f3aeb61618a0dfe2c49df54dc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:18:16 +0100 Subject: [PATCH 221/386] change IPluginPaths interface method signature --- client/ayon_core/addon/interfaces.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index bc44fd2d2e..0a17ec9fb9 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -185,9 +185,14 @@ class IPluginPaths(AYONInterface): """ return self._get_plugin_paths_by_type("inventory") - def get_loader_action_plugin_paths(self) -> list[str]: + def get_loader_action_plugin_paths( + self, host_name: Optional[str] + ) -> list[str]: """Receive loader action plugin paths. + Args: + host_name (Optional[str]): Current host name. + Returns: list[str]: Paths to loader action plugins. From 055bf3fc179587de9c3affcabc93f63ad9f6aae2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:31:36 +0100 Subject: [PATCH 222/386] ignore locked containers when updating versions --- client/ayon_core/tools/sceneinventory/view.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index 22bc170230..eb12fe5e06 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -1114,6 +1114,8 @@ class SceneInventoryView(QtWidgets.QTreeView): try: for item_id, item_version in zip(item_ids, versions): container = containers_by_id[item_id] + if container.get("version_locked"): + continue try: update_container(container, item_version) except Exception as exc: From 43b557d95e10978090492434ad8282dcd79d0958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 1 Dec 2025 18:17:08 +0100 Subject: [PATCH 223/386] :recycle: check for compatibility --- client/ayon_core/pipeline/compatibility.py | 3 ++- client/ayon_core/pipeline/publish/lib.py | 6 ++--- client/ayon_core/plugins/publish/integrate.py | 25 ++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py index f7d48526b7..dce627e391 100644 --- a/client/ayon_core/pipeline/compatibility.py +++ b/client/ayon_core/pipeline/compatibility.py @@ -13,4 +13,5 @@ def is_product_base_type_supported() -> bool: bool: True if product base types are supported, False otherwise. """ - return False + import ayon_api + return ayon_api.product_base_type_supported() diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index c2dcc89cd5..729e694c4f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -122,8 +122,8 @@ def get_publish_template_name( task_type, project_settings=None, hero=False, + product_base_type: Optional[str] = None, logger=None, - product_base_type: Optional[str] = None ): """Get template name which should be used for passed context. @@ -141,10 +141,10 @@ def get_publish_template_name( task_type (str): Task type on which is instance working. project_settings (Dict[str, Any]): Prepared project settings. hero (bool): Template is for hero version publishing. - logger (logging.Logger): Custom logger used for 'filter_profiles' - function. product_base_type (Optional[str]): Product type for which should be found template. + logger (logging.Logger): Custom logger used for 'filter_profiles' + function. Returns: str: Template name which should be used for integration. diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 4589f6f542..eaf82b37c4 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -28,6 +28,7 @@ from ayon_core.pipeline.publish import ( KnownPublishError, get_publish_template_name, ) +from pipeline import is_product_base_type_supported log = logging.getLogger(__name__) @@ -396,15 +397,21 @@ class IntegrateAsset(pyblish.api.InstancePlugin): product_id = None if existing_product_entity: product_id = existing_product_entity["id"] - product_entity = new_product_entity( - product_name, - product_type, - folder_entity["id"], - data=data, - attribs=attributes, - entity_id=product_id, - product_base_type=product_base_type - ) + + new_product_entity_kwargs = { + "product_name": product_name, + "product_type": product_type, + "folder_id": folder_entity["id"], + "data": data, + "attribs": attributes, + "entity_id": product_id, + "product_base_type": product_base_type, + } + + if not is_product_base_type_supported(): + new_product_entity_kwargs.pop("product_base_type") + + product_entity = new_product_entity(**new_product_entity_kwargs) if existing_product_entity is None: # Create a new product From b0c5b171c9dec840dffb8f3dff88640198031598 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 1 Dec 2025 19:04:23 +0100 Subject: [PATCH 224/386] After OIIO transcoding force the layer name to be "" --- client/ayon_core/plugins/publish/extract_review.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 16fb22524c..54aa52c3c3 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -401,6 +401,10 @@ class ExtractReview(pyblish.api.InstancePlugin): new_staging_dir, self.log ) + # The OIIO conversion will remap the RGBA channels just to + # `R,G,B,A` so we will pass the intermediate file to FFMPEG + # without layer name. + layer_name = "" try: self._render_output_definitions( From 6ade0bb665e31afab07249eaaddc3b45eb497df0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 2 Dec 2025 14:44:29 +0100 Subject: [PATCH 225/386] Added webpublisher to extract thumbnail for ftrack --- client/ayon_core/plugins/publish/extract_thumbnail.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 2a43c12af3..adfb4298b9 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -48,6 +48,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "unreal", "houdini", "batchdelivery", + "webpublisher", ] settings_category = "core" enabled = False From 2efda3d3fec2745ef658b79a1f280d57177e827e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:41:37 +0100 Subject: [PATCH 226/386] :bug: fix import and function call/check --- client/ayon_core/pipeline/compatibility.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/compatibility.py b/client/ayon_core/pipeline/compatibility.py index dce627e391..78ba5ad71e 100644 --- a/client/ayon_core/pipeline/compatibility.py +++ b/client/ayon_core/pipeline/compatibility.py @@ -1,4 +1,5 @@ """Package to handle compatibility checks for pipeline components.""" +import ayon_api def is_product_base_type_supported() -> bool: @@ -13,5 +14,7 @@ def is_product_base_type_supported() -> bool: bool: True if product base types are supported, False otherwise. """ - import ayon_api - return ayon_api.product_base_type_supported() + + if not hasattr(ayon_api, "is_product_base_type_supported"): + return False + return ayon_api.is_product_base_type_supported() From 1e6601786110e39f26ad26f7925471548cc45ab6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:47:39 +0100 Subject: [PATCH 227/386] :recycle: use product base type if defined when product base types are not supported by api, product base type should be the source of truth. --- client/ayon_core/plugins/publish/integrate.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index eaf82b37c4..e93cf62a3c 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -410,6 +410,18 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if not is_product_base_type_supported(): new_product_entity_kwargs.pop("product_base_type") + if ( + product_base_type is not None + and product_base_type != product_type): + self.log.warning(( + "Product base type %s is not supported by the server, " + "but it's defined - and it differs from product type %s. " + "Using product base type as product type." + ), product_base_type, product_type) + + new_product_entity_kwargs["product_type"] = ( + product_base_type + ) product_entity = new_product_entity(**new_product_entity_kwargs) From 206bcfe7176f3960bc764b5eee323c224aeeca6e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:47:58 +0100 Subject: [PATCH 228/386] :memo: add warning --- client/ayon_core/pipeline/publish/lib.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 729e694c4f..7365ffee09 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -149,6 +149,15 @@ def get_publish_template_name( Returns: str: Template name which should be used for integration. """ + if not product_base_type: + msg = ( + "Argument 'product_base_type' is not provided to" + " 'get_publish_template_name' function. This argument" + " will be required in future versions." + ) + warnings.warn(msg, DeprecationWarning) + if logger: + logger.warning(msg) template = None filter_criteria = { From a9c77857001bedb0b1f4dc5ec1b4e3b4599ac772 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:59:14 +0100 Subject: [PATCH 229/386] :dog: fix linter --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index e93cf62a3c..45209764ee 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -418,7 +418,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "but it's defined - and it differs from product type %s. " "Using product base type as product type." ), product_base_type, product_type) - + new_product_entity_kwargs["product_type"] = ( product_base_type ) From 7592bdcfcb99cfcc3e644330fe16e0bf694320f5 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 2 Dec 2025 16:39:48 +0000 Subject: [PATCH 230/386] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 3e6b3794b1..a8e949f008 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.12+dev" +__version__ = "1.6.13" diff --git a/package.py b/package.py index fbf7021b8e..c94fd7527c 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.12+dev" +version = "1.6.13" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 208bbec85f..01994e7133 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.12+dev" +version = "1.6.13" description = "" authors = ["Ynput Team "] readme = "README.md" From 83c4350277e61b97f0d287092020ec8896ea4139 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 2 Dec 2025 16:40:29 +0000 Subject: [PATCH 231/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index a8e949f008..e8b53001d5 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.13" +__version__ = "1.6.13+dev" diff --git a/package.py b/package.py index c94fd7527c..003c41f0f5 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.13" +version = "1.6.13+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 01994e7133..b06f812b27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.13" +version = "1.6.13+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 0ce6e705471f253804f0fa44289029194decd5b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Dec 2025 16:41:29 +0000 Subject: [PATCH 232/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 98e4b46e07..2b7756ba7e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.6.13 - 1.6.12 - 1.6.11 - 1.6.10 From 74dc83d14a18c0ff9acc646864efe3b294051fa3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:06:19 +0100 Subject: [PATCH 233/386] skip base classes --- client/ayon_core/pipeline/plugin_discover.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index dddd6847ec..896b7966d6 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -138,7 +138,14 @@ def discover_plugins( for item in modules: filepath, module = item result.add_module(module) - all_plugins.extend(classes_from_module(base_class, module)) + for cls in classes_from_module(base_class, module): + if cls is base_class: + continue + # Class has defined 'is_base_class = True' + is_base_class = cls.__dict__.get("is_base_class") + if is_base_class is True: + continue + all_plugins.append(cls) if base_class not in ignored_classes: ignored_classes.append(base_class) From b39dd35af9186bcbecd5a520f31fcfbe8b6ce0a2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:01:49 +0100 Subject: [PATCH 234/386] Removed webpublisher from regular extract_thumbnail Must use regular one as customer uses `review` as product type. Adding `review` to generic plugin might have unforeseen consequences. --- client/ayon_core/plugins/publish/extract_thumbnail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index adfb4298b9..2a43c12af3 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -48,7 +48,6 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "unreal", "houdini", "batchdelivery", - "webpublisher", ] settings_category = "core" enabled = False From 3edb0148cd682f01f9dfef387cc27889b29db458 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:02:23 +0100 Subject: [PATCH 235/386] Added setting to match more from source to regular extract thumbnail --- server/settings/publish_plugins.py | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index d7b794cb5b..0bf8e9c7de 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -469,6 +469,43 @@ class UseDisplayViewModel(BaseSettingsModel): ) +class ExtractThumbnailFromSourceProfileModel(BaseSettingsModel): + product_types: list[str] = SettingsField( + default_factory=list, title="Product types" + ) + hosts: list[str] = SettingsField(default_factory=list, title="Host names") + task_types: list[str] = SettingsField( + default_factory=list, title="Task types", enum_resolver=task_types_enum + ) + task_names: list[str] = SettingsField( + default_factory=list, title="Task names" + ) + product_names: list[str] = SettingsField( + default_factory=list, title="Product names" + ) + + integrate_thumbnail: bool = SettingsField( + True, title="Integrate Thumbnail Representation" + ) + target_size: ResizeModel = SettingsField( + default_factory=ResizeModel, title="Target size" + ) + background_color: ColorRGBA_uint8 = SettingsField( + (0, 0, 0, 0.0), title="Background color" + ) + ffmpeg_args: ExtractThumbnailFFmpegModel = SettingsField( + default_factory=ExtractThumbnailFFmpegModel + ) + + +class ExtractThumbnailFromSourceModel(BaseSettingsModel): + """Thumbnail extraction from source files using ffmpeg and oiiotool.""" + enabled: bool = SettingsField(True) + profiles: list[ExtractThumbnailFromSourceProfileModel] = SettingsField( + default_factory=list, title="Profiles" + ) + + class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): _layout = "expanded" name: str = SettingsField( @@ -1244,6 +1281,17 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractThumbnailModel, title="Extract Thumbnail" ) + ExtractThumbnailFromSource: ExtractThumbnailFromSourceModel = SettingsField( + default_factory=ExtractThumbnailFromSourceModel, + title="Extract Thumbnail (from source)", + description=( + "Extract thumbnails from explicit file set in " + "instance.data['thumbnailSource'] using ffmpeg " + "and oiiotool." + "Used when host does not provide thumbnail, but artist could set " + "custom thumbnail source file. (TrayPublisher, Webpublisher)" + ) + ) ExtractOIIOTranscode: ExtractOIIOTranscodeModel = SettingsField( default_factory=ExtractOIIOTranscodeModel, title="Extract OIIO Transcode" @@ -1475,6 +1523,30 @@ DEFAULT_PUBLISH_VALUES = { "output": [] } }, + "ExtractThumbnailFromSource": { + "enabled": True, + "profiles": [ + { + "product_types": [], + "hosts": [], + "task_types": [], + "task_names": [], + "product_names": [], + "integrate_thumbnail": True, + "target_size": { + "type": "source", + "resize": { + "width": 1920, + "height": 1080 + } + }, + "ffmpeg_args": { + "input": [], + "output": [] + } + } + ] + }, "ExtractOIIOTranscode": { "enabled": True, "profiles": [] From 08c03e980b14fcfbabe86a57d6f22bc9b59f647e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:03:18 +0100 Subject: [PATCH 236/386] Moved order to trigger later No need to trigger so early, but must be triggered before regular one to limit double creation of thumbnail. --- .../plugins/publish/extract_thumbnail_from_source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 59a62b1d7b..e3eda7dd61 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -33,7 +33,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): label = "Extract Thumbnail (from source)" # Before 'ExtractThumbnail' in global plugins - order = pyblish.api.ExtractorOrder - 0.00001 + order = pyblish.api.ExtractorOrder + 0.48 + + # Settings + profiles = None def process(self, instance): self._create_context_thumbnail(instance.context) From ad83f76318dc74ff2e81f53cd924ddb8a0f03a61 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:08:11 +0100 Subject: [PATCH 237/386] Introduced dataclass for config of selected profile It makes it nicer than using dictionary --- .../publish/extract_thumbnail_from_source.py | 164 ++++++++++++++++-- 1 file changed, 149 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index e3eda7dd61..8b1c50072e 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -13,7 +13,9 @@ Todos: """ import os +from dataclasses import dataclass, field, fields import tempfile +from typing import Dict, Any, List, Tuple import pyblish.api from ayon_core.lib import ( @@ -22,9 +24,57 @@ from ayon_core.lib import ( is_oiio_supported, run_subprocess, + get_rescaled_command_arguments, + filter_profiles, ) +@dataclass +class ProfileConfig: + """ + Data class representing the full configuration for selected profile + + Any change of controllable fields in Settings must propagate here! + """ + integrate_thumbnail: bool = False + + target_size: Dict[str, Any] = field( + default_factory=lambda: { + "type": "source", + "resize": {"width": 1920, "height": 1080}, + } + ) + + ffmpeg_args: Dict[str, List[Any]] = field( + default_factory=lambda: {"input": [], "output": []} + ) + + # Background color defined as (R, G, B, A) tuple. + # Note: Use float for alpha channel (0.0 to 1.0). + background_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ProfileConfig": + """ + Creates a ProfileConfig instance from a dictionary, safely ignoring + any keys in the dictionary that are not fields in the dataclass. + + Args: + data (Dict[str, Any]): The dictionary containing configuration data + + Returns: + MediaConfig: A new instance of the dataclass. + """ + # Get all field names defined in the dataclass + field_names = {f.name for f in fields(cls)} + + # Filter the input dictionary to include only keys that match field names + filtered_data = {k: v for k, v in data.items() if k in field_names} + + # Unpack the filtered dictionary into the constructor + return cls(**filtered_data) + + class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): """Create jpg thumbnail for instance based on 'thumbnailSource'. @@ -56,7 +106,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return dst_filepath = self._create_thumbnail( - instance.context, thumbnail_source + instance.context, thumbnail_source, profile_config ) if not dst_filepath: return @@ -79,7 +129,12 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): instance.data["representations"].append(new_repre) instance.data["thumbnailPath"] = dst_filepath - def _create_thumbnail(self, context, thumbnail_source): + def _create_thumbnail( + self, + context: pyblish.api.Context, + thumbnail_source: str, + profile_config: ProfileConfig + ) -> str: if not thumbnail_source: self.log.debug("Thumbnail source not filled. Skipping.") return @@ -112,7 +167,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg thumbnail_created = self.create_thumbnail_oiio( - thumbnail_source, full_output_path + thumbnail_source, full_output_path, profile_config ) # Try to use FFMPEG if OIIO is not supported or for cases when @@ -125,7 +180,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): ) thumbnail_created = self.create_thumbnail_ffmpeg( - thumbnail_source, full_output_path + thumbnail_source, full_output_path, profile_config ) # Skip representation and try next one if wasn't created @@ -146,13 +201,15 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return True return False - def create_thumbnail_oiio(self, src_path, dst_path): + def create_thumbnail_oiio( + self, + src_path: str, + dst_path: str, + profile_config: ProfileConfig + ) -> bool: self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) - oiio_cmd = get_oiio_tool_args( - "oiiotool", - "-a", src_path, - "--ch", "R,G,B", - "-o", dst_path + resolution_arg = self._get_resolution_arg( + "oiiotool", src_path, profile_config ) self.log.debug("Running: {}".format(" ".join(oiio_cmd))) try: @@ -165,7 +222,16 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): ) return False - def create_thumbnail_ffmpeg(self, src_path, dst_path): + def create_thumbnail_ffmpeg( + self, + src_path: str, + dst_path: str, + profile_config: ProfileConfig + ) -> bool: + resolution_arg = self._get_resolution_arg( + "ffmpeg", src_path, profile_config + ) + max_int = str(2147483647) ffmpeg_cmd = get_ffmpeg_tool_args( "ffmpeg", @@ -188,10 +254,78 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): ) return False - def _create_context_thumbnail(self, context): - if "thumbnailPath" in context.data: + def _create_context_thumbnail( + self, + context: pyblish.api.Context, + profile: ProfileConfig + ) -> str: + hasContextThumbnail = "thumbnailPath" in context.data + if hasContextThumbnail: return thumbnail_source = context.data.get("thumbnailSource") - thumbnail_path = self._create_thumbnail(context, thumbnail_source) - context.data["thumbnailPath"] = thumbnail_path + thumbnail_path = self._create_thumbnail( + context, thumbnail_source, profile + ) + return thumbnail_path + + def _get_config_from_profile( + self, + instance: pyblish.api.Instance + ) -> ProfileConfig: + """Returns profile if and how repre should be color transcoded.""" + host_name = instance.context.data["hostName"] + product_type = instance.data["productType"] + product_name = instance.data["productName"] + task_data = instance.data["anatomyData"].get("task", {}) + task_name = task_data.get("name") + task_type = task_data.get("type") + filtering_criteria = { + "hosts": host_name, + "product_types": product_type, + "product_names": product_name, + "task_names": task_name, + "task_types": task_type, + } + profile = filter_profiles( + self.profiles, filtering_criteria, + logger=self.log + ) + + if not profile: + self.log.debug( + ( + "Skipped instance. None of profiles in presets are for" + ' Host: "{}" | Product types: "{}" | Product names: "{}"' + ' | Task name "{}" | Task type "{}"' + ).format( + host_name, product_type, product_name, task_name, task_type + ) + ) + return + + return ProfileConfig.from_dict(profile) + + def _get_resolution_arg( + self, + application, + input_path, + profile + ): + # get settings + if profile.target_size["type"] == "source": + return [] + + resize = profile.target_size["resize"] + target_width = resize["width"] + target_height = resize["height"] + + # form arg string per application + return get_rescaled_command_arguments( + application, + input_path, + target_width, + target_height, + bg_color=profile.background_color, + log=self.log, + ) From 0ab00dbb4e3e34fec56a4dd219a6075fa51c5d43 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:08:36 +0100 Subject: [PATCH 238/386] Check for existing profile --- .../plugins/publish/extract_thumbnail_from_source.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 8b1c50072e..eaa1f356e1 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -89,7 +89,12 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): profiles = None def process(self, instance): - self._create_context_thumbnail(instance.context) + if not self.profiles: + self.log.debug("No profiles present for color transcode") + return + profile_config = self._get_config_from_profile(instance) + if not profile_config: + return product_name = instance.data["productName"] self.log.debug( From daa9effd040851d44e4098006eae633127c91fcd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:08:57 +0100 Subject: [PATCH 239/386] Reorganized position of context thumbnail --- .../plugins/publish/extract_thumbnail_from_source.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index eaa1f356e1..24f8b498b6 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -96,10 +96,12 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): if not profile_config: return - product_name = instance.data["productName"] - self.log.debug( - "Processing instance with product name {}".format(product_name) + context_thumbnail_path = self._create_context_thumbnail( + instance.context, profile_config ) + if context_thumbnail_path: + instance.context.data["thumbnailPath"] = context_thumbnail_path + thumbnail_source = instance.data.get("thumbnailSource") if not thumbnail_source: self.log.debug("Thumbnail source not filled. Skipping.") From 2928c62d2b5b5dfa55ede74e06e9ae37221afac1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:09:20 +0100 Subject: [PATCH 240/386] Added flag for integrate representation --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 24f8b498b6..139b3366d8 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -129,6 +129,9 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "outputName": "thumbnail", } + if not profile_config.integrate_thumbnail: + new_repre["tags"].append("delete") + # adding representation self.log.debug( "Adding thumbnail representation: {}".format(new_repre) From bfca3175d6c3e35a8f60af25666304c65e396c04 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:09:40 +0100 Subject: [PATCH 241/386] Typing --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 139b3366d8..7d0154ed7c 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -199,7 +199,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self.log.warning("Thumbnail has not been created.") - def _instance_has_thumbnail(self, instance): + def _instance_has_thumbnail(self, instance: pyblish.api.Instance) -> bool: if "representations" not in instance.data: self.log.warning( "Instance does not have 'representations' key filled" From d9344239dd3c7fccedb2f5c2b16db97ff37405bc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:11:36 +0100 Subject: [PATCH 242/386] Fixes for resizing to actually work Resizing argument must be before output arguments --- .../publish/extract_thumbnail_from_source.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 7d0154ed7c..0f532d721c 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -221,6 +221,15 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): resolution_arg = self._get_resolution_arg( "oiiotool", src_path, profile_config ) + oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path) + if resolution_arg: + # resize must be before -o + oiio_cmd.extend(resolution_arg) + else: + # resize provides own -ch, must be only one + oiio_cmd.extend(["--ch", "R,G,B"]) + + oiio_cmd.extend(["-o", dst_path]) self.log.debug("Running: {}".format(" ".join(oiio_cmd))) try: run_subprocess(oiio_cmd, logger=self.log) @@ -250,9 +259,17 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "-probesize", max_int, "-i", src_path, "-frames:v", "1", - dst_path ) + ffmpeg_cmd.extend(profile_config.ffmpeg_args.get("input") or []) + + if resolution_arg: + ffmpeg_cmd.extend(resolution_arg) + + # possible resize must be before output args + ffmpeg_cmd.extend(profile_config.ffmpeg_args.get("output") or []) + ffmpeg_cmd.append(dst_path) + self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd))) try: run_subprocess(ffmpeg_cmd, logger=self.log) From a426baf1a118b26950390e38cee19fc0b513f0e9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:30:46 +0100 Subject: [PATCH 243/386] Typing --- .../publish/extract_thumbnail_from_source.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 0f532d721c..4295489bea 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -15,7 +15,7 @@ Todos: import os from dataclasses import dataclass, field, fields import tempfile -from typing import Dict, Any, List, Tuple +from typing import Dict, Any, List, Tuple, Optional import pyblish.api from ayon_core.lib import ( @@ -88,7 +88,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # Settings profiles = None - def process(self, instance): + def process(self, instance: pyblish.api.Instance): if not self.profiles: self.log.debug("No profiles present for color transcode") return @@ -144,7 +144,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): context: pyblish.api.Context, thumbnail_source: str, profile_config: ProfileConfig - ) -> str: + ) -> Optional[str]: if not thumbnail_source: self.log.debug("Thumbnail source not filled. Skipping.") return @@ -285,7 +285,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, context: pyblish.api.Context, profile: ProfileConfig - ) -> str: + ) -> Optional[str]: hasContextThumbnail = "thumbnailPath" in context.data if hasContextThumbnail: return @@ -335,10 +335,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): def _get_resolution_arg( self, - application, - input_path, - profile - ): + application: str, + input_path: str, + profile: ProfileConfig + ) -> List[str]: # get settings if profile.target_size["type"] == "source": return [] From a187a7fc5608253995d117e87be76d4c58779a72 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 13:32:14 +0100 Subject: [PATCH 244/386] Ruff --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 4295489bea..abfbfc70e6 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -68,7 +68,7 @@ class ProfileConfig: # Get all field names defined in the dataclass field_names = {f.name for f in fields(cls)} - # Filter the input dictionary to include only keys that match field names + # Filter the input dictionary to include only keys matching field names filtered_data = {k: v for k, v in data.items() if k in field_names} # Unpack the filtered dictionary into the constructor From dabeb0d5522f7d72e3803995c60b972f6746bf51 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 14:04:51 +0100 Subject: [PATCH 245/386] Ruff --- server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 0bf8e9c7de..dcaa47a351 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1281,7 +1281,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractThumbnailModel, title="Extract Thumbnail" ) - ExtractThumbnailFromSource: ExtractThumbnailFromSourceModel = SettingsField( + ExtractThumbnailFromSource: ExtractThumbnailFromSourceModel = SettingsField( # noqa: E501 default_factory=ExtractThumbnailFromSourceModel, title="Extract Thumbnail (from source)", description=( From 2885ed180527e10a11dc18958f5c36bd87a26fbc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 17:24:37 +0100 Subject: [PATCH 246/386] Added profiles to ExtractThumbnail --- server/settings/publish_plugins.py | 75 +++++++++++++++++++----------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index d7b794cb5b..60098895d8 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -400,24 +400,28 @@ class ExtractThumbnailOIIODefaultsModel(BaseSettingsModel): ) -class ExtractThumbnailModel(BaseSettingsModel): - _isGroup = True - enabled: bool = SettingsField(True) +class ExtractThumbnailProfileModel(BaseSettingsModel): + product_types: list[str] = SettingsField( + default_factory=list, title="Product types" + ) + hosts: list[str] = SettingsField(default_factory=list, title="Host names") + task_types: list[str] = SettingsField( + default_factory=list, title="Task types", enum_resolver=task_types_enum + ) + task_names: list[str] = SettingsField( + default_factory=list, title="Task names" + ) product_names: list[str] = SettingsField( - default_factory=list, - title="Product names" + default_factory=list, title="Product names" ) integrate_thumbnail: bool = SettingsField( - True, - title="Integrate Thumbnail Representation" + True, title="Integrate Thumbnail Representation" ) target_size: ResizeModel = SettingsField( - default_factory=ResizeModel, - title="Target size" + default_factory=ResizeModel, title="Target size" ) background_color: ColorRGBA_uint8 = SettingsField( - (0, 0, 0, 0.0), - title="Background color" + (0, 0, 0, 0.0), title="Background color" ) duration_split: float = SettingsField( 0.5, @@ -434,6 +438,15 @@ class ExtractThumbnailModel(BaseSettingsModel): ) +class ExtractThumbnailModel(BaseSettingsModel): + _isGroup = True + enabled: bool = SettingsField(True) + + profiles: list[ExtractThumbnailProfileModel] = SettingsField( + default_factory=list, title="Profiles" + ) + + def _extract_oiio_transcoding_type(): return [ {"value": "colorspace", "label": "Use Colorspace"}, @@ -1458,22 +1471,30 @@ DEFAULT_PUBLISH_VALUES = { }, "ExtractThumbnail": { "enabled": True, - "product_names": [], - "integrate_thumbnail": True, - "target_size": { - "type": "source" - }, - "duration_split": 0.5, - "oiiotool_defaults": { - "type": "colorspace", - "colorspace": "color_picking" - }, - "ffmpeg_args": { - "input": [ - "-apply_trc gamma22" - ], - "output": [] - } + "profiles": [ + { + "product_types": [], + "hosts": [], + "task_types": [], + "task_names": [], + "product_names": [], + "integrate_thumbnail": True, + "target_size": { + "type": "source" + }, + "duration_split": 0.5, + "oiiotool_defaults": { + "type": "colorspace", + "colorspace": "color_picking" + }, + "ffmpeg_args": { + "input": [ + "-apply_trc gamma22" + ], + "output": [] + } + } + ] }, "ExtractOIIOTranscode": { "enabled": True, From 7f40b6c6a2632e80acc2bcc1263116ecc9373f9f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 17:25:20 +0100 Subject: [PATCH 247/386] Added conversion to profiles to ExtractThumbnail --- server/settings/conversion.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 757818a9ff..c572fe70a9 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -158,6 +158,35 @@ def _convert_publish_plugins(overrides): _convert_oiio_transcode_0_4_5(overrides["publish"]) +def _convert_extract_thumbnail(overrides): + """ExtractThumbnail config settings did change to profiles.""" + extract_thumbnail_overrides = overrides.get("ExtractThumbnail") or {} + if "profiles" in extract_thumbnail_overrides: + return + + base_value = { + "product_types": [], + "hosts": [], + "task_types": [], + "task_names": [], + "product_names": [], + "integrate_thumbnail": True, + "target_size": {"type": "source"}, + "duration_split": 0.5, + "oiiotool_defaults": { + "type": "colorspace", + "colorspace": "color_picking", + }, + "ffmpeg_args": {"input": ["-apply_trc gamma22"], "output": []}, + } + base_value.update(extract_thumbnail_overrides) + + extract_thumbnail_profiles = extract_thumbnail_overrides.setdefault( + "profiles", [] + ) + extract_thumbnail_profiles.append(base_value) + + def convert_settings_overrides( source_version: str, overrides: dict[str, Any], @@ -166,4 +195,5 @@ def convert_settings_overrides( _convert_imageio_configs_0_4_5(overrides) _convert_imageio_configs_1_6_5(overrides) _convert_publish_plugins(overrides) + _convert_extract_thumbnail(overrides) return overrides From c0ed22c4d7486dc7171605abfe51f9c3d4b634b8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 17:27:54 +0100 Subject: [PATCH 248/386] Added profiles for ExtractThumbnail --- .../plugins/publish/extract_thumbnail.py | 152 +++++++++++++++--- 1 file changed, 132 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index adfb4298b9..8607244d72 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -1,8 +1,10 @@ import copy +from dataclasses import dataclass, field, fields import os +import re import subprocess import tempfile -import re +from typing import Dict, Any, List, Tuple import pyblish.api from ayon_core.lib import ( @@ -15,6 +17,7 @@ from ayon_core.lib import ( path_to_subprocess_arg, run_subprocess, + filter_profiles, ) from ayon_core.lib.transcoding import ( MissingRGBAChannelsError, @@ -26,6 +29,63 @@ from ayon_core.lib.transcoding import ( from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS +@dataclass +class ProfileConfig: + """ + Data class representing the full configuration for selected profile + + Any change of controllable fields in Settings must propagate here! + """ + product_names: List[str] = field(default_factory=list) + + integrate_thumbnail: bool = False + + target_size: Dict[str, Any] = field( + default_factory=lambda: { + "type": "source", + "resize": {"width": 1920, "height": 1080}, + } + ) + + duration_split: float = 0.5 + + oiiotool_defaults: Dict[str, str] = field( + default_factory=lambda: { + "type": "colorspace", + "colorspace": "color_picking" + } + ) + + ffmpeg_args: Dict[str, List[Any]] = field( + default_factory=lambda: {"input": [], "output": []} + ) + + # Background color defined as (R, G, B, A) tuple. + # Note: Use float for alpha channel (0.0 to 1.0). + background_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ProfileConfig": + """ + Creates a ProfileConfig instance from a dictionary, safely ignoring + any keys in the dictionary that are not fields in the dataclass. + + Args: + data (Dict[str, Any]): The dictionary containing configuration data + + Returns: + MediaConfig: A new instance of the dataclass. + """ + # Get all field names defined in the dataclass + field_names = {f.name for f in fields(cls)} + + # Filter the input dictionary to include only keys matching field names + filtered_data = {k: v for k, v in data.items() if k in field_names} + + # Unpack the filtered dictionary into the constructor + return cls(**filtered_data) + + class ExtractThumbnail(pyblish.api.InstancePlugin): """Create jpg thumbnail from sequence using ffmpeg""" @@ -99,6 +159,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): instance.data["representations"].remove(repre) def _main_process(self, instance): + if not self.profiles: + self.log.debug("No profiles present for color transcode") + return + profile_config = self._get_config_from_profile(instance) + if not profile_config: + return + product_name = instance.data["productName"] instance_repres = instance.data.get("representations") if not instance_repres: @@ -138,7 +205,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True return False - product_names = self.product_names + product_names = profile_config.product_names if product_names: result = validate_string_against_patterns( product_name, product_names @@ -205,8 +272,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # exclude first frame if slate in representation tags if "slate-frame" in repre.get("tags", []): repre_files_thumb = repre_files_thumb[1:] - file_index = int( - float(len(repre_files_thumb)) * self.duration_split) + file_index = int(float(len(repre_files_thumb)) * profile_config.duration_split) # noqa: E501 input_file = repre_files[file_index] full_input_path = os.path.join(src_staging, input_file) @@ -243,13 +309,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # colorspace data if not repre_thumb_created: repre_thumb_created = self._create_thumbnail_ffmpeg( - full_input_path, full_output_path + full_input_path, full_output_path, profile_config ) # Skip representation and try next one if wasn't created if not repre_thumb_created and oiio_supported: repre_thumb_created = self._create_thumbnail_oiio( - full_input_path, full_output_path + full_input_path, full_output_path, profile_config ) if not repre_thumb_created: @@ -277,7 +343,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): new_repre_tags = ["thumbnail"] # for workflows which needs to have thumbnails published as # separate representations `delete` tag should not be added - if not self.integrate_thumbnail: + if not profile_config.integrate_thumbnail: new_repre_tags.append("delete") new_repre = { @@ -399,6 +465,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): src_path, dst_path, colorspace_data, + profile_config ): """Create thumbnail using OIIO tool oiiotool @@ -416,7 +483,9 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): str: path to created thumbnail """ self.log.info("Extracting thumbnail {}".format(dst_path)) - resolution_arg = self._get_resolution_arg("oiiotool", src_path) + resolution_arg = self._get_resolution_arg( + "oiiotool", src_path, profile_config + ) repre_display = colorspace_data.get("display") repre_view = colorspace_data.get("view") @@ -435,12 +504,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) # if representation doesn't have display and view then use # oiiotool_defaults - elif self.oiiotool_defaults: - oiio_default_type = self.oiiotool_defaults["type"] + elif profile_config.oiiotool_defaults: + oiiotool_defaults = profile_config.oiiotool_defaults + oiio_default_type = oiiotool_defaults["type"] if "colorspace" == oiio_default_type: - oiio_default_colorspace = self.oiiotool_defaults["colorspace"] + oiio_default_colorspace = oiiotool_defaults["colorspace"] else: - display_and_view = self.oiiotool_defaults["display_and_view"] + display_and_view = oiiotool_defaults["display_and_view"] oiio_default_display = display_and_view["display"] oiio_default_view = display_and_view["view"] @@ -467,11 +537,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True - def _create_thumbnail_oiio(self, src_path, dst_path): + def _create_thumbnail_oiio(self, src_path, dst_path, profile_config): self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}") try: - resolution_arg = self._get_resolution_arg("oiiotool", src_path) + resolution_arg = self._get_resolution_arg( + "oiiotool", src_path, profile_config + ) except RuntimeError: self.log.warning( "Failed to create thumbnail using oiio", exc_info=True @@ -511,9 +583,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False - def _create_thumbnail_ffmpeg(self, src_path, dst_path): + def _create_thumbnail_ffmpeg(self, src_path, dst_path, profile_config): try: - resolution_arg = self._get_resolution_arg("ffmpeg", src_path) + resolution_arg = self._get_resolution_arg( + "ffmpeg", src_path, profile_config + ) except RuntimeError: self.log.warning( "Failed to create thumbnail using ffmpeg", exc_info=True @@ -521,7 +595,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") - ffmpeg_args = self.ffmpeg_args or {} + ffmpeg_args = profile_config.ffmpeg_args or {} jpeg_items = [ subprocess.list2cmdline(ffmpeg_path_args) @@ -664,12 +738,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): self, application, input_path, + profile_config ): # get settings - if self.target_size["type"] == "source": + if profile_config.target_size["type"] == "source": return [] - resize = self.target_size["resize"] + resize = profile_config.target_size["resize"] target_width = resize["width"] target_height = resize["height"] @@ -679,6 +754,43 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): input_path, target_width, target_height, - bg_color=self.background_color, + bg_color=profile_config.background_color, log=self.log ) + + def _get_config_from_profile( + self, + instance: pyblish.api.Instance + ) -> ProfileConfig: + """Returns profile if and how repre should be color transcoded.""" + host_name = instance.context.data["hostName"] + product_type = instance.data["productType"] + product_name = instance.data["productName"] + task_data = instance.data["anatomyData"].get("task", {}) + task_name = task_data.get("name") + task_type = task_data.get("type") + filtering_criteria = { + "hosts": host_name, + "product_types": product_type, + "product_names": product_name, + "task_names": task_name, + "task_types": task_type, + } + profile = filter_profiles( + self.profiles, filtering_criteria, + logger=self.log + ) + + if not profile: + self.log.debug( + ( + "Skipped instance. None of profiles in presets are for" + ' Host: "{}" | Product types: "{}" | Product names: "{}"' + ' | Task name "{}" | Task type "{}"' + ).format( + host_name, product_type, product_name, task_name, task_type + ) + ) + return + + return ProfileConfig.from_dict(profile) From 24ff7f02d695a48511f323e180268a20a0cf4176 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:05:42 +0100 Subject: [PATCH 249/386] Fix wrongly resolved line --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 0cd3a1c51f..c4c27edc3b 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -54,7 +54,7 @@ def get_product_name_template( profiles = tools_settings["creator"]["product_name_profiles"] filtering_criteria = { "product_types": product_type, - "host_names": host_name,: host_name, + "host_names": host_name, "task_names": task_name, "task_types": task_type, "product_base_types": product_base_type, From c7672fd51127db9364eeda2fa5a63ca41ba69986 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 Dec 2025 18:53:30 +0100 Subject: [PATCH 250/386] Fix querying of overrides --- server/settings/conversion.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index c572fe70a9..4f28801ba1 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -160,7 +160,9 @@ def _convert_publish_plugins(overrides): def _convert_extract_thumbnail(overrides): """ExtractThumbnail config settings did change to profiles.""" - extract_thumbnail_overrides = overrides.get("ExtractThumbnail") or {} + extract_thumbnail_overrides = ( + overrides.get("publish", {}).get("ExtractThumbnail") or {} + ) if "profiles" in extract_thumbnail_overrides: return From 56df03848f5b2352b0f33dbc4a7d248f06dffb96 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 5 Dec 2025 11:59:33 +0100 Subject: [PATCH 251/386] Updated logging --- client/ayon_core/plugins/publish/extract_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 8607244d72..bc8246e1bd 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -160,7 +160,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _main_process(self, instance): if not self.profiles: - self.log.debug("No profiles present for color transcode") + self.log.debug("No profiles present for extract review thumbnail.") return profile_config = self._get_config_from_profile(instance) if not profile_config: From a59b2644968968a91a23e20e93d547753c5144cb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 5 Dec 2025 11:59:50 +0100 Subject: [PATCH 252/386] Updated Settings controlled variables --- .../plugins/publish/extract_thumbnail.py | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index bc8246e1bd..80e4054ecb 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -113,30 +113,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): settings_category = "core" enabled = False - integrate_thumbnail = False - target_size = { - "type": "source", - "resize": { - "width": 1920, - "height": 1080 - } - } - background_color = (0, 0, 0, 0.0) - duration_split = 0.5 - # attribute presets from settings - oiiotool_defaults = { - "type": "colorspace", - "colorspace": "color_picking", - "display_and_view": { - "display": "default", - "view": "sRGB" - } - } - ffmpeg_args = { - "input": [], - "output": [] - } - product_names = [] + profiles = [] def process(self, instance): # run main process From fa6e8b447842e96bbda3ca90c417d2967e3f873a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 5 Dec 2025 12:05:54 +0100 Subject: [PATCH 253/386] Added missed argument --- .../ayon_core/plugins/publish/extract_thumbnail.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 80e4054ecb..022979d09e 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -237,7 +237,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) file_path = self._create_frame_from_video( video_file_path, - dst_staging + dst_staging, + profile_config ) if file_path: src_staging, input_file = os.path.split(file_path) @@ -612,7 +613,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False - def _create_frame_from_video(self, video_file_path, output_dir): + def _create_frame_from_video( + self, + video_file_path, + output_dir, + profile_config + ): """Convert video file to one frame image via ffmpeg""" # create output file path base_name = os.path.basename(video_file_path) @@ -637,7 +643,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): seek_position = 0.0 # Only use timestamp calculation for videos longer than 0.1 seconds if duration > 0.1: - seek_position = duration * self.duration_split + seek_position = duration * profile_config.duration_split # Build command args cmd_args = [] From 32bc4248fc7f421334190f2f6be179db8968033c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 5 Dec 2025 12:11:08 +0100 Subject: [PATCH 254/386] Typing --- client/ayon_core/plugins/publish/extract_thumbnail.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 022979d09e..2cee12304a 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -4,7 +4,7 @@ import os import re import subprocess import tempfile -from typing import Dict, Any, List, Tuple +from typing import Dict, Any, List, Tuple, Optional import pyblish.api from ayon_core.lib import ( @@ -744,7 +744,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _get_config_from_profile( self, instance: pyblish.api.Instance - ) -> ProfileConfig: + ) -> Optional[ProfileConfig]: """Returns profile if and how repre should be color transcoded.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] From f7f0005511c7aaa34e967100afa6db5f5ad53a1d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 5 Dec 2025 12:23:30 +0100 Subject: [PATCH 255/386] Fix import --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 45209764ee..8fce5574e9 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -28,7 +28,7 @@ from ayon_core.pipeline.publish import ( KnownPublishError, get_publish_template_name, ) -from pipeline import is_product_base_type_supported +from ayon_core.pipeline import is_product_base_type_supported log = logging.getLogger(__name__) From 074c43ff68c0770bee03c84238dc6255e4825bd1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:05:56 +0100 Subject: [PATCH 256/386] added is_base_class to create base classes --- client/ayon_core/pipeline/create/creator_plugins.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 7573589b82..8595ff4ca5 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -651,7 +651,7 @@ class Creator(BaseCreator): Creation requires prepared product name and instance data. """ - + is_base_class = True # GUI Purposes # - default_variants may not be used if `get_default_variants` # is overridden @@ -949,6 +949,8 @@ class Creator(BaseCreator): class HiddenCreator(BaseCreator): + is_base_class = True + @abstractmethod def create(self, instance_data, source_data): pass @@ -959,6 +961,7 @@ class AutoCreator(BaseCreator): Can be used e.g. for `workfile`. """ + is_base_class = True def remove_instances(self, instances): """Skip removal.""" From f665528ee7a64e416bab859537b24a8eaabaec00 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:36:01 +0100 Subject: [PATCH 257/386] fix 'product_name' to 'name' --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 8fce5574e9..9f24b35754 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -399,7 +399,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): product_id = existing_product_entity["id"] new_product_entity_kwargs = { - "product_name": product_name, + "name": product_name, "product_type": product_type, "folder_id": folder_entity["id"], "data": data, From cf28f96eda987207bdaa6161d2cfd19d46aad264 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:49:11 +0100 Subject: [PATCH 258/386] fix formatting in docstring --- client/ayon_core/pipeline/create/product_name.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index b0bb2d3430..89ae7ef85b 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -367,13 +367,15 @@ def get_product_name( ) -> TemplateResult: """Calculate product name based on passed context and AYON settings. - Product name templates are defined in `project_settings/global/tools/creator - /product_name_profiles` where are profiles with host name, product type, - task name and task type filters. If context does not match any profile - then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. + Product name templates are defined in `project_settings/global/tools + /creator/product_name_profiles` where are profiles with host name, + product base type, product type, task name and task type filters. + + If context does not match any profile then `DEFAULT_PRODUCT_TEMPLATE` + is used as default template. That's main reason why so many arguments are required to calculate product - name. + name. Args: project_name (str): Project name. From 89129dfeb4046cb49d47c7ca9928c0eb2993033f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 10:10:51 +0100 Subject: [PATCH 259/386] Renamed hosts to host_names for ExtractThumbnail --- client/ayon_core/plugins/publish/extract_thumbnail.py | 2 +- server/settings/conversion.py | 2 +- server/settings/publish_plugins.py | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 2cee12304a..c7e99a186a 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -753,7 +753,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): task_name = task_data.get("name") task_type = task_data.get("type") filtering_criteria = { - "hosts": host_name, + "host_names": host_name, "product_types": product_type, "product_names": product_name, "task_names": task_name, diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 4f28801ba1..6fd534704c 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -168,7 +168,7 @@ def _convert_extract_thumbnail(overrides): base_value = { "product_types": [], - "hosts": [], + "host_names": [], "task_types": [], "task_names": [], "product_names": [], diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 60098895d8..fd20ccf9c6 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -404,7 +404,9 @@ class ExtractThumbnailProfileModel(BaseSettingsModel): product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField(default_factory=list, title="Host names") + host_names: list[str] = SettingsField( + default_factory=list, title="Host names" + ) task_types: list[str] = SettingsField( default_factory=list, title="Task types", enum_resolver=task_types_enum ) @@ -1474,7 +1476,7 @@ DEFAULT_PUBLISH_VALUES = { "profiles": [ { "product_types": [], - "hosts": [], + "host_names": [], "task_types": [], "task_names": [], "product_names": [], From a4559fe79ee19737974a83e34109ddb2e8040701 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 10:11:36 +0100 Subject: [PATCH 260/386] Changed datatype of rgb --- client/ayon_core/plugins/publish/extract_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index c7e99a186a..ff4ee5b431 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -62,7 +62,7 @@ class ProfileConfig: # Background color defined as (R, G, B, A) tuple. # Note: Use float for alpha channel (0.0 to 1.0). - background_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ProfileConfig": From 6cfb22a4b5defdaabfb57551506b390048684f34 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 10:13:00 +0100 Subject: [PATCH 261/386] Formatting change --- client/ayon_core/plugins/publish/extract_thumbnail.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index ff4ee5b431..b9e0a4a5b1 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -250,7 +250,9 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # exclude first frame if slate in representation tags if "slate-frame" in repre.get("tags", []): repre_files_thumb = repre_files_thumb[1:] - file_index = int(float(len(repre_files_thumb)) * profile_config.duration_split) # noqa: E501 + file_index = int( + float(len(repre_files_thumb)) * profile_config.duration_split # noqa: E501 + ) input_file = repre_files[file_index] full_input_path = os.path.join(src_staging, input_file) From d859ea2fc3742d0cb14be03fcc27db6a8f80ee2d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 10:13:57 +0100 Subject: [PATCH 262/386] Explicit key values updates --- server/settings/conversion.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 6fd534704c..348518f0a3 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -181,7 +181,16 @@ def _convert_extract_thumbnail(overrides): }, "ffmpeg_args": {"input": ["-apply_trc gamma22"], "output": []}, } - base_value.update(extract_thumbnail_overrides) + for key in ( + "product_names", + "integrate_thumbnail", + "target_size", + "duration_split", + "oiiotool_defaults", + "ffmpeg_args", + ): + if key in extract_thumbnail_overrides: + base_value[key] = extract_thumbnail_overrides[key] extract_thumbnail_profiles = extract_thumbnail_overrides.setdefault( "profiles", [] From f1288eb096d30c3788f54c64cf3f022e361a67d0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 10:15:46 +0100 Subject: [PATCH 263/386] Renamed ProfileConfig to ThumbnailDef --- client/ayon_core/plugins/publish/extract_thumbnail.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index b9e0a4a5b1..47d2a9419f 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -30,7 +30,7 @@ from ayon_core.lib.transcoding import VIDEO_EXTENSIONS, IMAGE_EXTENSIONS @dataclass -class ProfileConfig: +class ThumbnailDef: """ Data class representing the full configuration for selected profile @@ -65,9 +65,9 @@ class ProfileConfig: background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ProfileConfig": + def from_dict(cls, data: Dict[str, Any]) -> "ThumbnailDef": """ - Creates a ProfileConfig instance from a dictionary, safely ignoring + Creates a ThumbnailDef instance from a dictionary, safely ignoring any keys in the dictionary that are not fields in the dataclass. Args: @@ -746,7 +746,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _get_config_from_profile( self, instance: pyblish.api.Instance - ) -> Optional[ProfileConfig]: + ) -> Optional[ThumbnailDef]: """Returns profile if and how repre should be color transcoded.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] @@ -778,4 +778,4 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return - return ProfileConfig.from_dict(profile) + return ThumbnailDef.from_dict(profile) From 14bead732cc73335c557ab6f30fb1e98504debb0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 10:16:54 +0100 Subject: [PATCH 264/386] Removed unnecessary filtering Already done in profile filter --- .../plugins/publish/extract_thumbnail.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 47d2a9419f..23309f4d34 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -36,8 +36,6 @@ class ThumbnailDef: Any change of controllable fields in Settings must propagate here! """ - product_names: List[str] = field(default_factory=list) - integrate_thumbnail: bool = False target_size: Dict[str, Any] = field( @@ -175,24 +173,6 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): self.log.debug("Skipping crypto passes.") return - # We only want to process the produces needed from settings. - def validate_string_against_patterns(input_str, patterns): - for pattern in patterns: - if re.match(pattern, input_str): - return True - return False - - product_names = profile_config.product_names - if product_names: - result = validate_string_against_patterns( - product_name, product_names - ) - if not result: - self.log.debug(( - "Product name \"{}\" did not match settings filters: {}" - ).format(product_name, product_names)) - return - # first check for any explicitly marked representations for thumbnail explicit_repres = self._get_explicit_repres_for_thumbnail(instance) if explicit_repres: From cdac62aae77421ff583a88722cb772ad6672b68d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 11:07:05 +0100 Subject: [PATCH 265/386] Renamed hosts to host_names for ExtractThumbnailFromSource --- .../publish/extract_thumbnail_from_source.py | 2 +- server/settings/publish_plugins.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index abfbfc70e6..cb96ff4aef 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -308,7 +308,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): task_name = task_data.get("name") task_type = task_data.get("type") filtering_criteria = { - "hosts": host_name, + "host_names": host_name, "product_types": product_type, "product_names": product_name, "task_names": task_name, diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index dcaa47a351..5e4359b7bc 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -470,19 +470,21 @@ class UseDisplayViewModel(BaseSettingsModel): class ExtractThumbnailFromSourceProfileModel(BaseSettingsModel): + host_names: list[str] = SettingsField( + default_factory=list, title="Host names" + ) + product_names: list[str] = SettingsField( + default_factory=list, title="Product names" + ) product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) - hosts: list[str] = SettingsField(default_factory=list, title="Host names") task_types: list[str] = SettingsField( default_factory=list, title="Task types", enum_resolver=task_types_enum ) task_names: list[str] = SettingsField( default_factory=list, title="Task names" ) - product_names: list[str] = SettingsField( - default_factory=list, title="Product names" - ) integrate_thumbnail: bool = SettingsField( True, title="Integrate Thumbnail Representation" @@ -1527,11 +1529,11 @@ DEFAULT_PUBLISH_VALUES = { "enabled": True, "profiles": [ { + "product_names": [], "product_types": [], - "hosts": [], + "host_names": [], "task_types": [], "task_names": [], - "product_names": [], "integrate_thumbnail": True, "target_size": { "type": "source", From ad0cbad6636681512de1d70509e5e67b78f92e96 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 11:07:50 +0100 Subject: [PATCH 266/386] Changed data types of rgb --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index cb96ff4aef..603bca1aee 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -50,8 +50,7 @@ class ProfileConfig: ) # Background color defined as (R, G, B, A) tuple. - # Note: Use float for alpha channel (0.0 to 1.0). - background_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0) + background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0) @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ProfileConfig": From 00102dae85890a0e876c28055a9e11ae65fd7dc3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 8 Dec 2025 11:09:12 +0100 Subject: [PATCH 267/386] Renamed ProfileConfig --- .../publish/extract_thumbnail_from_source.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 603bca1aee..33850a4324 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -30,7 +30,7 @@ from ayon_core.lib import ( @dataclass -class ProfileConfig: +class ThumbnailDefinition: """ Data class representing the full configuration for selected profile @@ -53,10 +53,11 @@ class ProfileConfig: background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0) @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ProfileConfig": + def from_dict(cls, data: Dict[str, Any]) -> "ThumbnailDefinition": """ - Creates a ProfileConfig instance from a dictionary, safely ignoring - any keys in the dictionary that are not fields in the dataclass. + Creates a ThumbnailDefinition instance from a dictionary, + safely ignoring any keys in the dictionary that are not fields + in the dataclass. Args: data (Dict[str, Any]): The dictionary containing configuration data @@ -142,7 +143,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, context: pyblish.api.Context, thumbnail_source: str, - profile_config: ProfileConfig + profile_config: ThumbnailDefinition ) -> Optional[str]: if not thumbnail_source: self.log.debug("Thumbnail source not filled. Skipping.") @@ -214,7 +215,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, src_path: str, dst_path: str, - profile_config: ProfileConfig + profile_config: ThumbnailDefinition ) -> bool: self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) resolution_arg = self._get_resolution_arg( @@ -244,7 +245,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, src_path: str, dst_path: str, - profile_config: ProfileConfig + profile_config: ThumbnailDefinition ) -> bool: resolution_arg = self._get_resolution_arg( "ffmpeg", src_path, profile_config @@ -283,7 +284,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): def _create_context_thumbnail( self, context: pyblish.api.Context, - profile: ProfileConfig + profile: ThumbnailDefinition ) -> Optional[str]: hasContextThumbnail = "thumbnailPath" in context.data if hasContextThumbnail: @@ -298,7 +299,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): def _get_config_from_profile( self, instance: pyblish.api.Instance - ) -> ProfileConfig: + ) -> ThumbnailDefinition: """Returns profile if and how repre should be color transcoded.""" host_name = instance.context.data["hostName"] product_type = instance.data["productType"] @@ -330,13 +331,13 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): ) return - return ProfileConfig.from_dict(profile) + return ThumbnailDefinition.from_dict(profile) def _get_resolution_arg( self, application: str, input_path: str, - profile: ProfileConfig + profile: ThumbnailDefinition ) -> List[str]: # get settings if profile.target_size["type"] == "source": From fb2df3397063a5f06184cb2484d2c849d4d9d948 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:39:13 +0100 Subject: [PATCH 268/386] added option to define different help file --- client/ayon_core/pipeline/publish/lib.py | 17 +++++--- .../pipeline/publish/publish_plugins.py | 41 ++++++++++++++++--- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 7365ffee09..e512a0116f 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -192,7 +192,9 @@ class HelpContent: self.detail = detail -def load_help_content_from_filepath(filepath): +def load_help_content_from_filepath( + filepath: str +) -> dict[str, dict[str, HelpContent]]: """Load help content from xml file. Xml file may contain errors and warnings. """ @@ -227,15 +229,20 @@ def load_help_content_from_filepath(filepath): return output -def load_help_content_from_plugin(plugin): +def load_help_content_from_plugin( + plugin: pyblish.api.Plugin, + help_filename: Optional[str] = None, +) -> dict[str, dict[str, HelpContent]]: cls = plugin if not inspect.isclass(plugin): cls = plugin.__class__ + plugin_filepath = inspect.getfile(cls) plugin_dir = os.path.dirname(plugin_filepath) - basename = os.path.splitext(os.path.basename(plugin_filepath))[0] - filename = basename + ".xml" - filepath = os.path.join(plugin_dir, "help", filename) + if help_filename is None: + basename = os.path.splitext(os.path.basename(plugin_filepath))[0] + help_filename = basename + ".xml" + filepath = os.path.join(plugin_dir, "help", help_filename) return load_help_content_from_filepath(filepath) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index cc6887e762..90b8e90a3c 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -1,7 +1,7 @@ import inspect from abc import ABCMeta import typing -from typing import Optional +from typing import Optional, Any import pyblish.api import pyblish.logic @@ -82,22 +82,51 @@ class PublishValidationError(PublishError): class PublishXmlValidationError(PublishValidationError): + """Raise an error from a dedicated xml file. + + Can be useful to have one xml file with different possible messages that + helps to avoid flood code with dedicated artist messages. + + XML files should live relative to the plugin file location: + '{plugin dir}/help/some_plugin.xml'. + + Args: + plugin (pyblish.api.Plugin): Plugin that raised an error. Is used + to get path to xml file. + message (str): Exception message, can be technical, is used for + console output. + key (Optional[str]): XML file can contain multiple error messages, key + is used to get one of them. By default is used 'main'. + formatting_data (Optional[dict[str, Any]): Error message can have + variables to fill. + help_filename (Optional[str]): Name of xml file with messages. By + default, is used filename where plugin lives with .xml extension. + + """ def __init__( - self, plugin, message, key=None, formatting_data=None - ): + self, + plugin: pyblish.api.Plugin, + message: str, + key: Optional[str] = None, + formatting_data: Optional[dict[str, Any]] = None, + help_filename: Optional[str] = None, + ) -> None: if key is None: key = "main" if not formatting_data: formatting_data = {} - result = load_help_content_from_plugin(plugin) + result = load_help_content_from_plugin(plugin, help_filename) content_obj = result["errors"][key] description = content_obj.description.format(**formatting_data) detail = content_obj.detail if detail: detail = detail.format(**formatting_data) - super(PublishXmlValidationError, self).__init__( - message, content_obj.title, description, detail + super().__init__( + message, + content_obj.title, + description, + detail ) From f0bd2b7e98cb74cab1002874ac8b8d88d611957b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:39:44 +0100 Subject: [PATCH 269/386] use different help file for integrate review --- .../plugins/publish/help/upload_file.xml | 21 +++++++++++++++++++ .../publish/help/validate_publish_dir.xml | 18 ---------------- .../plugins/publish/integrate_review.py | 7 ++++--- 3 files changed, 25 insertions(+), 21 deletions(-) create mode 100644 client/ayon_core/plugins/publish/help/upload_file.xml diff --git a/client/ayon_core/plugins/publish/help/upload_file.xml b/client/ayon_core/plugins/publish/help/upload_file.xml new file mode 100644 index 0000000000..8c270c7b19 --- /dev/null +++ b/client/ayon_core/plugins/publish/help/upload_file.xml @@ -0,0 +1,21 @@ + + + +{upload_type} upload timed out + +## {upload_type} upload failed after retries + +The connection to the AYON server timed out while uploading a file. + +### How to resolve? + +1. Try publishing again. Intermittent network hiccups often resolve on retry. +2. Ensure your network/VPN is stable and large uploads are allowed. +3. If it keeps failing, try again later or contact your admin. + +
File: {file}
+Error: {error}
+ +
+
+
diff --git a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml index 0449e61fa2..9f62b264bf 100644 --- a/client/ayon_core/plugins/publish/help/validate_publish_dir.xml +++ b/client/ayon_core/plugins/publish/help/validate_publish_dir.xml @@ -1,23 +1,5 @@ - -Review upload timed out - -## Review upload failed after retries - -The connection to the AYON server timed out while uploading a reviewable file. - -### How to repair? - -1. Try publishing again. Intermittent network hiccups often resolve on retry. -2. Ensure your network/VPN is stable and large uploads are allowed. -3. If it keeps failing, try again later or contact your admin. - -
File: {file}
-Error: {error}
- -
-
Source directory not collected diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index f9fa862320..4d091aa17a 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -158,12 +158,13 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): raise PublishXmlValidationError( self, ( - "Upload of reviewable timed out or failed after multiple attempts." - " Please try publishing again." + "Upload of reviewable timed out or failed after multiple" + " attempts. Please try publishing again." ), - key="upload_timeout", formatting_data={ + "upload_type": "Review", "file": repre_path, "error": str(last_error), }, + help_filename="upload_file.xml", ) From 699673bbf2e55a0f35d9ca966baaec1a0e7705dd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:40:13 +0100 Subject: [PATCH 270/386] slightly modified upload --- .../plugins/publish/integrate_review.py | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 4d091aa17a..06cc2f55b4 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -9,10 +9,7 @@ from ayon_core.pipeline.publish import ( PublishXmlValidationError, get_publish_repre_path, ) -from requests import exceptions as req_exc - -# Narrow retryable failures to transient network issues -RETRYABLE_EXCEPTIONS = (req_exc.Timeout, req_exc.ConnectionError) +import requests.exceptions class IntegrateAYONReview(pyblish.api.InstancePlugin): @@ -82,19 +79,13 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): f"/projects/{project_name}" f"/versions/{version_id}/reviewables{query}" ) - filename = os.path.basename(repre_path) - # Upload the reviewable - self.log.info(f"Uploading reviewable '{label or filename}' ...") - - headers = ayon_con.get_headers(content_type) - headers["x-file-name"] = filename self.log.info(f"Uploading reviewable {repre_path}") # Upload with retries and clear help if it keeps failing self._upload_with_retries( ayon_con, endpoint, repre_path, - headers, + content_type, ) def _get_review_label(self, repre, uploaded_labels): @@ -111,48 +102,48 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): def _upload_with_retries( self, - ayon_con, - endpoint, - repre_path, - headers, - max_retries: int = 3, - backoff_seconds: int = 2, + ayon_con: ayon_api.ServerAPI, + endpoint: str, + repre_path: str, + content_type: str, ): """Upload file with simple exponential backoff retries. If all retries fail we raise a PublishXmlValidationError with a help key to guide the user to retry publish. """ + # How long to sleep before next attempt + wait_time = 1 + filename = os.path.basename(repre_path) + + headers = ayon_con.get_headers(content_type) + headers["x-file-name"] = filename + max_retries = ayon_con.get_default_max_retries() last_error = None for attempt in range(max_retries): - attempt_num = attempt + 1 + attempt += 1 try: - ayon_con.upload_file( + return ayon_con.upload_file( endpoint, repre_path, headers=headers, request_type=RequestTypes.post, ) - return - except RETRYABLE_EXCEPTIONS as exc: - last_error = exc + + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError + ): # Log and retry with backoff if attempts remain - if attempt_num < max_retries: - wait = backoff_seconds * (2 ** attempt) - self.log.warning( - "Review upload failed (attempt %s/%s). Retrying in %ss...", - attempt_num, max_retries, wait, - exc_info=True, - ) - try: - time.sleep(wait) - except Exception: - pass - else: - break - except Exception: - # Non retryable failures bubble immediately - raise + if attempt >= max_retries: + raise + + self.log.warning( + f"Review upload failed ({attempt}/{max_retries})." + f" Retrying in {wait_time}s...", + exc_info=True, + ) + time.sleep(wait_time) # Exhausted retries - raise a user-friendly validation error with help raise PublishXmlValidationError( From 989c54001c73bf56caa10780227e463faf7b1a45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:53:30 +0100 Subject: [PATCH 271/386] added retries in thumbnail integration --- .../plugins/publish/integrate_thumbnail.py | 82 +++++++++++++++++-- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 067c3470e8..7d36a1c7eb 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -24,11 +24,19 @@ import os import collections +import time import pyblish.api import ayon_api from ayon_api import RequestTypes from ayon_api.operations import OperationsSession +try: + from ayon_api.utils import get_media_mime_type +except ImportError: + from ayon_core.lib import get_media_mime_type +import requests + +from ayon_core.pipeline.publish import PublishXmlValidationError InstanceFilterResult = collections.namedtuple( @@ -170,19 +178,16 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): fix jpeg mime type. """ - mime_type = None - with open(src_filepath, "rb") as stream: - if b"\xff\xd8\xff" == stream.read(3): - mime_type = "image/jpeg" - + mime_type = get_media_mime_type(src_filepath) if mime_type is None: - return ayon_api.create_thumbnail(project_name, src_filepath) + return ayon_api.create_thumbnail( + project_name, src_filepath + ) - response = ayon_api.upload_file( + response = self._upload_with_retries( f"projects/{project_name}/thumbnails", src_filepath, - request_type=RequestTypes.post, - headers={"Content-Type": mime_type}, + mime_type, ) response.raise_for_status() return response.json()["id"] @@ -248,3 +253,62 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): or instance.data.get("name") or "N/A" ) + + def _upload_with_retries( + self, + endpoint: str, + repre_path: str, + content_type: str, + ): + """Upload file with simple exponential backoff retries. + + If all retries fail we raise a PublishXmlValidationError with a help key + to guide the user to retry publish. + """ + # How long to sleep before next attempt + wait_time = 1 + filename = os.path.basename(repre_path) + + ayon_con = ayon_api.get_server_api_connection() + headers = ayon_con.get_headers(content_type) + max_retries = ayon_con.get_default_max_retries() + last_error = None + for attempt in range(max_retries): + attempt += 1 + try: + return ayon_con.upload_file( + endpoint, + repre_path, + headers=headers, + request_type=RequestTypes.post, + ) + + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError + ): + # Log and retry with backoff if attempts remain + if attempt >= max_retries: + raise + + self.log.warning( + f"Review upload failed ({attempt}/{max_retries})." + f" Retrying in {wait_time}s...", + exc_info=True, + ) + time.sleep(wait_time) + + # Exhausted retries - raise a user-friendly validation error with help + raise PublishXmlValidationError( + self, + ( + "Upload of thumbnail timed out or failed after multiple" + " attempts. Please try publishing again." + ), + formatting_data={ + "upload_type": "Thumbnail", + "file": repre_path, + "error": str(last_error), + }, + help_filename="upload_file.xml", + ) From 9b35dd6cfc91ae99d03474888bd80d97a45a47d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:16:16 +0100 Subject: [PATCH 272/386] remove unused variable --- client/ayon_core/plugins/publish/integrate_thumbnail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 7d36a1c7eb..233ab751f6 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -267,7 +267,6 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): """ # How long to sleep before next attempt wait_time = 1 - filename = os.path.basename(repre_path) ayon_con = ayon_api.get_server_api_connection() headers = ayon_con.get_headers(content_type) From e0597ac6de2d5ddf6161fbb2687518d59ee7edb1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:20:02 +0100 Subject: [PATCH 273/386] remove unnecessary imports --- client/ayon_core/plugins/publish/integrate_thumbnail.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 233ab751f6..e68a4179ae 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -26,16 +26,13 @@ import os import collections import time -import pyblish.api import ayon_api from ayon_api import RequestTypes from ayon_api.operations import OperationsSession -try: - from ayon_api.utils import get_media_mime_type -except ImportError: - from ayon_core.lib import get_media_mime_type +import pyblish.api import requests +from ayon_core.lib import get_media_mime_type from ayon_core.pipeline.publish import PublishXmlValidationError From 647d91e288aaa4e2ad1facab2e907b0936c97632 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:26:02 +0100 Subject: [PATCH 274/386] update docstring --- client/ayon_core/plugins/publish/integrate_review.py | 6 +----- client/ayon_core/plugins/publish/integrate_thumbnail.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 06cc2f55b4..1b236d9070 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -107,11 +107,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): repre_path: str, content_type: str, ): - """Upload file with simple exponential backoff retries. - - If all retries fail we raise a PublishXmlValidationError with a help key - to guide the user to retry publish. - """ + """Upload file with simple retries.""" # How long to sleep before next attempt wait_time = 1 filename = os.path.basename(repre_path) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index e68a4179ae..aef95525cb 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -257,11 +257,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): repre_path: str, content_type: str, ): - """Upload file with simple exponential backoff retries. - - If all retries fail we raise a PublishXmlValidationError with a help key - to guide the user to retry publish. - """ + """Upload file with simple retries.""" # How long to sleep before next attempt wait_time = 1 From 44251c93c776935abb1b5eb0338e24c0768a77a0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 9 Dec 2025 10:33:12 +0100 Subject: [PATCH 275/386] Ruff --- client/ayon_core/plugins/publish/extract_thumbnail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 23309f4d34..447a24656d 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -1,7 +1,6 @@ import copy from dataclasses import dataclass, field, fields import os -import re import subprocess import tempfile from typing import Dict, Any, List, Tuple, Optional From faff50ce333eeff2e1d69be4f50b8b5a1f72d3a2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:03:19 +0100 Subject: [PATCH 276/386] don't use custom retries if are already handled by ayon api --- .../plugins/publish/integrate_review.py | 18 +++++++++++++----- .../plugins/publish/integrate_thumbnail.py | 17 +++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 1b236d9070..cdb16b5ac3 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -2,8 +2,10 @@ import os import time import ayon_api -import pyblish.api +from ayon_api import TransferProgress from ayon_api.server_api import RequestTypes +import pyblish.api + from ayon_core.lib import get_media_mime_type from ayon_core.pipeline.publish import ( PublishXmlValidationError, @@ -108,13 +110,18 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): content_type: str, ): """Upload file with simple retries.""" - # How long to sleep before next attempt - wait_time = 1 filename = os.path.basename(repre_path) headers = ayon_con.get_headers(content_type) headers["x-file-name"] = filename max_retries = ayon_con.get_default_max_retries() + # Retries are already implemented in 'ayon_api.upload_file' + # - added in ayon api 1.2.7 + if hasattr(TransferProgress, "get_attempt"): + max_retries = 1 + + # How long to sleep before next attempt + wait_time = 1 last_error = None for attempt in range(max_retries): attempt += 1 @@ -129,10 +136,11 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): except ( requests.exceptions.Timeout, requests.exceptions.ConnectionError - ): + ) as exc: # Log and retry with backoff if attempts remain if attempt >= max_retries: - raise + last_error = exc + break self.log.warning( f"Review upload failed ({attempt}/{max_retries})." diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index aef95525cb..12c5e483e3 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -27,7 +27,7 @@ import collections import time import ayon_api -from ayon_api import RequestTypes +from ayon_api import RequestTypes, TransferProgress from ayon_api.operations import OperationsSession import pyblish.api import requests @@ -258,12 +258,16 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): content_type: str, ): """Upload file with simple retries.""" - # How long to sleep before next attempt - wait_time = 1 - ayon_con = ayon_api.get_server_api_connection() headers = ayon_con.get_headers(content_type) max_retries = ayon_con.get_default_max_retries() + # Retries are already implemented in 'ayon_api.upload_file' + # - added in ayon api 1.2.7 + if hasattr(TransferProgress, "get_attempt"): + max_retries = 1 + + # How long to sleep before next attempt + wait_time = 1 last_error = None for attempt in range(max_retries): attempt += 1 @@ -278,10 +282,11 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): except ( requests.exceptions.Timeout, requests.exceptions.ConnectionError - ): + ) as exc: # Log and retry with backoff if attempts remain if attempt >= max_retries: - raise + last_error = exc + break self.log.warning( f"Review upload failed ({attempt}/{max_retries})." From dde471332fc2b01c88e23277e49924e4a46b6f7d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:33:19 +0100 Subject: [PATCH 277/386] added more logs --- .../ayon_core/plugins/publish/integrate_review.py | 15 ++++++++++++--- .../plugins/publish/integrate_thumbnail.py | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index cdb16b5ac3..6d7e98ae66 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -6,7 +6,7 @@ from ayon_api import TransferProgress from ayon_api.server_api import RequestTypes import pyblish.api -from ayon_core.lib import get_media_mime_type +from ayon_core.lib import get_media_mime_type, format_file_size from ayon_core.pipeline.publish import ( PublishXmlValidationError, get_publish_repre_path, @@ -120,18 +120,26 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): if hasattr(TransferProgress, "get_attempt"): max_retries = 1 + size = os.path.getsize(repre_path) + self.log.info( + f"Uploading '{repre_path}' (size: {format_file_size(size)})" + ) + # How long to sleep before next attempt wait_time = 1 last_error = None for attempt in range(max_retries): attempt += 1 + start = time.time() try: - return ayon_con.upload_file( + output = ayon_con.upload_file( endpoint, repre_path, headers=headers, request_type=RequestTypes.post, ) + self.log.info(f"Uploade in {time.time() - start}s.") + return output except ( requests.exceptions.Timeout, @@ -143,7 +151,8 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): break self.log.warning( - f"Review upload failed ({attempt}/{max_retries})." + f"Review upload failed ({attempt}/{max_retries})" + f" after {time.time() - start}s." f" Retrying in {wait_time}s...", exc_info=True, ) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 12c5e483e3..36b79570f0 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -32,7 +32,7 @@ from ayon_api.operations import OperationsSession import pyblish.api import requests -from ayon_core.lib import get_media_mime_type +from ayon_core.lib import get_media_mime_type, format_file_size from ayon_core.pipeline.publish import PublishXmlValidationError @@ -266,18 +266,26 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): if hasattr(TransferProgress, "get_attempt"): max_retries = 1 + size = os.path.getsize(repre_path) + self.log.info( + f"Uploading '{repre_path}' (size: {format_file_size(size)})" + ) + # How long to sleep before next attempt wait_time = 1 last_error = None for attempt in range(max_retries): attempt += 1 + start = time.time() try: - return ayon_con.upload_file( + output = ayon_con.upload_file( endpoint, repre_path, headers=headers, request_type=RequestTypes.post, ) + self.log.info(f"Uploade in {time.time() - start}s.") + return output except ( requests.exceptions.Timeout, @@ -289,7 +297,8 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): break self.log.warning( - f"Review upload failed ({attempt}/{max_retries})." + f"Review upload failed ({attempt}/{max_retries})" + f" after {time.time() - start}s." f" Retrying in {wait_time}s...", exc_info=True, ) From 5c17102d16e2af49448798f94dee1bc26ca11f7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:38:51 +0100 Subject: [PATCH 278/386] remove outdated docstring info --- client/ayon_core/plugins/publish/integrate_thumbnail.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index 36b79570f0..a55a9ac6d8 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -169,12 +169,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): return os.path.normpath(filled_path) def _create_thumbnail(self, project_name: str, src_filepath: str) -> str: - """Upload thumbnail to AYON and return its id. - - This is temporary fix of 'create_thumbnail' function in ayon_api to - fix jpeg mime type. - - """ + """Upload thumbnail to AYON and return its id.""" mime_type = get_media_mime_type(src_filepath) if mime_type is None: return ayon_api.create_thumbnail( From ab78158d6ef48b3a28c5ff3ddf0bc01445398d27 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:17:00 +0100 Subject: [PATCH 279/386] Fixed typo and use debug level Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/publish/integrate_review.py | 2 +- client/ayon_core/plugins/publish/integrate_thumbnail.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_review.py b/client/ayon_core/plugins/publish/integrate_review.py index 6d7e98ae66..b0cc41acc9 100644 --- a/client/ayon_core/plugins/publish/integrate_review.py +++ b/client/ayon_core/plugins/publish/integrate_review.py @@ -138,7 +138,7 @@ class IntegrateAYONReview(pyblish.api.InstancePlugin): headers=headers, request_type=RequestTypes.post, ) - self.log.info(f"Uploade in {time.time() - start}s.") + self.log.debug(f"Uploaded in {time.time() - start}s.") return output except ( diff --git a/client/ayon_core/plugins/publish/integrate_thumbnail.py b/client/ayon_core/plugins/publish/integrate_thumbnail.py index a55a9ac6d8..60b3a97639 100644 --- a/client/ayon_core/plugins/publish/integrate_thumbnail.py +++ b/client/ayon_core/plugins/publish/integrate_thumbnail.py @@ -279,7 +279,7 @@ class IntegrateThumbnailsAYON(pyblish.api.ContextPlugin): headers=headers, request_type=RequestTypes.post, ) - self.log.info(f"Uploade in {time.time() - start}s.") + self.log.debug(f"Uploaded in {time.time() - start}s.") return output except ( From f3a2cad425da75d51d38d77dffaa94aab28fb984 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:20:28 +0100 Subject: [PATCH 280/386] refresh my tasks filters on refresh --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 3c8be4679e..57524e8155 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -120,6 +120,8 @@ class HierarchyPage(QtWidgets.QWidget): self._project_name = None + self._my_tasks_filter_enabled = False + # Post init projects_combobox.set_listen_to_selection_change(self._is_visible) @@ -136,6 +138,9 @@ class HierarchyPage(QtWidgets.QWidget): self._folders_widget.refresh() self._tasks_widget.refresh() self._workfiles_page.refresh() + self._on_my_tasks_checkbox_state_changed( + self._my_tasks_filter_enabled + ) def _on_back_clicked(self): self._controller.set_selected_project(None) @@ -155,6 +160,7 @@ class HierarchyPage(QtWidgets.QWidget): ) folder_ids = entity_ids["folder_ids"] task_ids = entity_ids["task_ids"] + self._my_tasks_filter_enabled = enabled self._folders_widget.set_folder_ids_filter(folder_ids) self._tasks_widget.set_task_ids_filter(task_ids) From 9ade73fb27a5f11fe063cbe7fd5bcf007a47df10 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:28:35 +0100 Subject: [PATCH 281/386] refresh my tasks in all tools --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 1 + client/ayon_core/tools/loader/ui/window.py | 4 ++++ .../tools/publisher/widgets/create_context_widgets.py | 5 +++++ client/ayon_core/tools/publisher/widgets/folders_dialog.py | 1 + client/ayon_core/tools/utils/folders_widget.py | 6 ++++++ client/ayon_core/tools/workfiles/widgets/window.py | 6 ++++++ 6 files changed, 23 insertions(+) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 57524e8155..575666b64d 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -138,6 +138,7 @@ class HierarchyPage(QtWidgets.QWidget): self._folders_widget.refresh() self._tasks_widget.refresh() self._workfiles_page.refresh() + # Update my tasks self._on_my_tasks_checkbox_state_changed( self._my_tasks_filter_enabled ) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index a6807a1ebb..e4677a62d9 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -527,6 +527,10 @@ class LoaderWindow(QtWidgets.QWidget): if not self._refresh_handler.project_refreshed: self._projects_combobox.refresh() self._update_filters() + # Update my tasks + self._on_my_tasks_checkbox_state_changed( + self._filters_widget.is_my_tasks_checked() + ) def _on_load_finished(self, event): error_info = event["error_info"] diff --git a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py index 49d236353f..405445c8eb 100644 --- a/client/ayon_core/tools/publisher/widgets/create_context_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/create_context_widgets.py @@ -221,6 +221,7 @@ class CreateContextWidget(QtWidgets.QWidget): filters_widget.text_changed.connect(self._on_folder_filter_change) filters_widget.my_tasks_changed.connect(self._on_my_tasks_change) + self._filters_widget = filters_widget self._current_context_btn = current_context_btn self._folders_widget = folders_widget self._tasks_widget = tasks_widget @@ -290,6 +291,10 @@ class CreateContextWidget(QtWidgets.QWidget): self._hierarchy_controller.set_expected_selection( self._last_project_name, folder_id, task_name ) + # Update my tasks + self._on_my_tasks_change( + self._filters_widget.is_my_tasks_checked() + ) def _clear_selection(self): self._folders_widget.set_selected_folder(None) diff --git a/client/ayon_core/tools/publisher/widgets/folders_dialog.py b/client/ayon_core/tools/publisher/widgets/folders_dialog.py index e0d9c098d8..824ed728c9 100644 --- a/client/ayon_core/tools/publisher/widgets/folders_dialog.py +++ b/client/ayon_core/tools/publisher/widgets/folders_dialog.py @@ -113,6 +113,7 @@ class FoldersDialog(QtWidgets.QDialog): self._soft_reset_enabled = False self._folders_widget.set_project_name(self._project_name) + self._on_my_tasks_change(self._filters_widget.is_my_tasks_checked()) def get_selected_folder_path(self): """Get selected folder path.""" diff --git a/client/ayon_core/tools/utils/folders_widget.py b/client/ayon_core/tools/utils/folders_widget.py index f506af5352..ea278da6cb 100644 --- a/client/ayon_core/tools/utils/folders_widget.py +++ b/client/ayon_core/tools/utils/folders_widget.py @@ -834,6 +834,12 @@ class FoldersFiltersWidget(QtWidgets.QWidget): self._folders_filter_input = folders_filter_input self._my_tasks_checkbox = my_tasks_checkbox + def is_my_tasks_checked(self) -> bool: + return self._my_tasks_checkbox.isChecked() + + def text(self) -> str: + return self._folders_filter_input.text() + def set_text(self, text: str) -> None: self._folders_filter_input.setText(text) diff --git a/client/ayon_core/tools/workfiles/widgets/window.py b/client/ayon_core/tools/workfiles/widgets/window.py index 811fe602d1..bb3fd19ae1 100644 --- a/client/ayon_core/tools/workfiles/widgets/window.py +++ b/client/ayon_core/tools/workfiles/widgets/window.py @@ -205,6 +205,8 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._folders_widget = folder_widget + self._filters_widget = filters_widget + return col_widget def _create_col_3_widget(self, controller, parent): @@ -343,6 +345,10 @@ class WorkfilesToolWindow(QtWidgets.QWidget): self._project_name = self._controller.get_current_project_name() self._folders_widget.set_project_name(self._project_name) + # Update my tasks + self._on_my_tasks_checkbox_state_changed( + self._filters_widget.is_my_tasks_checked() + ) def _on_save_as_finished(self, event): if event["failed"]: From 8076615a5f87b131bbcac282b8148a8acc569e3b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:01:41 +0100 Subject: [PATCH 282/386] use same approach in launcher as in other tools --- client/ayon_core/tools/launcher/ui/hierarchy_page.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/launcher/ui/hierarchy_page.py b/client/ayon_core/tools/launcher/ui/hierarchy_page.py index 575666b64d..9d5cb8e8d0 100644 --- a/client/ayon_core/tools/launcher/ui/hierarchy_page.py +++ b/client/ayon_core/tools/launcher/ui/hierarchy_page.py @@ -112,6 +112,7 @@ class HierarchyPage(QtWidgets.QWidget): self._is_visible = False self._controller = controller + self._filters_widget = filters_widget self._btn_back = btn_back self._projects_combobox = projects_combobox self._folders_widget = folders_widget @@ -120,8 +121,6 @@ class HierarchyPage(QtWidgets.QWidget): self._project_name = None - self._my_tasks_filter_enabled = False - # Post init projects_combobox.set_listen_to_selection_change(self._is_visible) @@ -140,7 +139,7 @@ class HierarchyPage(QtWidgets.QWidget): self._workfiles_page.refresh() # Update my tasks self._on_my_tasks_checkbox_state_changed( - self._my_tasks_filter_enabled + self._filters_widget.is_my_tasks_checked() ) def _on_back_clicked(self): @@ -161,7 +160,7 @@ class HierarchyPage(QtWidgets.QWidget): ) folder_ids = entity_ids["folder_ids"] task_ids = entity_ids["task_ids"] - self._my_tasks_filter_enabled = enabled + self._folders_widget.set_folder_ids_filter(folder_ids) self._tasks_widget.set_task_ids_filter(task_ids) From 3d321b48960722a54baa74c7c833d739ffd9eda2 Mon Sep 17 00:00:00 2001 From: Vincent Ullmann Date: Tue, 9 Dec 2025 15:21:23 +0000 Subject: [PATCH 283/386] add verbose-flag to get_oiio_info_for_input and changed oiio_color_convert to use verbose=False --- client/ayon_core/lib/transcoding.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index b4a3e77f5a..feb31a46e1 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -131,16 +131,28 @@ def get_transcode_temp_directory(): ) -def get_oiio_info_for_input(filepath, logger=None, subimages=False): +def get_oiio_info_for_input( + filepath, + logger=None, + subimages=False, + verbose=True, +): """Call oiiotool to get information about input and return stdout. + Args: + filepath (str): Path to file. + logger (logging.Logger): Logger used for logging. + subimages (bool): include info about subimages in the output. + verbose (bool): get the full metadata about each input image. + Stdout should contain xml format string. """ args = get_oiio_tool_args( "oiiotool", "--info", - "-v" ) + if verbose: + args.append("-v") if subimages: args.append("-a") @@ -1178,7 +1190,11 @@ def oiio_color_convert( if logger is None: logger = logging.getLogger(__name__) - input_info = get_oiio_info_for_input(input_path, logger=logger) + input_info = get_oiio_info_for_input( + input_path, + logger=logger, + verbose=False, + ) # Collect channels to export input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) From f0e603fe7c73d18910e3a0fb2e708a60e9b284ac Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 00:30:16 +0100 Subject: [PATCH 284/386] Do not invert source display/view if it already matches target display/view --- client/ayon_core/lib/transcoding.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index b4a3e77f5a..1f9005d92b 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1234,17 +1234,21 @@ def oiio_color_convert( if source_view and source_display: color_convert_args = None ocio_display_args = None - oiio_cmd.extend([ - "--ociodisplay:inverse=1:subimages=0", - source_display, - source_view, - ]) + + if source_display != target_display or source_view != target_view: + # Undo source display/view if we have a source display/view + # that does not match the target display/view + oiio_cmd.extend([ + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view, + ]) if target_colorspace: # This is a two-step conversion process since there's no direct # display/view to colorspace command # This could be a config parameter or determined from OCIO config - # Use temporarty role space 'scene_linear' + # Use temporary role space 'scene_linear' color_convert_args = ("scene_linear", target_colorspace) elif source_display != target_display or source_view != target_view: # Complete display/view pair conversion From b1be956994acd10e1c0c52103e79a33187ac06e7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 00:38:49 +0100 Subject: [PATCH 285/386] Also invert if target_colorspace, which means - always invert source display/view if we have any target colorspace or a display/view that differs from the source display/view --- client/ayon_core/lib/transcoding.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 1f9005d92b..f1c1cd7aa6 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1234,16 +1234,6 @@ def oiio_color_convert( if source_view and source_display: color_convert_args = None ocio_display_args = None - - if source_display != target_display or source_view != target_view: - # Undo source display/view if we have a source display/view - # that does not match the target display/view - oiio_cmd.extend([ - "--ociodisplay:inverse=1:subimages=0", - source_display, - source_view, - ]) - if target_colorspace: # This is a two-step conversion process since there's no direct # display/view to colorspace command @@ -1260,6 +1250,15 @@ def oiio_color_convert( " No color conversion needed." ) + if color_convert_args or ocio_display_args: + # Invert source display/view so that we can go from there to the + # target colorspace or display/view + oiio_cmd.extend([ + "--ociodisplay:inverse=1:subimages=0", + source_display, + source_view, + ]) + if color_convert_args: # Use colorconvert for colorspace target oiio_cmd.extend([ From 97a8b13a4e5c7687190d4699caae7483d2fecff8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 12:56:42 +0100 Subject: [PATCH 286/386] Allow specifying a strength ordering offset for each contribution to a single department layer --- client/ayon_core/pipeline/usdlib.py | 24 ++++++++------- .../extract_usd_layer_contributions.py | 29 ++++++++++++++----- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/pipeline/usdlib.py b/client/ayon_core/pipeline/usdlib.py index 095f6fdc57..6b9d19fd35 100644 --- a/client/ayon_core/pipeline/usdlib.py +++ b/client/ayon_core/pipeline/usdlib.py @@ -299,7 +299,6 @@ def add_ordered_sublayer(layer, contribution_path, layer_id, order=None, sdf format args metadata if enabled) """ - # Add the order with the contribution path so that for future # contributions we can again use it to magically fit into the # ordering. We put this in the path because sublayer paths do @@ -317,20 +316,25 @@ def add_ordered_sublayer(layer, contribution_path, layer_id, order=None, # If the layer was already in the layers, then replace it for index, existing_path in enumerate(layer.subLayerPaths): args = get_sdf_format_args(existing_path) - existing_layer = args.get("layer_id") - if existing_layer == layer_id: + existing_layer_id = args.get("layer_id") + if existing_layer_id == layer_id: + existing_layer = layer.subLayerPaths[index] + existing_order = args.get("order") + existing_order = int(existing_order) if existing_order else None + if order is not None and order != existing_order: + # We need to move the layer, so we will remove this index + # and then re-insert it below at the right order + log.debug(f"Removing existing layer: {existing_layer}") + del layer.subLayerPaths[index] + break + # Put it in the same position where it was before when swapping # it with the original, also take over its order metadata - order = args.get("order") - if order is not None: - order = int(order) - else: - order = None contribution_path = _format_path(contribution_path, - order=order, + order=existing_order, layer_id=layer_id) log.debug( - f"Replacing existing layer: {layer.subLayerPaths[index]} " + f"Replacing existing layer: {existing_layer} " f"-> {contribution_path}" ) layer.subLayerPaths[index] = contribution_path 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 2c4cc5aac2..12e0931e89 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -16,7 +16,7 @@ from ayon_core.lib import ( UISeparatorDef, UILabelDef, EnumDef, - filter_profiles + filter_profiles, NumberDef ) try: from ayon_core.pipeline.usdlib import ( @@ -275,7 +275,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, # the contributions so that we can design a system where custom # contributions outside the predefined orders are possible to be # managed. So that if a particular asset requires an extra contribution - # level, you can add itdirectly from the publisher at that particular + # level, you can add it directly from the publisher at that particular # order. Future publishes will then see the existing contribution and will # persist adding it to future bootstraps at that order contribution_layers: Dict[str, int] = { @@ -334,10 +334,12 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, attr_values[key] = attr_values[key].format(**data) # Define contribution - order = self.contribution_layers.get( + order: int = self.contribution_layers.get( attr_values["contribution_layer"], 0 ) + # Allow offsetting the order to the contribution to department layer + order_offset: int = attr_values.get("contribution_order_offset", 0) if attr_values["contribution_apply_as_variant"]: contribution = VariantContribution( instance=instance, @@ -346,14 +348,14 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, variant_set_name=attr_values["contribution_variant_set_name"], variant_name=attr_values["contribution_variant"], variant_is_default=attr_values["contribution_variant_is_default"], # noqa: E501 - order=order + order=order + order_offset ) else: contribution = SublayerContribution( instance=instance, layer_id=attr_values["contribution_layer"], target_product=attr_values["contribution_target_product"], - order=order + order=order + order_offset ) asset_product = contribution.target_product @@ -370,7 +372,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, contribution ) layer_instance.data["usd_layer_id"] = contribution.layer_id - layer_instance.data["usd_layer_order"] = contribution.order + layer_instance.data["usd_layer_order"] = order layer_instance.data["productGroup"] = ( instance.data.get("productGroup") or "USD Layer" @@ -561,6 +563,19 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, items=list(cls.contribution_layers.keys()), default=default_contribution_layer, visible=visible), + # TODO: We may want to make the visibility of this optional + # based on studio preference, to avoid complexity when not needed + NumberDef("contribution_order_offset", + label="Strength order offset", + tooltip=( + "The contribution to the department layer will be " + "made with this offset applied. A higher number means " + "a stronger opinion." + ), + default=0, + minimum=-99999, + maximum=99999, + visible=visible), BoolDef("contribution_apply_as_variant", label="Add as variant", tooltip=( @@ -729,7 +744,7 @@ class ExtractUSDLayerContribution(publish.Extractor): layer=sdf_layer, contribution_path=path, layer_id=product_name, - order=None, # unordered + order=contribution.order, add_sdf_arguments_metadata=True ) else: From d4e5f96b3b49d7a0e2bf2041d67657e24005a392 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:46:48 +0100 Subject: [PATCH 287/386] upodate overload function --- .../ayon_core/pipeline/create/product_name.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 89ae7ef85b..2b1255c2b3 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -243,15 +243,16 @@ def get_product_name( project_name: str, folder_entity: dict[str, Any], task_entity: Optional[dict[str, Any]], - host_name: str, + product_base_type: str, product_type: str, + host_name: str, variant: str, *, - default_template: Optional[str] = None, dynamic_data: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None, - product_type_filter: Optional[str] = None, project_entity: Optional[dict[str, Any]] = None, + default_template: Optional[str] = None, + product_base_type_filter: Optional[str] = None, ) -> TemplateResult: """Calculate product name based on passed context and AYON settings. @@ -268,20 +269,21 @@ def get_product_name( folder_entity (Optional[dict[str, Any]]): Folder entity. task_entity (Optional[dict[str, Any]]): Task entity. host_name (str): Host name. + product_base_type (str): Product base type. product_type (str): Product type. variant (str): In most of the cases it is user input during creation. - default_template (Optional[str]): Default template if any profile does - not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' - is used if is not passed. dynamic_data (Optional[dict[str, Any]]): Dynamic data specific for a creator which creates instance. project_settings (Optional[dict[str, Any]]): Prepared settings for project. Settings are queried if not passed. - product_type_filter (Optional[str]): Use different product type for - product template filtering. Value of `product_type` is used when - not passed. project_entity (Optional[dict[str, Any]]): Project entity used when task short name is required by template. + default_template (Optional[str]): Default template if any profile does + not match passed context. Constant 'DEFAULT_PRODUCT_TEMPLATE' + is used if is not passed. + product_base_type_filter (Optional[str]): Use different product base + type for product template filtering. Value of + `product_base_type_filter` is used when not passed. Returns: TemplateResult: Product name. From bceb645a80da8a2f671d42e2a8d6b5feaea42b5a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:47:14 +0100 Subject: [PATCH 288/386] fix typo Co-authored-by: Roy Nieterau --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 2b1255c2b3..a0bfc18eba 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -321,7 +321,7 @@ def get_product_name( name. Deprecated: - This function is using deprecate signature that does not support + This function is using deprecated signature that does not support folder entity data to be used. Args: From 17b09d608bfc33b6bfa7904adfb9abbf3d7b3df8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:03:30 +0100 Subject: [PATCH 289/386] unify indentation --- client/ayon_core/pipeline/create/product_name.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index a0bfc18eba..d32de54774 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -370,8 +370,8 @@ def get_product_name( """Calculate product name based on passed context and AYON settings. Product name templates are defined in `project_settings/global/tools - /creator/product_name_profiles` where are profiles with host name, - product base type, product type, task name and task type filters. + /creator/product_name_profiles` where are profiles with host name, + product base type, product type, task name and task type filters. If context does not match any profile then `DEFAULT_PRODUCT_TEMPLATE` is used as default template. From ced9eadd3d3d182df5341cd3f60897058dfdf9b3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 16:31:05 +0100 Subject: [PATCH 290/386] Use the instance attribute `Strength order` as the in-layer order completely so that it's the exact value, not an offset to the department layer order --- .../extract_usd_layer_contributions.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) 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 12e0931e89..01162fc481 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -334,12 +334,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, attr_values[key] = attr_values[key].format(**data) # Define contribution - order: int = self.contribution_layers.get( - attr_values["contribution_layer"], 0 - ) - - # Allow offsetting the order to the contribution to department layer - order_offset: int = attr_values.get("contribution_order_offset", 0) + in_layer_order: int = attr_values.get("contribution_in_layer_order", 0) if attr_values["contribution_apply_as_variant"]: contribution = VariantContribution( instance=instance, @@ -348,18 +343,21 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, variant_set_name=attr_values["contribution_variant_set_name"], variant_name=attr_values["contribution_variant"], variant_is_default=attr_values["contribution_variant_is_default"], # noqa: E501 - order=order + order_offset + order=in_layer_order ) else: contribution = SublayerContribution( instance=instance, layer_id=attr_values["contribution_layer"], target_product=attr_values["contribution_target_product"], - order=order + order_offset + order=in_layer_order ) asset_product = contribution.target_product layer_product = "{}_{}".format(asset_product, contribution.layer_id) + layer_order: int = self.contribution_layers.get( + attr_values["contribution_layer"], 0 + ) # Layer contribution instance layer_instance = self.get_or_create_instance( @@ -372,7 +370,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, contribution ) layer_instance.data["usd_layer_id"] = contribution.layer_id - layer_instance.data["usd_layer_order"] = order + layer_instance.data["usd_layer_order"] = layer_order layer_instance.data["productGroup"] = ( instance.data.get("productGroup") or "USD Layer" @@ -565,10 +563,10 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, visible=visible), # TODO: We may want to make the visibility of this optional # based on studio preference, to avoid complexity when not needed - NumberDef("contribution_order_offset", - label="Strength order offset", + NumberDef("contribution_in_layer_order", + label="Strength order", tooltip=( - "The contribution to the department layer will be " + "The contribution inside the department layer will be " "made with this offset applied. A higher number means " "a stronger opinion." ), From 4eece5e6e9157e61a69ef960cd1065414403319d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 16:55:44 +0100 Subject: [PATCH 291/386] Allow to define department layers scoped only to a particular department layer type, e.g. "shot" versus "asset". This way, you can scope same layer names for both shot and asset at different orders if they have differing target scopes --- .../extract_usd_layer_contributions.py | 58 ++++++++++++------- server/settings/publish_plugins.py | 30 ++++++---- 2 files changed, 57 insertions(+), 31 deletions(-) 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 2c4cc5aac2..7da14a714b 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -2,6 +2,7 @@ from operator import attrgetter import dataclasses import os import platform +from collections import defaultdict from typing import Any, Dict, List import pyblish.api @@ -278,19 +279,23 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, # level, you can add itdirectly from the publisher at that particular # order. Future publishes will then see the existing contribution and will # persist adding it to future bootstraps at that order - contribution_layers: Dict[str, int] = { + contribution_layers: Dict[str, Dict[str, int]] = { # asset layers - "model": 100, - "assembly": 150, - "groom": 175, - "look": 200, - "rig": 300, + "asset": { + "model": 100, + "assembly": 150, + "groom": 175, + "look": 200, + "rig": 300, + }, # shot layers - "layout": 200, - "animation": 300, - "simulation": 400, - "fx": 500, - "lighting": 600, + "shot": { + "layout": 200, + "animation": 300, + "simulation": 400, + "fx": 500, + "lighting": 600, + } } # Default profiles to set certain instance attribute defaults based on # profiles in settings @@ -305,12 +310,13 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, cls.enabled = plugin_settings.get("enabled", cls.enabled) - # Define contribution layers via settings - contribution_layers = {} + # Define contribution layers via settings by their scope + contribution_layers = defaultdict(dict) for entry in plugin_settings.get("contribution_layers", []): - contribution_layers[entry["name"]] = int(entry["order"]) + for scope in entry.get("scope", []): + contribution_layers[scope][entry["name"]] = int(entry["order"]) if contribution_layers: - cls.contribution_layers = contribution_layers + cls.contribution_layers = dict(contribution_layers) cls.profiles = plugin_settings.get("profiles", []) @@ -489,14 +495,14 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, profile = {} # Define defaults - default_enabled = profile.get("contribution_enabled", True) + default_enabled: bool = profile.get("contribution_enabled", True) default_contribution_layer = profile.get( "contribution_layer", None) - default_apply_as_variant = profile.get( + default_apply_as_variant: bool = profile.get( "contribution_apply_as_variant", False) - default_target_product = profile.get( + default_target_product: str = profile.get( "contribution_target_product", "usdAsset") - default_init_as = ( + default_init_as: str = ( "asset" if profile.get("contribution_target_product") == "usdAsset" else "shot") @@ -509,6 +515,12 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, visible = publish_attributes.get("contribution_enabled", True) variant_visible = visible and publish_attributes.get( "contribution_apply_as_variant", True) + init_as: str = publish_attributes.get( + "contribution_target_product_init", default_init_as) + + contribution_layers = cls.contribution_layers.get( + init_as, {} + ) return [ UISeparatorDef("usd_container_settings1"), @@ -558,7 +570,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "predefined ordering.\nA higher order (further down " "the list) will contribute as a stronger opinion." ), - items=list(cls.contribution_layers.keys()), + items=list(contribution_layers.keys()), default=default_contribution_layer, visible=visible), BoolDef("contribution_apply_as_variant", @@ -606,7 +618,11 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, # Update attributes if any of the following plug-in attributes # change: - keys = ["contribution_enabled", "contribution_apply_as_variant"] + keys = { + "contribution_enabled", + "contribution_apply_as_variant", + "contribution_target_product_init", + } for instance_change in event["changes"]: instance = instance_change["instance"] diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index d7b794cb5b..5524f7920d 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -74,9 +74,19 @@ class CollectFramesFixDefModel(BaseSettingsModel): ) +def usd_contribution_layer_types(): + return [ + {"value": "asset", "label": "Asset"}, + {"value": "shot", "label": "Shot"}, + ] + + class ContributionLayersModel(BaseSettingsModel): _layout = "compact" name: str = SettingsField(title="Name") + scope: list[str] = SettingsField( + title="Scope", + enum_resolver=usd_contribution_layer_types) order: str = SettingsField( title="Order", description="Higher order means a higher strength and stacks the " @@ -1345,17 +1355,17 @@ DEFAULT_PUBLISH_VALUES = { "enabled": True, "contribution_layers": [ # Asset layers - {"name": "model", "order": 100}, - {"name": "assembly", "order": 150}, - {"name": "groom", "order": 175}, - {"name": "look", "order": 200}, - {"name": "rig", "order": 300}, + {"name": "model", "order": 100, "scope": ["asset"]}, + {"name": "assembly", "order": 150, "scope": ["asset"]}, + {"name": "groom", "order": 175, "scope": ["asset"]}, + {"name": "look", "order": 200, "scope": ["asset"]}, + {"name": "rig", "order": 300, "scope": ["asset"]}, # Shot layers - {"name": "layout", "order": 200}, - {"name": "animation", "order": 300}, - {"name": "simulation", "order": 400}, - {"name": "fx", "order": 500}, - {"name": "lighting", "order": 600}, + {"name": "layout", "order": 200, "scope": ["shot"]}, + {"name": "animation", "order": 300, "scope": ["shot"]}, + {"name": "simulation", "order": 400, "scope": ["shot"]}, + {"name": "fx", "order": 500, "scope": ["shot"]}, + {"name": "lighting", "order": 600, "scope": ["shot"]}, ], "profiles": [ { From c52a7e367bf4312fe68b6652db758634ad1a61f0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 Dec 2025 18:35:22 +0100 Subject: [PATCH 292/386] Simplified ExtractThumbnailFrom source Removed profiles Changed defaults for smaller resolution --- .../publish/extract_thumbnail_from_source.py | 125 ++---------------- server/settings/publish_plugins.py | 59 ++------- 2 files changed, 23 insertions(+), 161 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 33850a4324..702244a45f 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -29,52 +29,6 @@ from ayon_core.lib import ( ) -@dataclass -class ThumbnailDefinition: - """ - Data class representing the full configuration for selected profile - - Any change of controllable fields in Settings must propagate here! - """ - integrate_thumbnail: bool = False - - target_size: Dict[str, Any] = field( - default_factory=lambda: { - "type": "source", - "resize": {"width": 1920, "height": 1080}, - } - ) - - ffmpeg_args: Dict[str, List[Any]] = field( - default_factory=lambda: {"input": [], "output": []} - ) - - # Background color defined as (R, G, B, A) tuple. - background_color: Tuple[int, int, int, float] = (0, 0, 0, 0.0) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ThumbnailDefinition": - """ - Creates a ThumbnailDefinition instance from a dictionary, - safely ignoring any keys in the dictionary that are not fields - in the dataclass. - - Args: - data (Dict[str, Any]): The dictionary containing configuration data - - Returns: - MediaConfig: A new instance of the dataclass. - """ - # Get all field names defined in the dataclass - field_names = {f.name for f in fields(cls)} - - # Filter the input dictionary to include only keys matching field names - filtered_data = {k: v for k, v in data.items() if k in field_names} - - # Unpack the filtered dictionary into the constructor - return cls(**filtered_data) - - class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): """Create jpg thumbnail for instance based on 'thumbnailSource'. @@ -86,18 +40,13 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): order = pyblish.api.ExtractorOrder + 0.48 # Settings - profiles = None + target_size = {"type": "source", "resize": {"width": 1920, "height": 1080}} + background_color = (0, 0, 0, 0.0) + def process(self, instance: pyblish.api.Instance): - if not self.profiles: - self.log.debug("No profiles present for color transcode") - return - profile_config = self._get_config_from_profile(instance) - if not profile_config: - return - context_thumbnail_path = self._create_context_thumbnail( - instance.context, profile_config + instance.context ) if context_thumbnail_path: instance.context.data["thumbnailPath"] = context_thumbnail_path @@ -113,7 +62,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): return dst_filepath = self._create_thumbnail( - instance.context, thumbnail_source, profile_config + instance.context, thumbnail_source ) if not dst_filepath: return @@ -129,8 +78,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "outputName": "thumbnail", } - if not profile_config.integrate_thumbnail: - new_repre["tags"].append("delete") + new_repre["tags"].append("delete") # adding representation self.log.debug( @@ -143,7 +91,6 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, context: pyblish.api.Context, thumbnail_source: str, - profile_config: ThumbnailDefinition ) -> Optional[str]: if not thumbnail_source: self.log.debug("Thumbnail source not filled. Skipping.") @@ -177,7 +124,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg thumbnail_created = self.create_thumbnail_oiio( - thumbnail_source, full_output_path, profile_config + thumbnail_source, full_output_path ) # Try to use FFMPEG if OIIO is not supported or for cases when @@ -190,7 +137,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): ) thumbnail_created = self.create_thumbnail_ffmpeg( - thumbnail_source, full_output_path, profile_config + thumbnail_source, full_output_path ) # Skip representation and try next one if wasn't created @@ -215,11 +162,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, src_path: str, dst_path: str, - profile_config: ThumbnailDefinition ) -> bool: self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) resolution_arg = self._get_resolution_arg( - "oiiotool", src_path, profile_config + "oiiotool", src_path ) oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path) if resolution_arg: @@ -245,10 +191,9 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, src_path: str, dst_path: str, - profile_config: ThumbnailDefinition ) -> bool: resolution_arg = self._get_resolution_arg( - "ffmpeg", src_path, profile_config + "ffmpeg", src_path ) max_int = str(2147483647) @@ -261,13 +206,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "-frames:v", "1", ) - ffmpeg_cmd.extend(profile_config.ffmpeg_args.get("input") or []) - if resolution_arg: ffmpeg_cmd.extend(resolution_arg) # possible resize must be before output args - ffmpeg_cmd.extend(profile_config.ffmpeg_args.get("output") or []) ffmpeg_cmd.append(dst_path) self.log.debug("Running: {}".format(" ".join(ffmpeg_cmd))) @@ -284,7 +226,6 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): def _create_context_thumbnail( self, context: pyblish.api.Context, - profile: ThumbnailDefinition ) -> Optional[str]: hasContextThumbnail = "thumbnailPath" in context.data if hasContextThumbnail: @@ -292,58 +233,20 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): thumbnail_source = context.data.get("thumbnailSource") thumbnail_path = self._create_thumbnail( - context, thumbnail_source, profile + context, thumbnail_source ) return thumbnail_path - def _get_config_from_profile( - self, - instance: pyblish.api.Instance - ) -> ThumbnailDefinition: - """Returns profile if and how repre should be color transcoded.""" - host_name = instance.context.data["hostName"] - product_type = instance.data["productType"] - product_name = instance.data["productName"] - task_data = instance.data["anatomyData"].get("task", {}) - task_name = task_data.get("name") - task_type = task_data.get("type") - filtering_criteria = { - "host_names": host_name, - "product_types": product_type, - "product_names": product_name, - "task_names": task_name, - "task_types": task_type, - } - profile = filter_profiles( - self.profiles, filtering_criteria, - logger=self.log - ) - - if not profile: - self.log.debug( - ( - "Skipped instance. None of profiles in presets are for" - ' Host: "{}" | Product types: "{}" | Product names: "{}"' - ' | Task name "{}" | Task type "{}"' - ).format( - host_name, product_type, product_name, task_name, task_type - ) - ) - return - - return ThumbnailDefinition.from_dict(profile) - def _get_resolution_arg( self, application: str, input_path: str, - profile: ThumbnailDefinition ) -> List[str]: # get settings - if profile.target_size["type"] == "source": + if self.target_size["type"] == "source": return [] - resize = profile.target_size["resize"] + resize = self.target_size["resize"] target_width = resize["width"] target_height = resize["height"] @@ -353,6 +256,6 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): input_path, target_width, target_height, - bg_color=profile.background_color, + bg_color=self.background_color, log=self.log, ) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 5e4359b7bc..76fadbf9ca 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -469,43 +469,16 @@ class UseDisplayViewModel(BaseSettingsModel): ) -class ExtractThumbnailFromSourceProfileModel(BaseSettingsModel): - host_names: list[str] = SettingsField( - default_factory=list, title="Host names" - ) - product_names: list[str] = SettingsField( - default_factory=list, title="Product names" - ) - product_types: list[str] = SettingsField( - default_factory=list, title="Product types" - ) - task_types: list[str] = SettingsField( - default_factory=list, title="Task types", enum_resolver=task_types_enum - ) - task_names: list[str] = SettingsField( - default_factory=list, title="Task names" - ) +class ExtractThumbnailFromSourceModel(BaseSettingsModel): + """Thumbnail extraction from source files using ffmpeg and oiiotool.""" + enabled: bool = SettingsField(True) - integrate_thumbnail: bool = SettingsField( - True, title="Integrate Thumbnail Representation" - ) target_size: ResizeModel = SettingsField( default_factory=ResizeModel, title="Target size" ) background_color: ColorRGBA_uint8 = SettingsField( (0, 0, 0, 0.0), title="Background color" ) - ffmpeg_args: ExtractThumbnailFFmpegModel = SettingsField( - default_factory=ExtractThumbnailFFmpegModel - ) - - -class ExtractThumbnailFromSourceModel(BaseSettingsModel): - """Thumbnail extraction from source files using ffmpeg and oiiotool.""" - enabled: bool = SettingsField(True) - profiles: list[ExtractThumbnailFromSourceProfileModel] = SettingsField( - default_factory=list, title="Profiles" - ) class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): @@ -1527,27 +1500,13 @@ DEFAULT_PUBLISH_VALUES = { }, "ExtractThumbnailFromSource": { "enabled": True, - "profiles": [ - { - "product_names": [], - "product_types": [], - "host_names": [], - "task_types": [], - "task_names": [], - "integrate_thumbnail": True, - "target_size": { - "type": "source", - "resize": { - "width": 1920, - "height": 1080 - } - }, - "ffmpeg_args": { - "input": [], - "output": [] - } + "target_size": { + "type": "resize", + "resize": { + "width": 300, + "height": 170 } - ] + }, }, "ExtractOIIOTranscode": { "enabled": True, From aff0ecf4362982640cc60c3921885f9314970fe8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 20:29:10 +0100 Subject: [PATCH 293/386] Fix #1598: Do not fallback to current task name --- client/ayon_core/tools/publisher/widgets/create_widget.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index d98bc95eb2..f2afdfffd9 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -328,9 +328,6 @@ class CreateWidget(QtWidgets.QWidget): folder_path = self._context_widget.get_selected_folder_path() if folder_path: task_name = self._context_widget.get_selected_task_name() - - if not task_name: - task_name = self.get_current_task_name() return task_name def _set_context_enabled(self, enabled): From e3206796a764e2adf74a1097acf7780f91836863 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 22:16:03 +0100 Subject: [PATCH 294/386] Fix #1600: Filter out containers that lack required keys (looking at you `ayon-unreal`!) --- .../publish/collect_scene_loaded_versions.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index f509ed807a..6d1563a33a 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -1,3 +1,5 @@ +from __future__ import annotations +from typing import Any import ayon_api import ayon_api.utils @@ -32,6 +34,8 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): self.log.debug("No loaded containers found in scene.") return + containers = self._filter_invalid_containers(containers) + repre_ids = { container["representation"] for container in containers @@ -78,3 +82,29 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): self.log.debug(f"Collected {len(loaded_versions)} loaded versions.") context.data["loadedVersions"] = loaded_versions + + def _filter_invalid_containers( + self, + containers: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Filter out invalid containers lacking required keys. + + Skip any invalid containers that lack 'representation' or 'name' + keys to avoid KeyError. + """ + # Only filter by what's required for this plug-in instead of validating + # a full container schema. + required_keys = {"name", "representation"} + valid = [] + for container in containers: + missing = [key for key in required_keys if key not in container] + if missing: + self.log.debug( + "Skipping invalid container, missing required keys:" + " {}. {}".format(", ".join(missing), container) + ) + continue + valid.append(container) + + + return valid From 5e674844b5cb2a5172b12d4008fd31e22497ceeb Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Dec 2025 22:19:12 +0100 Subject: [PATCH 295/386] Cosmetics --- .../ayon_core/plugins/publish/collect_scene_loaded_versions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 6d1563a33a..2c214cd1a7 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -106,5 +106,4 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): continue valid.append(container) - return valid From ec510ab14915f8b624c4cf9e607d3a30cbcac82b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 12:16:45 +0100 Subject: [PATCH 296/386] Formatting change Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../plugins/publish/extract_thumbnail_from_source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 702244a45f..2e1d437f5d 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -40,7 +40,10 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): order = pyblish.api.ExtractorOrder + 0.48 # Settings - target_size = {"type": "source", "resize": {"width": 1920, "height": 1080}} + target_size = { + "type": "resize", + "resize": {"width": 1920, "height": 1080} + } background_color = (0, 0, 0, 0.0) From 7a5d6ae77e8a72188b3d5faa0bc861bbc1df255f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 12:19:24 +0100 Subject: [PATCH 297/386] Fix imports --- .../plugins/publish/extract_thumbnail_from_source.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 2e1d437f5d..3c74721922 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -13,9 +13,8 @@ Todos: """ import os -from dataclasses import dataclass, field, fields import tempfile -from typing import Dict, Any, List, Tuple, Optional +from typing import List, Optional import pyblish.api from ayon_core.lib import ( @@ -25,7 +24,6 @@ from ayon_core.lib import ( run_subprocess, get_rescaled_command_arguments, - filter_profiles, ) From 55c74196ab51231fecd2d803ccc42904ddb9af30 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 12:20:24 +0100 Subject: [PATCH 298/386] Formatting changes --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 3c74721922..a2118d908b 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -44,7 +44,6 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): } background_color = (0, 0, 0, 0.0) - def process(self, instance: pyblish.api.Instance): context_thumbnail_path = self._create_context_thumbnail( instance.context From 505021344b97192aaa7fe703ddb7fde70ef2ed42 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 12:24:40 +0100 Subject: [PATCH 299/386] Fix logic for context thumbnail creation --- .../plugins/publish/extract_thumbnail_from_source.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index a2118d908b..1f00d96a70 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -45,11 +45,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): background_color = (0, 0, 0, 0.0) def process(self, instance: pyblish.api.Instance): - context_thumbnail_path = self._create_context_thumbnail( - instance.context - ) - if context_thumbnail_path: - instance.context.data["thumbnailPath"] = context_thumbnail_path + self._create_context_thumbnail(instance.context) thumbnail_source = instance.data.get("thumbnailSource") if not thumbnail_source: @@ -226,16 +222,15 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): def _create_context_thumbnail( self, context: pyblish.api.Context, - ) -> Optional[str]: + ): hasContextThumbnail = "thumbnailPath" in context.data if hasContextThumbnail: return thumbnail_source = context.data.get("thumbnailSource") - thumbnail_path = self._create_thumbnail( + context.data["thumbnailPath"] = self._create_thumbnail( context, thumbnail_source ) - return thumbnail_path def _get_resolution_arg( self, From b6709f98590841cc80a616635c7f9c4f77f1ed1f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 11 Dec 2025 15:36:45 +0100 Subject: [PATCH 300/386] Also remove fallback for current folder in `_get_folder_path` --- client/ayon_core/tools/publisher/widgets/create_widget.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index f2afdfffd9..db93632471 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -310,9 +310,6 @@ class CreateWidget(QtWidgets.QWidget): folder_path = None if self._context_change_is_enabled(): folder_path = self._context_widget.get_selected_folder_path() - - if folder_path is None: - folder_path = self.get_current_folder_path() return folder_path or None def _get_folder_id(self): From bbff0562684243eb1a96e26be6c51be27fd3f806 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:02:04 +0100 Subject: [PATCH 301/386] Added psd to ExtractReview ffmpeg and oiiotool seem to handle it fine. --- client/ayon_core/plugins/publish/extract_review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 54aa52c3c3..dda69470cf 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -169,7 +169,9 @@ class ExtractReview(pyblish.api.InstancePlugin): settings_category = "core" # Supported extensions - image_exts = {"exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"} + image_exts = { + "exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif", "psd" + } video_exts = {"mov", "mp4"} supported_exts = image_exts | video_exts From 9f6840a18d2ae4dc3c5607a36226166c98111857 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:12:17 +0100 Subject: [PATCH 302/386] Formatting change Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 1f00d96a70..1889566012 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -223,8 +223,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self, context: pyblish.api.Context, ): - hasContextThumbnail = "thumbnailPath" in context.data - if hasContextThumbnail: + if "thumbnailPath" in context.data: return thumbnail_source = context.data.get("thumbnailSource") From c1f36199c283bda2da15175c88b5364de3610d65 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:20:07 +0100 Subject: [PATCH 303/386] Renamed method --- .../plugins/publish/extract_thumbnail_from_source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 1f00d96a70..819dbab567 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -160,7 +160,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): dst_path: str, ) -> bool: self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) - resolution_arg = self._get_resolution_arg( + resolution_arg = self._get_resolution_args( "oiiotool", src_path ) oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path) @@ -188,7 +188,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): src_path: str, dst_path: str, ) -> bool: - resolution_arg = self._get_resolution_arg( + resolution_arg = self._get_resolution_args( "ffmpeg", src_path ) @@ -232,7 +232,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): context, thumbnail_source ) - def _get_resolution_arg( + def _get_resolution_args( self, application: str, input_path: str, From 061e9c501552fd33c624286909cc710239c739d1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:23:17 +0100 Subject: [PATCH 304/386] Renamed variable --- .../plugins/publish/extract_thumbnail_from_source.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 819dbab567..f91621e328 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -160,13 +160,13 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): dst_path: str, ) -> bool: self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) - resolution_arg = self._get_resolution_args( + resolution_args = self._get_resolution_args( "oiiotool", src_path ) oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path) - if resolution_arg: + if resolution_args: # resize must be before -o - oiio_cmd.extend(resolution_arg) + oiio_cmd.extend(resolution_args) else: # resize provides own -ch, must be only one oiio_cmd.extend(["--ch", "R,G,B"]) @@ -188,7 +188,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): src_path: str, dst_path: str, ) -> bool: - resolution_arg = self._get_resolution_args( + resolution_args = self._get_resolution_args( "ffmpeg", src_path ) @@ -202,8 +202,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "-frames:v", "1", ) - if resolution_arg: - ffmpeg_cmd.extend(resolution_arg) + ffmpeg_cmd.extend(resolution_args) # possible resize must be before output args ffmpeg_cmd.append(dst_path) From 738d9cf8d87a6e33ace402ec55a9ef59a0894485 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:24:09 +0100 Subject: [PATCH 305/386] Updated docstring --- server/settings/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 76fadbf9ca..0b49ce30f0 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1264,7 +1264,7 @@ class PublishPuginsModel(BaseSettingsModel): "instance.data['thumbnailSource'] using ffmpeg " "and oiiotool." "Used when host does not provide thumbnail, but artist could set " - "custom thumbnail source file. (TrayPublisher, Webpublisher)" + "custom thumbnail source file." ) ) ExtractOIIOTranscode: ExtractOIIOTranscodeModel = SettingsField( From e6eaf872722cd7c84cc2a19295a55959811541ff Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:25:16 +0100 Subject: [PATCH 306/386] Updated titles --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 2 +- server/settings/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index f91621e328..9be157d7f0 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -33,7 +33,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): Thumbnail source must be a single image or video filepath. """ - label = "Extract Thumbnail (from source)" + label = "Extract Thumbnail from source" # Before 'ExtractThumbnail' in global plugins order = pyblish.api.ExtractorOrder + 0.48 diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 0b49ce30f0..9fcb104be7 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1258,7 +1258,7 @@ class PublishPuginsModel(BaseSettingsModel): ) ExtractThumbnailFromSource: ExtractThumbnailFromSourceModel = SettingsField( # noqa: E501 default_factory=ExtractThumbnailFromSourceModel, - title="Extract Thumbnail (from source)", + title="Extract Thumbnail from source", description=( "Extract thumbnails from explicit file set in " "instance.data['thumbnailSource'] using ffmpeg " From 8865e7a2b413c9a8661f09c8449297a819485cee Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 Dec 2025 18:32:11 +0100 Subject: [PATCH 307/386] Reverting change of order Deemed unnecessary (by Kuba) --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 3d34d84bed..913bf818a4 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -35,7 +35,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): label = "Extract Thumbnail from source" # Before 'ExtractThumbnail' in global plugins - order = pyblish.api.ExtractorOrder + 0.48 + order = pyblish.api.ExtractorOrder - 0.00001 # Settings target_size = { From 82427cb004003e415583266fe036bb56fc240481 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:21:45 +0100 Subject: [PATCH 308/386] Fix merge conflicts --- 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 9848063133..d462b94153 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1122,9 +1122,6 @@ def convert_colorspace( view=None, display=None, additional_command_args=None, - frames=None, - frame_padding=None, - parallel_frames=False, logger=None, ): """DEPRECATED function use `oiio_color_convert` instead @@ -1176,6 +1173,9 @@ def oiio_color_convert( target_display=None, target_view=None, additional_command_args=None, + frames=None, + frame_padding=None, + parallel_frames=False, logger=None, ): """Transcode source file to other with colormanagement. @@ -1212,10 +1212,10 @@ def oiio_color_convert( frames (Optional[str]): Complex frame range to process. This requires input path and output path to use frame token placeholder like `#` or `%d`, e.g. file.#.exr - parallel_frames (bool): If True, process frames in parallel inside - the `oiiotool` process. Only supported in OIIO 2.5.20.0+. frame_padding (Optional[int]): Frame padding to use for the input and output when using a sequence filepath. + parallel_frames (bool): If True, process frames in parallel inside + the `oiiotool` process. Only supported in OIIO 2.5.20.0+. logger (logging.Logger): Logger used for logging. Raises: From 11ecc69b3537b6f3af974c794a18ff37fa066a17 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:31:25 +0100 Subject: [PATCH 309/386] Refactor `_input` -> `input_item` --- client/ayon_core/lib/transcoding.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index d462b94153..c37a0a88b7 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -733,7 +733,7 @@ def convert_input_paths_for_ffmpeg( ) input_items = list(input_collections) input_items.extend(input_remainder) - for _input in input_items: + for input_item in input_items: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -746,21 +746,21 @@ def convert_input_paths_for_ffmpeg( # Convert a sequence of files using a single oiiotool command # using its sequence syntax - if isinstance(_input, clique.Collection): - frames = _input.format("{head}#{tail}").replace(" ", "") + if isinstance(input_item, clique.Collection): + frames = input_item.format("{head}#{tail}").replace(" ", "") oiio_cmd.extend([ - "--framepadding", _input.padding, + "--framepadding", input_item.padding, "--frames", frames, "--parallel-frames" ]) - _input: str = _input.format("{head}#{tail}") - elif not isinstance(_input, str): + input_item: str = input_item.format("{head}#{tail}") + elif not isinstance(input_item, str): raise TypeError( - f"Input is not a string or Collection: {_input}" + f"Input is not a string or Collection: {input_item}" ) oiio_cmd.extend([ - input_arg, _input, + input_arg, input_item, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, @@ -772,7 +772,7 @@ def convert_input_paths_for_ffmpeg( oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output - base_filename = os.path.basename(_input) + base_filename = os.path.basename(input_item) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path From af901213a2fd31571fe3d06252eb20989fe2b6dc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:31:54 +0100 Subject: [PATCH 310/386] Update client/ayon_core/lib/transcoding.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/lib/transcoding.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index c37a0a88b7..e82905fe98 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1261,9 +1261,7 @@ def oiio_color_convert( ]) if parallel_frames: - oiio_cmd.extend([ - "--parallel-frames" - ]) + oiio_cmd.append("--parallel-frames") oiio_cmd.extend([ input_arg, input_path, From f9ca97ec714d261b6b1d1b94fa90f3b057bd57a7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:32:54 +0100 Subject: [PATCH 311/386] Perform actual type hint --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index c37a0a88b7..965ab5e865 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1228,7 +1228,7 @@ def oiio_color_convert( # Get oiioinfo only from first image, otherwise file can't be found first_input_path = input_path if frames: - assert isinstance(frames, str) # for type hints + frames: str first_frame = int(re.split("[ x-]", frames, 1)[0]) first_frame = str(first_frame).zfill(frame_padding or 0) for token in ["#", "%d"]: From 2a7316b262cc54c0ee50e05819c64d8ab3b61cc1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:40:10 +0100 Subject: [PATCH 312/386] Type hints to the arguments --- client/ayon_core/lib/transcoding.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index cc74964a2f..061a7bd398 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1173,10 +1173,10 @@ def oiio_color_convert( target_display=None, target_view=None, additional_command_args=None, - frames=None, - frame_padding=None, - parallel_frames=False, - logger=None, + frames: Optional[str] = None, + frame_padding: Optional[int] = None, + parallel_frames: bool = False, + logger: Optional[logging.Logger] = None, ): """Transcode source file to other with colormanagement. From 55eb4cccbe267af4f8560604e73a7214b13a276b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 10:44:46 +0100 Subject: [PATCH 313/386] Add soft compatibility requirement to `ayon_third_party` `>=1.3.0` --- package.py | 1 + 1 file changed, 1 insertion(+) diff --git a/package.py b/package.py index 003c41f0f5..857b3f6906 100644 --- a/package.py +++ b/package.py @@ -12,6 +12,7 @@ ayon_server_version = ">=1.8.4,<2.0.0" ayon_launcher_version = ">=1.0.2" ayon_required_addons = {} ayon_compatible_addons = { + "ayon_third_party": ">=1.3.0", "ayon_ocio": ">=1.2.1", "applications": ">=1.1.2", "harmony": ">0.4.0", From 4d8d9078b8e94963edbcfb06585d8a45a78f9e9a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 10:56:39 +0100 Subject: [PATCH 314/386] Returned None IDK why, comment, must be really important though. --- client/ayon_core/plugins/publish/extract_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 447a24656d..d855c0d530 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -755,6 +755,6 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): host_name, product_type, product_name, task_name, task_type ) ) - return + return None return ThumbnailDef.from_dict(profile) From 3dbba063cae4a42f13064067b01b4683f10488bf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 10:57:02 +0100 Subject: [PATCH 315/386] Renamed variables --- .../plugins/publish/extract_thumbnail.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index d855c0d530..242b5e3987 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -136,8 +136,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): if not self.profiles: self.log.debug("No profiles present for extract review thumbnail.") return - profile_config = self._get_config_from_profile(instance) - if not profile_config: + thumbnail_def = self._get_config_from_profile(instance) + if not thumbnail_def: return product_name = instance.data["productName"] @@ -217,7 +217,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): file_path = self._create_frame_from_video( video_file_path, dst_staging, - profile_config + thumbnail_def ) if file_path: src_staging, input_file = os.path.split(file_path) @@ -230,7 +230,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): if "slate-frame" in repre.get("tags", []): repre_files_thumb = repre_files_thumb[1:] file_index = int( - float(len(repre_files_thumb)) * profile_config.duration_split # noqa: E501 + float(len(repre_files_thumb)) * thumbnail_def.duration_split # noqa: E501 ) input_file = repre_files[file_index] @@ -268,13 +268,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # colorspace data if not repre_thumb_created: repre_thumb_created = self._create_thumbnail_ffmpeg( - full_input_path, full_output_path, profile_config + full_input_path, full_output_path, thumbnail_def ) # Skip representation and try next one if wasn't created if not repre_thumb_created and oiio_supported: repre_thumb_created = self._create_thumbnail_oiio( - full_input_path, full_output_path, profile_config + full_input_path, full_output_path, thumbnail_def ) if not repre_thumb_created: @@ -302,7 +302,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): new_repre_tags = ["thumbnail"] # for workflows which needs to have thumbnails published as # separate representations `delete` tag should not be added - if not profile_config.integrate_thumbnail: + if not thumbnail_def.integrate_thumbnail: new_repre_tags.append("delete") new_repre = { @@ -442,7 +442,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): str: path to created thumbnail """ self.log.info("Extracting thumbnail {}".format(dst_path)) - resolution_arg = self._get_resolution_arg( + resolution_arg = self._get_resolution_args( "oiiotool", src_path, profile_config ) @@ -500,7 +500,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}") try: - resolution_arg = self._get_resolution_arg( + resolution_arg = self._get_resolution_args( "oiiotool", src_path, profile_config ) except RuntimeError: @@ -544,7 +544,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _create_thumbnail_ffmpeg(self, src_path, dst_path, profile_config): try: - resolution_arg = self._get_resolution_arg( + resolution_arg = self._get_resolution_args( "ffmpeg", src_path, profile_config ) except RuntimeError: @@ -698,7 +698,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ): os.remove(output_thumb_file_path) - def _get_resolution_arg( + def _get_resolution_args( self, application, input_path, From 41fa48dbe726e2fcf24677db44a408c622c486bd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 10:57:19 +0100 Subject: [PATCH 316/386] Formatting change --- client/ayon_core/plugins/publish/extract_thumbnail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 242b5e3987..e51eda0da6 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -741,7 +741,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "task_types": task_type, } profile = filter_profiles( - self.profiles, filtering_criteria, + self.profiles, + filtering_criteria, logger=self.log ) From f03ae1bc156ab8e8ca3aafabbd9a2cdc90bd2380 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 10:59:02 +0100 Subject: [PATCH 317/386] Formatting change --- .../ayon_core/plugins/publish/extract_thumbnail.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index e51eda0da6..7376237f9b 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -748,13 +748,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): if not profile: self.log.debug( - ( - "Skipped instance. None of profiles in presets are for" - ' Host: "{}" | Product types: "{}" | Product names: "{}"' - ' | Task name "{}" | Task type "{}"' - ).format( - host_name, product_type, product_name, task_name, task_type - ) + "Skipped instance. None of profiles in presets are for" + f' Host: "{host_name}"' + f' | Product types: "{product_type}"' + f' | Product names: "{product_name}"' + f' | Task name "{task_name}"' + f' | Task type "{task_type}"' ) return None From 7d248880cc6cab7bac604c3b4b0f19436c67c333 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 11:01:12 +0100 Subject: [PATCH 318/386] Changed variable resolution --- server/settings/conversion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 348518f0a3..45250fc9d9 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -161,9 +161,9 @@ def _convert_publish_plugins(overrides): def _convert_extract_thumbnail(overrides): """ExtractThumbnail config settings did change to profiles.""" extract_thumbnail_overrides = ( - overrides.get("publish", {}).get("ExtractThumbnail") or {} + overrides.get("publish", {}).get("ExtractThumbnail") ) - if "profiles" in extract_thumbnail_overrides: + if extract_thumbnail_overrides is None: return base_value = { From 73297259795e5132e7eed644086d6d415c61c2f8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 11:02:01 +0100 Subject: [PATCH 319/386] Used pop IDK why --- server/settings/conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 45250fc9d9..9da765e366 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -190,7 +190,7 @@ def _convert_extract_thumbnail(overrides): "ffmpeg_args", ): if key in extract_thumbnail_overrides: - base_value[key] = extract_thumbnail_overrides[key] + base_value[key] = extract_thumbnail_overrides.pop(key) extract_thumbnail_profiles = extract_thumbnail_overrides.setdefault( "profiles", [] From ea2642ab15692910c41bf1151e1f89cd56d0c722 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:21:06 +0100 Subject: [PATCH 320/386] added resolution and pixel aspect to version --- client/ayon_core/plugins/publish/integrate.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 9f24b35754..b37091e8d4 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -924,8 +924,12 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Include optional data if present in optionals = [ - "frameStart", "frameEnd", "step", - "handleEnd", "handleStart", "sourceHashes" + "frameStart", "frameEnd", + "handleEnd", "handleStart", + "resolutionWidth", "resolutionHeight", + "pixelAspect", + "step", + "sourceHashes" ] for key in optionals: if key in instance.data: From 15b0192d4effe4723a41ffeffe96440346aba5d2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 12:54:17 +0100 Subject: [PATCH 321/386] move step next to frames Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index b37091e8d4..5afdcbdf07 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -926,9 +926,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): optionals = [ "frameStart", "frameEnd", "handleEnd", "handleStart", + "step", "resolutionWidth", "resolutionHeight", "pixelAspect", - "step", "sourceHashes" ] for key in optionals: From d0034b60078ed20f04156d0432d593748690cfe9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:00:51 +0100 Subject: [PATCH 322/386] use 'skip_discovery' instead --- client/ayon_core/pipeline/create/creator_plugins.py | 8 +++++--- client/ayon_core/pipeline/plugin_discover.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index b8de1af691..16cd34d9b9 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -147,6 +147,8 @@ class BaseCreator(ABC): create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ + skip_discovery = True + # Label shown in UI label = None group_label = None @@ -642,7 +644,7 @@ class Creator(BaseCreator): Creation requires prepared product name and instance data. """ - is_base_class = True + skip_discovery = True # GUI Purposes # - default_variants may not be used if `get_default_variants` # is overridden @@ -931,7 +933,7 @@ class Creator(BaseCreator): class HiddenCreator(BaseCreator): - is_base_class = True + skip_discovery = True @abstractmethod def create(self, instance_data, source_data): @@ -943,7 +945,7 @@ class AutoCreator(BaseCreator): Can be used e.g. for `workfile`. """ - is_base_class = True + skip_discovery = True def remove_instances(self, instances): """Skip removal.""" diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index 896b7966d6..fd907eb22c 100644 --- a/client/ayon_core/pipeline/plugin_discover.py +++ b/client/ayon_core/pipeline/plugin_discover.py @@ -141,9 +141,9 @@ def discover_plugins( for cls in classes_from_module(base_class, module): if cls is base_class: continue - # Class has defined 'is_base_class = True' - is_base_class = cls.__dict__.get("is_base_class") - if is_base_class is True: + # Class has defined 'skip_discovery = True' + skip_discovery = cls.__dict__.get("skip_discovery") + if skip_discovery is True: continue all_plugins.append(cls) From 4faf61dd22d4712405b994684c24f0b0dbeea1c0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:01:04 +0100 Subject: [PATCH 323/386] add logic description --- client/ayon_core/pipeline/create/creator_plugins.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 16cd34d9b9..7b168984ef 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -146,7 +146,13 @@ class BaseCreator(ABC): project_settings (dict[str, Any]): Project settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. + """ + # Attribute 'skip_discovery' is used during discovery phase to skip + # plugins, which can be used to mark base plugins that should not be + # considered as plugins "to use". The discovery logic does NOT use + # the attribute value from parent classes. Each base class has to define + # the attribute again. skip_discovery = True # Label shown in UI From 65791a1d9f0aec602fcf241175d4f2d3f1333d6d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 12 Dec 2025 15:03:31 +0100 Subject: [PATCH 324/386] added 'skip_discovery' to loader plugin --- client/ayon_core/pipeline/load/plugins.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index ed963110c6..b8cca08802 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -21,6 +21,13 @@ from .utils import get_representation_path_from_context class LoaderPlugin(list): """Load representation into host application""" + # Attribute 'skip_discovery' is used during discovery phase to skip + # plugins, which can be used to mark base plugins that should not be + # considered as plugins "to use". The discovery logic does NOT use + # the attribute value from parent classes. Each base class has to define + # the attribute again. + skip_discovery = True + product_types: set[str] = set() product_base_types: Optional[set[str]] = None representations = set() From 52e4932c97e611561a466bfefa3969cf44e44f28 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 Dec 2025 16:39:22 +0100 Subject: [PATCH 325/386] Used renamed class name as variable --- .../plugins/publish/extract_thumbnail.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 7376237f9b..ff3c77b79c 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -424,7 +424,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): src_path, dst_path, colorspace_data, - profile_config + thumbnail_def ): """Create thumbnail using OIIO tool oiiotool @@ -443,7 +443,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): """ self.log.info("Extracting thumbnail {}".format(dst_path)) resolution_arg = self._get_resolution_args( - "oiiotool", src_path, profile_config + "oiiotool", src_path, thumbnail_def ) repre_display = colorspace_data.get("display") @@ -463,8 +463,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) # if representation doesn't have display and view then use # oiiotool_defaults - elif profile_config.oiiotool_defaults: - oiiotool_defaults = profile_config.oiiotool_defaults + elif thumbnail_def.oiiotool_defaults: + oiiotool_defaults = thumbnail_def.oiiotool_defaults oiio_default_type = oiiotool_defaults["type"] if "colorspace" == oiio_default_type: oiio_default_colorspace = oiiotool_defaults["colorspace"] @@ -496,12 +496,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return True - def _create_thumbnail_oiio(self, src_path, dst_path, profile_config): + def _create_thumbnail_oiio(self, src_path, dst_path, thumbnail_def): self.log.debug(f"Extracting thumbnail with OIIO: {dst_path}") try: resolution_arg = self._get_resolution_args( - "oiiotool", src_path, profile_config + "oiiotool", src_path, thumbnail_def ) except RuntimeError: self.log.warning( @@ -542,10 +542,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False - def _create_thumbnail_ffmpeg(self, src_path, dst_path, profile_config): + def _create_thumbnail_ffmpeg(self, src_path, dst_path, thumbnail_def): try: resolution_arg = self._get_resolution_args( - "ffmpeg", src_path, profile_config + "ffmpeg", src_path, thumbnail_def ) except RuntimeError: self.log.warning( @@ -554,7 +554,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return False ffmpeg_path_args = get_ffmpeg_tool_args("ffmpeg") - ffmpeg_args = profile_config.ffmpeg_args or {} + ffmpeg_args = thumbnail_def.ffmpeg_args or {} jpeg_items = [ subprocess.list2cmdline(ffmpeg_path_args) @@ -598,7 +598,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): self, video_file_path, output_dir, - profile_config + thumbnail_def ): """Convert video file to one frame image via ffmpeg""" # create output file path @@ -624,7 +624,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): seek_position = 0.0 # Only use timestamp calculation for videos longer than 0.1 seconds if duration > 0.1: - seek_position = duration * profile_config.duration_split + seek_position = duration * thumbnail_def.duration_split # Build command args cmd_args = [] @@ -702,13 +702,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): self, application, input_path, - profile_config + thumbnail_def ): # get settings - if profile_config.target_size["type"] == "source": + if thumbnail_def.target_size["type"] == "source": return [] - resize = profile_config.target_size["resize"] + resize = thumbnail_def.target_size["resize"] target_width = resize["width"] target_height = resize["height"] @@ -718,7 +718,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): input_path, target_width, target_height, - bg_color=profile_config.background_color, + bg_color=thumbnail_def.background_color, log=self.log ) From 6f534f4ff0ca281b360edd8b5b382746a48f0e7b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 22:41:28 +0100 Subject: [PATCH 326/386] Update client/ayon_core/plugins/publish/collect_scene_loaded_versions.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../ayon_core/plugins/publish/collect_scene_loaded_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 2c214cd1a7..54eeefc60b 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -99,7 +99,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): for container in containers: missing = [key for key in required_keys if key not in container] if missing: - self.log.debug( + self.log.warning( "Skipping invalid container, missing required keys:" " {}. {}".format(", ".join(missing), container) ) From 2871ecac7d319dd4e3b9dc8d1469b2a253b06453 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 22:52:52 +0100 Subject: [PATCH 327/386] Fix setting the correct order --- .../plugins/publish/extract_usd_layer_contributions.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 7da14a714b..63b642b685 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -340,8 +340,9 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, attr_values[key] = attr_values[key].format(**data) # Define contribution - order = self.contribution_layers.get( - attr_values["contribution_layer"], 0 + scope: str = attr_values["contribution_target_product_init"] + order: int = ( + self.contribution_layers[scope][attr_values["contribution_layer"]] ) if attr_values["contribution_apply_as_variant"]: From c93eb31b54b10089ca2652dddeefe36d34f51915 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Dec 2025 22:53:05 +0100 Subject: [PATCH 328/386] Fix typo --- .../plugins/publish/extract_usd_layer_contributions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 63b642b685..a78767a76d 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -276,7 +276,7 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, # the contributions so that we can design a system where custom # contributions outside the predefined orders are possible to be # managed. So that if a particular asset requires an extra contribution - # level, you can add itdirectly from the publisher at that particular + # level, you can add it directly from the publisher at that particular # order. Future publishes will then see the existing contribution and will # persist adding it to future bootstraps at that order contribution_layers: Dict[str, Dict[str, int]] = { From b39e09142f8b5ac7aca323e717d1fef7667c19c5 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 12 Dec 2025 23:21:38 +0100 Subject: [PATCH 329/386] :recycle: change pytest-ayon dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b06f812b27..562bb72035 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ opentimelineio = "^0.17.0" speedcopy = "^2.1" qtpy="^2.4.3" pyside6 = "^6.5.2" -pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "chore/align-dependencies" } +pytest-ayon = { git = "https://github.com/ynput/pytest-ayon.git", branch = "develop" } [tool.codespell] # Ignore words that are not in the dictionary. From 3dacfec4ecfe9cbda62a21981b95e8fe45172a3c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:24:49 +0100 Subject: [PATCH 330/386] allow to change registry name in controller --- .../tools/console_interpreter/abstract.py | 14 ++++++++------ .../tools/console_interpreter/control.py | 17 ++++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/tools/console_interpreter/abstract.py b/client/ayon_core/tools/console_interpreter/abstract.py index a945e6e498..953365d18c 100644 --- a/client/ayon_core/tools/console_interpreter/abstract.py +++ b/client/ayon_core/tools/console_interpreter/abstract.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import List, Dict, Optional +from typing import Optional @dataclass @@ -13,8 +15,8 @@ class TabItem: class InterpreterConfig: width: Optional[int] height: Optional[int] - splitter_sizes: List[int] = field(default_factory=list) - tabs: List[TabItem] = field(default_factory=list) + splitter_sizes: list[int] = field(default_factory=list) + tabs: list[TabItem] = field(default_factory=list) class AbstractInterpreterController(ABC): @@ -27,7 +29,7 @@ class AbstractInterpreterController(ABC): self, width: int, height: int, - splitter_sizes: List[int], - tabs: List[Dict[str, str]], - ): + splitter_sizes: list[int], + tabs: list[dict[str, str]], + ) -> None: pass diff --git a/client/ayon_core/tools/console_interpreter/control.py b/client/ayon_core/tools/console_interpreter/control.py index b931b6252c..4c5a4b3419 100644 --- a/client/ayon_core/tools/console_interpreter/control.py +++ b/client/ayon_core/tools/console_interpreter/control.py @@ -1,4 +1,5 @@ -from typing import List, Dict +from __future__ import annotations +from typing import Optional from ayon_core.lib import JSONSettingRegistry from ayon_core.lib.local_settings import get_launcher_local_dir @@ -11,13 +12,15 @@ from .abstract import ( class InterpreterController(AbstractInterpreterController): - def __init__(self): + def __init__(self, name: Optional[str] = None) -> None: + if name is None: + name = "python_interpreter_tool" self._registry = JSONSettingRegistry( - "python_interpreter_tool", + name, get_launcher_local_dir(), ) - def get_config(self): + def get_config(self) -> InterpreterConfig: width = None height = None splitter_sizes = [] @@ -54,9 +57,9 @@ class InterpreterController(AbstractInterpreterController): self, width: int, height: int, - splitter_sizes: List[int], - tabs: List[Dict[str, str]], - ): + splitter_sizes: list[int], + tabs: list[dict[str, str]], + ) -> None: self._registry.set_item("width", width) self._registry.set_item("height", height) self._registry.set_item("splitter_sizes", splitter_sizes) From e3fa6e446ec7e1b6ec362bd803569121655eccde Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 15 Dec 2025 12:10:50 +0100 Subject: [PATCH 331/386] Updated docstring Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/publish_plugins.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 9fcb104be7..3d7c0d04ca 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1263,8 +1263,7 @@ class PublishPuginsModel(BaseSettingsModel): "Extract thumbnails from explicit file set in " "instance.data['thumbnailSource'] using ffmpeg " "and oiiotool." - "Used when host does not provide thumbnail, but artist could set " - "custom thumbnail source file." + "Used when artist provided thumbnail source." ) ) ExtractOIIOTranscode: ExtractOIIOTranscodeModel = SettingsField( From e3b94654f8aa838743cef0a3c2f77ceb5d5b6758 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 15 Dec 2025 12:11:07 +0100 Subject: [PATCH 332/386] Updated docstrings Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/publish_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 3d7c0d04ca..ffd25079d1 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -1261,8 +1261,8 @@ class PublishPuginsModel(BaseSettingsModel): title="Extract Thumbnail from source", description=( "Extract thumbnails from explicit file set in " - "instance.data['thumbnailSource'] using ffmpeg " - "and oiiotool." + "instance.data['thumbnailSource'] using oiiotool" + " or ffmpeg." "Used when artist provided thumbnail source." ) ) From 3c0dd4335ede5c5a42506651e02c7d3b90e0bc4b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:24:22 +0100 Subject: [PATCH 333/386] override full stdout and stderr --- .../tools/console_interpreter/ui/utils.py | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/client/ayon_core/tools/console_interpreter/ui/utils.py b/client/ayon_core/tools/console_interpreter/ui/utils.py index 427483215d..b57f22a886 100644 --- a/client/ayon_core/tools/console_interpreter/ui/utils.py +++ b/client/ayon_core/tools/console_interpreter/ui/utils.py @@ -3,40 +3,41 @@ import sys import collections +class _CustomSTD: + def __init__(self, orig_std, write_callback): + self.orig_std = orig_std + self._valid_orig = bool(orig_std) + self._write_callback = write_callback + + def __getattr__(self, attr): + return getattr(self.orig_std, attr) + + def __setattr__(self, key, value): + if key in ("orig_std", "_valid_orig", "_write_callback"): + super().__setattr__(key, value) + else: + setattr(self.orig_std, key, value) + + def write(self, text): + if self._valid_orig: + self.orig_std.write(text) + self._write_callback(text) + + class StdOEWrap: def __init__(self): - self._origin_stdout_write = None - self._origin_stderr_write = None - self._listening = False self.lines = collections.deque() - - if not sys.stdout: - sys.stdout = open(os.devnull, "w") - - if not sys.stderr: - sys.stderr = open(os.devnull, "w") - - if self._origin_stdout_write is None: - self._origin_stdout_write = sys.stdout.write - - if self._origin_stderr_write is None: - self._origin_stderr_write = sys.stderr.write - self._listening = True - sys.stdout.write = self._stdout_listener - sys.stderr.write = self._stderr_listener + + self._stdout_wrap = _CustomSTD(sys.stdout, self._listener) + self._stderr_wrap = _CustomSTD(sys.stderr, self._listener) + + sys.stdout = self._stdout_wrap + sys.stderr = self._stderr_wrap def stop_listen(self): self._listening = False - def _stdout_listener(self, text): + def _listener(self, text): if self._listening: self.lines.append(text) - if self._origin_stdout_write is not None: - self._origin_stdout_write(text) - - def _stderr_listener(self, text): - if self._listening: - self.lines.append(text) - if self._origin_stderr_write is not None: - self._origin_stderr_write(text) From dbdc4c590b13d6878e761ee9c2d15154d89464ad Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:38:17 +0100 Subject: [PATCH 334/386] remove unused import --- client/ayon_core/tools/console_interpreter/ui/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/console_interpreter/ui/utils.py b/client/ayon_core/tools/console_interpreter/ui/utils.py index b57f22a886..c073b784ef 100644 --- a/client/ayon_core/tools/console_interpreter/ui/utils.py +++ b/client/ayon_core/tools/console_interpreter/ui/utils.py @@ -1,4 +1,3 @@ -import os import sys import collections From bd2e26ea50453fd26b51b7c43c77fa17c5953d28 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:03:27 +0100 Subject: [PATCH 335/386] use 'verbose=False' at other places --- client/ayon_core/lib/transcoding.py | 13 ++++++++++--- .../ayon_core/plugins/publish/extract_thumbnail.py | 6 +++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index feb31a46e1..c712472998 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -582,7 +582,7 @@ def get_review_layer_name(src_filepath): return None # Load info about file from oiio tool - input_info = get_oiio_info_for_input(src_filepath) + input_info = get_oiio_info_for_input(src_filepath, verbose=False) if not input_info: return None @@ -1389,7 +1389,11 @@ def get_rescaled_command_arguments( command_args.extend(["-vf", "{0},{1}".format(scale, pad)]) elif application == "oiiotool": - input_info = get_oiio_info_for_input(input_path, logger=log) + input_info = get_oiio_info_for_input( + input_path, + logger=log, + verbose=False, + ) # Collect channels to export _, channels_arg = get_oiio_input_and_channel_args( input_info, alpha_default=1.0) @@ -1529,10 +1533,13 @@ def get_oiio_input_and_channel_args(oiio_input_info, alpha_default=None): """Get input and channel arguments for oiiotool. Args: oiio_input_info (dict): Information about input from oiio tool. - Should be output of function `get_oiio_info_for_input`. + Should be output of function 'get_oiio_info_for_input' (can be + called with 'verbose=False'). alpha_default (float, optional): Default value for alpha channel. + Returns: tuple[str, str]: Tuple of input and channel arguments. + """ channel_names = oiio_input_info["channelnames"] review_channels = get_convert_rgb_channels(channel_names) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index adfb4298b9..2cc1c53d57 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -478,7 +478,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ) return False - input_info = get_oiio_info_for_input(src_path, logger=self.log) + input_info = get_oiio_info_for_input( + src_path, + logger=self.log, + verbose=False, + ) try: input_arg, channels_arg = get_oiio_input_and_channel_args( input_info From a2387d185619cf1007e629db99278b32581c604d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:09:27 +0100 Subject: [PATCH 336/386] require kwargs --- client/ayon_core/lib/transcoding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index ed30a58f98..fcd68fafc7 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -136,6 +136,7 @@ def get_transcode_temp_directory(): def get_oiio_info_for_input( filepath: str, + *, subimages: bool = False, verbose: bool = True, logger: logging.Logger = None, From 0cfc9598750992c80e30897e07c636ececa27cbf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:25:51 +0100 Subject: [PATCH 337/386] swap order of kwargs --- client/ayon_core/lib/transcoding.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index fcd68fafc7..8e9ed90d1a 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -145,9 +145,9 @@ def get_oiio_info_for_input( Args: filepath (str): Path to file. - logger (logging.Logger): Logger used for logging. subimages (bool): include info about subimages in the output. verbose (bool): get the full metadata about each input image. + logger (logging.Logger): Logger used for logging. Stdout should contain xml format string. """ @@ -1252,8 +1252,8 @@ def oiio_color_convert( input_info = get_oiio_info_for_input( first_input_path, - logger=logger, verbose=False, + logger=logger, ) # Collect channels to export From 19f84805bd45cf9fb33faa6178c7f32c370da3c5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Dec 2025 22:11:12 +0100 Subject: [PATCH 338/386] Fix scope defaults, fix order to `int` and enforce `name` to not be empty --- server/settings/publish_plugins.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index a328465932..90f6d1c5ef 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -83,14 +83,21 @@ def usd_contribution_layer_types(): class ContributionLayersModel(BaseSettingsModel): _layout = "compact" - name: str = SettingsField(title="Name") + name: str = SettingsField( + default="", + regex="[A-Za-z0-9_-]+", + title="Name") scope: list[str] = SettingsField( + default_factory=list, title="Scope", enum_resolver=usd_contribution_layer_types) - order: str = SettingsField( + order: int = SettingsField( + default=0, title="Order", - description="Higher order means a higher strength and stacks the " - "layer on top.") + description=( + "Higher order means a higher strength and stacks the layer on top." + ) + ) class CollectUSDLayerContributionsProfileModel(BaseSettingsModel): From 74971bd3dcac94d87fd190ab83bdfd89384ef81c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Dec 2025 22:14:14 +0100 Subject: [PATCH 339/386] Cosmetics --- .../plugins/publish/extract_usd_layer_contributions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c6f6497a6a..eb51f1d491 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -18,7 +18,7 @@ from ayon_core.lib import ( UISeparatorDef, UILabelDef, EnumDef, - filter_profiles + filter_profiles, ) try: from ayon_core.pipeline.usdlib import ( From ea59a764cd8d54618f6dabc83695a5b8b20d0685 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 16 Dec 2025 10:48:59 +0100 Subject: [PATCH 340/386] Refactor/remove legacy usage of asset, family and subset --- client/ayon_core/host/host.py | 2 +- client/ayon_core/pipeline/load/utils.py | 4 ++-- client/ayon_core/pipeline/template_data.py | 2 -- .../pipeline/workfile/workfile_template_builder.py | 2 +- .../ayon_core/plugins/load/create_hero_version.py | 4 ++-- .../publish/collect_anatomy_instance_data.py | 2 -- .../plugins/publish/collect_managed_staging_dir.py | 2 +- client/ayon_core/plugins/publish/extract_burnin.py | 14 -------------- client/ayon_core/plugins/publish/integrate.py | 4 ---- .../plugins/publish/integrate_hero_version.py | 3 --- .../plugins/publish/integrate_product_group.py | 2 -- client/ayon_core/style/style.css | 6 ------ .../tools/push_to_project/models/integrate.py | 2 -- client/ayon_core/tools/texture_copy/app.py | 2 -- 14 files changed, 7 insertions(+), 44 deletions(-) diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 7d6d3ddbe4..b52506c0b8 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -137,7 +137,7 @@ class HostBase(AbstractHost): def get_current_folder_path(self) -> Optional[str]: """ Returns: - Optional[str]: Current asset name. + Optional[str]: Current folder path. """ return os.environ.get("AYON_FOLDER_PATH") diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 8aed7b8b52..a02a4b30e0 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -948,7 +948,7 @@ def get_representation_by_names( version_name: Union[int, str], representation_name: str, ) -> Optional[dict]: - """Get representation entity for asset and subset. + """Get representation entity for folder and product. If version_name is "hero" then return the hero version If version_name is "latest" then return the latest version @@ -966,7 +966,7 @@ def get_representation_by_names( return None if isinstance(product_name, dict) and "name" in product_name: - # Allow explicitly passing subset document + # Allow explicitly passing product entity document product_entity = product_name else: product_entity = ayon_api.get_product_by_name( diff --git a/client/ayon_core/pipeline/template_data.py b/client/ayon_core/pipeline/template_data.py index dc7e95c788..955b1aaac8 100644 --- a/client/ayon_core/pipeline/template_data.py +++ b/client/ayon_core/pipeline/template_data.py @@ -96,7 +96,6 @@ def get_folder_template_data(folder_entity, project_name): Output dictionary contains keys: - 'folder' - dictionary with 'name' key filled with folder name - - 'asset' - folder name - 'hierarchy' - parent folder names joined with '/' - 'parent' - direct parent name, project name used if is under project @@ -132,7 +131,6 @@ def get_folder_template_data(folder_entity, project_name): "path": path, "parents": parents, }, - "asset": folder_name, "hierarchy": hierarchy, "parent": parent_name } diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 2f9e7250c0..b6757db66d 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1483,7 +1483,7 @@ class PlaceholderLoadMixin(object): tooltip=( "Link Type\n" "\nDefines what type of link will be used to" - " link the asset to the current folder." + " link the product to the current folder." ) ), attribute_definitions.EnumDef( diff --git a/client/ayon_core/plugins/load/create_hero_version.py b/client/ayon_core/plugins/load/create_hero_version.py index d01a97e2ff..531f024fc4 100644 --- a/client/ayon_core/plugins/load/create_hero_version.py +++ b/client/ayon_core/plugins/load/create_hero_version.py @@ -62,8 +62,8 @@ class CreateHeroVersion(load.ProductLoaderPlugin): ignored_representation_names: list[str] = [] db_representation_context_keys = [ - "project", "folder", "asset", "hierarchy", "task", "product", - "subset", "family", "representation", "username", "user", "output" + "project", "folder", "hierarchy", "task", "product", + "representation", "username", "user", "output" ] use_hardlinks = False diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index 2cb2297bf7..554cf42aa2 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -301,8 +301,6 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): product_name = instance.data["productName"] product_type = instance.data["productType"] anatomy_data.update({ - "family": product_type, - "subset": product_name, "product": { "name": product_name, "type": product_type, diff --git a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py index 62b007461a..ee88dadfa0 100644 --- a/client/ayon_core/plugins/publish/collect_managed_staging_dir.py +++ b/client/ayon_core/plugins/publish/collect_managed_staging_dir.py @@ -25,7 +25,7 @@ class CollectManagedStagingDir(pyblish.api.InstancePlugin): Location of the folder is configured in: `ayon+anatomy://_/templates/staging`. - Which family/task type/subset is applicable is configured in: + Which product type/task type/product is applicable is configured in: `ayon+settings://core/tools/publish/custom_staging_dir_profiles` """ diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 351d85a97f..fe929505ab 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -318,20 +318,6 @@ class ExtractBurnin(publish.Extractor): value = burnin_def.get(key) if not value: continue - # TODO remove replacements - burnin_values[key] = ( - value - .replace("{task}", "{task[name]}") - .replace("{product[name]}", "{subset}") - .replace("{Product[name]}", "{Subset}") - .replace("{PRODUCT[NAME]}", "{SUBSET}") - .replace("{product[type]}", "{family}") - .replace("{Product[type]}", "{Family}") - .replace("{PRODUCT[TYPE]}", "{FAMILY}") - .replace("{folder[name]}", "{asset}") - .replace("{Folder[name]}", "{Asset}") - .replace("{FOLDER[NAME]}", "{ASSET}") - ) # Remove "delete" tag from new representation if "delete" in new_repre["tags"]: diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 5afdcbdf07..2e5a733533 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -123,10 +123,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "representation", "username", "output", - # OpenPype keys - should be removed - "asset", # folder[name] - "subset", # product[name] - "family", # product[type] ] def process(self, instance): diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index a591cfe880..03b9dddf3a 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -81,12 +81,9 @@ class IntegrateHeroVersion( db_representation_context_keys = [ "project", "folder", - "asset", "hierarchy", "task", "product", - "subset", - "family", "representation", "username", "output" diff --git a/client/ayon_core/plugins/publish/integrate_product_group.py b/client/ayon_core/plugins/publish/integrate_product_group.py index 8904d21d69..107f409312 100644 --- a/client/ayon_core/plugins/publish/integrate_product_group.py +++ b/client/ayon_core/plugins/publish/integrate_product_group.py @@ -62,10 +62,8 @@ class IntegrateProductGroup(pyblish.api.InstancePlugin): product_type = instance.data["productType"] fill_pairs = prepare_template_data({ - "family": product_type, "task": filter_criteria["tasks"], "host": filter_criteria["hosts"], - "subset": product_name, "product": { "name": product_name, "type": product_type, diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 0d057beb7b..23a6998316 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -969,12 +969,6 @@ SearchItemDisplayWidget #ValueWidget { background: {color:bg-buttons}; } -/* Subset Manager */ -#SubsetManagerDetailsText {} -#SubsetManagerDetailsText[state="invalid"] { - border: 1px solid #ff0000; -} - /* Creator */ #CreatorsView::item { padding: 1px 5px; 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 6d6dd35a9d..1dab461019 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1129,8 +1129,6 @@ class ProjectPushItemProcess: self.host_name ) formatting_data.update({ - "subset": self._product_name, - "family": self._product_type, "product": { "name": self._product_name, "type": self._product_type, diff --git a/client/ayon_core/tools/texture_copy/app.py b/client/ayon_core/tools/texture_copy/app.py index c288187aac..1013020185 100644 --- a/client/ayon_core/tools/texture_copy/app.py +++ b/client/ayon_core/tools/texture_copy/app.py @@ -32,8 +32,6 @@ class TextureCopy: product_type = "texture" template_data = get_template_data(project_entity, folder_entity) template_data.update({ - "family": product_type, - "subset": product_name, "product": { "name": product_name, "type": product_type, From a90eb2d54ad56a54feb5720b8951e4deb0d9af72 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:41:14 +0100 Subject: [PATCH 341/386] fix burnins --- client/ayon_core/plugins/publish/extract_burnin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index fe929505ab..6e7b4ef07e 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -316,8 +316,8 @@ class ExtractBurnin(publish.Extractor): burnin_values = {} for key in self.positions: value = burnin_def.get(key) - if not value: - continue + if value: + burnin_values[key] = value # Remove "delete" tag from new representation if "delete" in new_repre["tags"]: From e32b54f9113066183c3284d15c5118ca6e4fce43 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:53:34 +0100 Subject: [PATCH 342/386] fix 'get_versioning_start' --- client/ayon_core/pipeline/version_start.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/version_start.py b/client/ayon_core/pipeline/version_start.py index 7ee20a5dd4..b3e5acb65a 100644 --- a/client/ayon_core/pipeline/version_start.py +++ b/client/ayon_core/pipeline/version_start.py @@ -1,15 +1,18 @@ +from __future__ import annotations +from typing import Optional, Any + from ayon_core.lib.profiles_filtering import filter_profiles from ayon_core.settings import get_project_settings def get_versioning_start( - project_name, - host_name, - task_name=None, - task_type=None, - product_type=None, - product_name=None, - project_settings=None, + project_name: str, + host_name: str, + task_name: Optional[str] = None, + task_type: Optional[str] = None, + product_type: Optional[str] = None, + product_name: Optional[str] = None, + project_settings: Optional[dict[str, Any]] = None, ): """Get anatomy versioning start""" if not project_settings: @@ -26,10 +29,10 @@ def get_versioning_start( # 'families' and 'subsets' filtering_criteria = { "host_names": host_name, - "families": product_type, + "product_types": product_type, + "product_names": product_name, "task_names": task_name, "task_types": task_type, - "subsets": product_name } profile = filter_profiles(profiles, filtering_criteria) From 18a4461e8345f5b64ba07acef8cee9d80c8caf4d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:55:16 +0100 Subject: [PATCH 343/386] remove todo --- client/ayon_core/pipeline/version_start.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/pipeline/version_start.py b/client/ayon_core/pipeline/version_start.py index b3e5acb65a..e7c0c6aacd 100644 --- a/client/ayon_core/pipeline/version_start.py +++ b/client/ayon_core/pipeline/version_start.py @@ -25,8 +25,6 @@ def get_versioning_start( if not profiles: return version_start - # TODO use 'product_types' and 'product_name' instead of - # 'families' and 'subsets' filtering_criteria = { "host_names": host_name, "product_types": product_type, From e2c9cacdd32c9c3da39c066602fa42f4cf8b3da3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:02:57 +0100 Subject: [PATCH 344/386] remove deprecated 'extractenvironments' --- client/ayon_core/cli.py | 47 ----------------------------------------- 1 file changed, 47 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 85c254e7eb..3474cdd38e 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -90,53 +90,6 @@ def addon(ctx): pass -@main_cli.command() -@click.pass_context -@click.argument("output_json_path") -@click.option("--project", help="Project name", default=None) -@click.option("--asset", help="Folder path", default=None) -@click.option("--task", help="Task name", default=None) -@click.option("--app", help="Application name", default=None) -@click.option( - "--envgroup", help="Environment group (e.g. \"farm\")", default=None -) -def extractenvironments( - ctx, output_json_path, project, asset, task, app, envgroup -): - """Extract environment variables for entered context to a json file. - - Entered output filepath will be created if does not exists. - - All context options must be passed otherwise only AYON's global - environments will be extracted. - - Context options are "project", "asset", "task", "app" - - Deprecated: - This function is deprecated and will be removed in future. Please use - 'addon applications extractenvironments ...' instead. - """ - warnings.warn( - ( - "Command 'extractenvironments' is deprecated and will be" - " removed in future. Please use" - " 'addon applications extractenvironments ...' instead." - ), - DeprecationWarning - ) - - addons_manager = ctx.obj["addons_manager"] - applications_addon = addons_manager.get_enabled_addon("applications") - if applications_addon is None: - raise RuntimeError( - "Applications addon is not available or enabled." - ) - - # Please ignore the fact this is using private method - applications_addon._cli_extract_environments( - output_json_path, project, asset, task, app, envgroup - ) - @main_cli.command() @click.pass_context From 0b141009767f376cfd2dcc6c88a47400fb61929c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:08:44 +0100 Subject: [PATCH 345/386] remove line --- client/ayon_core/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 3474cdd38e..0ff927f959 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -90,7 +90,6 @@ def addon(ctx): pass - @main_cli.command() @click.pass_context @click.argument("path", required=True) From d80fc97604087fa65e975658d7407292d0db00f8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:09:38 +0100 Subject: [PATCH 346/386] remove unused import --- client/ayon_core/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 0ff927f959..4135aa2e31 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -6,7 +6,6 @@ import logging import code import traceback from pathlib import Path -import warnings import click From 7313025572ba8c957d81312657055518464ed87b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:30:53 +0100 Subject: [PATCH 347/386] add return type hint --- client/ayon_core/pipeline/version_start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/version_start.py b/client/ayon_core/pipeline/version_start.py index e7c0c6aacd..54022012a0 100644 --- a/client/ayon_core/pipeline/version_start.py +++ b/client/ayon_core/pipeline/version_start.py @@ -13,7 +13,7 @@ def get_versioning_start( product_type: Optional[str] = None, product_name: Optional[str] = None, project_settings: Optional[dict[str, Any]] = None, -): +) -> int: """Get anatomy versioning start""" if not project_settings: project_settings = get_project_settings(project_name) From 3f72115a5edeb572135694249df1a3be7f21a352 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:12:35 +0100 Subject: [PATCH 348/386] added option to filter crashed files --- client/ayon_core/pipeline/publish/lib.py | 59 ++++++++++++++++++++++++ server/settings/publish_plugins.py | 31 +++++++++++++ 2 files changed, 90 insertions(+) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index e512a0116f..6af37aa388 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -246,6 +246,65 @@ def load_help_content_from_plugin( return load_help_content_from_filepath(filepath) +def filter_crashed_publish_paths( + project_name: str, + crashed_paths: set[str], + *, + project_settings: Optional[dict[str, Any]] = None, +) -> set[str]: + """Filter crashed paths happened during plugins discovery. + + Check if plugins discovery has enabled strict mode and filter crashed + paths that happened during discover based on regexes from settings. + + Publishing should not start if any paths are returned. + + Args: + project_name (str): Project name in which context plugins discovery + happened. + crashed_paths (set[str]): Crashed paths from plugins discovery report. + project_settings (Optional[dict[str, Any]]): Project settings. + + Returns: + set[str]: Filtered crashed paths. + + """ + filtered_paths = set() + # Nothing crashed all good... + if not crashed_paths: + return filtered_paths + + if project_settings is None: + project_settings = get_project_settings(project_name) + + discover_validation = project_settings["core"]["discover_validation"] + # Strict mode is not enabled. + if not discover_validation["enabled"]: + return filtered_paths + + regexes = [ + re.compile(value, re.IGNORECASE) + for value in discover_validation["ignore_paths"] + if value + ] + is_windows = platform.system().lower() == "windows" + # Fitler path with regexes from settings + for path in crashed_paths: + # Normalize paths to use forward slashes on windows + if is_windows: + path = path.replace("\\", "/") + is_invalid = True + for regex in regexes: + if regex.match(path): + is_invalid = False + break + + if is_invalid: + filtered_paths.add(path) + + return filtered_paths + + def publish_plugins_discover( paths: Optional[list[str]] = None) -> DiscoverResult: """Find and return available pyblish plug-ins. diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index d7b794cb5b..f47cf24fea 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -34,6 +34,28 @@ class ValidateBaseModel(BaseSettingsModel): active: bool = SettingsField(True, title="Active") +class DiscoverValidationModel(BaseSettingsModel): + """Strictly validate publish plugins discovery. + + Artist won't be able to publish if path to publish plugin fails to be + imported. + + """ + _isGroup = True + enabled: bool = SettingsField( + False, + description="Enable strict mode of plugins discovery", + ) + ignore_paths: list[str] = SettingsField( + default_factory=list, + title="Ignored paths (regex)", + description=( + "Paths that do match regex will be skipped in validation." + ), + ) + + + class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = SettingsField( @@ -1188,6 +1210,11 @@ class CleanUpFarmModel(BaseSettingsModel): class PublishPuginsModel(BaseSettingsModel): + discover_validation: DiscoverValidationModel = SettingsField( + default_factory=DiscoverValidationModel, + title="Validate plugins discovery", + ) + CollectAnatomyInstanceData: CollectAnatomyInstanceDataModel = ( SettingsField( default_factory=CollectAnatomyInstanceDataModel, @@ -1308,6 +1335,10 @@ class PublishPuginsModel(BaseSettingsModel): DEFAULT_PUBLISH_VALUES = { + "discover_validation": { + "enabled": False, + "ignore_paths": [], + }, "CollectAnatomyInstanceData": { "follow_workfile_version": False }, From 09364a4f7e9adc2485e5544468117b15ef96f224 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:20:40 +0100 Subject: [PATCH 349/386] use the function in main cli publish --- client/ayon_core/pipeline/publish/lib.py | 42 +++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 6af37aa388..2777f7511c 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -8,19 +8,19 @@ import warnings import hashlib import xml.etree.ElementTree from typing import TYPE_CHECKING, Optional, Union, List, Any -import clique -import speedcopy import logging -import pyblish.util -import pyblish.plugin -import pyblish.api - from ayon_api import ( get_server_api_connection, get_representations, get_last_version_by_product_name ) +import clique +import pyblish.util +import pyblish.plugin +import pyblish.api +import speedcopy + from ayon_core.lib import ( import_filepath, Logger, @@ -1158,14 +1158,16 @@ def main_cli_publish( except ValueError: pass + context = get_global_context() + project_settings = get_project_settings(context["project_name"]) + install_ayon_plugins() if addons_manager is None: - addons_manager = AddonsManager() + addons_manager = AddonsManager(project_settings) applications_addon = addons_manager.get_enabled_addon("applications") if applications_addon is not None: - context = get_global_context() env = applications_addon.get_farm_publish_environment_variables( context["project_name"], context["folder_path"], @@ -1188,17 +1190,33 @@ def main_cli_publish( log.info("Running publish ...") discover_result = publish_plugins_discover() - publish_plugins = discover_result.plugins print(discover_result.get_report(only_errors=False)) + filtered_crashed_paths = filter_crashed_publish_paths( + context["project_name"], + set(discover_result.crashed_file_paths), + project_settings=project_settings, + ) + if filtered_crashed_paths: + joined_paths = "\n".join([ + f"- {path}" + for path in filtered_crashed_paths + ]) + log.error( + "Plugin discovery strict mode is enabled." + " Crashed plugin paths that prevent from publishing:" + f"\n{joined_paths}" + ) + sys.exit(1) + + publish_plugins = discover_result.plugins + # Error exit as soon as any error occurs. - error_format = ("Failed {plugin.__name__}: " - "{error} -- {error.traceback}") + error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" for result in pyblish.util.publish_iter(plugins=publish_plugins): if result["error"]: log.error(error_format.format(**result)) - # uninstall() sys.exit(1) log.info("Publish finished.") From 096a5a809e1225374a9f6777ec322293f698427b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:28:30 +0100 Subject: [PATCH 350/386] fix imports --- client/ayon_core/pipeline/publish/__init__.py | 1 + client/ayon_core/pipeline/publish/lib.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index ede7fc3a35..105ab53fb0 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -29,6 +29,7 @@ from .lib import ( get_publish_template_name, publish_plugins_discover, + filter_crashed_publish_paths, load_help_content_from_plugin, load_help_content_from_filepath, diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 2777f7511c..934542bc1c 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -1,6 +1,8 @@ """Library functions for publishing.""" from __future__ import annotations import os +import platform +import re import sys import inspect import copy From c53a2f68e54e99dcfbd41f411fcd25fc2806e72b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:01:10 +0100 Subject: [PATCH 351/386] fix settings load --- client/ayon_core/pipeline/publish/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 934542bc1c..d511ad8b45 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -279,7 +279,9 @@ def filter_crashed_publish_paths( if project_settings is None: project_settings = get_project_settings(project_name) - discover_validation = project_settings["core"]["discover_validation"] + discover_validation = ( + project_settings["core"]["publish"]["discover_validation"] + ) # Strict mode is not enabled. if not discover_validation["enabled"]: return filtered_paths From 856a58dc35ecbccd2b17e4e73f77d6047e3c8362 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:03:12 +0100 Subject: [PATCH 352/386] block publisher on blocking failed plugins --- .../tools/publisher/models/publish.py | 17 ++++- client/ayon_core/tools/publisher/window.py | 73 ++++++++++++++----- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 97070d106f..cd99a952e3 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -21,6 +21,7 @@ from ayon_core.pipeline.plugin_discover import DiscoverResult from ayon_core.pipeline.publish import ( get_publish_instance_label, PublishError, + filter_crashed_publish_paths, ) from ayon_core.tools.publisher.abstract import AbstractPublisherBackend @@ -107,11 +108,14 @@ class PublishReportMaker: creator_discover_result: Optional[DiscoverResult] = None, convertor_discover_result: Optional[DiscoverResult] = None, publish_discover_result: Optional[DiscoverResult] = None, + blocking_crashed_paths: Optional[list[str]] = None, ): self._create_discover_result: Union[DiscoverResult, None] = None self._convert_discover_result: Union[DiscoverResult, None] = None self._publish_discover_result: Union[DiscoverResult, None] = None + self._blocking_crashed_paths: list[str] = [] + self._all_instances_by_id: Dict[str, pyblish.api.Instance] = {} self._plugin_data_by_id: Dict[str, Any] = {} self._current_plugin_id: Optional[str] = None @@ -120,6 +124,7 @@ class PublishReportMaker: creator_discover_result, convertor_discover_result, publish_discover_result, + blocking_crashed_paths, ) def reset( @@ -127,12 +132,14 @@ class PublishReportMaker: creator_discover_result: Union[DiscoverResult, None], convertor_discover_result: Union[DiscoverResult, None], publish_discover_result: Union[DiscoverResult, None], + blocking_crashed_paths: list[str], ): """Reset report and clear all data.""" self._create_discover_result = creator_discover_result self._convert_discover_result = convertor_discover_result self._publish_discover_result = publish_discover_result + self._blocking_crashed_paths = blocking_crashed_paths self._all_instances_by_id = {} self._plugin_data_by_id = {} @@ -242,9 +249,10 @@ class PublishReportMaker: "instances": instances_details, "context": self._extract_context_data(publish_context), "crashed_file_paths": crashed_file_paths, + "blocking_crashed_paths": list(self._blocking_crashed_paths), "id": uuid.uuid4().hex, "created_at": now.isoformat(), - "report_version": "1.1.0", + "report_version": "1.1.1", } def _add_plugin_data_item(self, plugin: pyblish.api.Plugin): @@ -959,11 +967,16 @@ class PublishModel: self._publish_plugins_proxy = PublishPluginsProxy( publish_plugins ) - + blocking_crashed_paths = filter_crashed_publish_paths( + create_context.get_current_project_name(), + set(create_context.publish_discover_result.crashed_file_paths), + project_settings=create_context.get_current_project_settings(), + ) self._publish_report.reset( create_context.creator_discover_result, create_context.convertor_discover_result, create_context.publish_discover_result, + blocking_crashed_paths, ) for plugin in create_context.publish_plugins_mismatch_targets: self._publish_report.set_plugin_skipped(plugin.id) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 19994f9f62..2d7bcc5a2a 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -393,6 +393,9 @@ class PublisherWindow(QtWidgets.QDialog): self._publish_frame_visible = None self._tab_on_reset = None + self._create_context_valid: bool = True + self._blocked_by_crashed_paths: bool = False + self._error_messages_to_show = collections.deque() self._errors_dialog_message_timer = errors_dialog_message_timer @@ -406,6 +409,8 @@ class PublisherWindow(QtWidgets.QDialog): self._show_counter = 0 self._window_is_visible = False + self._update_footer_state() + @property def controller(self) -> AbstractPublisherFrontend: """Kept for compatibility with traypublisher.""" @@ -664,12 +669,27 @@ class PublisherWindow(QtWidgets.QDialog): self._tab_on_reset = tab + def set_current_tab(self, tab): + if tab == "create": + self._go_to_create_tab() + elif tab == "publish": + self._go_to_publish_tab() + elif tab == "report": + self._go_to_report_tab() + elif tab == "details": + self._go_to_details_tab() + + if not self._window_is_visible: + self.set_tab_on_reset(tab) + def _update_publish_details_widget(self, force=False): if not force and not self._is_on_details_tab(): return report_data = self._controller.get_publish_report() + blocked = bool(report_data["blocking_crashed_paths"]) self._publish_details_widget.set_report_data(report_data) + self._set_blocked(blocked) def _on_help_click(self): if self._help_dialog.isVisible(): @@ -752,19 +772,6 @@ class PublisherWindow(QtWidgets.QDialog): def _set_current_tab(self, identifier): self._tabs_widget.set_current_tab(identifier) - def set_current_tab(self, tab): - if tab == "create": - self._go_to_create_tab() - elif tab == "publish": - self._go_to_publish_tab() - elif tab == "report": - self._go_to_report_tab() - elif tab == "details": - self._go_to_details_tab() - - if not self._window_is_visible: - self.set_tab_on_reset(tab) - def _is_current_tab(self, identifier): return self._tabs_widget.is_current_tab(identifier) @@ -865,8 +872,39 @@ class PublisherWindow(QtWidgets.QDialog): # Reset style self._comment_input.setStyleSheet("") - def _set_footer_enabled(self, enabled): - self._save_btn.setEnabled(True) + def _set_create_context_valid(self, valid: bool) -> None: + if self._create_context_valid is valid: + return + self._create_context_valid = valid + self._update_footer_state() + + def _set_blocked(self, blocked: bool) -> None: + if self._blocked_by_crashed_paths is blocked: + return + self._blocked_by_crashed_paths = blocked + self._overview_widget.setEnabled(not blocked) + self._update_footer_state() + if not blocked: + return + + QtWidgets.QMessageBox.critical( + self, + "Failed to load plugins", + ( + "Failed to load plugins that do prevent you from" + " using publish tool." + " Please contact your TD or administrator." + ) + ) + + def _update_footer_state(self) -> None: + enabled = ( + not self._blocked_by_crashed_paths + and self._create_context_valid + ) + save_enabled = not self._blocked_by_crashed_paths + + self._save_btn.setEnabled(save_enabled) self._reset_btn.setEnabled(True) if enabled: self._stop_btn.setEnabled(False) @@ -885,6 +923,7 @@ class PublisherWindow(QtWidgets.QDialog): self._update_publish_details_widget() def _on_controller_reset(self): + self._update_publish_details_widget(force=True) self._first_reset, first_reset = False, self._first_reset if self._tab_on_reset is not None: self._tab_on_reset, new_tab = None, self._tab_on_reset @@ -952,7 +991,7 @@ class PublisherWindow(QtWidgets.QDialog): def _validate_create_instances(self): if not self._controller.is_host_valid(): - self._set_footer_enabled(True) + self._set_create_context_valid(True) return active_instances_by_id = { @@ -973,7 +1012,7 @@ class PublisherWindow(QtWidgets.QDialog): if all_valid is None: all_valid = True - self._set_footer_enabled(bool(all_valid)) + self._set_create_context_valid(bool(all_valid)) def _on_create_model_reset(self): self._validate_create_instances() From ae7726bdef849c58634cdf01e84bc21753230a39 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:31:35 +0100 Subject: [PATCH 353/386] change tabs and mark blocking filepath --- .../publish_report_viewer/report_items.py | 3 ++ .../publish_report_viewer/widgets.py | 38 +++++++++++++++---- client/ayon_core/tools/publisher/window.py | 3 ++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py index a3c5a7a2fd..24955d18c3 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py @@ -139,3 +139,6 @@ class PublishReport: self.logs = logs self.crashed_plugin_paths = report_data["crashed_file_paths"] + self.blocking_crashed_paths = report_data.get( + "blocking_crashed_paths", [] + ) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 5fa1c04dc0..74ff647538 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -7,6 +7,7 @@ from ayon_core.tools.utils import ( SeparatorWidget, IconButton, paint_image_with_color, + get_qt_icon, ) from ayon_core.resources import get_image_path from ayon_core.style import get_objected_colors @@ -46,10 +47,13 @@ def get_pretty_milliseconds(value): class PluginLoadReportModel(QtGui.QStandardItemModel): + _blocking_icon = None + def __init__(self): super().__init__() self._traceback_by_filepath = {} self._items_by_filepath = {} + self._blocking_crashed_paths = set() self._is_active = True self._need_refresh = False @@ -75,6 +79,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): for filepath in to_remove: self._traceback_by_filepath.pop(filepath) + self._blocking_crashed_paths = set(report.blocking_crashed_paths) self._update_items() def _update_items(self): @@ -91,12 +96,18 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): set(self._items_by_filepath) - set(self._traceback_by_filepath) ) for filepath in self._traceback_by_filepath: - if filepath in self._items_by_filepath: - continue - item = QtGui.QStandardItem(filepath) - new_items.append(item) - new_items_by_filepath[filepath] = item - self._items_by_filepath[filepath] = item + item = self._items_by_filepath.get(filepath) + if item is None: + item = QtGui.QStandardItem(filepath) + new_items.append(item) + new_items_by_filepath[filepath] = item + self._items_by_filepath[filepath] = item + + if filepath.replace("\\", "/") in self._blocking_crashed_paths: + item.setData( + self._get_blocking_icon(), + QtCore.Qt.DecorationRole + ) if new_items: parent.appendRows(new_items) @@ -113,6 +124,15 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): item = self._items_by_filepath.pop(filepath) parent.removeRow(item.row()) + @classmethod + def _get_blocking_icon(cls): + if cls._blocking_icon is None: + cls._blocking_icon = get_qt_icon({ + "type": "material-symbols", + "name": "block", + "color": "red", + }) + return cls._blocking_icon class DetailWidget(QtWidgets.QTextEdit): def __init__(self, text, *args, **kwargs): @@ -856,7 +876,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame): report = PublishReport(report_data) self.set_report(report) - def set_report(self, report): + def set_report(self, report: PublishReport) -> None: self._ignore_selection_changes = True self._report_item = report @@ -866,6 +886,10 @@ class PublishReportViewerWidget(QtWidgets.QFrame): self._logs_text_widget.set_report(report) self._plugin_load_report_widget.set_report(report) self._plugins_details_widget.set_report(report) + if report.blocking_crashed_paths: + self._details_tab_widget.setCurrentWidget( + self._plugin_load_report_widget + ) self._ignore_selection_changes = False diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 2d7bcc5a2a..859fa96132 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -887,6 +887,9 @@ class PublisherWindow(QtWidgets.QDialog): if not blocked: return + self.set_tab_on_reset("details") + self._go_to_details_tab() + QtWidgets.QMessageBox.critical( self, "Failed to load plugins", From e8635725facb681982dc791d5cd1c3c68037e4f9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:54:28 +0100 Subject: [PATCH 354/386] ruff fixes --- client/ayon_core/pipeline/publish/__init__.py | 1 + .../ayon_core/tools/publisher/publish_report_viewer/widgets.py | 1 + server/settings/publish_plugins.py | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index 105ab53fb0..179d749f48 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -88,6 +88,7 @@ __all__ = ( "get_publish_template_name", "publish_plugins_discover", + "filter_crashed_publish_paths", "load_help_content_from_plugin", "load_help_content_from_filepath", diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 74ff647538..7e7c31db04 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -134,6 +134,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): }) return cls._blocking_icon + class DetailWidget(QtWidgets.QTextEdit): def __init__(self, text, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 2d0ac8aa31..e9eb5f7e93 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -55,7 +55,6 @@ class DiscoverValidationModel(BaseSettingsModel): ) - class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = SettingsField( From 5404153b942db01a0efd0ccfdb79ddce1c3ffa9f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:51:05 +0100 Subject: [PATCH 355/386] Add new line character. Co-authored-by: Roy Nieterau --- client/ayon_core/tools/publisher/window.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 859fa96132..50bccb8aa0 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -895,8 +895,8 @@ class PublisherWindow(QtWidgets.QDialog): "Failed to load plugins", ( "Failed to load plugins that do prevent you from" - " using publish tool." - " Please contact your TD or administrator." + " using publish tool.\n" + "Please contact your TD or administrator." ) ) From f4bd5d49f949b2ff45c6c95a5fd25427873662ea Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:38:48 +0100 Subject: [PATCH 356/386] move settings to tools --- server/settings/publish_plugins.py | 30 ------------------------------ server/settings/tools.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index e9eb5f7e93..da22e4206f 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -34,27 +34,6 @@ class ValidateBaseModel(BaseSettingsModel): active: bool = SettingsField(True, title="Active") -class DiscoverValidationModel(BaseSettingsModel): - """Strictly validate publish plugins discovery. - - Artist won't be able to publish if path to publish plugin fails to be - imported. - - """ - _isGroup = True - enabled: bool = SettingsField( - False, - description="Enable strict mode of plugins discovery", - ) - ignore_paths: list[str] = SettingsField( - default_factory=list, - title="Ignored paths (regex)", - description=( - "Paths that do match regex will be skipped in validation." - ), - ) - - class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = SettingsField( @@ -1236,11 +1215,6 @@ class CleanUpFarmModel(BaseSettingsModel): class PublishPuginsModel(BaseSettingsModel): - discover_validation: DiscoverValidationModel = SettingsField( - default_factory=DiscoverValidationModel, - title="Validate plugins discovery", - ) - CollectAnatomyInstanceData: CollectAnatomyInstanceDataModel = ( SettingsField( default_factory=CollectAnatomyInstanceDataModel, @@ -1371,10 +1345,6 @@ class PublishPuginsModel(BaseSettingsModel): DEFAULT_PUBLISH_VALUES = { - "discover_validation": { - "enabled": False, - "ignore_paths": [], - }, "CollectAnatomyInstanceData": { "follow_workfile_version": False }, diff --git a/server/settings/tools.py b/server/settings/tools.py index da3b4ebff8..9b16ca5d94 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -352,6 +352,27 @@ class CustomStagingDirProfileModel(BaseSettingsModel): ) +class DiscoverValidationModel(BaseSettingsModel): + """Strictly validate publish plugins discovery. + + Artist won't be able to publish if path to publish plugin fails to be + imported. + + """ + _isGroup = True + enabled: bool = SettingsField( + False, + description="Enable strict mode of plugins discovery", + ) + ignore_paths: list[str] = SettingsField( + default_factory=list, + title="Ignored paths (regex)", + description=( + "Paths that do match regex will be skipped in validation." + ), + ) + + class PublishToolModel(BaseSettingsModel): template_name_profiles: list[PublishTemplateNameProfile] = SettingsField( default_factory=list, @@ -369,6 +390,10 @@ class PublishToolModel(BaseSettingsModel): title="Custom Staging Dir Profiles" ) ) + discover_validation: DiscoverValidationModel = SettingsField( + default_factory=DiscoverValidationModel, + title="Validate plugins discovery", + ) comment_minimum_required_chars: int = SettingsField( 0, title="Publish comment minimum required characters", @@ -691,6 +716,10 @@ DEFAULT_TOOLS_VALUES = { "template_name": "simpleUnrealTextureHero" } ], + "discover_validation": { + "enabled": False, + "ignore_paths": [], + }, "comment_minimum_required_chars": 0, } } From 73cc4c53b4ad02f8e0e6971bd456935fd29e5970 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:53:43 +0100 Subject: [PATCH 357/386] use correct settings --- client/ayon_core/pipeline/publish/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index d511ad8b45..8492145979 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -280,7 +280,7 @@ def filter_crashed_publish_paths( project_settings = get_project_settings(project_name) discover_validation = ( - project_settings["core"]["publish"]["discover_validation"] + project_settings["core"]["tools"]["publish"]["discover_validation"] ) # Strict mode is not enabled. if not discover_validation["enabled"]: From e1dc93cb4452d9ed535009efc31d0ca366e6fe4c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 10:55:27 +0100 Subject: [PATCH 358/386] unset icon if is not blocking anymore --- .../tools/publisher/publish_report_viewer/widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 7e7c31db04..d595593898 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -103,11 +103,11 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): new_items_by_filepath[filepath] = item self._items_by_filepath[filepath] = item + icon = None if filepath.replace("\\", "/") in self._blocking_crashed_paths: - item.setData( - self._get_blocking_icon(), - QtCore.Qt.DecorationRole - ) + icon = self._get_blocking_icon() + + item.setData(icon, QtCore.Qt.DecorationRole) if new_items: parent.appendRows(new_items) From d1db95d8cb5269e77692b3c283bb0b7dd1221c19 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:16:03 +0100 Subject: [PATCH 359/386] fix conversion function name --- server/settings/conversion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 9da765e366..952df7f970 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -7,7 +7,7 @@ from .publish_plugins import DEFAULT_PUBLISH_VALUES PRODUCT_NAME_REPL_REGEX = re.compile(r"[^<>{}\[\]a-zA-Z0-9_.]") -def _convert_imageio_configs_1_6_5(overrides): +def _convert_product_name_templates_1_6_5(overrides): product_name_profiles = ( overrides .get("tools", {}) @@ -204,7 +204,7 @@ def convert_settings_overrides( ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) _convert_imageio_configs_0_4_5(overrides) - _convert_imageio_configs_1_6_5(overrides) + _convert_product_name_templates_1_6_5(overrides) _convert_publish_plugins(overrides) _convert_extract_thumbnail(overrides) return overrides From 67d9ec366c15eb0a8e1150455e71fb5b557261f4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:16:48 +0100 Subject: [PATCH 360/386] added product base types to product name template settings --- server/settings/conversion.py | 25 +++++++++++++++++++++++++ server/settings/tools.py | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 952df7f970..8eb42d8e6b 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -7,6 +7,30 @@ from .publish_plugins import DEFAULT_PUBLISH_VALUES PRODUCT_NAME_REPL_REGEX = re.compile(r"[^<>{}\[\]a-zA-Z0-9_.]") +def _convert_product_name_templates_1_7_0(overrides): + product_name_profiles = ( + overrides + .get("tools", {}) + .get("creator", {}) + .get("product_name_profiles") + ) + if ( + not product_name_profiles + or not isinstance(product_name_profiles, list) + ): + return + + # Already converted + item = product_name_profiles[0] + if "product_base_types" in item or "product_types" not in item: + return + + # Move product base types to product types + for item in product_name_profiles: + item["product_base_types"] = item["product_types"] + item["product_types"] = [] + + def _convert_product_name_templates_1_6_5(overrides): product_name_profiles = ( overrides @@ -205,6 +229,7 @@ def convert_settings_overrides( _convert_imageio_configs_0_3_1(overrides) _convert_imageio_configs_0_4_5(overrides) _convert_product_name_templates_1_6_5(overrides) + _convert_product_name_templates_1_7_0(overrides) _convert_publish_plugins(overrides) _convert_extract_thumbnail(overrides) return overrides diff --git a/server/settings/tools.py b/server/settings/tools.py index da3b4ebff8..02dfc79e46 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -24,6 +24,10 @@ class ProductTypeSmartSelectModel(BaseSettingsModel): class ProductNameProfile(BaseSettingsModel): _layout = "expanded" + product_base_types: list[str] = SettingsField( + default_factory=list, + title="Product base types", + ) product_types: list[str] = SettingsField( default_factory=list, title="Product types", From ad36a449fd874d2972d6817a487333d82dd8f88a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:17:10 +0100 Subject: [PATCH 361/386] fix filter criteria for backwards compatibility --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index d32de54774..9a50e18afd 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -60,11 +60,11 @@ def get_product_name_template( tools_settings = project_settings["core"]["tools"] profiles = tools_settings["creator"]["product_name_profiles"] filtering_criteria = { + "product_base_types": product_base_type or product_type, "product_types": product_type, "host_names": host_name, "task_names": task_name, "task_types": task_type, - "product_base_types": product_base_type, } matching_profile = filter_profiles(profiles, filtering_criteria) template = None From 6a3f28cfb828c34db15351cd4da6fa941afd46c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:19:15 +0100 Subject: [PATCH 362/386] change defaults --- server/settings/tools.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index 02dfc79e46..60b5a67f64 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -447,6 +447,7 @@ DEFAULT_TOOLS_VALUES = { ], "product_name_profiles": [ { + "product_base_types": [], "product_types": [], "host_names": [], "task_types": [], @@ -454,28 +455,31 @@ DEFAULT_TOOLS_VALUES = { "template": "{product[type]}{variant}" }, { - "product_types": [ + "product_base_types": [ "workfile" ], + "product_types": [], "host_names": [], "task_types": [], "task_names": [], "template": "{product[type]}{Task[name]}" }, { - "product_types": [ + "product_base_types": [ "render" ], + "product_types": [], "host_names": [], "task_types": [], "task_names": [], "template": "{product[type]}{Task[name]}{Variant}<_{Aov}>" }, { - "product_types": [ + "product_base_types": [ "renderLayer", "renderPass" ], + "product_types": [], "host_names": [ "tvpaint" ], @@ -486,10 +490,11 @@ DEFAULT_TOOLS_VALUES = { ) }, { - "product_types": [ + "product_base_types": [ "review", "workfile" ], + "product_types": [], "host_names": [ "aftereffects", "tvpaint" @@ -499,7 +504,8 @@ DEFAULT_TOOLS_VALUES = { "template": "{product[type]}{Task[name]}" }, { - "product_types": ["render"], + "product_base_types": ["render"], + "product_types": [], "host_names": [ "aftereffects" ], @@ -508,9 +514,10 @@ DEFAULT_TOOLS_VALUES = { "template": "{product[type]}{Task[name]}{Composition}{Variant}" }, { - "product_types": [ + "product_base_types": [ "staticMesh" ], + "product_types": [], "host_names": [ "maya" ], @@ -519,9 +526,10 @@ DEFAULT_TOOLS_VALUES = { "template": "S_{folder[name]}{variant}" }, { - "product_types": [ + "product_base_types": [ "skeletalMesh" ], + "product_types": [], "host_names": [ "maya" ], @@ -530,9 +538,10 @@ DEFAULT_TOOLS_VALUES = { "template": "SK_{folder[name]}{variant}" }, { - "product_types": [ + "product_base_types": [ "hda" ], + "product_types": [], "host_names": [ "houdini" ], @@ -541,9 +550,10 @@ DEFAULT_TOOLS_VALUES = { "template": "{folder[name]}_{variant}" }, { - "product_types": [ + "product_base_types": [ "textureSet" ], + "product_types": [], "host_names": [ "substancedesigner" ], From cd1c2cdb0f38f611a4133fc4d8eaba9a94d5e3c7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Dec 2025 11:57:29 +0100 Subject: [PATCH 363/386] Set the default value for new entries to be scoped to `asset`, `task` so that copying from older releases automatically sets it to both. This way, also newly added entries will have both by default which is better than none. --- server/settings/publish_plugins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 90f6d1c5ef..6b1ee5562f 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -88,7 +88,11 @@ class ContributionLayersModel(BaseSettingsModel): regex="[A-Za-z0-9_-]+", title="Name") scope: list[str] = SettingsField( - default_factory=list, + # This should actually be returned from a callable to `default_factory` + # because lists are mutable. However, the frontend can't interpret + # the callable. It will fail to apply it as the default. Specifying + # this default directly did not show any ill side effects. + default=["asset", "shot"], title="Scope", enum_resolver=usd_contribution_layer_types) order: int = SettingsField( From 108286aa341cffefd135fc0ac52bffbbe06f9606 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 11:59:41 +0100 Subject: [PATCH 364/386] fix refresh issue --- .../ayon_core/tools/publisher/publish_report_viewer/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index d595593898..225dd15ade 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -88,6 +88,7 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): parent = self.invisibleRootItem() if not self._traceback_by_filepath: parent.removeRows(0, parent.rowCount()) + self._items_by_filepath = {} return new_items = [] From 3e3cd49beaddf28e1685d0147deb65ebc3bcf5d5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Dec 2025 11:59:48 +0100 Subject: [PATCH 365/386] Add a warning if plug-in defaults are used --- .../plugins/publish/extract_usd_layer_contributions.py | 5 +++++ 1 file changed, 5 insertions(+) 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 eb51f1d491..ed3c16b5c2 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -318,6 +318,11 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, contribution_layers[scope][entry["name"]] = int(entry["order"]) if contribution_layers: cls.contribution_layers = dict(contribution_layers) + else: + cls.log.warning( + "No scoped contribution layers found in settings, falling back" + " to CollectUSDLayerContributions plug-in defaults..." + ) cls.profiles = plugin_settings.get("profiles", []) From 2baffc253ceeaf21c67727c84d1b322740abdf6c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Dec 2025 12:13:21 +0100 Subject: [PATCH 366/386] Set `min_items=1` for `scope` attribute in `CollectUSDLayerContributions` --- server/settings/publish_plugins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index df76170a20..eb41c75699 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -94,6 +94,7 @@ class ContributionLayersModel(BaseSettingsModel): # this default directly did not show any ill side effects. default=["asset", "shot"], title="Scope", + min_items=1, enum_resolver=usd_contribution_layer_types) order: int = SettingsField( default=0, From de7b49e68ff80368695c569b6a86baadc3c8974a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:31:49 +0100 Subject: [PATCH 367/386] simplify update state --- client/ayon_core/tools/publisher/window.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 50bccb8aa0..ddbda8eedd 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -909,14 +909,9 @@ class PublisherWindow(QtWidgets.QDialog): self._save_btn.setEnabled(save_enabled) self._reset_btn.setEnabled(True) - if enabled: - self._stop_btn.setEnabled(False) - self._validate_btn.setEnabled(True) - self._publish_btn.setEnabled(True) - else: - self._stop_btn.setEnabled(enabled) - self._validate_btn.setEnabled(enabled) - self._publish_btn.setEnabled(enabled) + self._stop_btn.setEnabled(False) + self._validate_btn.setEnabled(enabled) + self._publish_btn.setEnabled(enabled) def _on_publish_reset(self): self._create_tab.setEnabled(True) From 5982ad7944b23bfcdfede55132b5e75c295d9737 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:32:19 +0100 Subject: [PATCH 368/386] call set_blocked only on reset --- client/ayon_core/tools/publisher/window.py | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index ddbda8eedd..f74ca9abb9 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import os import json import time import collections import copy -from typing import Optional +from typing import Optional, Any from qtpy import QtWidgets, QtCore, QtGui @@ -682,14 +684,21 @@ class PublisherWindow(QtWidgets.QDialog): if not self._window_is_visible: self.set_tab_on_reset(tab) - def _update_publish_details_widget(self, force=False): - if not force and not self._is_on_details_tab(): + def _update_publish_details_widget( + self, + force: bool = False, + report_data: Optional[dict[str, Any]] = None, + ) -> None: + if ( + report_data is None + and not force + and not self._is_on_details_tab() + ): return - report_data = self._controller.get_publish_report() - blocked = bool(report_data["blocking_crashed_paths"]) + if report_data is None: + report_data = self._controller.get_publish_report() self._publish_details_widget.set_report_data(report_data) - self._set_blocked(blocked) def _on_help_click(self): if self._help_dialog.isVisible(): @@ -918,7 +927,11 @@ class PublisherWindow(QtWidgets.QDialog): self._set_comment_input_visiblity(True) self._set_publish_overlay_visibility(False) self._set_publish_visibility(False) - self._update_publish_details_widget() + + report_data = self._controller.get_publish_report() + blocked = bool(report_data["blocking_crashed_paths"]) + self._set_blocked(blocked) + self._update_publish_details_widget(report_data=report_data) def _on_controller_reset(self): self._update_publish_details_widget(force=True) From e462dca88986b339fd7a7860df05597820039654 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:01:54 +0100 Subject: [PATCH 369/386] always call '_update_footer_state' --- client/ayon_core/tools/publisher/window.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index f74ca9abb9..6f7444ed04 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -882,14 +882,10 @@ class PublisherWindow(QtWidgets.QDialog): self._comment_input.setStyleSheet("") def _set_create_context_valid(self, valid: bool) -> None: - if self._create_context_valid is valid: - return self._create_context_valid = valid self._update_footer_state() def _set_blocked(self, blocked: bool) -> None: - if self._blocked_by_crashed_paths is blocked: - return self._blocked_by_crashed_paths = blocked self._overview_widget.setEnabled(not blocked) self._update_footer_state() From 3fe508e773718c314b04c3a83685cc41cf8bd494 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:32:11 +0100 Subject: [PATCH 370/386] pass thumbnail def to _create_colorspace_thumbnail --- client/ayon_core/plugins/publish/extract_thumbnail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index b94a4c4dbb..ff492f8021 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -259,7 +259,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): repre_thumb_created = self._create_colorspace_thumbnail( full_input_path, full_output_path, - colorspace_data + colorspace_data, + thumbnail_def, ) # Try to use FFMPEG if OIIO is not supported or for cases when From a9af964f4c2364d058eee53fdbf8eb3f178c1d94 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:34:36 +0100 Subject: [PATCH 371/386] added some typehints --- .../plugins/publish/extract_thumbnail.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index ff492f8021..1dde8cfb55 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -401,7 +401,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return review_repres + other_repres - def _is_valid_images_repre(self, repre): + def _is_valid_images_repre(self, repre: dict) -> bool: """Check if representation contains valid image files Args: @@ -421,10 +421,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _create_colorspace_thumbnail( self, - src_path, - dst_path, - colorspace_data, - thumbnail_def + src_path: str, + dst_path: str, + colorspace_data: dict, + thumbnail_def: ThumbnailDef, ): """Create thumbnail using OIIO tool oiiotool @@ -437,11 +437,12 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): config (dict) display (Optional[str]) view (Optional[str]) + thumbnail_def (ThumbnailDefinition): Thumbnail definition. Returns: str: path to created thumbnail """ - self.log.info("Extracting thumbnail {}".format(dst_path)) + self.log.info(f"Extracting thumbnail {dst_path}") resolution_arg = self._get_resolution_args( "oiiotool", src_path, thumbnail_def ) @@ -600,10 +601,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _create_frame_from_video( self, - video_file_path, - output_dir, - thumbnail_def - ): + video_file_path: str, + output_dir: str, + thumbnail_def: ThumbnailDef, + ) -> Optional[str]: """Convert video file to one frame image via ffmpeg""" # create output file path base_name = os.path.basename(video_file_path) @@ -704,10 +705,10 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _get_resolution_args( self, - application, - input_path, - thumbnail_def - ): + application: str, + input_path: str, + thumbnail_def: ThumbnailDef, + ) -> list: # get settings if thumbnail_def.target_size["type"] == "source": return [] From b77b0583ddddb8f0c8db8cd99446aedfe519a6cc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Dec 2025 23:58:30 +0100 Subject: [PATCH 372/386] No workfile is allowed, and only looks scary in e.g. tray-publisher. It's not up to the Input integrator to have a strong opinion on it that it should be warning the user, so debug log level is better. --- client/ayon_core/plugins/publish/integrate_inputlinks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index be399a95fc..671e55905a 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -105,7 +105,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): created links by its type """ if workfile_instance is None: - self.log.warning("No workfile in this publish session.") + self.log.debug("No workfile in this publish session.") return workfile_version_id = workfile_instance.data["versionEntity"]["id"] From ef93ab833abc0c9784253b00bd9d52966853491f Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 18 Dec 2025 13:11:14 +0000 Subject: [PATCH 373/386] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e8b53001d5..9d6e07a455 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.6.13+dev" +__version__ = "1.7.0" diff --git a/package.py b/package.py index 857b3f6906..243b9b3fd0 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.6.13+dev" +version = "1.7.0" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 562bb72035..5f586ccb26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.6.13+dev" +version = "1.7.0" description = "" authors = ["Ynput Team "] readme = "README.md" From 11f5c4ba8b891fbdf35b6147fdbd28b191d68f7d Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 18 Dec 2025 13:11:59 +0000 Subject: [PATCH 374/386] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 9d6e07a455..7ba13a0b63 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'core' version.""" -__version__ = "1.7.0" +__version__ = "1.7.0+dev" diff --git a/package.py b/package.py index 243b9b3fd0..795131463b 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.7.0" +version = "1.7.0+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 5f586ccb26..64c884bd37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.7.0" +version = "1.7.0+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 4b4ccad0852a7116a7181ee9c1a8c68d0786eecf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Dec 2025 13:13:01 +0000 Subject: [PATCH 375/386] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2b7756ba7e..77e1e14479 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to AYON Tray options: + - 1.7.0 - 1.6.13 - 1.6.12 - 1.6.11 From 0b6c0f3de964949dae48198648043285b09e9654 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 18 Dec 2025 16:24:06 +0100 Subject: [PATCH 376/386] Added source version description Links copied version to original with author information. Author is not passed on version as it might require admin privileges. --- .../tools/push_to_project/models/integrate.py | 23 ++++++++++++++++++- 1 file changed, 22 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 6d6dd35a9d..51865e6af0 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1045,10 +1045,15 @@ class ProjectPushItemProcess: copied_tags = self._get_transferable_tags(src_version_entity) copied_status = self._get_transferable_status(src_version_entity) + description = self._create_src_version_description( + self._item.src_project_name, + src_version_entity + ) + dst_attrib["description"] = dst_attrib.get("description", "") + description + version_entity = new_version_entity( dst_version, product_id, - author=src_version_entity["author"], status=copied_status, tags=copied_tags, task_id=self._task_info.get("id"), @@ -1372,6 +1377,22 @@ class ProjectPushItemProcess: return copied_status["name"] return None + def _create_src_version_description( + self, + src_project_name: str, + src_version_entity: dict[str, Any] + ) -> str: + """Creates description text about source version.""" + src_version_id = src_version_entity["id"] + src_author = src_version_entity["author"] + version_url = f"{ayon_api.get_base_url()}/projects/{src_project_name}/products?project={src_project_name}&type=version&id={src_version_id}" # noqa: E501 + description = ( + f"Version copied from from {version_url} " + f"created by '{src_author}', " + ) + + return description + class IntegrateModel: def __init__(self, controller): From 818a9f21f3e2db35f7d730b414820ee0cad77849 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:39:52 +0100 Subject: [PATCH 377/386] safe collection of rescale arguments --- .../publish/extract_thumbnail_from_source.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index 913bf818a4..fba7133453 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -160,9 +160,14 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): dst_path: str, ) -> bool: self.log.debug("Outputting thumbnail with OIIO: {}".format(dst_path)) - resolution_args = self._get_resolution_args( - "oiiotool", src_path - ) + try: + resolution_args = self._get_resolution_args( + "oiiotool", src_path + ) + except Exception: + self.log.warning("Failed to get resolution args for OIIO.") + return False + oiio_cmd = get_oiio_tool_args("oiiotool", "-a", src_path) if resolution_args: # resize must be before -o @@ -188,9 +193,14 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): src_path: str, dst_path: str, ) -> bool: - resolution_args = self._get_resolution_args( - "ffmpeg", src_path - ) + try: + resolution_args = self._get_resolution_args( + "ffmpeg", src_path + ) + except Exception: + self.log.warning("Failed to get resolution args for ffmpeg.") + return False + max_int = str(2147483647) ffmpeg_cmd = get_ffmpeg_tool_args( From c55c6a2675d7761effaa8525b0f40849a616b413 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:44:15 +0100 Subject: [PATCH 378/386] fix doubled line --- .../ayon_core/plugins/publish/extract_thumbnail_from_source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py index fba7133453..5535c503f3 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail_from_source.py @@ -201,7 +201,6 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self.log.warning("Failed to get resolution args for ffmpeg.") return False - max_int = str(2147483647) ffmpeg_cmd = get_ffmpeg_tool_args( "ffmpeg", From 1be1a30b38e2c6ade1b33a13f76a6d9aab07ecd6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 18 Dec 2025 16:47:33 +0100 Subject: [PATCH 379/386] Always put src description on new line --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 4c69b1a2c7..5b80b9a358 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1385,7 +1385,7 @@ class ProjectPushItemProcess: src_author = src_version_entity["author"] version_url = f"{ayon_api.get_base_url()}/projects/{src_project_name}/products?project={src_project_name}&type=version&id={src_version_id}" # noqa: E501 description = ( - f"Version copied from from {version_url} " + f"\n\nVersion copied from from {version_url} " f"created by '{src_author}', " ) From f46f1d2e8db3b0cd106aff4cf2bfd9744c3199e9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 19 Dec 2025 11:21:54 +0100 Subject: [PATCH 380/386] Refactor description concatenation Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../tools/push_to_project/models/integrate.py | 10 +++++++++- 1 file changed, 9 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 5b80b9a358..478d280886 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1045,11 +1045,19 @@ class ProjectPushItemProcess: copied_tags = self._get_transferable_tags(src_version_entity) copied_status = self._get_transferable_status(src_version_entity) + description_parts = [] + dst_attr_description = dst_attrib.get("description") + if dst_attr_description: + description_parts.append(dst_attr_description) + description = self._create_src_version_description( self._item.src_project_name, src_version_entity ) - dst_attrib["description"] = dst_attrib.get("description", "") + description + if description: + description_parts.append(description) + + dst_attrib["description"] = "\n\n".join(description_parts) version_entity = new_version_entity( dst_version, From 8f1eebfcbfaaafdbc0164da624d9f94e42a61074 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 19 Dec 2025 11:22:20 +0100 Subject: [PATCH 381/386] Refactor version parts concatenation Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../tools/push_to_project/models/integrate.py | 10 +++++++++- 1 file changed, 9 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 478d280886..829d58d244 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1391,7 +1391,15 @@ class ProjectPushItemProcess: """Creates description text about source version.""" src_version_id = src_version_entity["id"] src_author = src_version_entity["author"] - version_url = f"{ayon_api.get_base_url()}/projects/{src_project_name}/products?project={src_project_name}&type=version&id={src_version_id}" # noqa: E501 + query = "&".join([ + f"project={src_project_name}", + f"type=version", + f"id={src_version_id}, + ]) + version_url = ( + f"{ayon_api.get_base_url()}" + f"/projects/{src_project_name}/products?{query}" + ) description = ( f"\n\nVersion copied from from {version_url} " f"created by '{src_author}', " From 3d0cd51e65413c15c49895cbb27b9e2f7ece339f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 19 Dec 2025 11:22:39 +0100 Subject: [PATCH 382/386] Updates to description format Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 829d58d244..edec1f55c9 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1401,7 +1401,7 @@ class ProjectPushItemProcess: f"/projects/{src_project_name}/products?{query}" ) description = ( - f"\n\nVersion copied from from {version_url} " + f"Version copied from from {version_url} " f"created by '{src_author}', " ) From 7485d99cf6d3457b67c97496a98d48bd899cb977 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:23:37 +0100 Subject: [PATCH 383/386] use FileTransaction in integrate hero --- .../plugins/publish/integrate_hero_version.py | 101 +++++++----------- 1 file changed, 41 insertions(+), 60 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 03b9dddf3a..99fdfc94f5 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -1,11 +1,8 @@ import os +import sys import copy -import errno import itertools import shutil -from concurrent.futures import ThreadPoolExecutor - -from speedcopy import copyfile import clique import pyblish.api @@ -16,11 +13,15 @@ from ayon_api.operations import ( ) from ayon_api.utils import create_entity_id -from ayon_core.lib import create_hard_link, source_hash -from ayon_core.lib.file_transaction import wait_for_future_errors +from ayon_core.lib import source_hash +from ayon_core.lib.file_transaction import ( + FileTransaction, + DuplicateDestinationError, +) from ayon_core.pipeline.publish import ( get_publish_template_name, OptionalPyblishPluginMixin, + KnownPublishError, ) @@ -421,19 +422,41 @@ class IntegrateHeroVersion( (repre_entity, dst_paths) ) - self.path_checks = [] + file_transactions = FileTransaction( + log=self.log, + # Enforce unique transfers + allow_queue_replacements=False + ) + mode = FileTransaction.MODE_COPY + if self.use_hardlinks: + mode = FileTransaction.MODE_LINK - # Copy(hardlink) paths of source and destination files - # TODO should we *only* create hardlinks? - # TODO should we keep files for deletion until this is successful? - with ThreadPoolExecutor(max_workers=8) as executor: - futures = [ - executor.submit(self.copy_file, src_path, dst_path) - for src_path, dst_path in itertools.chain( - src_to_dst_file_paths, other_file_paths_mapping - ) - ] - wait_for_future_errors(executor, futures) + try: + for src_path, dst_path in itertools.chain( + src_to_dst_file_paths, + other_file_paths_mapping + ): + file_transactions.add(src_path, dst_path, mode=mode) + + self.log.debug("Integrating source files to destination ...") + file_transactions.process() + + except DuplicateDestinationError as exc: + # Raise DuplicateDestinationError as KnownPublishError + # and rollback the transactions + file_transactions.rollback() + raise KnownPublishError(exc).with_traceback(sys.exc_info()[2]) + + except Exception as exc: + # clean destination + # todo: preferably we'd also rollback *any* changes to the database + file_transactions.rollback() + self.log.critical("Error when copying files", exc_info=True) + raise exc + + # Finalizing can't rollback safely so no use for moving it to + # the try, except. + file_transactions.finalize() # Update prepared representation etity data with files # and integrate it to server. @@ -622,48 +645,6 @@ class IntegrateHeroVersion( ).format(path)) return path - def copy_file(self, src_path, dst_path): - # TODO check drives if are the same to check if cas hardlink - dirname = os.path.dirname(dst_path) - - try: - os.makedirs(dirname) - self.log.debug("Folder(s) created: \"{}\"".format(dirname)) - except OSError as exc: - if exc.errno != errno.EEXIST: - self.log.error("An unexpected error occurred.", exc_info=True) - raise - - self.log.debug("Folder already exists: \"{}\"".format(dirname)) - - if self.use_hardlinks: - # First try hardlink and copy if paths are cross drive - self.log.debug("Hardlinking file \"{}\" to \"{}\"".format( - src_path, dst_path - )) - try: - create_hard_link(src_path, dst_path) - # Return when successful - return - - except OSError as exc: - # re-raise exception if different than - # EXDEV - cross drive path - # EINVAL - wrong format, must be NTFS - self.log.debug( - "Hardlink failed with errno:'{}'".format(exc.errno)) - if exc.errno not in [errno.EXDEV, errno.EINVAL]: - raise - - self.log.debug( - "Hardlinking failed, falling back to regular copy...") - - self.log.debug("Copying file \"{}\" to \"{}\"".format( - src_path, dst_path - )) - - copyfile(src_path, dst_path) - def version_from_representations(self, project_name, repres): for repre in repres: version = ayon_api.get_version_by_id( From 07edce9c9c7682d5cdf3e8c1dcef852e1c387384 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 19 Dec 2025 11:25:08 +0100 Subject: [PATCH 384/386] Fix missing quote --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index edec1f55c9..928287751b 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1394,7 +1394,7 @@ class ProjectPushItemProcess: query = "&".join([ f"project={src_project_name}", f"type=version", - f"id={src_version_id}, + f"id={src_version_id}" ]) version_url = ( f"{ayon_api.get_base_url()}" From a802285a6cc84648827fdab84595787dbca9c521 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 19 Dec 2025 11:25:30 +0100 Subject: [PATCH 385/386] Removed unnecessary f --- client/ayon_core/tools/push_to_project/models/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 928287751b..d0e191a412 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -1393,7 +1393,7 @@ class ProjectPushItemProcess: src_author = src_version_entity["author"] query = "&".join([ f"project={src_project_name}", - f"type=version", + "type=version", f"id={src_version_id}" ]) version_url = ( From 1612b0297da95836fd56f0292a10132363d184aa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:35:26 +0100 Subject: [PATCH 386/386] fix long lines --- client/ayon_core/plugins/publish/integrate_hero_version.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate_hero_version.py b/client/ayon_core/plugins/publish/integrate_hero_version.py index 99fdfc94f5..ee499d6d45 100644 --- a/client/ayon_core/plugins/publish/integrate_hero_version.py +++ b/client/ayon_core/plugins/publish/integrate_hero_version.py @@ -448,8 +448,7 @@ class IntegrateHeroVersion( raise KnownPublishError(exc).with_traceback(sys.exc_info()[2]) except Exception as exc: - # clean destination - # todo: preferably we'd also rollback *any* changes to the database + # Rollback the transactions file_transactions.rollback() self.log.critical("Error when copying files", exc_info=True) raise exc