From cb125a192f0728562d5e76d2b510370d65c4f1f8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 31 Mar 2025 23:14:17 +0200 Subject: [PATCH 001/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] 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/223] :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/223] :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/223] :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/223] :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/223] :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/223] :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/223] 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/223] :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/223] :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/223] :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/223] :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 a0f6a3f37971c30390f5a1b99d81f1a582ab5122 Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Mon, 25 Aug 2025 19:09:20 -0400 Subject: [PATCH 027/223] 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 32c022cd4daeb4027f88021a0a5ea2163734f9de Mon Sep 17 00:00:00 2001 From: Aleks Berland Date: Tue, 26 Aug 2025 09:55:47 -0400 Subject: [PATCH 028/223] 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 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 029/223] :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 030/223] :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 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 031/223] 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 032/223] 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 033/223] 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 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 034/223] 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 035/223] 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 036/223] 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 037/223] 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 038/223] 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 f147d28c528f67635c9aafe1d03fa7f8a51cbafd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 17 Oct 2025 17:36:47 +0200 Subject: [PATCH 039/223] =?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 040/223] =?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 041/223] 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 042/223] 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 04527b00616908377547a6c1a71a88c1a2db7f76 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 21 Nov 2025 19:06:36 +0100 Subject: [PATCH 043/223] =?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 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 044/223] 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 045/223] =?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 046/223] =?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 047/223] =?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 048/223] =?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 049/223] =?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 050/223] =?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 051/223] =?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 052/223] =?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 053/223] :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 054/223] :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 055/223] :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 056/223] 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 057/223] :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 67364633f0d3f68278d1d292a83eebd5e6ffd925 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 10:56:02 +0100 Subject: [PATCH 058/223] :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 f8e8ab2b27f0619b98bca16069da99d033ea0ee0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 10:58:13 +0100 Subject: [PATCH 059/223] :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 060/223] :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 feb16122009857c4192914b1b3b9d9ca4936d4f7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 27 Nov 2025 17:18:35 +0100 Subject: [PATCH 061/223] =?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 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 062/223] 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 063/223] :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 2efda3d3fec2745ef658b79a1f280d57177e827e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 2 Dec 2025 15:41:37 +0100 Subject: [PATCH 064/223] :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 065/223] :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 066/223] :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 067/223] :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 83c4350277e61b97f0d287092020ec8896ea4139 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 2 Dec 2025 16:40:29 +0000 Subject: [PATCH 068/223] [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 069/223] 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 070/223] 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 071/223] 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 072/223] 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 073/223] 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 074/223] 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 075/223] 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 076/223] 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 077/223] 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 078/223] 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 079/223] 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 080/223] 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 081/223] 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 082/223] 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 083/223] 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 084/223] 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 085/223] 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 086/223] 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 087/223] 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 088/223] 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 089/223] 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 090/223] 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 091/223] 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 092/223] 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 093/223] 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 094/223] 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 095/223] 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 096/223] 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 097/223] 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 098/223] 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 099/223] 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 100/223] 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 101/223] 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 102/223] 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 103/223] 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 104/223] 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 105/223] 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 106/223] 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 107/223] 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 108/223] 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 109/223] 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 110/223] 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 111/223] 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 112/223] 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 113/223] 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 114/223] 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 115/223] 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 116/223] 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 117/223] 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 118/223] 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 119/223] 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 120/223] 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 121/223] 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 122/223] 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 123/223] 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 124/223] 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 125/223] 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 126/223] 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 127/223] 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 128/223] 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 129/223] 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 130/223] 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 131/223] 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 132/223] 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 133/223] 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 134/223] 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 135/223] 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 136/223] 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 137/223] 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 138/223] 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 139/223] 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 140/223] 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 141/223] 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 142/223] 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 143/223] 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 144/223] 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 145/223] 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 146/223] 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 147/223] 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 148/223] 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 149/223] 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 150/223] 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 151/223] 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 152/223] 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 153/223] 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 154/223] 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 155/223] 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 156/223] 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 157/223] 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 158/223] 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 159/223] 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 160/223] 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 161/223] 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 162/223] 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 163/223] 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 164/223] 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 165/223] 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 166/223] :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 167/223] 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 168/223] 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 169/223] 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 170/223] 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 171/223] 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 172/223] 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 173/223] 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 174/223] 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 175/223] 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 176/223] 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 177/223] 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 178/223] 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 179/223] 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 180/223] 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 181/223] 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 182/223] 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 183/223] 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 184/223] 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 185/223] 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 186/223] 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 187/223] 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 188/223] 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 189/223] 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 190/223] 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 191/223] 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 192/223] 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 193/223] 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 194/223] 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 195/223] 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 196/223] 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 197/223] 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 198/223] 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 199/223] 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 200/223] 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 201/223] 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 202/223] 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 203/223] 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 204/223] 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 205/223] 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 206/223] 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 207/223] 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 208/223] 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 209/223] 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 210/223] [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 211/223] [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 212/223] 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 213/223] 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 214/223] 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 215/223] 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 216/223] 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 217/223] 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 218/223] 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 219/223] 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 220/223] 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 221/223] 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 222/223] 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 223/223] 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