Merge branch 'develop' into enhancement/usd_contribution_attributes_per_instance_toggle

This commit is contained in:
Roy Nieterau 2024-10-23 23:38:38 +02:00 committed by GitHub
commit d797b78fca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1339 additions and 283 deletions

View file

@ -28,7 +28,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"substancepainter",
"aftereffects",
"wrap",
"openrv"
"openrv",
"cinema4d"
}
launch_types = {LaunchTypes.local}

View file

@ -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]

View file

@ -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():

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -36,7 +36,8 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
"traypublisher",
"substancepainter",
"nuke",
"aftereffects"
"aftereffects",
"unreal"
]
enabled = False

View file

@ -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

View file

@ -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}<br>"
msg += f"Plugin: {plugin_name}"
if plugin_instance is not None:
msg += f" -> instance: {plugin_instance}"
msg += "<br>"
msg += f"Duration: {duration} ms<br>"
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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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"]

View file

@ -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:

View file

@ -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.

View file

@ -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):

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.0.0+dev"
__version__ = "1.0.4+dev"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.0.0+dev"
version = "1.0.4+dev"
client_dir = "ayon_core"

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.0.0+dev"
version = "1.0.4+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"

View file

@ -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"
}

View file

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