diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index b4a3e77f5a..061a7bd398 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 @@ -12,6 +13,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, @@ -634,6 +637,37 @@ def should_convert_for_ffmpeg(src_filepath): return False +def _get_attributes_to_erase( + input_info: dict, logger: logging.Logger +) -> list[str]: + """FFMPEG does not support some attributes in metadata.""" + 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 + + # Remove attributes that have string value longer than allowed length + # 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_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_attrs[attr_name] = reason + break + + 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()) + + def convert_input_paths_for_ffmpeg( input_paths, output_dir, @@ -659,7 +693,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__) @@ -684,7 +718,22 @@ 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 + ) + + # 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=[pattern], + assume_padded_when_ambiguous=True, + ) + input_items = list(input_collections) + input_items.extend(input_remainder) + for input_item in input_items: # Prepare subprocess arguments oiio_cmd = get_oiio_tool_args( "oiiotool", @@ -695,8 +744,23 @@ 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_item, clique.Collection): + frames = input_item.format("{head}#{tail}").replace(" ", "") + oiio_cmd.extend([ + "--framepadding", input_item.padding, + "--frames", frames, + "--parallel-frames" + ]) + 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_item}" + ) + oiio_cmd.extend([ - input_arg, input_path, + input_arg, input_item, # Tell oiiotool which channels should be put to top stack # (and output) "--ch", channels_arg, @@ -704,38 +768,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_item) output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path @@ -1136,7 +1173,10 @@ def oiio_color_convert( target_display=None, target_view=None, additional_command_args=None, - 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. @@ -1148,7 +1188,7 @@ def oiio_color_convert( 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.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`) @@ -1169,6 +1209,13 @@ def oiio_color_convert( 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 + `#` or `%d`, e.g. file.#.exr + 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: @@ -1178,7 +1225,16 @@ def oiio_color_convert( 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: + 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"]: + 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) @@ -1191,6 +1247,22 @@ def oiio_color_convert( "--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 frame_padding: + oiio_cmd.extend([ + "--framepadding", str(frame_padding), + ]) + + if parallel_frames: + oiio_cmd.append("--parallel-frames") + oiio_cmd.extend([ input_arg, input_path, # Tell oiiotool which channels should be put to top stack @@ -1234,17 +1306,11 @@ 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 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 @@ -1256,6 +1322,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([ diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index a034451d83..7b168984ef 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -146,7 +146,15 @@ 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 label = None group_label = None @@ -544,11 +552,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, @@ -564,16 +567,15 @@ 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, + product_base_type=self.product_base_type, + product_type=self.product_type, + host_name=host_name, + variant=variant, dynamic_data=dynamic_data, project_settings=self.project_settings, project_entity=project_entity, - # until we make product_base_type mandatory - product_base_type=self.product_base_type ) def get_instance_attr_defs(self): @@ -648,7 +650,7 @@ class Creator(BaseCreator): Creation requires prepared product name and instance data. """ - + skip_discovery = True # GUI Purposes # - default_variants may not be used if `get_default_variants` # is overridden @@ -937,6 +939,8 @@ class Creator(BaseCreator): class HiddenCreator(BaseCreator): + skip_discovery = True + @abstractmethod def create(self, instance_data, source_data): pass @@ -947,6 +951,7 @@ class AutoCreator(BaseCreator): Can be used e.g. for `workfile`. """ + skip_discovery = True def remove_instances(self, instances): """Skip removal.""" diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index c4c27edc3b..d32de54774 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,7 +1,9 @@ """Functions for handling product names.""" from __future__ import annotations -from typing import Any, Optional, Union +import warnings +from functools import wraps +from typing import Any, Optional, Union, overload from warnings import warn import ayon_api @@ -9,12 +11,17 @@ from ayon_core.lib import ( StringTemplate, 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 from .exceptions import TaskNotSetError, TemplateFillError +log = Logger.get_logger(__name__) + def get_product_name_template( project_name: str, @@ -82,7 +89,7 @@ def get_product_name_template( return template -def get_product_name( +def _get_product_name_old( project_name: str, task_name: Optional[str], task_type: Optional[str], @@ -94,61 +101,16 @@ def get_product_name( 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. - - 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. - - 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 (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. - product_base_type (Optional[str]): Base type of product. - This will be mandatory in future versions. - - 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. - - """ + product_base_type: Optional[str] = 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=project_name, @@ -160,19 +122,26 @@ def get_product_name( 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 - if not task_name and "{task" in template.lower(): + + 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.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 = { @@ -217,3 +186,305 @@ def get_product_name( f" Available values are {fill_pairs}" ) raise TemplateFillError(msg) from exp + + +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. + # >>> geattr(get_product_name, "use_entities", False) + setattr(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 7 positional arguments is not allowed + # in the new function + if len(args) > 7: + 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 + + +@overload +def get_product_name( + project_name: str, + folder_entity: dict[str, Any], + task_entity: Optional[dict[str, Any]], + product_base_type: str, + product_type: str, + host_name: str, + variant: str, + *, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = 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. + + 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. + + 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_base_type (str): Product base type. + product_type (str): Product type. + variant (str): In most of the cases it is user input during creation. + 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. + 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. + + 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 deprecated 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]], + product_base_type: str, + product_type: str, + host_name: str, + variant: str, + *, + dynamic_data: Optional[dict[str, Any]] = None, + project_settings: Optional[dict[str, Any]] = 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. + + 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. + + 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_base_type (str): Product base type. + product_type (str): Product type. + variant (str): In most of the cases it is user input during creation. + 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. + 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. + + 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({}) + + 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=project_name, + product_base_type=product_base_type_filter or product_base_type, + product_type=product_type, + task_name=task_name, + task_type=task_type, + host_name=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, + "basetype": product_base_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(): + fill_pairs[key] = value + + try: + return StringTemplate.format_strict_template( + template=template, + data=prepare_template_data(fill_pairs) + ) + except KeyError as exp: + msg = ( + f"Value for {exp} key is missing in template '{template}'." + f" Available values are {fill_pairs}" + ) + raise TemplateFillError(msg) 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() diff --git a/client/ayon_core/pipeline/plugin_discover.py b/client/ayon_core/pipeline/plugin_discover.py index dddd6847ec..fd907eb22c 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 'skip_discovery = True' + skip_discovery = cls.__dict__.get("skip_discovery") + if skip_discovery is True: + continue + all_plugins.append(cls) if base_class not in ignored_classes: ignored_classes.append(base_class) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index b293bd29c3..63a73e07fa 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -172,20 +172,33 @@ class ExtractOIIOTranscode(publish.Extractor): additional_command_args = (output_def["oiiotool_args"] ["additional_command_args"]) - sequence_files = self._translate_to_sequence(files_to_convert) + sequence_files = self._translate_to_sequence( + files_to_convert) self.log.debug("Files to convert: {}".format(sequence_files)) missing_rgba_review_channels = False for file_name in sequence_files: if isinstance(file_name, clique.Collection): - # Convert to filepath that can be directly converted - # by oiio like `frame.1001-1025%04d.exr` - file_name: str = file_name.format( - "{head}{range}{padding}{tail}" + # Support sequences with holes by supplying + # dedicated `--frames` argument to `oiiotool` + # Create `frames` string like "1001-1002,1004,1010-1012 + # 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 + frames = None + frame_padding = None + parallel_frames = False + else: + 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) + input_path = os.path.join(original_staging_dir, file_name) output_path = self._get_output_file_path(input_path, new_staging_dir, output_extension) @@ -201,6 +214,9 @@ class ExtractOIIOTranscode(publish.Extractor): source_display=source_display, source_view=source_view, additional_command_args=additional_command_args, + frames=frames, + frame_padding=frame_padding, + parallel_frames=parallel_frames, logger=self.log ) except MissingRGBAChannelsError as exc: @@ -294,16 +310,18 @@ class ExtractOIIOTranscode(publish.Extractor): new_repre["files"] = renamed_files def _translate_to_sequence(self, files_to_convert): - """Returns original list or a clique.Collection of a sequence. + """Returns original individual filepaths or list of clique.Collection. - Uses clique to find frame sequence Collection. - If sequence not found, it returns original list. + Uses clique to find frame sequence, and return the collections instead. + 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[str | clique.Collection]: List of filepaths or a list - of Collections (usually one, unless there are holes) + list[str | clique.Collection]: List of + filepaths ['fileA.exr', 'fileB.exr'] + or clique.Collection for a sequence. + """ pattern = [clique.PATTERNS["frames"]] collections, _ = clique.assemble( @@ -314,14 +332,7 @@ class ExtractOIIOTranscode(publish.Extractor): raise ValueError( "Too many collections {}".format(collections)) - collection = collections[0] - # TODO: Technically oiiotool supports holes in the sequence as well - # using the dedicated --frames argument to specify the frames. - # We may want to use that too so conversions of sequences with - # holes will perform faster as well. - # Separate the collection so that we have no holes/gaps per - # collection. - return collection.separate() + return collections return files_to_convert diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 9f24b35754..5afdcbdf07 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", + "step", + "resolutionWidth", "resolutionHeight", + "pixelAspect", + "sourceHashes" ] for key in optionals: if key in instance.data: diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index d98bc95eb2..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): @@ -328,9 +325,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): 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",