Merge branch 'develop' into bugfix/1586-yn-0299-launcher-my-tasks-view-doesnt-refresh-on-new-assignments

This commit is contained in:
Roy Nieterau 2025-12-12 22:36:43 +01:00 committed by GitHub
commit 48c5bb2ea7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 522 additions and 147 deletions

View file

@ -1,3 +1,4 @@
from __future__ import annotations
import os import os
import re import re
import logging import logging
@ -12,6 +13,8 @@ from typing import Optional
import xml.etree.ElementTree import xml.etree.ElementTree
import clique
from .execute import run_subprocess from .execute import run_subprocess
from .vendor_bin_utils import ( from .vendor_bin_utils import (
get_ffmpeg_tool_args, get_ffmpeg_tool_args,
@ -634,6 +637,37 @@ def should_convert_for_ffmpeg(src_filepath):
return False 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( def convert_input_paths_for_ffmpeg(
input_paths, input_paths,
output_dir, output_dir,
@ -659,7 +693,7 @@ def convert_input_paths_for_ffmpeg(
Raises: Raises:
ValueError: If input filepath has extension not supported by function. 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: if logger is None:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -684,7 +718,22 @@ def convert_input_paths_for_ffmpeg(
# Collect channels to export # Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) 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<index>(?P<padding>0*)\\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 # Prepare subprocess arguments
oiio_cmd = get_oiio_tool_args( oiio_cmd = get_oiio_tool_args(
"oiiotool", "oiiotool",
@ -695,8 +744,23 @@ def convert_input_paths_for_ffmpeg(
if compression: if compression:
oiio_cmd.extend(["--compression", 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([ oiio_cmd.extend([
input_arg, input_path, input_arg, input_item,
# Tell oiiotool which channels should be put to top stack # Tell oiiotool which channels should be put to top stack
# (and output) # (and output)
"--ch", channels_arg, "--ch", channels_arg,
@ -704,38 +768,11 @@ def convert_input_paths_for_ffmpeg(
"--subimage", "0" "--subimage", "0"
]) ])
for attr_name, attr_value in input_info["attribs"].items(): for attr_name in erase_attributes:
if not isinstance(attr_value, str): oiio_cmd.extend(["--eraseattrib", attr_name])
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])
# Add last argument - path to output # 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) output_path = os.path.join(output_dir, base_filename)
oiio_cmd.extend([ oiio_cmd.extend([
"-o", output_path "-o", output_path
@ -1136,7 +1173,10 @@ def oiio_color_convert(
target_display=None, target_display=None,
target_view=None, target_view=None,
additional_command_args=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. """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 input_path (str): Path that should be converted. It is expected that
contains single file or image sequence of same type contains single file or image sequence of same type
(sequence in format 'file.FRAMESTART-FRAMEEND#.ext', see oiio docs, (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. output_path (str): Path to output filename.
(must follow format of 'input_path', eg. single file or (must follow format of 'input_path', eg. single file or
sequence in 'file.FRAMESTART-FRAMEEND#.ext', `output.1-3#.tif`) 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') both 'view' and 'display' must be filled (if 'target_colorspace')
additional_command_args (list): arguments for oiiotool (like binary additional_command_args (list): arguments for oiiotool (like binary
depth for .dpx) 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. logger (logging.Logger): Logger used for logging.
Raises: Raises:
@ -1178,7 +1225,16 @@ def oiio_color_convert(
if logger is None: if logger is None:
logger = logging.getLogger(__name__) 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 # Collect channels to export
input_arg, channels_arg = get_oiio_input_and_channel_args(input_info) input_arg, channels_arg = get_oiio_input_and_channel_args(input_info)
@ -1191,6 +1247,22 @@ def oiio_color_convert(
"--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 frame_padding:
oiio_cmd.extend([
"--framepadding", str(frame_padding),
])
if parallel_frames:
oiio_cmd.append("--parallel-frames")
oiio_cmd.extend([ oiio_cmd.extend([
input_arg, input_path, input_arg, input_path,
# Tell oiiotool which channels should be put to top stack # Tell oiiotool which channels should be put to top stack
@ -1234,17 +1306,11 @@ def oiio_color_convert(
if source_view and source_display: if source_view and source_display:
color_convert_args = None color_convert_args = None
ocio_display_args = None ocio_display_args = None
oiio_cmd.extend([
"--ociodisplay:inverse=1:subimages=0",
source_display,
source_view,
])
if target_colorspace: if target_colorspace:
# This is a two-step conversion process since there's no direct # This is a two-step conversion process since there's no direct
# display/view to colorspace command # display/view to colorspace command
# This could be a config parameter or determined from OCIO config # 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) color_convert_args = ("scene_linear", target_colorspace)
elif source_display != target_display or source_view != target_view: elif source_display != target_display or source_view != target_view:
# Complete display/view pair conversion # Complete display/view pair conversion
@ -1256,6 +1322,15 @@ def oiio_color_convert(
" No color conversion needed." " 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: if color_convert_args:
# Use colorconvert for colorspace target # Use colorconvert for colorspace target
oiio_cmd.extend([ oiio_cmd.extend([

View file

@ -146,7 +146,15 @@ class BaseCreator(ABC):
project_settings (dict[str, Any]): Project settings. project_settings (dict[str, Any]): Project settings.
create_context (CreateContext): Context which initialized creator. create_context (CreateContext): Context which initialized creator.
headless (bool): Running in headless mode. 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 shown in UI
label = None label = None
group_label = None group_label = None
@ -544,11 +552,6 @@ class BaseCreator(ABC):
if host_name is None: if host_name is None:
host_name = self.create_context.host_name 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( dynamic_data = self.get_dynamic_data(
project_name, project_name,
folder_entity, folder_entity,
@ -564,16 +567,15 @@ class BaseCreator(ABC):
return get_product_name( return get_product_name(
project_name, project_name,
task_name, folder_entity=folder_entity,
task_type, task_entity=task_entity,
host_name, product_base_type=self.product_base_type,
self.product_type, product_type=self.product_type,
variant, host_name=host_name,
variant=variant,
dynamic_data=dynamic_data, dynamic_data=dynamic_data,
project_settings=self.project_settings, project_settings=self.project_settings,
project_entity=project_entity, project_entity=project_entity,
# until we make product_base_type mandatory
product_base_type=self.product_base_type
) )
def get_instance_attr_defs(self): def get_instance_attr_defs(self):
@ -648,7 +650,7 @@ class Creator(BaseCreator):
Creation requires prepared product name and instance data. Creation requires prepared product name and instance data.
""" """
skip_discovery = True
# GUI Purposes # GUI Purposes
# - default_variants may not be used if `get_default_variants` # - default_variants may not be used if `get_default_variants`
# is overridden # is overridden
@ -937,6 +939,8 @@ class Creator(BaseCreator):
class HiddenCreator(BaseCreator): class HiddenCreator(BaseCreator):
skip_discovery = True
@abstractmethod @abstractmethod
def create(self, instance_data, source_data): def create(self, instance_data, source_data):
pass pass
@ -947,6 +951,7 @@ class AutoCreator(BaseCreator):
Can be used e.g. for `workfile`. Can be used e.g. for `workfile`.
""" """
skip_discovery = True
def remove_instances(self, instances): def remove_instances(self, instances):
"""Skip removal.""" """Skip removal."""

View file

@ -1,7 +1,9 @@
"""Functions for handling product names.""" """Functions for handling product names."""
from __future__ import annotations 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 from warnings import warn
import ayon_api import ayon_api
@ -9,12 +11,17 @@ from ayon_core.lib import (
StringTemplate, StringTemplate,
filter_profiles, filter_profiles,
prepare_template_data, 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 ayon_core.settings import get_project_settings
from .constants import DEFAULT_PRODUCT_TEMPLATE from .constants import DEFAULT_PRODUCT_TEMPLATE
from .exceptions import TaskNotSetError, TemplateFillError from .exceptions import TaskNotSetError, TemplateFillError
log = Logger.get_logger(__name__)
def get_product_name_template( def get_product_name_template(
project_name: str, project_name: str,
@ -82,7 +89,7 @@ def get_product_name_template(
return template return template
def get_product_name( def _get_product_name_old(
project_name: str, project_name: str,
task_name: Optional[str], task_name: Optional[str],
task_type: Optional[str], task_type: Optional[str],
@ -94,61 +101,16 @@ def get_product_name(
project_settings: Optional[dict[str, Any]] = None, project_settings: Optional[dict[str, Any]] = None,
product_type_filter: Optional[str] = None, product_type_filter: Optional[str] = None,
project_entity: Optional[dict[str, Any]] = None, project_entity: Optional[dict[str, Any]] = None,
product_base_type: Optional[str] = None product_base_type: Optional[str] = None,
): ) -> TemplateResult:
"""Calculate product name based on passed context and AYON settings. warnings.warn(
"Used deprecated 'task_name' and 'task_type' arguments."
Subst name templates are defined in `project_settings/global/tools/creator " Please use new signature with 'folder_entity' and 'task_entity'.",
/product_name_profiles` where are profiles with host name, product type, DeprecationWarning,
task name and task type filters. If context does not match any profile stacklevel=2
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.
"""
if not product_type: if not product_type:
return "" return StringTemplate("").format({})
template = get_product_name_template( template = get_product_name_template(
project_name=project_name, project_name=project_name,
@ -160,19 +122,26 @@ def get_product_name(
project_settings=project_settings, project_settings=project_settings,
product_base_type=product_base_type, 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 template_low = template.lower()
if not task_name and "{task" in 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() raise TaskNotSetError()
task_value = { task_value = {
"name": task_name, "name": task_name,
"type": task_type, "type": task_type,
} }
if "{task}" in template.lower(): if "{task}" in template_low:
task_value = task_name 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: if project_entity is None:
project_entity = ayon_api.get_project(project_name) project_entity = ayon_api.get_project(project_name)
task_types_by_name = { task_types_by_name = {
@ -217,3 +186,305 @@ def get_product_name(
f" Available values are {fill_pairs}" f" Available values are {fill_pairs}"
) )
raise TemplateFillError(msg) from exp 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)

View file

@ -21,6 +21,13 @@ from .utils import get_representation_path_from_context
class LoaderPlugin(list): class LoaderPlugin(list):
"""Load representation into host application""" """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_types: set[str] = set()
product_base_types: Optional[set[str]] = None product_base_types: Optional[set[str]] = None
representations = set() representations = set()

View file

@ -138,7 +138,14 @@ def discover_plugins(
for item in modules: for item in modules:
filepath, module = item filepath, module = item
result.add_module(module) 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: if base_class not in ignored_classes:
ignored_classes.append(base_class) ignored_classes.append(base_class)

View file

@ -172,20 +172,33 @@ class ExtractOIIOTranscode(publish.Extractor):
additional_command_args = (output_def["oiiotool_args"] additional_command_args = (output_def["oiiotool_args"]
["additional_command_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)) self.log.debug("Files to convert: {}".format(sequence_files))
missing_rgba_review_channels = False missing_rgba_review_channels = False
for file_name in sequence_files: for file_name in sequence_files:
if isinstance(file_name, clique.Collection): if isinstance(file_name, clique.Collection):
# Convert to filepath that can be directly converted # Support sequences with holes by supplying
# by oiio like `frame.1001-1025%04d.exr` # dedicated `--frames` argument to `oiiotool`
file_name: str = file_name.format( # Create `frames` string like "1001-1002,1004,1010-1012
"{head}{range}{padding}{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
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)) self.log.debug("Transcoding file: `{}`".format(file_name))
input_path = os.path.join(original_staging_dir, input_path = os.path.join(original_staging_dir, file_name)
file_name)
output_path = self._get_output_file_path(input_path, output_path = self._get_output_file_path(input_path,
new_staging_dir, new_staging_dir,
output_extension) output_extension)
@ -201,6 +214,9 @@ class ExtractOIIOTranscode(publish.Extractor):
source_display=source_display, source_display=source_display,
source_view=source_view, source_view=source_view,
additional_command_args=additional_command_args, additional_command_args=additional_command_args,
frames=frames,
frame_padding=frame_padding,
parallel_frames=parallel_frames,
logger=self.log logger=self.log
) )
except MissingRGBAChannelsError as exc: except MissingRGBAChannelsError as exc:
@ -294,16 +310,18 @@ class ExtractOIIOTranscode(publish.Extractor):
new_repre["files"] = renamed_files new_repre["files"] = renamed_files
def _translate_to_sequence(self, files_to_convert): 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. Uses clique to find frame sequence, and return the collections instead.
If sequence not found, it returns original list. If sequence not detected in input filenames, it returns original list.
Args: Args:
files_to_convert (list): list of file names files_to_convert (list[str]): list of file names
Returns: Returns:
list[str | clique.Collection]: List of filepaths or a list list[str | clique.Collection]: List of
of Collections (usually one, unless there are holes) filepaths ['fileA.exr', 'fileB.exr']
or clique.Collection for a sequence.
""" """
pattern = [clique.PATTERNS["frames"]] pattern = [clique.PATTERNS["frames"]]
collections, _ = clique.assemble( collections, _ = clique.assemble(
@ -314,14 +332,7 @@ class ExtractOIIOTranscode(publish.Extractor):
raise ValueError( raise ValueError(
"Too many collections {}".format(collections)) "Too many collections {}".format(collections))
collection = collections[0] return collections
# TODO: Technically oiiotool supports holes in the sequence as well
# using the dedicated --frames argument to specify the frames.
# We may want to use that too so conversions of sequences with
# holes will perform faster as well.
# Separate the collection so that we have no holes/gaps per
# collection.
return collection.separate()
return files_to_convert return files_to_convert

View file

@ -924,8 +924,12 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# Include optional data if present in # Include optional data if present in
optionals = [ optionals = [
"frameStart", "frameEnd", "step", "frameStart", "frameEnd",
"handleEnd", "handleStart", "sourceHashes" "handleEnd", "handleStart",
"step",
"resolutionWidth", "resolutionHeight",
"pixelAspect",
"sourceHashes"
] ]
for key in optionals: for key in optionals:
if key in instance.data: if key in instance.data:

View file

@ -310,9 +310,6 @@ class CreateWidget(QtWidgets.QWidget):
folder_path = None folder_path = None
if self._context_change_is_enabled(): if self._context_change_is_enabled():
folder_path = self._context_widget.get_selected_folder_path() 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 return folder_path or None
def _get_folder_id(self): def _get_folder_id(self):
@ -328,9 +325,6 @@ class CreateWidget(QtWidgets.QWidget):
folder_path = self._context_widget.get_selected_folder_path() folder_path = self._context_widget.get_selected_folder_path()
if folder_path: if folder_path:
task_name = self._context_widget.get_selected_task_name() task_name = self._context_widget.get_selected_task_name()
if not task_name:
task_name = self.get_current_task_name()
return task_name return task_name
def _set_context_enabled(self, enabled): def _set_context_enabled(self, enabled):

View file

@ -12,6 +12,7 @@ ayon_server_version = ">=1.8.4,<2.0.0"
ayon_launcher_version = ">=1.0.2" ayon_launcher_version = ">=1.0.2"
ayon_required_addons = {} ayon_required_addons = {}
ayon_compatible_addons = { ayon_compatible_addons = {
"ayon_third_party": ">=1.3.0",
"ayon_ocio": ">=1.2.1", "ayon_ocio": ">=1.2.1",
"applications": ">=1.1.2", "applications": ">=1.1.2",
"harmony": ">0.4.0", "harmony": ">0.4.0",