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,
+ )