diff --git a/client/ayon_core/hooks/pre_add_last_workfile_arg.py b/client/ayon_core/hooks/pre_add_last_workfile_arg.py index 74964e0df9..d5914c2352 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -28,7 +28,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "substancepainter", "aftereffects", "wrap", - "openrv" + "openrv", + "cinema4d" } launch_types = {LaunchTypes.local} diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 894b012d59..4877a45118 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -4,7 +4,9 @@ import collections import uuid import json import copy +import warnings from abc import ABCMeta, abstractmethod +from typing import Any, Optional import clique @@ -90,6 +92,30 @@ class AbstractAttrDefMeta(ABCMeta): return obj +def _convert_reversed_attr( + main_value, depr_value, main_label, depr_label, default +): + if main_value is not None and depr_value is not None: + if main_value == depr_value: + print( + f"Got invalid '{main_label}' and '{depr_label}' arguments." + f" Using '{main_label}' value." + ) + elif depr_value is not None: + warnings.warn( + ( + "DEPRECATION WARNING: Using deprecated argument" + f" '{depr_label}' please use '{main_label}' instead." + ), + DeprecationWarning, + stacklevel=4, + ) + main_value = not depr_value + elif main_value is None: + main_value = default + return main_value + + class AbstractAttrDef(metaclass=AbstractAttrDefMeta): """Abstraction of attribute definition. @@ -106,12 +132,14 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): Args: key (str): Under which key will be attribute value stored. default (Any): Default value of an attribute. - label (str): Attribute label. - tooltip (str): Attribute tooltip. - is_label_horizontal (bool): UI specific argument. Specify if label is - next to value input or ahead. - hidden (bool): Will be item hidden (for UI purposes). - disabled (bool): Item will be visible but disabled (for UI purposes). + label (Optional[str]): Attribute label. + tooltip (Optional[str]): Attribute tooltip. + is_label_horizontal (Optional[bool]): UI specific argument. Specify + if label is next to value input or ahead. + visible (Optional[bool]): Item is shown to user (for UI purposes). + enabled (Optional[bool]): Item is enabled (for UI purposes). + hidden (Optional[bool]): DEPRECATED: Use 'visible' instead. + disabled (Optional[bool]): DEPRECATED: Use 'enabled' instead. """ type_attributes = [] @@ -120,51 +148,105 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): def __init__( self, - key, - default, - label=None, - tooltip=None, - is_label_horizontal=None, - hidden=False, - disabled=False + key: str, + default: Any, + label: Optional[str] = None, + tooltip: Optional[str] = None, + is_label_horizontal: Optional[bool] = None, + visible: Optional[bool] = None, + enabled: Optional[bool] = None, + hidden: Optional[bool] = None, + disabled: Optional[bool] = None, ): if is_label_horizontal is None: is_label_horizontal = True - if hidden is None: - hidden = False + enabled = _convert_reversed_attr( + enabled, disabled, "enabled", "disabled", True + ) + visible = _convert_reversed_attr( + visible, hidden, "visible", "hidden", True + ) - self.key = key - self.label = label - self.tooltip = tooltip - self.default = default - self.is_label_horizontal = is_label_horizontal - self.hidden = hidden - self.disabled = disabled - self._id = uuid.uuid4().hex + self.key: str = key + self.label: Optional[str] = label + self.tooltip: Optional[str] = tooltip + self.default: Any = default + self.is_label_horizontal: bool = is_label_horizontal + self.visible: bool = visible + self.enabled: bool = enabled + self._id: str = uuid.uuid4().hex self.__init__class__ = AbstractAttrDef @property - def id(self): + def id(self) -> str: return self._id - def __eq__(self, other): - if not isinstance(other, self.__class__): + def clone(self): + data = self.serialize() + data.pop("type") + return self.deserialize(data) + + @property + def hidden(self) -> bool: + return not self.visible + + @hidden.setter + def hidden(self, value: bool): + self.visible = not value + + @property + def disabled(self) -> bool: + return not self.enabled + + @disabled.setter + def disabled(self, value: bool): + self.enabled = not value + + def __eq__(self, other: Any) -> bool: + return self.compare_to_def(other) + + def __ne__(self, other: Any) -> bool: + return not self.compare_to_def(other) + + def compare_to_def( + self, + other: Any, + ignore_default: Optional[bool] = False, + ignore_enabled: Optional[bool] = False, + ignore_visible: Optional[bool] = False, + ignore_def_type_compare: Optional[bool] = False, + ) -> bool: + if not isinstance(other, self.__class__) or self.key != other.key: + return False + if not ignore_def_type_compare and not self._def_type_compare(other): return False return ( - self.key == other.key - and self.hidden == other.hidden - and self.default == other.default - and self.disabled == other.disabled + (ignore_default or self.default == other.default) + and (ignore_visible or self.visible == other.visible) + and (ignore_enabled or self.enabled == other.enabled) ) - def __ne__(self, other): - return not self.__eq__(other) + @abstractmethod + def is_value_valid(self, value: Any) -> bool: + """Check if value is valid. + + This should return False if value is not valid based + on definition type. + + Args: + value (Any): Value to validate based on definition type. + + Returns: + bool: True if value is valid. + + """ + pass @property @abstractmethod - def type(self): + def type(self) -> str: """Attribute definition type also used as identifier of class. Returns: @@ -198,8 +280,8 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): "tooltip": self.tooltip, "default": self.default, "is_label_horizontal": self.is_label_horizontal, - "hidden": self.hidden, - "disabled": self.disabled + "visible": self.visible, + "enabled": self.enabled } for attr in self.type_attributes: data[attr] = getattr(self, attr) @@ -211,9 +293,15 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): Data can be received using 'serialize' method. """ + if "type" in data: + data = dict(data) + data.pop("type") return cls(**data) + def _def_type_compare(self, other: "AbstractAttrDef") -> bool: + return True + # ----------------------------------------- # UI attribute definitions won't hold value @@ -223,7 +311,10 @@ class UIDef(AbstractAttrDef): is_value_def = False def __init__(self, key=None, default=None, *args, **kwargs): - super(UIDef, self).__init__(key, default, *args, **kwargs) + super().__init__(key, default, *args, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return True def convert_value(self, value): return value @@ -237,11 +328,9 @@ class UILabelDef(UIDef): type = "label" def __init__(self, label, key=None): - super(UILabelDef, self).__init__(label=label, key=key) + super().__init__(label=label, key=key) - def __eq__(self, other): - if not super(UILabelDef, self).__eq__(other): - return False + def _def_type_compare(self, other: "UILabelDef") -> bool: return self.label == other.label @@ -260,7 +349,10 @@ class UnknownDef(AbstractAttrDef): def __init__(self, key, default=None, **kwargs): kwargs["default"] = default - super(UnknownDef, self).__init__(key, **kwargs) + super().__init__(key, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return True def convert_value(self, value): return value @@ -279,8 +371,11 @@ class HiddenDef(AbstractAttrDef): def __init__(self, key, default=None, **kwargs): kwargs["default"] = default - kwargs["hidden"] = True - super(HiddenDef, self).__init__(key, **kwargs) + kwargs["visible"] = False + super().__init__(key, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return True def convert_value(self, value): return value @@ -331,21 +426,21 @@ class NumberDef(AbstractAttrDef): elif default > maximum: default = maximum - super(NumberDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) self.minimum = minimum self.maximum = maximum self.decimals = 0 if decimals is None else decimals - def __eq__(self, other): - if not super(NumberDef, self).__eq__(other): + def is_value_valid(self, value: Any) -> bool: + if self.decimals == 0: + if not isinstance(value, int): + return False + elif not isinstance(value, float): return False - - return ( - self.decimals == other.decimals - and self.maximum == other.maximum - and self.maximum == other.maximum - ) + if self.minimum > value > self.maximum: + return False + return True def convert_value(self, value): if isinstance(value, str): @@ -361,6 +456,13 @@ class NumberDef(AbstractAttrDef): return int(value) return round(float(value), self.decimals) + def _def_type_compare(self, other: "NumberDef") -> bool: + return ( + self.decimals == other.decimals + and self.maximum == other.maximum + and self.maximum == other.maximum + ) + class TextDef(AbstractAttrDef): """Text definition. @@ -390,7 +492,7 @@ class TextDef(AbstractAttrDef): if default is None: default = "" - super(TextDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) if multiline is None: multiline = False @@ -407,14 +509,12 @@ class TextDef(AbstractAttrDef): self.placeholder = placeholder self.regex = regex - def __eq__(self, other): - if not super(TextDef, self).__eq__(other): + def is_value_valid(self, value: Any) -> bool: + if not isinstance(value, str): return False - - return ( - self.multiline == other.multiline - and self.regex == other.regex - ) + if self.regex and not self.regex.match(value): + return False + return True def convert_value(self, value): if isinstance(value, str): @@ -422,10 +522,18 @@ class TextDef(AbstractAttrDef): return self.default def serialize(self): - data = super(TextDef, self).serialize() + data = super().serialize() data["regex"] = self.regex.pattern + data["multiline"] = self.multiline + data["placeholder"] = self.placeholder return data + def _def_type_compare(self, other: "TextDef") -> bool: + return ( + self.multiline == other.multiline + and self.regex == other.regex + ) + class EnumDef(AbstractAttrDef): """Enumeration of items. @@ -464,21 +572,12 @@ class EnumDef(AbstractAttrDef): elif default not in item_values: default = next(iter(item_values), None) - super(EnumDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) self.items = items self._item_values = item_values_set self.multiselection = multiselection - def __eq__(self, other): - if not super(EnumDef, self).__eq__(other): - return False - - return ( - self.items == other.items - and self.multiselection == other.multiselection - ) - def convert_value(self, value): if not self.multiselection: if value in self._item_values: @@ -489,8 +588,19 @@ class EnumDef(AbstractAttrDef): return copy.deepcopy(self.default) return list(self._item_values.intersection(value)) + def is_value_valid(self, value: Any) -> bool: + """Check if item is available in possible values.""" + if isinstance(value, list): + if not self.multiselection: + return False + return all(value in self._item_values for value in value) + + if self.multiselection: + return False + return value in self._item_values + def serialize(self): - data = super(EnumDef, self).serialize() + data = super().serialize() data["items"] = copy.deepcopy(self.items) data["multiselection"] = self.multiselection return data @@ -557,6 +667,12 @@ class EnumDef(AbstractAttrDef): return output + def _def_type_compare(self, other: "EnumDef") -> bool: + return ( + self.items == other.items + and self.multiselection == other.multiselection + ) + class BoolDef(AbstractAttrDef): """Boolean representation. @@ -570,7 +686,10 @@ class BoolDef(AbstractAttrDef): def __init__(self, key, default=None, **kwargs): if default is None: default = False - super(BoolDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return isinstance(value, bool) def convert_value(self, value): if isinstance(value, bool): @@ -868,10 +987,10 @@ class FileDef(AbstractAttrDef): self.extensions = set(extensions) self.allow_sequences = allow_sequences self.extensions_label = extensions_label - super(FileDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) def __eq__(self, other): - if not super(FileDef, self).__eq__(other): + if not super().__eq__(other): return False return ( @@ -881,6 +1000,29 @@ class FileDef(AbstractAttrDef): and self.allow_sequences == other.allow_sequences ) + def is_value_valid(self, value: Any) -> bool: + if self.single_item: + if not isinstance(value, dict): + return False + try: + FileDefItem.from_dict(value) + return True + except (ValueError, KeyError): + return False + + if not isinstance(value, list): + return False + + for item in value: + if not isinstance(item, dict): + return False + + try: + FileDefItem.from_dict(item) + except (ValueError, KeyError): + return False + return True + def convert_value(self, value): if isinstance(value, (str, dict)): value = [value] diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 8594d82848..bcc9a87c49 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -148,6 +148,9 @@ class AttributeValues: for key in self._attr_defs_by_key.keys(): yield key, self._data.get(key) + def get_attr_def(self, key, default=None): + return self._attr_defs_by_key.get(key, default) + def update(self, value): changes = {} for _key, _value in dict(value).items(): diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 029775e1db..2a2adf984a 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -3,11 +3,20 @@ import os import copy import shutil import glob -import clique import collections +from typing import Dict, Any, Iterable + +import clique +import ayon_api from ayon_core.lib import create_hard_link +from .template_data import ( + get_general_template_data, + get_folder_template_data, + get_task_template_data, +) + def _copy_file(src_path, dst_path): """Hardlink file if possible(to save space), copy if not. @@ -327,3 +336,71 @@ def deliver_sequence( uploaded += 1 return report_items, uploaded + + +def _merge_data(data, new_data): + queue = collections.deque() + queue.append((data, new_data)) + while queue: + q_data, q_new_data = queue.popleft() + for key, value in q_new_data.items(): + if key in q_data and isinstance(value, dict): + queue.append((q_data[key], value)) + continue + q_data[key] = value + + +def get_representations_delivery_template_data( + project_name: str, + representation_ids: Iterable[str], +) -> Dict[str, Dict[str, Any]]: + representation_ids = set(representation_ids) + + output = { + repre_id: {} + for repre_id in representation_ids + } + if not representation_ids: + return output + + project_entity = ayon_api.get_project(project_name) + + general_template_data = get_general_template_data() + + repres_hierarchy = ayon_api.get_representations_hierarchy( + project_name, + representation_ids, + project_fields=set(), + folder_fields={"path", "folderType"}, + task_fields={"name", "taskType"}, + product_fields={"name", "productType"}, + version_fields={"version", "productId"}, + representation_fields=None, + ) + for repre_id, repre_hierarchy in repres_hierarchy.items(): + repre_entity = repre_hierarchy.representation + if repre_entity is None: + continue + + template_data = repre_entity["context"] + template_data.update(copy.deepcopy(general_template_data)) + template_data.update(get_folder_template_data( + repre_hierarchy.folder, project_name + )) + if repre_hierarchy.task: + template_data.update(get_task_template_data( + project_entity, repre_hierarchy.task + )) + + product_entity = repre_hierarchy.product + version_entity = repre_hierarchy.version + template_data.update({ + "product": { + "name": product_entity["name"], + "type": product_entity["productType"], + }, + "version": version_entity["version"], + }) + _merge_data(template_data, repre_entity["context"]) + output[repre_id] = template_data + return output diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index f382f91fec..a49a981d2a 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -292,13 +292,26 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # Note that 24fps is slower than 25fps hence extended duration # to preserve media range - # Compute new source range based on available rate - conformed_src_in = source_range.start_time.rescaled_to(available_range_rate) - conformed_src_duration = source_range.duration.rescaled_to(available_range_rate) - conformed_source_range = otio.opentime.TimeRange( - start_time=conformed_src_in, - duration=conformed_src_duration - ) + # Compute new source range based on available rate. + + # Backward-compatibility for Hiero OTIO exporter. + # NTSC compatibility might introduce floating rates, when these are + # not exactly the same (23.976 vs 23.976024627685547) + # this will cause precision issue in computation. + # Currently round to 2 decimals for comparison, + # but this should always rescale after that. + rounded_av_rate = round(available_range_rate, 2) + rounded_src_rate = round(source_range.start_time.rate, 2) + if rounded_av_rate != rounded_src_rate: + conformed_src_in = source_range.start_time.rescaled_to(available_range_rate) + conformed_src_duration = source_range.duration.rescaled_to(available_range_rate) + conformed_source_range = otio.opentime.TimeRange( + start_time=conformed_src_in, + duration=conformed_src_duration + ) + + else: + conformed_source_range = source_range # modifiers time_scalar = 1. diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index af90903bd8..98951b2766 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -788,15 +788,15 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, colorspace = product.colorspace break - if isinstance(files, (list, tuple)): - files = [os.path.basename(f) for f in files] + if isinstance(collected_files, (list, tuple)): + collected_files = [os.path.basename(f) for f in collected_files] else: - files = os.path.basename(files) + collected_files = os.path.basename(collected_files) rep = { "name": ext, "ext": ext, - "files": files, + "files": collected_files, "frameStart": int(skeleton["frameStartHandle"]), "frameEnd": int(skeleton["frameEndHandle"]), # If expectedFile are absolute, we need only filenames diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 2475800cbb..1fb906fd65 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -242,6 +242,26 @@ class LoaderPlugin(list): if hasattr(self, "_fname"): return self._fname + @classmethod + def get_representation_name_aliases(cls, representation_name: str): + """Return representation names to which switching is allowed from + the input representation name, like an alias replacement of the input + `representation_name`. + + For example, to allow an automated switch on update from representation + `ma` to `mb` or `abc`, then when `representation_name` is `ma` return: + ["mb", "abc"] + + The order of the names in the returned representation names is + important, because the first one existing under the new version will + be chosen. + + Returns: + List[str]: Representation names switching to is allowed on update + if the input representation name is not found on the new version. + """ + return [] + class ProductLoaderPlugin(LoaderPlugin): """Load product into host application diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 9ba407193e..ee2c1af07f 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -505,21 +505,6 @@ def update_container(container, version=-1): project_name, product_entity["folderId"] ) - repre_name = current_representation["name"] - new_representation = ayon_api.get_representation_by_name( - project_name, repre_name, new_version["id"] - ) - if new_representation is None: - raise ValueError( - "Representation '{}' wasn't found on requested version".format( - repre_name - ) - ) - - path = get_representation_path(new_representation) - if not path or not os.path.exists(path): - raise ValueError("Path {} doesn't exist".format(path)) - # Run update on the Loader for this container Loader = _get_container_loader(container) if not Loader: @@ -527,6 +512,39 @@ def update_container(container, version=-1): "Can't update container because loader '{}' was not found." .format(container.get("loader")) ) + + repre_name = current_representation["name"] + new_representation = ayon_api.get_representation_by_name( + project_name, repre_name, new_version["id"] + ) + if new_representation is None: + # The representation name is not found in the new version. + # Allow updating to a 'matching' representation if the loader + # has defined compatible update conversions + repre_name_aliases = Loader.get_representation_name_aliases(repre_name) + if repre_name_aliases: + representations = ayon_api.get_representations( + project_name, + representation_names=repre_name_aliases, + version_ids=[new_version["id"]]) + representations_by_name = { + repre["name"]: repre for repre in representations + } + for name in repre_name_aliases: + if name in representations_by_name: + new_representation = representations_by_name[name] + break + + if new_representation is None: + raise ValueError( + "Representation '{}' wasn't found on requested version".format( + repre_name + ) + ) + + path = get_representation_path(new_representation) + if not path or not os.path.exists(path): + raise ValueError("Path {} doesn't exist".format(path)) project_entity = ayon_api.get_project(project_name) context = { "project": project_entity, diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 3c2bafdba3..d2c70894cc 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -1,10 +1,19 @@ import inspect from abc import ABCMeta +import typing +from typing import Optional + import pyblish.api import pyblish.logic from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin + from ayon_core.lib import BoolDef +from ayon_core.pipeline.colorspace import ( + get_colorspace_settings_from_publish_context, + set_colorspace_data_to_representation +) + from .lib import ( load_help_content_from_plugin, get_errored_instances_from_context, @@ -12,10 +21,8 @@ from .lib import ( get_instance_staging_dir, ) -from ayon_core.pipeline.colorspace import ( - get_colorspace_settings_from_publish_context, - set_colorspace_data_to_representation -) +if typing.TYPE_CHECKING: + from ayon_core.pipeline.create import CreateContext, CreatedInstance class AbstractMetaInstancePlugin(ABCMeta, MetaPlugin): @@ -127,7 +134,9 @@ class AYONPyblishPluginMixin: # callback(self) @classmethod - def register_create_context_callbacks(cls, create_context): + def register_create_context_callbacks( + cls, create_context: "CreateContext" + ): """Register callbacks for create context. It is possible to register callbacks listening to changes happened @@ -160,7 +169,7 @@ class AYONPyblishPluginMixin: return [] @classmethod - def get_attr_defs_for_context (cls, create_context): + def get_attr_defs_for_context(cls, create_context: "CreateContext"): """Publish attribute definitions for context. Attributes available for all families in plugin's `families` attribute. @@ -177,7 +186,9 @@ class AYONPyblishPluginMixin: return cls.get_attribute_defs() @classmethod - def instance_matches_plugin_families(cls, instance): + def instance_matches_plugin_families( + cls, instance: Optional["CreatedInstance"] + ): """Check if instance matches families. Args: @@ -201,7 +212,9 @@ class AYONPyblishPluginMixin: return False @classmethod - def get_attr_defs_for_instance(cls, create_context, instance): + def get_attr_defs_for_instance( + cls, create_context: "CreateContext", instance: "CreatedInstance" + ): """Publish attribute definitions for an instance. Attributes available for all families in plugin's `families` attribute. @@ -220,7 +233,9 @@ class AYONPyblishPluginMixin: return cls.get_attribute_defs() @classmethod - def convert_attribute_values(cls, create_context, instance): + def convert_attribute_values( + cls, create_context: "CreateContext", instance: "CreatedInstance" + ): """Convert attribute values for instance. Args: diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 5c53d170eb..406040d936 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -1,23 +1,22 @@ -import copy import platform from collections import defaultdict import ayon_api from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.pipeline import load, Anatomy from ayon_core import resources, style - from ayon_core.lib import ( format_file_size, collect_frames, get_datetime_data, ) +from ayon_core.pipeline import load, Anatomy from ayon_core.pipeline.load import get_representation_path_with_anatomy from ayon_core.pipeline.delivery import ( get_format_dict, check_destination_path, - deliver_single_file + deliver_single_file, + get_representations_delivery_template_data, ) @@ -200,20 +199,31 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): format_dict = get_format_dict(self.anatomy, self.root_line_edit.text()) renumber_frame = self.renumber_frame.isChecked() frame_offset = self.first_frame_start.value() + filtered_repres = [] + repre_ids = set() for repre in self._representations: - if repre["name"] not in selected_repres: - continue + if repre["name"] in selected_repres: + filtered_repres.append(repre) + repre_ids.add(repre["id"]) + template_data_by_repre_id = ( + get_representations_delivery_template_data( + self.anatomy.project_name, repre_ids + ) + ) + for repre in filtered_repres: repre_path = get_representation_path_with_anatomy( repre, self.anatomy ) - anatomy_data = copy.deepcopy(repre["context"]) - new_report_items = check_destination_path(repre["id"], - self.anatomy, - anatomy_data, - datetime_data, - template_name) + template_data = template_data_by_repre_id[repre["id"]] + new_report_items = check_destination_path( + repre["id"], + self.anatomy, + template_data, + datetime_data, + template_name + ) report_items.update(new_report_items) if new_report_items: @@ -224,7 +234,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): repre, self.anatomy, template_name, - anatomy_data, + template_data, format_dict, report_items, self.log @@ -267,9 +277,9 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): if frame is not None: if repre["context"].get("frame"): - anatomy_data["frame"] = frame + template_data["frame"] = frame elif repre["context"].get("udim"): - anatomy_data["udim"] = frame + template_data["udim"] = frame else: # Fallback self.log.warning( @@ -277,7 +287,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): " data. Supplying sequence frame to '{frame}'" " formatting data." ) - anatomy_data["frame"] = frame + template_data["frame"] = frame new_report_items, uploaded = deliver_single_file(*args) report_items.update(new_report_items) self._update_progress(uploaded) @@ -342,8 +352,8 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): def _get_selected_repres(self): """Returns list of representation names filtered from checkboxes.""" selected_repres = [] - for repre_name, chckbox in self._representation_checkboxes.items(): - if chckbox.isChecked(): + for repre_name, checkbox in self._representation_checkboxes.items(): + if checkbox.isChecked(): selected_repres.append(repre_name) return selected_repres diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index 4ffabf6028..37bbac8898 100644 --- a/client/ayon_core/plugins/publish/extract_thumbnail.py +++ b/client/ayon_core/plugins/publish/extract_thumbnail.py @@ -36,7 +36,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): "traypublisher", "substancepainter", "nuke", - "aftereffects" + "aftereffects", + "unreal" ] enabled = False diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 5ead3f46a6..026aea00ad 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -28,10 +28,10 @@ from .files_widget import FilesWidget def create_widget_for_attr_def(attr_def, parent=None): widget = _create_widget_for_attr_def(attr_def, parent) - if attr_def.hidden: + if not attr_def.visible: widget.setVisible(False) - if attr_def.disabled: + if not attr_def.enabled: widget.setEnabled(False) return widget @@ -135,7 +135,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): widget = create_widget_for_attr_def(attr_def, self) self._widgets.append(widget) - if attr_def.hidden: + if not attr_def.visible: continue expand_cols = 2 diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py new file mode 100644 index 0000000000..33de4bf036 --- /dev/null +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -0,0 +1,273 @@ +""" +Brought from https://gist.github.com/BigRoy/1972822065e38f8fae7521078e44eca2 +Code Credits: [BigRoy](https://github.com/BigRoy) + +Requirement: + It requires pyblish version >= 1.8.12 + +How it works: + This tool makes use of pyblish event `pluginProcessed` to: + 1. Pause the publishing. + 2. Collect some info about the plugin. + 3. Show that info to the tool's window. + 4. Continue publishing on clicking `step` button. + +How to use it: + 1. Launch the tool from AYON experimental tools window. + 2. Launch the publisher tool and click validate. + 3. Click Step to run plugins one by one. + +Note : + Pyblish debugger also works when triggering the validation or + publishing from code. + Here's an example about validating from code: + https://github.com/MustafaJafar/ayon-recipes/blob/main/validate_from_code.py + +""" + +import copy +import json +from qtpy import QtWidgets, QtCore, QtGui + +import pyblish.api +from ayon_core import style + +TAB = 4* " " +HEADER_SIZE = "15px" + +KEY_COLOR = QtGui.QColor("#ffffff") +NEW_KEY_COLOR = QtGui.QColor("#00ff00") +VALUE_TYPE_COLOR = QtGui.QColor("#ffbbbb") +NEW_VALUE_TYPE_COLOR = QtGui.QColor("#ff4444") +VALUE_COLOR = QtGui.QColor("#777799") +NEW_VALUE_COLOR = QtGui.QColor("#DDDDCC") +CHANGED_VALUE_COLOR = QtGui.QColor("#CCFFCC") + +MAX_VALUE_STR_LEN = 100 + + +def failsafe_deepcopy(data): + """Allow skipping the deepcopy for unsupported types""" + try: + return copy.deepcopy(data) + except TypeError: + if isinstance(data, dict): + return { + key: failsafe_deepcopy(value) + for key, value in data.items() + } + elif isinstance(data, list): + return data.copy() + return data + + +class DictChangesModel(QtGui.QStandardItemModel): + # TODO: Replace this with a QAbstractItemModel + def __init__(self, *args, **kwargs): + super(DictChangesModel, self).__init__(*args, **kwargs) + self._data = {} + + columns = ["Key", "Type", "Value"] + self.setColumnCount(len(columns)) + for i, label in enumerate(columns): + self.setHeaderData(i, QtCore.Qt.Horizontal, label) + + def _update_recursive(self, data, parent, previous_data): + for key, value in data.items(): + + # Find existing item or add new row + parent_index = parent.index() + for row in range(self.rowCount(parent_index)): + # Update existing item if it exists + index = self.index(row, 0, parent_index) + if index.data() == key: + item = self.itemFromIndex(index) + type_item = self.itemFromIndex(self.index(row, 1, parent_index)) # noqa + value_item = self.itemFromIndex(self.index(row, 2, parent_index)) # noqa + break + else: + item = QtGui.QStandardItem(key) + type_item = QtGui.QStandardItem() + value_item = QtGui.QStandardItem() + parent.appendRow([item, type_item, value_item]) + + # Key + key_color = NEW_KEY_COLOR if key not in previous_data else KEY_COLOR # noqa + item.setData(key_color, QtCore.Qt.ForegroundRole) + + # Type + type_str = type(value).__name__ + type_color = VALUE_TYPE_COLOR + if ( + key in previous_data + and type(previous_data[key]).__name__ != type_str + ): + type_color = NEW_VALUE_TYPE_COLOR + + type_item.setText(type_str) + type_item.setData(type_color, QtCore.Qt.ForegroundRole) + + # Value + value_changed = False + if key not in previous_data or previous_data[key] != value: + value_changed = True + value_color = NEW_VALUE_COLOR if value_changed else VALUE_COLOR + + value_item.setData(value_color, QtCore.Qt.ForegroundRole) + if value_changed: + value_str = str(value) + if len(value_str) > MAX_VALUE_STR_LEN: + value_str = value_str[:MAX_VALUE_STR_LEN] + "..." + value_item.setText(value_str) + + # Preferably this is deferred to only when the data gets + # requested since this formatting can be slow for very large + # data sets like project settings and system settings + # This will also be MUCH faster if we don't clear the + # items on each update but only updated/add/remove changed + # items so that this also runs much less often + value_item.setData( + json.dumps(value, default=str, indent=4), + QtCore.Qt.ToolTipRole + ) + + if isinstance(value, dict): + previous_value = previous_data.get(key, {}) + if previous_data.get(key) != value: + # Update children if the value is not the same as before + self._update_recursive(value, + parent=item, + previous_data=previous_value) + else: + # TODO: Ensure all children are updated to be not marked + # as 'changed' in the most optimal way possible + self._update_recursive(value, + parent=item, + previous_data=previous_value) + + self._data = data + + def update(self, data): + parent = self.invisibleRootItem() + + data = failsafe_deepcopy(data) + previous_data = self._data + self._update_recursive(data, parent, previous_data) + self._data = data # store previous data for next update + + +class DebugUI(QtWidgets.QDialog): + + def __init__(self, parent=None): + super(DebugUI, self).__init__(parent=parent) + self.setStyleSheet(style.load_stylesheet()) + + self._set_window_title() + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowMinimizeButtonHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowStaysOnTopHint + ) + + layout = QtWidgets.QVBoxLayout(self) + text_edit = QtWidgets.QTextEdit() + text_edit.setFixedHeight(65) + font = QtGui.QFont("NONEXISTENTFONT") + font.setStyleHint(QtGui.QFont.TypeWriter) + text_edit.setFont(font) + text_edit.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) + + step = QtWidgets.QPushButton("Step") + step.setEnabled(False) + + model = DictChangesModel() + proxy = QtCore.QSortFilterProxyModel() + proxy.setRecursiveFilteringEnabled(True) + proxy.setSourceModel(model) + view = QtWidgets.QTreeView() + view.setModel(proxy) + view.setSortingEnabled(True) + + filter_field = QtWidgets.QLineEdit() + filter_field.setPlaceholderText("Filter keys...") + filter_field.textChanged.connect(proxy.setFilterFixedString) + + layout.addWidget(text_edit) + layout.addWidget(filter_field) + layout.addWidget(view) + layout.addWidget(step) + + step.clicked.connect(self.on_step) + + self._pause = False + self.model = model + self.filter = filter_field + self.proxy = proxy + self.view = view + self.text = text_edit + self.step = step + self.resize(700, 500) + + self._previous_data = {} + + def _set_window_title(self, plugin=None): + title = "Pyblish Debug Stepper" + if plugin is not None: + plugin_label = plugin.label or plugin.__name__ + title += f" | {plugin_label}" + self.setWindowTitle(title) + + def pause(self, state): + self._pause = state + self.step.setEnabled(state) + + def on_step(self): + self.pause(False) + + def showEvent(self, event): + print("Registering callback..") + pyblish.api.register_callback("pluginProcessed", + self.on_plugin_processed) + + def hideEvent(self, event): + self.pause(False) + print("Deregistering callback..") + pyblish.api.deregister_callback("pluginProcessed", + self.on_plugin_processed) + + def on_plugin_processed(self, result): + self.pause(True) + + self._set_window_title(plugin=result["plugin"]) + + print(10*"<", result["plugin"].__name__, 10*">") + + plugin_order = result["plugin"].order + plugin_name = result["plugin"].__name__ + duration = result['duration'] + plugin_instance = result["instance"] + context = result["context"] + + msg = "" + msg += f"Order: {plugin_order}
" + msg += f"Plugin: {plugin_name}" + if plugin_instance is not None: + msg += f" -> instance: {plugin_instance}" + msg += "
" + msg += f"Duration: {duration} ms
" + self.text.setHtml(msg) + + data = { + "context": context.data + } + for instance in context: + data[instance.name] = instance.data + self.model.update(data) + + app = QtWidgets.QApplication.instance() + while self._pause: + # Allow user interaction with the UI + app.processEvents() diff --git a/client/ayon_core/tools/experimental_tools/tools_def.py b/client/ayon_core/tools/experimental_tools/tools_def.py index 7def3551de..30e5211b41 100644 --- a/client/ayon_core/tools/experimental_tools/tools_def.py +++ b/client/ayon_core/tools/experimental_tools/tools_def.py @@ -1,4 +1,5 @@ import os +from .pyblish_debug_stepper import DebugUI # Constant key under which local settings are stored LOCAL_EXPERIMENTAL_KEY = "experimental_tools" @@ -95,6 +96,12 @@ class ExperimentalTools: "hiero", "resolve", ] + ), + ExperimentalHostTool( + "pyblish_debug_stepper", + "Pyblish Debug Stepper", + "Debug Pyblish plugins step by step.", + self._show_pyblish_debugger, ) ] @@ -162,9 +169,16 @@ class ExperimentalTools: local_settings.get(LOCAL_EXPERIMENTAL_KEY) ) or {} - for identifier, eperimental_tool in self.tools_by_identifier.items(): + # Enable the following tools by default. + # Because they will always be disabled due + # to the fact their settings don't exist. + experimental_settings.update({ + "pyblish_debug_stepper": True, + }) + + for identifier, experimental_tool in self.tools_by_identifier.items(): enabled = experimental_settings.get(identifier, False) - eperimental_tool.set_enabled(enabled) + experimental_tool.set_enabled(enabled) def _show_publisher(self): if self._publisher_tool is None: @@ -175,3 +189,7 @@ class ExperimentalTools: ) self._publisher_tool.show() + + def _show_pyblish_debugger(self): + window = DebugUI(parent=self._parent_widget) + window.show() diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 3a968eee28..a6ae93cecd 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -353,6 +353,12 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ): pass + @abstractmethod + def set_instances_active_state( + self, active_state_by_id: Dict[str, bool] + ): + pass + @abstractmethod def get_existing_product_names(self, folder_path: str) -> List[str]: pass diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 43b491a20f..347755d557 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -35,7 +35,27 @@ class PublisherController( Known topics: "show.detailed.help" - Detailed help requested (UI related). "show.card.message" - Show card message request (UI related). - "instances.refresh.finished" - Instances are refreshed. + # --- Create model --- + "create.model.reset" - Reset of create model. + "instances.create.failed" - Creation failed. + "convertors.convert.failed" - Convertor failed. + "instances.save.failed" - Save failed. + "instance.thumbnail.changed" - Thumbnail changed. + "instances.collection.failed" - Collection of instances failed. + "convertors.find.failed" - Convertor find failed. + "instances.create.failed" - Create instances failed. + "instances.remove.failed" - Remove instances failed. + "create.context.added.instance" - Create instance added to context. + "create.context.value.changed" - Create instance or context value + changed. + "create.context.pre.create.attrs.changed" - Pre create attributes + changed. + "create.context.create.attrs.changed" - Create attributes changed. + "create.context.publish.attrs.changed" - Publish attributes changed. + "create.context.removed.instance" - Instance removed from context. + "create.model.instances.context.changed" - Instances changed context. + like folder, task or variant. + # --- Publish model --- "plugins.refresh.finished" - Plugins refreshed. "publish.reset.finished" - Reset finished. "controller.reset.started" - Controller reset started. @@ -200,6 +220,9 @@ class PublisherController( changes_by_instance_id ) + def set_instances_active_state(self, active_state_by_id): + self._create_model.set_instances_active_state(active_state_by_id) + def get_convertor_items(self): return self._create_model.get_convertor_items() diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 4b27081db2..9c13d8ae2f 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -1,11 +1,21 @@ import logging import re -from typing import Union, List, Dict, Tuple, Any, Optional, Iterable, Pattern +from typing import ( + Union, + List, + Dict, + Tuple, + Any, + Optional, + Iterable, + Pattern, +) from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, AbstractAttrDef, + EnumDef, ) from ayon_core.lib.profiles_filtering import filter_profiles from ayon_core.lib.attribute_definitions import UIDef @@ -17,6 +27,7 @@ from ayon_core.pipeline.create import ( Creator, CreateContext, CreatedInstance, + AttributeValues, ) from ayon_core.pipeline.create import ( CreatorsOperationFailed, @@ -296,7 +307,88 @@ class InstanceItem: ) +def _merge_attr_defs( + attr_def_src: AbstractAttrDef, attr_def_new: AbstractAttrDef +) -> Optional[AbstractAttrDef]: + if not attr_def_src.enabled and attr_def_new.enabled: + attr_def_src.enabled = True + if not attr_def_src.visible and attr_def_new.visible: + attr_def_src.visible = True + + if not isinstance(attr_def_src, EnumDef): + return None + if attr_def_src.items == attr_def_new.items: + return None + + src_item_values = { + item["value"] + for item in attr_def_src.items + } + for item in attr_def_new.items: + if item["value"] not in src_item_values: + attr_def_src.items.append(item) + + +def merge_attr_defs(attr_defs: List[List[AbstractAttrDef]]): + if not attr_defs: + return [] + if len(attr_defs) == 1: + return attr_defs[0] + + # Pop first and create clone of attribute definitions + defs_union: List[AbstractAttrDef] = [ + attr_def.clone() + for attr_def in attr_defs.pop(0) + ] + for instance_attr_defs in attr_defs: + idx = 0 + for attr_idx, attr_def in enumerate(instance_attr_defs): + # QUESTION should we merge NumberDef too? Use lowest min and + # biggest max... + is_enum = isinstance(attr_def, EnumDef) + match_idx = None + match_attr = None + for union_idx, union_def in enumerate(defs_union): + if is_enum and ( + not isinstance(union_def, EnumDef) + or union_def.multiselection != attr_def.multiselection + ): + continue + + if ( + attr_def.compare_to_def( + union_def, + ignore_default=True, + ignore_enabled=True, + ignore_visible=True, + ignore_def_type_compare=is_enum + ) + ): + match_idx = union_idx + match_attr = union_def + break + + if match_attr is not None: + new_attr_def = _merge_attr_defs(match_attr, attr_def) + if new_attr_def is not None: + defs_union[match_idx] = new_attr_def + idx = match_idx + 1 + continue + + defs_union.insert(idx, attr_def.clone()) + idx += 1 + return defs_union + + class CreateModel: + _CONTEXT_KEYS = { + "active", + "folderPath", + "task", + "variant", + "productName", + } + def __init__(self, controller: AbstractPublisherBackend): self._log = None self._controller: AbstractPublisherBackend = controller @@ -453,6 +545,27 @@ class CreateModel: instance = self._get_instance_by_id(instance_id) for key, value in changes.items(): instance[key] = value + self._emit_event( + "create.model.instances.context.changed", + { + "instance_ids": list(changes_by_instance_id.keys()) + } + ) + + def set_instances_active_state( + self, active_state_by_id: Dict[str, bool] + ): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id, active in active_state_by_id.items(): + instance = self._create_context.get_instance_by_id(instance_id) + instance["active"] = active + + self._emit_event( + "create.model.instances.context.changed", + { + "instance_ids": set(active_state_by_id.keys()) + } + ) def get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id @@ -643,8 +756,16 @@ class CreateModel: for instance_id in instance_ids: instance = self._get_instance_by_id(instance_id) creator_attributes = instance["creator_attributes"] - if key in creator_attributes: - creator_attributes[key] = value + attr_def = creator_attributes.get_attr_def(key) + if ( + attr_def is None + or not attr_def.is_value_def + or not attr_def.visible + or not attr_def.enabled + or not attr_def.is_value_valid(value) + ): + continue + creator_attributes[key] = value def get_creator_attribute_definitions( self, instance_ids: List[str] @@ -693,6 +814,18 @@ class CreateModel: else: instance = self._get_instance_by_id(instance_id) plugin_val = instance.publish_attributes[plugin_name] + attr_def = plugin_val.get_attr_def(key) + # Ignore if attribute is not available or enabled/visible + # on the instance, or the value is not valid for definition + if ( + attr_def is None + or not attr_def.is_value_def + or not attr_def.visible + or not attr_def.enabled + or not attr_def.is_value_valid(value) + ): + continue + plugin_val[key] = value def get_publish_attribute_definitions( @@ -725,13 +858,17 @@ class CreateModel: item_id = None if isinstance(item, CreatedInstance): item_id = item.id + for plugin_name, attr_val in item.publish_attributes.items(): + if not isinstance(attr_val, AttributeValues): + continue attr_defs = attr_val.attr_defs if not attr_defs: continue - - if plugin_name not in all_defs_by_plugin_name: - all_defs_by_plugin_name[plugin_name] = attr_val.attr_defs + plugin_attr_defs = all_defs_by_plugin_name.setdefault( + plugin_name, [] + ) + plugin_attr_defs.append(attr_defs) plugin_values = all_plugin_values.setdefault(plugin_name, {}) @@ -744,6 +881,10 @@ class CreateModel: value = attr_val[attr_def.key] attr_values.append((item_id, value)) + attr_defs_by_plugin_name = {} + for plugin_name, attr_defs in all_defs_by_plugin_name.items(): + attr_defs_by_plugin_name[plugin_name] = merge_attr_defs(attr_defs) + output = [] for plugin in self._create_context.plugins_with_defs: plugin_name = plugin.__name__ @@ -751,7 +892,7 @@ class CreateModel: continue output.append(( plugin_name, - all_defs_by_plugin_name[plugin_name], + attr_defs_by_plugin_name[plugin_name], all_plugin_values )) return output @@ -783,8 +924,12 @@ class CreateModel: } ) - def _emit_event(self, topic: str, data: Optional[Dict[str, Any]] = None): - self._controller.emit_event(topic, data) + def _emit_event( + self, + topic: str, + data: Optional[Dict[str, Any]] = None + ): + self._controller.emit_event(topic, data, CREATE_EVENT_SOURCE) def _get_current_project_settings(self) -> Dict[str, Any]: """Current project settings. @@ -933,16 +1078,28 @@ class CreateModel: return instance_changes = {} + context_changed_ids = set() for item in event.data["changes"]: instance_id = None if item["instance"]: instance_id = item["instance"].id - instance_changes[instance_id] = item["changes"] + changes = item["changes"] + instance_changes[instance_id] = changes + if instance_id is None: + continue + + if self._CONTEXT_KEYS.intersection(set(changes)): + context_changed_ids.add(instance_id) self._emit_event( "create.context.value.changed", {"instance_changes": instance_changes}, ) + if context_changed_ids: + self._emit_event( + "create.model.instances.context.changed", + {"instance_ids": list(context_changed_ids)}, + ) def _cc_pre_create_attr_changed(self, event): identifiers = event["identifiers"] diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 6dfda38885..97a956b18f 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -32,17 +32,20 @@ PLUGIN_ORDER_OFFSET = 0.5 class MessageHandler(logging.Handler): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.records = [] + self._records = [] def clear_records(self): - self.records = [] + self._records = [] def emit(self, record): try: record.msg = record.getMessage() except Exception: record.msg = str(record.msg) - self.records.append(record) + self._records.append(record) + + def get_records(self): + return self._records class PublishErrorInfo: @@ -1328,7 +1331,18 @@ class PublishModel: plugin, self._publish_context, instance ) if log_handler is not None: - result["records"] = log_handler.records + records = log_handler.get_records() + exception = result.get("error") + if exception is not None and records: + last_record = records[-1] + if ( + last_record.name == "pyblish.plugin" + and last_record.levelno == logging.ERROR + ): + # Remove last record made by pyblish + # - `log.exception(formatted_traceback)` + records.pop(-1) + result["records"] = records exception = result.get("error") if exception: diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index 6ef34b86f8..095a4eae7c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -22,6 +22,7 @@ Only one item can be selected at a time. import re import collections +from typing import Dict from qtpy import QtWidgets, QtCore @@ -217,11 +218,18 @@ class InstanceGroupWidget(BaseGroupWidget): def update_icons(self, group_icons): self._group_icons = group_icons - def update_instance_values(self, context_info_by_id): + def update_instance_values( + self, context_info_by_id, instance_items_by_id, instance_ids + ): """Trigger update on instance widgets.""" for instance_id, widget in self._widgets_by_id.items(): - widget.update_instance_values(context_info_by_id[instance_id]) + if instance_ids is not None and instance_id not in instance_ids: + continue + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id] + ) def update_instances(self, instances, context_info_by_id): """Update instances for the group. @@ -307,8 +315,9 @@ class CardWidget(BaseClickableFrame): def set_selected(self, selected): """Set card as selected.""" - if selected == self._selected: + if selected is self._selected: return + self._selected = selected state = "selected" if selected else "" self.setProperty("state", state) @@ -391,9 +400,6 @@ class ConvertorItemCardWidget(CardWidget): self._icon_widget = icon_widget self._label_widget = label_widget - def update_instance_values(self, context_info): - pass - class InstanceCardWidget(CardWidget): """Card widget representing instance.""" @@ -461,7 +467,7 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self.update_instance_values(context_info) + self._update_instance_values(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -470,23 +476,16 @@ class InstanceCardWidget(CardWidget): def is_active(self): return self._active_checkbox.isChecked() - def set_active(self, new_value): + def _set_active(self, new_value): """Set instance as active.""" checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance.is_active - - # First change instance value and them change checkbox - # - prevent to trigger `active_changed` signal - if instance_value != new_value: - self.instance.is_active = new_value - if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance - self.update_instance_values(context_info) + self._update_instance_values(context_info) def _validate_context(self, context_info): valid = context_info.is_valid @@ -522,10 +521,10 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def update_instance_values(self, context_info): + def _update_instance_values(self, context_info): """Update instance data""" self._update_product_name() - self.set_active(self.instance.is_active) + self._set_active(self.instance.is_active) self._validate_context(context_info) def _set_expanded(self, expanded=None): @@ -539,7 +538,6 @@ class InstanceCardWidget(CardWidget): if new_value == old_value: return - self.instance.is_active = new_value self.active_changed.emit(self._id, new_value) def _on_expend_clicked(self): @@ -596,7 +594,7 @@ class InstanceCardView(AbstractInstanceView): self._context_widget = None self._convertor_items_group = None self._active_toggle_enabled = True - self._widgets_by_group = {} + self._widgets_by_group: Dict[str, InstanceGroupWidget] = {} self._ordered_groups = [] self._explicitly_selected_instance_ids = [] @@ -625,24 +623,25 @@ class InstanceCardView(AbstractInstanceView): return widgets = self._get_selected_widgets() - changed = False + active_state_by_id = {} for widget in widgets: if not isinstance(widget, InstanceCardWidget): continue + instance_id = widget.id is_active = widget.is_active if value == -1: - widget.set_active(not is_active) - changed = True + active_state_by_id[instance_id] = not is_active continue _value = bool(value) if is_active is not _value: - widget.set_active(_value) - changed = True + active_state_by_id[instance_id] = _value - if changed: - self.active_changed.emit() + if not active_state_by_id: + return + + self._controller.set_instances_active_state(active_state_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: @@ -702,7 +701,7 @@ class InstanceCardView(AbstractInstanceView): # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) - for instance in self._controller.get_instances(): + for instance in self._controller.get_instance_items(): group_name = instance.group_label instances_by_group[group_name].append(instance) identifiers_by_group[group_name].add( @@ -817,23 +816,31 @@ class InstanceCardView(AbstractInstanceView): self._convertor_items_group.update_items(convertor_items) - def refresh_instance_states(self): + def refresh_instance_states(self, instance_ids=None): """Trigger update of instances on group widgets.""" + if instance_ids is not None: + instance_ids = set(instance_ids) context_info_by_id = self._controller.get_instances_context_info() + instance_items_by_id = self._controller.get_instance_items_by_id( + instance_ids + ) for widget in self._widgets_by_group.values(): - widget.update_instance_values(context_info_by_id) + widget.update_instance_values( + context_info_by_id, instance_items_by_id, instance_ids + ) def _on_active_changed(self, group_name, instance_id, value): group_widget = self._widgets_by_group[group_name] instance_widget = group_widget.get_widget_by_item_id(instance_id) - if instance_widget.is_selected: + active_state_by_id = {} + if not instance_widget.is_selected: + active_state_by_id[instance_id] = value + else: for widget in self._get_selected_widgets(): if isinstance(widget, InstanceCardWidget): - widget.set_active(value) - else: - self._select_item_clear(instance_id, group_name, instance_widget) - self.selection_changed.emit() - self.active_changed.emit() + active_state_by_id[widget.id] = value + + self._controller.set_instances_active_state(active_state_by_id) def _on_widget_selection(self, instance_id, group_name, selection_type): """Select specific item by instance id. diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index 14814a4aa6..bc3353ba5e 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -110,7 +110,7 @@ class ListItemDelegate(QtWidgets.QStyledItemDelegate): class InstanceListItemWidget(QtWidgets.QWidget): """Widget with instance info drawn over delegate paint. - This is required to be able use custom checkbox on custom place. + This is required to be able to use custom checkbox on custom place. """ active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() @@ -118,7 +118,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): def __init__(self, instance, context_info, parent): super().__init__(parent) - self.instance = instance + self._instance_id = instance.id instance_label = instance.label if instance_label is None: @@ -171,47 +171,34 @@ class InstanceListItemWidget(QtWidgets.QWidget): def is_active(self): """Instance is activated.""" - return self.instance.is_active + return self._active_checkbox.isChecked() def set_active(self, new_value): """Change active state of instance and checkbox.""" - checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance.is_active + old_value = self.is_active() if new_value is None: - new_value = not instance_value + new_value = not old_value - # First change instance value and them change checkbox - # - prevent to trigger `active_changed` signal - if instance_value != new_value: - self.instance.is_active = new_value - - if checkbox_value != new_value: + if new_value != old_value: + self._active_checkbox.blockSignals(True) self._active_checkbox.setChecked(new_value) + self._active_checkbox.blockSignals(False) def update_instance(self, instance, context_info): """Update instance object.""" - self.instance = instance - self.update_instance_values(context_info) - - def update_instance_values(self, context_info): - """Update instance data propagated to widgets.""" # Check product name - label = self.instance.label + label = instance.label if label != self._instance_label_widget.text(): self._instance_label_widget.setText(html_escape(label)) # Check active state - self.set_active(self.instance.is_active) + self.set_active(instance.is_active) # Check valid states self._set_valid_property(context_info.is_valid) def _on_active_change(self): - new_value = self._active_checkbox.isChecked() - old_value = self.instance.is_active - if new_value == old_value: - return - - self.instance.is_active = new_value - self.active_changed.emit(self.instance.id, new_value) + self.active_changed.emit( + self._instance_id, self._active_checkbox.isChecked() + ) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -245,8 +232,8 @@ class ListContextWidget(QtWidgets.QFrame): class InstanceListGroupWidget(QtWidgets.QFrame): """Widget representing group of instances. - Has collapse/expand indicator, label of group and checkbox modifying all of - it's children. + Has collapse/expand indicator, label of group and checkbox modifying all + of its children. """ expand_changed = QtCore.Signal(str, bool) toggle_requested = QtCore.Signal(str, int) @@ -392,7 +379,7 @@ class InstanceTreeView(QtWidgets.QTreeView): def _mouse_press(self, event): """Store index of pressed group. - This is to be able change state of group and process mouse + This is to be able to change state of group and process mouse "double click" as 2x "single click". """ if event.button() != QtCore.Qt.LeftButton: @@ -588,7 +575,7 @@ class InstanceListView(AbstractInstanceView): # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) group_names = set() - for instance in self._controller.get_instances(): + for instance in self._controller.get_instance_items(): group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) @@ -612,7 +599,7 @@ class InstanceListView(AbstractInstanceView): # Mapping of existing instances under group item existing_mapping = {} - # Get group index to be able get children indexes + # Get group index to be able to get children indexes group_index = self._instance_model.index( group_item.row(), group_item.column() ) @@ -873,30 +860,40 @@ class InstanceListView(AbstractInstanceView): widget = self._group_widgets.pop(group_name) widget.deleteLater() - def refresh_instance_states(self): + def refresh_instance_states(self, instance_ids=None): """Trigger update of all instances.""" + if instance_ids is not None: + instance_ids = set(instance_ids) context_info_by_id = self._controller.get_instances_context_info() + instance_items_by_id = self._controller.get_instance_items_by_id( + instance_ids + ) for instance_id, widget in self._widgets_by_id.items(): - context_info = context_info_by_id[instance_id] - widget.update_instance_values(context_info) + if instance_ids is not None and instance_id not in instance_ids: + continue + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + ) def _on_active_changed(self, changed_instance_id, new_value): selected_instance_ids, _, _ = self.get_selected_items() - selected_ids = set() + active_by_id = {} found = False for instance_id in selected_instance_ids: - selected_ids.add(instance_id) + active_by_id[instance_id] = new_value if not found and instance_id == changed_instance_id: found = True if not found: - selected_ids = set() - selected_ids.add(changed_instance_id) + active_by_id = {changed_instance_id: new_value} - self._change_active_instances(selected_ids, new_value) + self._controller.set_instances_active_state(active_by_id) + + self._change_active_instances(active_by_id, new_value) group_names = set() - for instance_id in selected_ids: + for instance_id in active_by_id: group_name = self._group_by_instance_id.get(instance_id) if group_name is not None: group_names.add(group_name) @@ -908,16 +905,11 @@ class InstanceListView(AbstractInstanceView): if not instance_ids: return - changed_ids = set() for instance_id in instance_ids: widget = self._widgets_by_id.get(instance_id) if widget: - changed_ids.add(instance_id) widget.set_active(new_value) - if changed_ids: - self.active_changed.emit() - def _on_selection_change(self, *_args): self.selection_changed.emit() @@ -956,14 +948,16 @@ class InstanceListView(AbstractInstanceView): if not group_item: return - instance_ids = set() + active_by_id = {} for row in range(group_item.rowCount()): item = group_item.child(row) instance_id = item.data(INSTANCE_ID_ROLE) if instance_id is not None: - instance_ids.add(instance_id) + active_by_id[instance_id] = active - self._change_active_instances(instance_ids, active) + self._controller.set_instances_active_state(active_by_id) + + self._change_active_instances(active_by_id, active) proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index beefa1ca98..a09ee80ed5 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -15,8 +15,6 @@ from .product_info import ProductInfoWidget class OverviewWidget(QtWidgets.QFrame): - active_changed = QtCore.Signal() - instance_context_changed = QtCore.Signal() create_requested = QtCore.Signal() convert_requested = QtCore.Signal() publish_tab_requested = QtCore.Signal() @@ -126,17 +124,7 @@ class OverviewWidget(QtWidgets.QFrame): product_view_cards.double_clicked.connect( self.publish_tab_requested ) - # Active instances changed - product_list_view.active_changed.connect( - self._on_active_changed - ) - product_view_cards.active_changed.connect( - self._on_active_changed - ) # Instance context has changed - product_attributes_widget.instance_context_changed.connect( - self._on_instance_context_change - ) product_attributes_widget.convert_requested.connect( self._on_convert_requested ) @@ -163,6 +151,10 @@ class OverviewWidget(QtWidgets.QFrame): "create.context.removed.instance", self._on_instances_removed ) + controller.register_event_callback( + "create.model.instances.context.changed", + self._on_instance_context_change + ) self._product_content_widget = product_content_widget self._product_content_layout = product_content_layout @@ -312,11 +304,6 @@ class OverviewWidget(QtWidgets.QFrame): instances, context_selected, convertor_identifiers ) - def _on_active_changed(self): - if self._refreshing_instances: - return - self.active_changed.emit() - def _on_change_anim(self, value): self._create_widget.setVisible(True) self._product_attributes_wrap.setVisible(True) @@ -362,7 +349,7 @@ class OverviewWidget(QtWidgets.QFrame): self._current_state == "publish" ) - def _on_instance_context_change(self): + def _on_instance_context_change(self, event): current_idx = self._product_views_layout.currentIndex() for idx in range(self._product_views_layout.count()): if idx == current_idx: @@ -372,9 +359,7 @@ class OverviewWidget(QtWidgets.QFrame): widget.set_refreshed(False) current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states() - - self.instance_context_changed.emit() + current_widget.refresh_instance_states(event["instance_ids"]) def _on_convert_requested(self): self.convert_requested.emit() diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py index 7372e66efe..61d5ca111d 100644 --- a/client/ayon_core/tools/publisher/widgets/product_attributes.py +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -111,7 +111,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): self._attr_def_id_to_instances[attr_def.id] = instance_ids self._attr_def_id_to_attr_def[attr_def.id] = attr_def - if attr_def.hidden: + if not attr_def.visible: continue expand_cols = 2 @@ -282,15 +282,15 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): widget = create_widget_for_attr_def( attr_def, content_widget ) - hidden_widget = attr_def.hidden + visible_widget = attr_def.visible # Hide unknown values of publish plugins # - The keys in most of the cases does not represent what # would label represent if isinstance(attr_def, UnknownDef): widget.setVisible(False) - hidden_widget = True + visible_widget = False - if not hidden_widget: + if visible_widget: expand_cols = 2 if attr_def.is_value_def and attr_def.is_label_horizontal: expand_cols = 1 diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index f11dc90a5d..04c9ca7e56 100644 --- a/client/ayon_core/tools/publisher/widgets/product_context.py +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -621,7 +621,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget): product name: [ immutable ] [Submit] [Cancel] """ - instance_context_changed = QtCore.Signal() multiselection_text = "< Multiselection >" unknown_value = "N/A" @@ -775,7 +774,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self._controller.set_instances_context_info(changes_by_id) self._refresh_items() - self.instance_context_changed.emit() def _on_cancel(self): """Cancel changes and set back to their irigin value.""" @@ -917,7 +915,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if instance_id not in self._current_instances_by_id: continue - for key, attr_name in ( + for key in ( "folderPath", "task", "variant", @@ -933,4 +931,3 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if changed: self._refresh_items() self._refresh_content() - self.instance_context_changed.emit() diff --git a/client/ayon_core/tools/publisher/widgets/product_info.py b/client/ayon_core/tools/publisher/widgets/product_info.py index 9a7700d73d..27b7aacf38 100644 --- a/client/ayon_core/tools/publisher/widgets/product_info.py +++ b/client/ayon_core/tools/publisher/widgets/product_info.py @@ -26,7 +26,6 @@ class ProductInfoWidget(QtWidgets.QWidget): │ │ attributes │ └───────────────────────────────┘ """ - instance_context_changed = QtCore.Signal() convert_requested = QtCore.Signal() def __init__( @@ -123,13 +122,14 @@ class ProductInfoWidget(QtWidgets.QWidget): self._context_selected = False self._all_instances_valid = True - global_attrs_widget.instance_context_changed.connect( - self._on_instance_context_changed - ) convert_btn.clicked.connect(self._on_convert_click) thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear) + controller.register_event_callback( + "create.model.instances.context.changed", + self._on_instance_context_change + ) controller.register_event_callback( "instance.thumbnail.changed", self._on_thumbnail_changed @@ -196,7 +196,7 @@ class ProductInfoWidget(QtWidgets.QWidget): self._update_thumbnails() - def _on_instance_context_changed(self): + def _on_instance_context_change(self): instance_ids = { instance.id for instance in self._current_instances @@ -214,8 +214,6 @@ class ProductInfoWidget(QtWidgets.QWidget): self.creator_attrs_widget.set_instances_valid(all_valid) self.publish_attrs_widget.set_instances_valid(all_valid) - self.instance_context_changed.emit() - def _on_convert_click(self): self.convert_requested.emit() diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 00c87ac249..a9d34c4c66 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -298,7 +298,6 @@ class ChangeViewBtn(PublishIconBtn): class AbstractInstanceView(QtWidgets.QWidget): """Abstract class for instance view in creation part.""" selection_changed = QtCore.Signal() - active_changed = QtCore.Signal() # Refreshed attribute is not changed by view itself # - widget which triggers `refresh` is changing the state # TODO store that information in widget which cares about refreshing diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index e4da71b3d6..a912495d4e 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -253,12 +253,6 @@ class PublisherWindow(QtWidgets.QDialog): help_btn.clicked.connect(self._on_help_click) tabs_widget.tab_changed.connect(self._on_tab_change) - overview_widget.active_changed.connect( - self._on_context_or_active_change - ) - overview_widget.instance_context_changed.connect( - self._on_context_or_active_change - ) overview_widget.create_requested.connect( self._on_create_request ) @@ -281,7 +275,19 @@ class PublisherWindow(QtWidgets.QDialog): ) controller.register_event_callback( - "instances.refresh.finished", self._on_instances_refresh + "create.model.reset", self._on_create_model_reset + ) + controller.register_event_callback( + "create.context.added.instance", + self._event_callback_validate_instances + ) + controller.register_event_callback( + "create.context.removed.instance", + self._event_callback_validate_instances + ) + controller.register_event_callback( + "create.model.instances.context.changed", + self._event_callback_validate_instances ) controller.register_event_callback( "publish.reset.finished", self._on_publish_reset @@ -936,13 +942,16 @@ class PublisherWindow(QtWidgets.QDialog): self._set_footer_enabled(bool(all_valid)) - def _on_instances_refresh(self): + def _on_create_model_reset(self): self._validate_create_instances() context_title = self._controller.get_context_title() self.set_context_label(context_title) self._update_publish_details_widget() + def _event_callback_validate_instances(self, _event): + self._validate_create_instances() + def _set_comment_input_visiblity(self, visible): self._comment_input.setVisible(visible) self._footer_spacer.setVisible(not visible) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 75116c703e..8a7065c93c 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.0.0+dev" +__version__ = "1.0.4+dev" diff --git a/package.py b/package.py index 1466031daa..7c5bffe81f 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.0+dev" +version = "1.0.4+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 4a63529c67..c686d685fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.0+dev" +version = "1.0.4+dev" description = "" authors = ["Ynput Team "] readme = "README.md" diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_23.976_metadata.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_23.976_metadata.json new file mode 100644 index 0000000000..af74ab4252 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_23.976_metadata.json @@ -0,0 +1,255 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "active": true, + "applieswhole": 1, + "asset": "sh020", + "audio": true, + "families": [ + "clip" + ], + "family": "plate", + "handleEnd": 8, + "handleStart": 0, + "heroTrack": true, + "hierarchy": "shots/sq001", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq001", + "shot": "sh020", + "track": "reference" + }, + "hiero_source_type": "TrackItem", + "id": "pyblish.avalon.instance", + "label": "openpypeData", + "note": "OpenPype data container", + "parents": [ + { + "entity_name": "shots", + "entity_type": "folder" + }, + { + "entity_name": "sq001", + "entity_type": "sequence" + } + ], + "publish": true, + "reviewTrack": null, + "sourceResolution": false, + "subset": "plateP01", + "variant": "Main", + "workfileFrameStart": 1001 + }, + "name": "sh020", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 51.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "active": true, + "applieswhole": 1, + "asset": "sh020", + "audio": true, + "families": [ + "clip" + ], + "family": "plate", + "handleEnd": 8, + "handleStart": 0, + "heroTrack": true, + "hierarchy": "shots/sq001", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq001", + "shot": "sh020", + "track": "reference" + }, + "hiero_source_type": "TrackItem", + "id": "pyblish.avalon.instance", + "label": "openpypeData", + "note": "OpenPype data container", + "parents": [ + { + "entity_name": "shots", + "entity_type": "folder" + }, + { + "entity_name": "sq001", + "entity_type": "sequence" + } + ], + "publish": true, + "reviewTrack": null, + "sourceResolution": false, + "subset": "plateP01", + "variant": "Main", + "workfileFrameStart": 1001 + }, + "name": "openpypeData", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + }, + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "applieswhole": 1, + "family": "task", + "hiero_source_type": "TrackItem", + "label": "comp", + "note": "Compositing", + "type": "Compositing" + }, + "name": "comp", + "color": "RED", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976024627685547, + "value": 0.0 + } + } + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "clip.properties.blendfunc": "0", + "clip.properties.colourspacename": "default", + "clip.properties.domainroot": "", + "clip.properties.enabled": "1", + "clip.properties.expanded": "1", + "clip.properties.opacity": "1", + "clip.properties.valuesource": "", + "foundry.source.audio": "", + "foundry.source.bitmapsize": "0", + "foundry.source.bitsperchannel": "0", + "foundry.source.channelformat": "integer", + "foundry.source.colourtransform": "ACES - ACES2065-1", + "foundry.source.duration": "59", + "foundry.source.filename": "MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.filesize": "", + "foundry.source.fragments": "59", + "foundry.source.framerate": "23.98", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.path": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01/MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 11", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "MER_sq001_sh020_P01.%04d.exr 997-1055", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "997", + "foundry.source.timecode": "172800", + "foundry.source.umid": "1bf7437a-b446-440c-07c5-7cae7acf4f5e", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "ACES - ACES2065-1", + "foundry.timeline.duration": "59", + "foundry.timeline.framerate": "23.98", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAMAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.exr.channels": "B:{1 0 1 1},G:{1 0 1 1},R:{1 0 1 1}", + "media.exr.compression": "8", + "media.exr.compressionName": "DWAA", + "media.exr.dataWindow": "0,0,1919,1079", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.dwaCompressionLevel": "90", + "media.exr.lineOrder": "0", + "media.exr.pixelAspectRatio": "1", + "media.exr.screenWindowCenter": "0,0", + "media.exr.screenWindowWidth": "1", + "media.exr.type": "scanlineimage", + "media.exr.version": "1", + "media.input.bitsperchannel": "16-bit half float", + "media.input.ctime": "2022-04-21 11:56:03", + "media.input.filename": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01/MER_sq001_sh020_P01.0997.exr", + "media.input.filereader": "exr", + "media.input.filesize": "1235182", + "media.input.frame": "1", + "media.input.frame_rate": "23.976", + "media.input.height": "1080", + "media.input.mtime": "2022-03-06 10:14:41", + "media.input.timecode": "02:00:00:00", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "ffffffffffffffff", + "media.nuke.version": "12.2v3", + "openpype.source.colourtransform": "ACES - ACES2065-1", + "openpype.source.height": 1080, + "openpype.source.pixelAspect": 1.0, + "openpype.source.width": 1920, + "padding": 4 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 59.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 23.976, + "value": 997.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:/projects/AY01_VFX_demo/resources/plates/MER_sq001_sh020_P01\\", + "name_prefix": "MER_sq001_sh020_P01.", + "name_suffix": ".exr", + "start_frame": 997, + "frame_step": 1, + "rate": 23.976, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py index e5f0d335b5..7f9256c6d8 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py +++ b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py @@ -166,3 +166,24 @@ def test_img_sequence_relative_source_range(): "legacy_img_sequence.json", expected_data ) + +def test_img_sequence_conform_to_23_976fps(): + """ + Img sequence clip + available files = 997-1047 23.976fps + source_range = 997-1055 23.976024627685547fps + """ + expected_data = { + 'mediaIn': 997, + 'mediaOut': 1047, + 'handleStart': 0, + 'handleEnd': 8, + 'speed': 1.0 + } + + _check_expected_retimed_values( + "img_seq_23.976_metadata.json", + expected_data, + handle_start=0, + handle_end=8, + )