From 4ae8f277b99298018b999c1539e051fd82eb4de7 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 29 May 2024 22:29:13 +0300 Subject: [PATCH 001/266] add 'pyblish_debug_stepper' tool --- .../pyblish_debug_stepper.py | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py 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..e6cf3c69d6 --- /dev/null +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -0,0 +1,269 @@ +""" +Brought from https://gist.github.com/BigRoy/1972822065e38f8fae7521078e44eca2 +Code Credits: [BigRoy](https://github.com/BigRoy) + +Requirement: + This tool requires some modification in ayon-core. + Add the following two lines in sa similar fashion to this commit + https://github.com/ynput/OpenPype/commit/6a0ce21aa1f8cb17452fe066aa15134d22fda440 + i.e. Add them just after + https://github.com/ynput/ayon-core/blob/8366d2e8b4003a252b8da822f7e38c6db08292b4/client/ayon_core/tools/publisher/control.py#L2483-L2487 + + ``` + result["context"] = self._publish_context + pyblish.api.emit("pluginProcessedCustom", result=result) + ``` + + This modification should be temporary till the following PR get merged and released. + https://github.com/pyblish/pyblish-base/pull/401 + +How it works: + It registers a callback function `on_plugin_processed` + when event `pluginProcessedCustom` is emitted. + The logic of this function is roughly: + 1. Pauses the publishing. + 2. Collects some info about the plugin. + 3. Shows that info to the tool's window. + 4. Continues 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: + It won't work when triggering validation from code as our custom event lives inside ayon-core. + But, It should work when the mentioned PR above (#401) get merged and released. + +""" + +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)) + value_item = self.itemFromIndex(self.index(row, 2, parent_index)) + 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 + 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 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(font.TypeWriter) + text_edit.setFont(font) + text_edit.setLineWrapMode(text_edit.NoWrap) + + step = QtWidgets.QPushButton("Step") + step.setEnabled(False) + + model = DictChangesModel() + proxy = QtCore.QSortFilterProxyModel() + proxy.setSourceModel(model) + view = QtWidgets.QTreeView() + view.setModel(proxy) + view.setSortingEnabled(True) + + layout.addWidget(text_edit) + layout.addWidget(view) + layout.addWidget(step) + + step.clicked.connect(self.on_step) + + self._pause = False + self.model = model + 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("pluginProcessedCustom", # "pluginProcessed" + self.on_plugin_processed) + + def hideEvent(self, event): + self.pause(False) + print("Deregistering callback..") + pyblish.api.deregister_callback("pluginProcessedCustom", # "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() From 3de28f870d54415fc0d14e454f770772cf11d554 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 24 Jun 2024 23:12:23 +0300 Subject: [PATCH 002/266] Add DebugExprimentalTool to ExperimentalTools --- .../tools/experimental_tools/tools_def.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/client/ayon_core/tools/experimental_tools/tools_def.py b/client/ayon_core/tools/experimental_tools/tools_def.py index 7def3551de..e3acfa72ab 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 @@ def __init__(self, parent_widget=None, refresh=True): "hiero", "resolve", ] + ), + ExperimentalHostTool( + "DebugExprimentalTool", + "Pyblish Debug Stepper", + "Debug Pyblish plugins step by step.", + self._show_pyblish_debugger, ) ] @@ -162,6 +169,13 @@ def refresh_availability(self): local_settings.get(LOCAL_EXPERIMENTAL_KEY) ) or {} + # Enable the following tools by default. + # Because they will always be disabled due + # to the fact their settings don't exist. + experimental_settings.update({ + "DebugExprimentalTool": True, + }) + for identifier, eperimental_tool in self.tools_by_identifier.items(): enabled = experimental_settings.get(identifier, False) eperimental_tool.set_enabled(enabled) @@ -175,3 +189,7 @@ def _show_publisher(self): ) self._publisher_tool.show() + + def _show_pyblish_debugger(self): + window = DebugUI(parent=self._parent_widget) + window.show() From 36caeba8dbd5ed3da3f736bebed52330f90b12a9 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 24 Jun 2024 23:26:34 +0300 Subject: [PATCH 003/266] Update pyblish debug stepper docstring --- .../pyblish_debug_stepper.py | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py index e6cf3c69d6..51b7ba2c06 100644 --- a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -3,37 +3,25 @@ Code Credits: [BigRoy](https://github.com/BigRoy) Requirement: - This tool requires some modification in ayon-core. - Add the following two lines in sa similar fashion to this commit - https://github.com/ynput/OpenPype/commit/6a0ce21aa1f8cb17452fe066aa15134d22fda440 - i.e. Add them just after - https://github.com/ynput/ayon-core/blob/8366d2e8b4003a252b8da822f7e38c6db08292b4/client/ayon_core/tools/publisher/control.py#L2483-L2487 - - ``` - result["context"] = self._publish_context - pyblish.api.emit("pluginProcessedCustom", result=result) - ``` - - This modification should be temporary till the following PR get merged and released. - https://github.com/pyblish/pyblish-base/pull/401 + It requires pyblish version >= 1.8.12 How it works: - It registers a callback function `on_plugin_processed` - when event `pluginProcessedCustom` is emitted. - The logic of this function is roughly: - 1. Pauses the publishing. - 2. Collects some info about the plugin. - 3. Shows that info to the tool's window. - 4. Continues publishing on clicking `step` button. + 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: - It won't work when triggering validation from code as our custom event lives inside ayon-core. - But, It should work when the mentioned PR above (#401) get merged and released. +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 """ @@ -225,13 +213,13 @@ def on_step(self): def showEvent(self, event): print("Registering callback..") - pyblish.api.register_callback("pluginProcessedCustom", # "pluginProcessed" + pyblish.api.register_callback("pluginProcessed", self.on_plugin_processed) def hideEvent(self, event): self.pause(False) print("Deregistering callback..") - pyblish.api.deregister_callback("pluginProcessedCustom", # "pluginProcessed" + pyblish.api.deregister_callback("pluginProcessed", self.on_plugin_processed) def on_plugin_processed(self, result): From f5006273ae69f6cb6404522488ae3141ed376200 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 5 Jul 2024 19:33:23 +0300 Subject: [PATCH 004/266] fix typo --- client/ayon_core/tools/experimental_tools/tools_def.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/experimental_tools/tools_def.py b/client/ayon_core/tools/experimental_tools/tools_def.py index e3acfa72ab..30e5211b41 100644 --- a/client/ayon_core/tools/experimental_tools/tools_def.py +++ b/client/ayon_core/tools/experimental_tools/tools_def.py @@ -98,7 +98,7 @@ def __init__(self, parent_widget=None, refresh=True): ] ), ExperimentalHostTool( - "DebugExprimentalTool", + "pyblish_debug_stepper", "Pyblish Debug Stepper", "Debug Pyblish plugins step by step.", self._show_pyblish_debugger, @@ -173,12 +173,12 @@ def refresh_availability(self): # Because they will always be disabled due # to the fact their settings don't exist. experimental_settings.update({ - "DebugExprimentalTool": True, + "pyblish_debug_stepper": True, }) - for identifier, eperimental_tool in self.tools_by_identifier.items(): + 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: From d7a2c57fd836ae6e26bc0f7e02f3b46a81a70dd8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Jul 2024 13:54:41 +0200 Subject: [PATCH 005/266] Refactor OCIO config handling, introduce fallback mechanism - Added function to extract config path from profile data - Updated global config retrieval to use new function - Introduced fallback mechanism for missing product entities --- client/ayon_core/pipeline/colorspace.py | 67 +++++++++++++++++----- server/settings/conversion.py | 38 +++++++++++++ server/settings/main.py | 75 ++++++++++++++++++++++--- 3 files changed, 156 insertions(+), 24 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 099616ff4a..1986e2d407 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -699,6 +699,33 @@ def get_ocio_config_views(config_path): ) +def _get_config_path_from_profile_data( + data, data_type, template_data) -> dict: + """Get config path from profile data. + + Args: + data (dict[str, Any]): Profile data. + data_type (str): Profile type. + template_data (dict[str, Any]): Template data. + + Returns: + dict[str, str]: Config data with path and template. + """ + template = data[data_type] + result = StringTemplate.format_strict_template( + template, template_data + ) + normalized_path = str(result.normalized()) + if not os.path.exists(normalized_path): + log.warning(f"Path was not found '{normalized_path}'.") + return None + + return { + "path": normalized_path, + "template": template + } + + def _get_global_config_data( project_name, host_name, @@ -717,7 +744,7 @@ def _get_global_config_data( 2. Custom path to ocio config. 3. Path to 'ocioconfig' representation on product. Name of product can be defined in settings. Product name can be regex but exact match is - always preferred. + always preferred. Fallback can be defined in case no product is found. None is returned when no profile is found, when path @@ -755,19 +782,8 @@ def _get_global_config_data( profile_type = profile["type"] if profile_type in ("builtin_path", "custom_path"): - template = profile[profile_type] - result = StringTemplate.format_strict_template( - template, template_data - ) - normalized_path = str(result.normalized()) - if not os.path.exists(normalized_path): - log.warning(f"Path was not found '{normalized_path}'.") - return None - - return { - "path": normalized_path, - "template": template - } + return _get_config_path_from_profile_data( + profile, profile_type, template_data) # TODO decide if this is the right name for representation repre_name = "ocioconfig" @@ -778,7 +794,21 @@ def _get_global_config_data( return None folder_path = folder_info["path"] - product_name = profile["product_name"] + # Backward compatibility for old projects + # TODO remove in future 0.4.4 onwards + product_name = profile.get("product_name") + # TODO: this should be required after backward compatibility is removed + fallback_data = None + published_product_data = profile.get("published_product") + + if product_name is None and published_product_data is None: + log.warning("Product name or published product is missing.") + return None + + if published_product_data: + product_name = published_product_data["product_name"] + fallback_data = published_product_data["fallback"] + if folder_id is None: folder_entity = ayon_api.get_folder_by_path( project_name, folder_path, fields={"id"} @@ -798,6 +828,13 @@ def _get_global_config_data( ) } if not product_entities_by_name: + # TODO: make this required in future 0.4.4 onwards + if fallback_data: + # in case no product was found we need to use fallback + fallback_type = fallback_data["type"] + return _get_config_path_from_profile_data( + fallback_data, fallback_type, template_data + ) log.debug( f"No product entities were found for folder '{folder_path}' with" f" product name filter '{product_name}'." diff --git a/server/settings/conversion.py b/server/settings/conversion.py index f513738603..b29c827cae 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -4,6 +4,43 @@ from .publish_plugins import DEFAULT_PUBLISH_VALUES +def _convert_imageio_configs_0_4_3(overrides): + """Imageio config settings did change to profiles since 0.4.3.""" + imageio_overrides = overrides.get("imageio") or {} + + # make sure settings are already converted to profiles + if ( + "ocio_config_profiles" not in imageio_overrides + ): + return + + ocio_config_profiles = imageio_overrides["ocio_config_profiles"] + + for inx, profile in enumerate(ocio_config_profiles): + if profile["type"] != "product_name": + continue + + # create new profile + new_profile = { + "type": "published_product", + "published_product": { + "product_name": profile["product_name"], + "fallback": { + "type": "builtin_path", + "builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", + }, + }, + "host_names": profile["host_names"], + "task_names": profile["task_names"], + "task_types": profile["task_types"], + "custom_path": profile["custom_path"], + "builtin_path": profile["builtin_path"], + } + + # replace old profile with new profile + ocio_config_profiles[inx] = new_profile + + def _convert_imageio_configs_0_3_1(overrides): """Imageio config settings did change to profiles since 0.3.1. .""" imageio_overrides = overrides.get("imageio") or {} @@ -82,5 +119,6 @@ def convert_settings_overrides( overrides: dict[str, Any], ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) + _convert_imageio_configs_0_4_3(overrides) _conver_publish_plugins(overrides) return overrides diff --git a/server/settings/main.py b/server/settings/main.py index 0972ccdfb9..09c9bf0065 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -58,7 +58,14 @@ def _ocio_config_profile_types(): return [ {"value": "builtin_path", "label": "AYON built-in OCIO config"}, {"value": "custom_path", "label": "Path to OCIO config"}, - {"value": "product_name", "label": "Published product"}, + {"value": "published_product", "label": "Published product"}, + ] + + +def _fallback_ocio_config_profile_types(): + return [ + {"value": "builtin_path", "label": "AYON built-in OCIO config"}, + {"value": "custom_path", "label": "Path to OCIO config"}, ] @@ -76,6 +83,49 @@ def _ocio_built_in_paths(): ] +class FallbackProductModel(BaseSettingsModel): + _layout = "expanded" + type: str = SettingsField( + title="Fallback config type", + enum_resolver=_fallback_ocio_config_profile_types, + conditionalEnum=True, + default="builtin_path", + description=( + "Type of config which needs to be used in case published " + "product is not found." + ), + ) + builtin_path: str = SettingsField( + "ACES 1.2", + title="Built-in OCIO config", + enum_resolver=_ocio_built_in_paths, + description=( + "AYON ocio addon distributed OCIO config. " + "Activated addon in bundle is required: 'ayon_ocio' >= 1.1.1" + ), + ) + custom_path: str = SettingsField( + "", + title="OCIO config path", + description="Path to OCIO config. Anatomy formatting is supported.", + ) + + +class PublishedProductModel(BaseSettingsModel): + _layout = "expanded" + product_name: str = SettingsField( + "", + title="Product name", + description=( + "Context related published product name to get OCIO config from. " + "Partial match is supported via use of regex expression." + ), + ) + fallback: FallbackProductModel = SettingsField( + default_factory=FallbackProductModel, + ) + + class CoreImageIOConfigProfilesModel(BaseSettingsModel): _layout = "expanded" host_names: list[str] = SettingsField( @@ -102,19 +152,19 @@ class CoreImageIOConfigProfilesModel(BaseSettingsModel): "ACES 1.2", title="Built-in OCIO config", enum_resolver=_ocio_built_in_paths, + description=( + "AYON ocio addon distributed OCIO config. " + "Activated addon in bundle is required: 'ayon_ocio' >= 1.1.1" + ), ) custom_path: str = SettingsField( "", title="OCIO config path", description="Path to OCIO config. Anatomy formatting is supported.", ) - product_name: str = SettingsField( - "", - title="Product name", - description=( - "Published product name to get OCIO config from. " - "Partial match is supported." - ), + published_product: PublishedProductModel = SettingsField( + default_factory=PublishedProductModel, + title="Published product", ) @@ -294,7 +344,14 @@ def validate_json(cls, value): "type": "builtin_path", "builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", "custom_path": "", - "product_name": "", + "published_product": { + "product_name": "", + "fallback": { + "type": "builtin_path", + "builtin_path": "ACES 1.2", + "custom_path": "" + } + } } ], "file_rules": { From 9375b8bee2985f251e5aad1b6b4309caa1c4c5d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Jul 2024 13:57:47 +0200 Subject: [PATCH 006/266] Update version references for future removal and conversion functions in colorspace and conversion modules. - Update version references to 0.4.5 for future removal in colorspace module. - Update function name and version reference to 0.4.4 for imageio config conversion in the conversion module. --- client/ayon_core/pipeline/colorspace.py | 4 ++-- server/settings/conversion.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 1986e2d407..106d43d55a 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -795,7 +795,7 @@ def _get_global_config_data( folder_path = folder_info["path"] # Backward compatibility for old projects - # TODO remove in future 0.4.4 onwards + # TODO remove in future 0.4.5 onwards product_name = profile.get("product_name") # TODO: this should be required after backward compatibility is removed fallback_data = None @@ -828,7 +828,7 @@ def _get_global_config_data( ) } if not product_entities_by_name: - # TODO: make this required in future 0.4.4 onwards + # TODO: make this required in future 0.4.5 onwards if fallback_data: # in case no product was found we need to use fallback fallback_type = fallback_data["type"] diff --git a/server/settings/conversion.py b/server/settings/conversion.py index b29c827cae..d99483d21f 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -4,8 +4,8 @@ from .publish_plugins import DEFAULT_PUBLISH_VALUES -def _convert_imageio_configs_0_4_3(overrides): - """Imageio config settings did change to profiles since 0.4.3.""" +def _convert_imageio_configs_0_4_4(overrides): + """Imageio config settings did change to profiles since 0.4.4.""" imageio_overrides = overrides.get("imageio") or {} # make sure settings are already converted to profiles @@ -119,6 +119,6 @@ def convert_settings_overrides( overrides: dict[str, Any], ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) - _convert_imageio_configs_0_4_3(overrides) + _convert_imageio_configs_0_4_4(overrides) _conver_publish_plugins(overrides) return overrides From 1e026d8fcb10e053615b1795edc5157600935e32 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 1 Aug 2024 15:50:45 +0200 Subject: [PATCH 007/266] Refactor config data retrieval logic in colorspace module - Removed redundant folder info handling - Added fallback mechanism for missing folder info --- client/ayon_core/pipeline/colorspace.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 106d43d55a..57a36286db 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -788,12 +788,6 @@ def _get_global_config_data( # TODO decide if this is the right name for representation repre_name = "ocioconfig" - folder_info = template_data.get("folder") - if not folder_info: - log.warning("Folder info is missing.") - return None - folder_path = folder_info["path"] - # Backward compatibility for old projects # TODO remove in future 0.4.5 onwards product_name = profile.get("product_name") @@ -809,6 +803,23 @@ def _get_global_config_data( product_name = published_product_data["product_name"] fallback_data = published_product_data["fallback"] + folder_info = template_data.get("folder") + if not folder_info: + log.warning("Folder info is missing.") + + # TODO: this fallback should be required after backward compatibility + # is removed + if fallback_data: + log.info("Using fallback data for ocio config path.") + # in case no product was found we need to use fallback + fallback_type = fallback_data["type"] + return _get_config_path_from_profile_data( + fallback_data, fallback_type, template_data + ) + return None + + folder_path = folder_info["path"] + if folder_id is None: folder_entity = ayon_api.get_folder_by_path( project_name, folder_path, fields={"id"} @@ -827,6 +838,7 @@ def _get_global_config_data( fields={"id", "name"} ) } + if not product_entities_by_name: # TODO: make this required in future 0.4.5 onwards if fallback_data: @@ -874,6 +886,7 @@ def _get_global_config_data( path = get_representation_path_with_anatomy(repre_entity, anatomy) template = repre_entity["attrib"]["template"] + return { "path": path, "template": template, From f9e0b05bf2dac3f596a9c53f32368219072970dd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Aug 2024 13:27:05 +0200 Subject: [PATCH 008/266] Refactor fallback handling in colorspace module Improve handling of fallback data for OCIO config path. Simplified logic and error messages for better clarity. --- client/ayon_core/pipeline/colorspace.py | 53 +++++++++---------------- 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 57a36286db..82025fabaf 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -788,35 +788,27 @@ def _get_global_config_data( # TODO decide if this is the right name for representation repre_name = "ocioconfig" - # Backward compatibility for old projects - # TODO remove in future 0.4.5 onwards - product_name = profile.get("product_name") - # TODO: this should be required after backward compatibility is removed - fallback_data = None - published_product_data = profile.get("published_product") - - if product_name is None and published_product_data is None: - log.warning("Product name or published product is missing.") + published_product_data = profile["published_product"] + product_name = published_product_data["product_name"] + fallback_data = published_product_data["fallback"] + + if product_name == "": + log.error( + "Colorspace OCIO config path cannot be set. " + "Profile is set to published product but `Product name` is empty." + ) return None - if published_product_data: - product_name = published_product_data["product_name"] - fallback_data = published_product_data["fallback"] - folder_info = template_data.get("folder") if not folder_info: log.warning("Folder info is missing.") - # TODO: this fallback should be required after backward compatibility - # is removed - if fallback_data: - log.info("Using fallback data for ocio config path.") - # in case no product was found we need to use fallback - fallback_type = fallback_data["type"] - return _get_config_path_from_profile_data( - fallback_data, fallback_type, template_data - ) - return None + log.info("Using fallback data for ocio config path.") + # in case no product was found we need to use fallback + fallback_type = fallback_data["type"] + return _get_config_path_from_profile_data( + fallback_data, fallback_type, template_data + ) folder_path = folder_info["path"] @@ -840,18 +832,11 @@ def _get_global_config_data( } if not product_entities_by_name: - # TODO: make this required in future 0.4.5 onwards - if fallback_data: - # in case no product was found we need to use fallback - fallback_type = fallback_data["type"] - return _get_config_path_from_profile_data( - fallback_data, fallback_type, template_data - ) - log.debug( - f"No product entities were found for folder '{folder_path}' with" - f" product name filter '{product_name}'." + # in case no product was found we need to use fallback + fallback_type = fallback_data["type"] + return _get_config_path_from_profile_data( + fallback_data, fallback_type, template_data ) - return None # Try to use exact match first, otherwise use first available product product_entity = product_entities_by_name.get(product_name) From d51a04c8ac2b43f2b181709c728e7bda895e7658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 9 Aug 2024 13:31:25 +0200 Subject: [PATCH 009/266] Update client/ayon_core/pipeline/colorspace.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/colorspace.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 82025fabaf..867c3ec22a 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -700,18 +700,19 @@ def get_ocio_config_views(config_path): def _get_config_path_from_profile_data( - data, data_type, template_data) -> dict: + profile, profile_type, template_data +): """Get config path from profile data. Args: - data (dict[str, Any]): Profile data. - data_type (str): Profile type. + profile(dict[str, Any]): Profile data. + profile_type (str): Profile type. template_data (dict[str, Any]): Template data. Returns: dict[str, str]: Config data with path and template. """ - template = data[data_type] + template = profile[profile_type] result = StringTemplate.format_strict_template( template, template_data ) From f87057506a63c12fcc9835e4a093f60da9e1c52c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Aug 2024 13:34:46 +0200 Subject: [PATCH 010/266] adding space --- client/ayon_core/pipeline/colorspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 867c3ec22a..1e6f98f272 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -705,7 +705,7 @@ def _get_config_path_from_profile_data( """Get config path from profile data. Args: - profile(dict[str, Any]): Profile data. + profile (dict[str, Any]): Profile data. profile_type (str): Profile type. template_data (dict[str, Any]): Template data. From d1bae6d167e086333bd70ad637d52216981c8fac Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:29:48 +0200 Subject: [PATCH 011/266] don't store 'attr_plugins' --- client/ayon_core/pipeline/create/structures.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 4f7caa6e11..45839ddaf5 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -252,9 +252,6 @@ def __init__(self, parent, origin_data, attr_plugins=None): self.parent = parent self._origin_data = copy.deepcopy(origin_data) - attr_plugins = attr_plugins or [] - self.attr_plugins = attr_plugins - self._data = copy.deepcopy(origin_data) self._plugin_names_order = [] self._missing_plugins = [] @@ -325,10 +322,9 @@ def origin_data(self): def set_publish_plugins(self, attr_plugins): """Set publish plugins attribute definitions.""" - + attr_plugins = attr_plugins or [] self._plugin_names_order = [] self._missing_plugins = [] - self.attr_plugins = attr_plugins or [] origin_data = self._origin_data data = self._data From fe9cef1c8fe931324ac880d4bb68a66e431b84d5 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 22 Aug 2024 23:07:14 +0300 Subject: [PATCH 012/266] Easier settings for ExtractOIIOTranscode --- server/settings/publish_plugins.py | 42 +++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 8ca96432f4..cce1891fda 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -57,7 +57,7 @@ class CollectFramesFixDefModel(BaseSettingsModel): True, title="Show 'Rewrite latest version' toggle" ) - + class ContributionLayersModel(BaseSettingsModel): _layout = "compact" @@ -256,8 +256,8 @@ class ExtractThumbnailModel(BaseSettingsModel): def _extract_oiio_transcoding_type(): return [ - {"value": "colorspace", "label": "Use Colorspace"}, - {"value": "display", "label": "Use Display&View"} + {"value": "use_colorspace", "label": "Use Colorspace"}, + {"value": "use_display_view", "label": "Use Display&View"} ] @@ -266,6 +266,17 @@ class OIIOToolArgumentsModel(BaseSettingsModel): default_factory=list, title="Arguments") +class UseColorspaceModel(BaseSettingsModel): + _layout = "expanded" + colorspace: str = SettingsField("", title="Target Colorspace") + + +class UseDisplayViewModel(BaseSettingsModel): + _layout = "expanded" + display: str = SettingsField("", title="Target Display") + view: str = SettingsField("", title="Target View") + + class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): _layout = "expanded" name: str = SettingsField( @@ -276,13 +287,20 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): ) extension: str = SettingsField("", title="Extension") transcoding_type: str = SettingsField( - "colorspace", + "use_colorspace", title="Transcoding type", - enum_resolver=_extract_oiio_transcoding_type + enum_resolver=_extract_oiio_transcoding_type, + conditionalEnum=True + ) + use_colorspace: UseColorspaceModel = SettingsField( + title="Use Colorspace", + default_factory=UseColorspaceModel ) - colorspace: str = SettingsField("", title="Colorspace") - display: str = SettingsField("", title="Display") - view: str = SettingsField("", title="View") + use_display_view: UseDisplayViewModel = SettingsField( + title="Use Display&View", + default_factory=UseDisplayViewModel + ) + oiiotool_args: OIIOToolArgumentsModel = SettingsField( default_factory=OIIOToolArgumentsModel, title="OIIOtool arguments") @@ -360,7 +378,7 @@ class ExtractReviewFFmpegModel(BaseSettingsModel): def extract_review_filter_enum(): return [ { - "value": "everytime", + "value": "everytime", # codespell:ignore everytime "label": "Always" }, { @@ -382,7 +400,7 @@ class ExtractReviewFilterModel(BaseSettingsModel): default_factory=list, title="Custom Tags" ) single_frame_filter: str = SettingsField( - "everytime", + "everytime", # codespell:ignore everytime description=( "Use output always / only if input is 1 frame" " image / only if has 2+ frames or is video" @@ -780,7 +798,7 @@ class IntegrateHeroVersionModel(BaseSettingsModel): class CleanUpModel(BaseSettingsModel): _isGroup = True - paterns: list[str] = SettingsField( + paterns: list[str] = SettingsField( # codespell:ignore paterns default_factory=list, title="Patterns (regex)" ) @@ -1200,7 +1218,7 @@ class PublishPuginsModel(BaseSettingsModel): "use_hardlinks": False }, "CleanUp": { - "paterns": [], + "paterns": [], # codespell:ignore paterns "remove_temp_renders": False }, "CleanUpFarm": { From 00d7f6c378225b7643450605587bab0724243a3a Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 22 Aug 2024 23:08:42 +0300 Subject: [PATCH 013/266] adopt to the new settings --- .../plugins/publish/extract_color_transcode.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index a28a761e7e..c5f90e4c99 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -122,12 +122,15 @@ def process(self, instance): transcoding_type = output_def["transcoding_type"] target_colorspace = view = display = None - if transcoding_type == "colorspace": - target_colorspace = (output_def["colorspace"] or + # NOTE: we use colorspace_data as the fallback values for the target colorspace. + if transcoding_type == "use_colorspace": + # TODO: Should we fallback to the colorspace (which used as source above) ? + # or should we compute the target colorspace from current view and display ? + target_colorspace = (output_def["use_colorspace"]["colorspace"] or colorspace_data.get("colorspace")) - else: - view = output_def["view"] or colorspace_data.get("view") - display = (output_def["display"] or + elif transcoding_type == "use_display_view": + view = output_def["use_display_view"]["view"] or colorspace_data.get("view") + display = (output_def["use_display_view"]["display"] or colorspace_data.get("display")) # both could be already collected by DCC, @@ -192,7 +195,7 @@ def process(self, instance): new_repre["files"] = new_repre["files"][0] # If the source representation has "review" tag, but its not - # part of the output defintion tags, then both the + # part of the output definition tags, then both the # representations will be transcoded in ExtractReview and # their outputs will clash in integration. if "review" in repre.get("tags", []): From c337f2cc281c1d91967b9aa14fe1b19d1ea075fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 Aug 2024 16:50:47 +0200 Subject: [PATCH 014/266] added 2 new methods to be able to receive attribute definitions per instance --- .../pipeline/publish/publish_plugins.py | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 6b1984d92b..3593e781b4 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -1,6 +1,7 @@ import inspect from abc import ABCMeta import pyblish.api +import pyblish.logic from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin from ayon_core.lib import BoolDef @@ -114,10 +115,53 @@ def get_attribute_defs(cls): """Publish attribute definitions. Attributes available for all families in plugin's `families` attribute. + + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ + return [] + + @classmethod + def get_attribute_defs_for_context(cls, create_context): + """Publish attribute definitions for context. + + Attributes available for all families in plugin's `families` attribute. + + Args: + create_context (CreateContext): Create context. + + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ + if cls.__instanceEnabled__: + return [] + return cls.get_attribute_defs() + + @classmethod + def get_attribute_defs_for_instance(cls, create_context, instance): + """Publish attribute definitions for an instance. + + Attributes available for all families in plugin's `families` attribute. + + + Args: + create_context (CreateContext): Create context. + instance (CreatedInstance): Instance for which attributes are + collected. + Returns: - list: Attribute definitions for plugin. + list[AbstractAttrDef]: Attribute definitions for plugin. + """ + if not cls.__instanceEnabled__: + return [] + for _ in pyblish.logic.plugins_by_families( + [cls], [instance.product_type] + ): + return cls.get_attribute_defs() return [] @classmethod From 738cc82f2617bd1d336926535d00ac7c0e3ee03e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 Aug 2024 18:48:12 +0200 Subject: [PATCH 015/266] added helper methods to create plugin --- .../pipeline/create/creator_plugins.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 61c10ee736..3b90b11a51 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import copy import collections -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Dict, Any from abc import ABC, abstractmethod @@ -19,11 +19,12 @@ from .product_name import get_product_name from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator +from .structures import CreatedInstance if TYPE_CHECKING: from ayon_core.lib import AbstractAttrDef # Avoid cyclic imports - from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401 + from .context import CreateContext, UpdateData # noqa: F401 class ProductConvertorPlugin(ABC): @@ -362,6 +363,18 @@ def log(self): self._log = Logger.get_logger(self.__class__.__name__) return self._log + def _create_instance( + self, product_type: str, product_name: str, data: Dict[str, Any] + ) -> CreatedInstance: + instance = CreatedInstance( + product_type, + product_name, + data, + creator=self, + ) + self._add_instance_to_context(instance) + return instance + def _add_instance_to_context(self, instance): """Helper method to add instance to create context. @@ -551,6 +564,16 @@ def get_instance_attr_defs(self): return self.instance_attr_defs + def get_attr_defs_for_instance(self, instance): + """Get attribute definitions for an instance. + + Args: + instance (CreatedInstance): Instance for which to get + attribute definitions. + + """ + return self.get_instance_attr_defs() + @property def collection_shared_data(self): """Access to shared data that can be used during creator's collection. From cb3df926a9f31373c9728135c277e2a59aea5182 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 29 Aug 2024 18:59:56 +0200 Subject: [PATCH 016/266] attribute definitions are defined per instance --- client/ayon_core/pipeline/create/context.py | 88 +++++++-------- .../ayon_core/pipeline/create/structures.py | 103 +++++++----------- .../pipeline/publish/publish_plugins.py | 24 ++-- 3 files changed, 86 insertions(+), 129 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 3f067427fa..3387a5a5fa 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -6,7 +6,7 @@ import collections import inspect from contextlib import contextmanager -from typing import Optional +from typing import Optional, Dict, Any, Callable import pyblish.logic import pyblish.api @@ -171,7 +171,6 @@ def __init__( self.publish_plugins_mismatch_targets = [] self.publish_plugins = [] self.plugins_with_defs = [] - self._attr_plugins_by_product_type = {} # Helpers for validating context of collected instances # - they can be validation for multiple instances at one time @@ -564,9 +563,6 @@ def _reset_publish_plugins(self, discover_publish_plugins): publish_plugins_discover ) - # Reset publish plugins - self._attr_plugins_by_product_type = {} - discover_result = DiscoverResult(pyblish.api.Plugin) plugins_with_defs = [] plugins_by_targets = [] @@ -694,11 +690,29 @@ def reset_context_data(self): publish_attributes = original_data.get("publish_attributes") or {} - attr_plugins = self._get_publish_plugins_with_attr_for_context() self._publish_attributes = PublishAttributes( - self, publish_attributes, attr_plugins + self, publish_attributes ) + for plugin in self.plugins_with_defs: + if is_func_signature_supported( + plugin.convert_attribute_values, self, None + ): + plugin.convert_attribute_values(self, None) + + elif not plugin.__instanceEnabled__: + output = plugin.convert_attribute_values(publish_attributes) + if output: + publish_attributes.update(output) + + for plugin in self.plugins_with_defs: + attr_defs = plugin.get_attribute_defs_for_context(self) + if not attr_defs: + continue + self._publish_attributes.set_publish_plugin_attr_defs( + plugin.__name__, attr_defs + ) + def context_data_to_store(self): """Data that should be stored by host function. @@ -734,11 +748,25 @@ def creator_adds_instance(self, instance): return self._instances_by_id[instance.id] = instance + + publish_attributes = instance.publish_attributes # Prepare publish plugin attributes and set it on instance - attr_plugins = self._get_publish_plugins_with_attr_for_product_type( - instance.product_type - ) - instance.set_publish_plugins(attr_plugins) + for plugin in self.plugins_with_defs: + if is_func_signature_supported( + plugin.convert_attribute_values, self, instance + ): + plugin.convert_attribute_values(self, instance) + + elif plugin.__instanceEnabled__: + output = plugin.convert_attribute_values(publish_attributes) + if output: + publish_attributes.update(output) + + for plugin in self.plugins_with_defs: + attr_defs = plugin.get_attribute_defs_for_instance(self, instance) + if not attr_defs: + continue + instance.set_publish_plugin_attr_defs(plugin.__name__, attr_defs) # Add instance to be validated inside 'bulk_instances_collection' # context manager if is inside bulk @@ -1309,44 +1337,6 @@ def remove_instances(self, instances): if failed_info: raise CreatorsRemoveFailed(failed_info) - def _get_publish_plugins_with_attr_for_product_type(self, product_type): - """Publish plugin attributes for passed product type. - - Attribute definitions for specific product type are cached. - - Args: - product_type(str): Instance product type for which should be - attribute definitions returned. - """ - - if product_type not in self._attr_plugins_by_product_type: - import pyblish.logic - - filtered_plugins = pyblish.logic.plugins_by_families( - self.plugins_with_defs, [product_type] - ) - plugins = [] - for plugin in filtered_plugins: - if plugin.__instanceEnabled__: - plugins.append(plugin) - self._attr_plugins_by_product_type[product_type] = plugins - - return self._attr_plugins_by_product_type[product_type] - - def _get_publish_plugins_with_attr_for_context(self): - """Publish plugins attributes for Context plugins. - - Returns: - List[pyblish.api.Plugin]: Publish plugins that have attribute - definitions for context. - """ - - plugins = [] - for plugin in self.plugins_with_defs: - if not plugin.__instanceEnabled__: - plugins.append(plugin) - return plugins - @property def collection_shared_data(self): """Access to shared data that can be used during creator's collection. diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 45839ddaf5..81b0ce7d6d 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -4,6 +4,7 @@ from ayon_core.lib.attribute_definitions import ( UnknownDef, + UIDef, serialize_attr_defs, deserialize_attr_defs, ) @@ -248,15 +249,11 @@ class PublishAttributes: plugins that may have defined attribute definitions. """ - def __init__(self, parent, origin_data, attr_plugins=None): + def __init__(self, parent, origin_data): self.parent = parent self._origin_data = copy.deepcopy(origin_data) self._data = copy.deepcopy(origin_data) - self._plugin_names_order = [] - self._missing_plugins = [] - - self.set_publish_plugins(attr_plugins) def __getitem__(self, key): return self._data[key] @@ -287,10 +284,9 @@ def pop(self, key, default=None): if key not in self._data: return default - if key in self._missing_plugins: - self._missing_plugins.remove(key) - removed_item = self._data.pop(key) - return removed_item.data_to_store() + value = self._data[key] + if not isinstance(value, AttributeValues): + return self._data.pop(key) value_item = self._data[key] # Prepare value to return @@ -299,12 +295,6 @@ def pop(self, key, default=None): value_item.reset_values() return output - def plugin_names_order(self): - """Plugin names order by their 'order' attribute.""" - - for name in self._plugin_names_order: - yield name - def mark_as_stored(self): self._origin_data = copy.deepcopy(self.data_to_store()) @@ -320,40 +310,29 @@ def data_to_store(self): def origin_data(self): return copy.deepcopy(self._origin_data) - def set_publish_plugins(self, attr_plugins): - """Set publish plugins attribute definitions.""" - attr_plugins = attr_plugins or [] - self._plugin_names_order = [] - self._missing_plugins = [] + def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): + """Set attribute definitions for plugin. - origin_data = self._origin_data - data = self._data - self._data = {} - added_keys = set() - for plugin in attr_plugins: - output = plugin.convert_attribute_values(data) - if output is not None: - data = output - attr_defs = plugin.get_attribute_defs() - if not attr_defs: - continue + Args: + plugin_name(str): Name of plugin. + attr_defs(Optional[List[AbstractAttrDef]]): Attribute definitions. - key = plugin.__name__ - added_keys.add(key) - self._plugin_names_order.append(key) + """ + # TODO what if 'attr_defs' is 'None'? + value = self._data.get(plugin_name) + if value is None: + value = {} - value = data.get(key) or {} - orig_value = copy.deepcopy(origin_data.get(key) or {}) - self._data[key] = PublishAttributeValues( - self, attr_defs, value, orig_value - ) + for attr_def in attr_defs: + if isinstance(attr_def, (UIDef, UnknownDef)): + continue + key = attr_def.key + if key in value: + value[key] = attr_def.convert_value(value[key]) - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) + self._data[plugin_name] = PublishAttributeValues( + self, attr_defs, value, value + ) def serialize_attributes(self): return { @@ -361,14 +340,9 @@ def serialize_attributes(self): plugin_name: attrs_value.get_serialized_attr_defs() for plugin_name, attrs_value in self._data.items() }, - "plugin_names_order": self._plugin_names_order, - "missing_plugins": self._missing_plugins } def deserialize_attributes(self, data): - self._plugin_names_order = data["plugin_names_order"] - self._missing_plugins = data["missing_plugins"] - attr_defs = deserialize_attr_defs(data["attr_defs"]) origin_data = self._origin_data @@ -386,10 +360,7 @@ def deserialize_attributes(self, data): for key, value in data.items(): if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) + self._data[key] = value class CreatedInstance: @@ -445,7 +416,6 @@ def __init__( creator_identifier = creator.identifier group_label = creator.get_group_label() creator_label = creator.label - creator_attr_defs = creator.get_instance_attr_defs() self._creator_label = creator_label self._group_label = group_label or creator_identifier @@ -505,6 +475,9 @@ def __init__( # {key: value} creator_values = copy.deepcopy(orig_creator_attributes) + if creator is not None: + creator_attr_defs = creator.get_attr_defs_for_instance(self) + self._data["creator_attributes"] = CreatorAttributeValues( self, list(creator_attr_defs), @@ -514,9 +487,8 @@ def __init__( # Stored publish specific attribute values # {: {key: value}} - # - must be set using 'set_publish_plugins' self._data["publish_attributes"] = PublishAttributes( - self, orig_publish_attributes, None + self, orig_publish_attributes ) if data: self._data.update(data) @@ -745,18 +717,17 @@ def from_existing(cls, instance_data, creator): product_type, product_name, instance_data, creator ) - def set_publish_plugins(self, attr_plugins): - """Set publish plugins with attribute definitions. - - This method should be called only from 'CreateContext'. + def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): + """Set attribute definitions for publish plugin. Args: - attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which - inherit from 'AYONPyblishPluginMixin' and may contain - attribute definitions. - """ + plugin_name(str): Name of publish plugin. + attr_defs(List[AbstractAttrDef]): Attribute definitions. - self.publish_attributes.set_publish_plugins(attr_plugins) + """ + self.publish_attributes.set_publish_plugin_attr_defs( + plugin_name, attr_defs + ) def add_members(self, members): """Currently unused method.""" diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 3593e781b4..6ea2cb1efa 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -165,20 +165,16 @@ def get_attribute_defs_for_instance(cls, create_context, instance): return [] @classmethod - def convert_attribute_values(cls, attribute_values): - if cls.__name__ not in attribute_values: - return attribute_values - - plugin_values = attribute_values[cls.__name__] - - attr_defs = cls.get_attribute_defs() - for attr_def in attr_defs: - key = attr_def.key - if key in plugin_values: - plugin_values[key] = attr_def.convert_value( - plugin_values[key] - ) - return attribute_values + def convert_attribute_values(cls, create_context, instance): + """Convert attribute values for instance. + + Args: + create_context (CreateContext): Create context. + instance (CreatedInstance): Instance for which attributes are + converted. + + """ + return @staticmethod def get_attr_values_from_data_for_plugin(plugin, data): From 35d727747787d6f67bc73fb26bb2563e0fb5c969 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 30 Aug 2024 18:43:03 +0200 Subject: [PATCH 017/266] removed unused imports --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 3387a5a5fa..81e35a1c11 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -6,7 +6,7 @@ import collections import inspect from contextlib import contextmanager -from typing import Optional, Dict, Any, Callable +from typing import Optional import pyblish.logic import pyblish.api From 050f816ce4cca5c314c8cb19bf2e3af8088f34d7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:35:19 +0200 Subject: [PATCH 018/266] controller has new method 'get_error_info' --- client/ayon_core/tools/publisher/abstract.py | 9 ++- client/ayon_core/tools/publisher/control.py | 4 +- .../tools/publisher/models/__init__.py | 3 +- .../tools/publisher/models/publish.py | 58 ++++++++++++++----- .../tools/publisher/widgets/publish_frame.py | 6 +- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 362fa38882..78f7756c96 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -26,7 +26,7 @@ ) if TYPE_CHECKING: - from .models import CreatorItem + from .models import CreatorItem, PublishErrorInfo class CardMessageTypes: @@ -537,14 +537,13 @@ def get_publish_max_progress(self) -> int: pass @abstractmethod - def get_publish_error_msg(self) -> Union[str, None]: + def get_publish_error_info(self) -> Union["PublishErrorInfo", None]: """Current error message which cause fail of publishing. Returns: - Union[str, None]: Message which will be showed to artist or - None. - """ + Union[PublishErrorInfo, None]: Error info or None. + """ pass @abstractmethod diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 257b45de08..5c76e01f0c 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -493,8 +493,8 @@ def get_publish_max_progress(self): def get_publish_progress(self): return self._publish_model.get_progress() - def get_publish_error_msg(self): - return self._publish_model.get_error_msg() + def get_publish_error_info(self): + return self._publish_model.get_error_info() def get_publish_report(self): return self._publish_model.get_publish_report() diff --git a/client/ayon_core/tools/publisher/models/__init__.py b/client/ayon_core/tools/publisher/models/__init__.py index bd593be29b..07f061deaa 100644 --- a/client/ayon_core/tools/publisher/models/__init__.py +++ b/client/ayon_core/tools/publisher/models/__init__.py @@ -1,5 +1,5 @@ from .create import CreateModel, CreatorItem -from .publish import PublishModel +from .publish import PublishModel, PublishErrorInfo __all__ = ( @@ -7,4 +7,5 @@ "CreatorItem", "PublishModel", + "PublishErrorInfo", ) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index a60ef69fac..e15f4f6080 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -23,6 +23,40 @@ PLUGIN_ORDER_OFFSET = 0.5 +class PublishErrorInfo: + def __init__(self, description, title, detail): + self.description = description + self.title = title + self.detail = detail + + def __eq__(self, other): + if not isinstance(other, PublishErrorInfo): + return False + return ( + self.description == other.description + and self.title == other.title + and self.detail == other.detail + ) + + def __ne__(self, other): + return not self.__eq__(other) + + @classmethod + def from_exception(cls, exc): + title = "This is not your fault" + detail = ( + "Please report the error to your pipeline support" + " using one of the options below." + ) + if isinstance(exc, KnownPublishError): + msg = str(exc) + else: + msg = ( + "Something went wrong. Send report" + " to your supervisor or Ynput team." + ) + return cls(msg, title, detail) + class PublishReportMaker: """Report for single publishing process. @@ -801,7 +835,7 @@ def __init__(self, controller: AbstractPublisherBackend): self._publish_comment_is_set: bool = False # Any other exception that happened during publishing - self._publish_error_msg: Optional[str] = None + self._publish_error_info: Optional[PublishErrorInfo] = None # Publishing is in progress self._publish_is_running: bool = False # Publishing is over validation order @@ -851,7 +885,7 @@ def reset(self): self._publish_comment_is_set = False self._publish_has_started = False - self._set_publish_error_msg(None) + self._set_publish_error_info(None) self._set_progress(0) self._set_is_running(False) self._set_has_validated(False) @@ -977,8 +1011,8 @@ def get_publish_report(self) -> Dict[str, Any]: def get_validation_errors(self) -> PublishValidationErrorsReport: return self._publish_validation_errors.create_report() - def get_error_msg(self) -> Optional[str]: - return self._publish_error_msg + def get_error_info(self) -> Optional[PublishErrorInfo]: + return self._publish_error_info def set_comment(self, comment: str): # Ignore change of comment when publishing started @@ -1077,9 +1111,9 @@ def _set_progress(self, value: int): {"value": value} ) - def _set_publish_error_msg(self, value: Optional[str]): - if self._publish_error_msg != value: - self._publish_error_msg = value + def _set_publish_error_info(self, value: Optional[PublishErrorInfo]): + if self._publish_error_info != value: + self._publish_error_info = value self._emit_event( "publish.publish_error.changed", {"value": value} @@ -1234,14 +1268,8 @@ def _process_and_continue( self._add_validation_error(result) else: - if isinstance(exception, KnownPublishError): - msg = str(exception) - else: - msg = ( - "Something went wrong. Send report" - " to your supervisor or Ynput team." - ) - self._set_publish_error_msg(msg) + error_info = PublishErrorInfo.from_exception(exception) + self._set_publish_error_info(error_info) self._set_is_crashed(True) result["is_validation_error"] = has_validation_error diff --git a/client/ayon_core/tools/publisher/widgets/publish_frame.py b/client/ayon_core/tools/publisher/widgets/publish_frame.py index 6eaeb6daf2..d9a9e501ef 100644 --- a/client/ayon_core/tools/publisher/widgets/publish_frame.py +++ b/client/ayon_core/tools/publisher/widgets/publish_frame.py @@ -411,10 +411,8 @@ def _set_error_msg(self): """Show error message to artist on publish crash.""" self._set_main_label("Error happened") - - self._message_label_top.setText( - self._controller.get_publish_error_msg() - ) + error_info = self._controller.get_publish_error_info() + self._message_label_top.setText(error_info.description) self._set_success_property(1) From 830dd068696dc2651f496d97d7fb3c16a335ada9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:39:22 +0200 Subject: [PATCH 019/266] added new 'PublishArtistError' --- client/ayon_core/pipeline/__init__.py | 2 + client/ayon_core/pipeline/publish/__init__.py | 2 + .../pipeline/publish/publish_plugins.py | 37 +++++++++++++------ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 8fd00ee6b6..513dd10897 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -51,6 +51,7 @@ ) from .publish import ( + PublishArtistError, PublishValidationError, PublishXmlValidationError, KnownPublishError, @@ -164,6 +165,7 @@ "get_repres_contexts", # --- Publish --- + "PublishArtistError", "PublishValidationError", "PublishXmlValidationError", "KnownPublishError", diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index ab19b6e360..cf07d47e9a 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -9,6 +9,7 @@ AbstractMetaInstancePlugin, AbstractMetaContextPlugin, + PublishArtistError, PublishValidationError, PublishXmlValidationError, KnownPublishError, @@ -62,6 +63,7 @@ "AbstractMetaInstancePlugin", "AbstractMetaContextPlugin", + "PublishArtistError", "PublishValidationError", "PublishXmlValidationError", "KnownPublishError", diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 6b1984d92b..ababe1da1e 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -25,27 +25,40 @@ class AbstractMetaContextPlugin(ABCMeta, ExplicitMetaPlugin): pass -class PublishValidationError(Exception): - """Validation error happened during publishing. - - This exception should be used when validation publishing failed. +class PublishArtistError(Exception): + """Publishing crashed because of known error. - Has additional UI specific attributes that may be handy for artist. + Message will be shown in UI for artist. Args: - message(str): Message of error. Short explanation an issue. - title(str): Title showed in UI. All instances are grouped under - single title. - description(str): Detailed description of an error. It is possible - to use Markdown syntax. - """ + message (str): Message of error. Short explanation an issue. + title (Optional[str]): Title showed in UI. + description (Optional[str]): Detailed description of an error. + It is possible to use Markdown syntax. + """ def __init__(self, message, title=None, description=None, detail=None): self.message = message self.title = title self.description = description or message self.detail = detail - super(PublishValidationError, self).__init__(message) + super().__init__(message) + + +class PublishValidationError(PublishArtistError): + """Validation error happened during publishing. + + This exception should be used when validation publishing failed. + + Publishing does not stop during validation order if this + exception is raised. + + Has additional UI specific attributes that may be handy for artist. + + Argument 'title' is used to group errors. + + """ + pass class PublishXmlValidationError(PublishValidationError): From f5c331bcb982b20ed5bb02b97ceb88e851a1276a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:44:08 +0200 Subject: [PATCH 020/266] change docstring of KnownPublishError --- client/ayon_core/pipeline/__init__.py | 4 ++-- client/ayon_core/pipeline/publish/__init__.py | 6 ++++-- .../pipeline/publish/publish_plugins.py | 17 ++++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 513dd10897..cb9d4ee46a 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -51,10 +51,10 @@ ) from .publish import ( + KnownPublishError, PublishArtistError, PublishValidationError, PublishXmlValidationError, - KnownPublishError, AYONPyblishPluginMixin, OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, @@ -165,10 +165,10 @@ "get_repres_contexts", # --- Publish --- + "KnownPublishError", "PublishArtistError", "PublishValidationError", "PublishXmlValidationError", - "KnownPublishError", "AYONPyblishPluginMixin", "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index cf07d47e9a..3f0bc0bb85 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -9,10 +9,11 @@ AbstractMetaInstancePlugin, AbstractMetaContextPlugin, + KnownPublishError, PublishArtistError, PublishValidationError, PublishXmlValidationError, - KnownPublishError, + AYONPyblishPluginMixin, OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, @@ -63,10 +64,11 @@ "AbstractMetaInstancePlugin", "AbstractMetaContextPlugin", + "KnownPublishError", "PublishArtistError", "PublishValidationError", "PublishXmlValidationError", - "KnownPublishError", + "AYONPyblishPluginMixin", "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index ababe1da1e..70040d7395 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -25,6 +25,14 @@ class AbstractMetaContextPlugin(ABCMeta, ExplicitMetaPlugin): pass +class KnownPublishError(Exception): + """Publishing crashed because of known error. + + Artist can't affect source of the error. + """ + pass + + class PublishArtistError(Exception): """Publishing crashed because of known error. @@ -81,15 +89,6 @@ def __init__( ) -class KnownPublishError(Exception): - """Publishing crashed because of known error. - - Message will be shown in UI for artist. - """ - - pass - - class AYONPyblishPluginMixin: # TODO # executable_in_thread = False From f45af87787a8f06e656ddbbb24df043d940271f4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:48:00 +0200 Subject: [PATCH 021/266] handle PublishArtistError in a different way --- client/ayon_core/tools/publisher/models/publish.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index e15f4f6080..4afcc5772d 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -15,7 +15,10 @@ OptionalPyblishPluginMixin, ) from ayon_core.pipeline.plugin_discover import DiscoverResult -from ayon_core.pipeline.publish import get_publish_instance_label +from ayon_core.pipeline.publish import ( + get_publish_instance_label, + PublishArtistError, +) from ayon_core.tools.publisher.abstract import AbstractPublisherBackend PUBLISH_EVENT_SOURCE = "publisher.publish.model" @@ -43,6 +46,12 @@ def __ne__(self, other): @classmethod def from_exception(cls, exc): + if isinstance(exc, PublishArtistError): + return cls( + exc.description or exc.message, + title=exc.title, + detail=exc.detail, + ) title = "This is not your fault" detail = ( "Please report the error to your pipeline support" From 90bc20a783d8a95ee9d8a9eb9c325970153c268f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:48:20 +0200 Subject: [PATCH 022/266] modified crash widget to show error info --- .../tools/publisher/widgets/report_page.py | 176 +++++++++++------- 1 file changed, 110 insertions(+), 66 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index ecf1376ec0..f63b58eab2 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1454,6 +1454,73 @@ def set_plugins_filter(self, plugin_ids=None): self._update_instances() +class ErrorDetailWidget(QtWidgets.QWidget): + def __init__(self, parent): + super().__init__(parent) + + error_detail_top = ClickableFrame(self) + + error_detail_expand_btn = ClassicExpandBtn(error_detail_top) + error_detail_expand_label = QtWidgets.QLabel( + "Details", error_detail_top) + + line_widget = SeparatorWidget(1, parent=error_detail_top) + + error_detail_top_l = QtWidgets.QHBoxLayout(error_detail_top) + error_detail_top_l.setContentsMargins(0, 0, 10, 0) + error_detail_top_l.addWidget(error_detail_expand_btn, 0) + error_detail_top_l.addWidget(error_detail_expand_label, 0) + error_detail_top_l.addWidget(line_widget, 1) + + error_detail_input = ExpandingTextEdit(self) + error_detail_input.setObjectName("InfoText") + error_detail_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(error_detail_top, 0) + main_layout.addWidget(error_detail_input, 0) + main_layout.addStretch(1) + + error_detail_top.clicked.connect(self._on_detail_toggle) + + error_detail_input.setVisible(not error_detail_expand_btn.collapsed) + + self._error_detail_top = error_detail_top + self._error_detail_expand_btn = error_detail_expand_btn + self._error_detail_input = error_detail_input + + def set_detail(self, detail): + if not detail: + self._set_visible_inputs(False) + return + + if commonmark: + self._error_detail_input.setHtml( + commonmark.commonmark(detail) + ) + + elif hasattr(self._error_detail_input, "setMarkdown"): + self._error_detail_input.setMarkdown(detail) + + else: + self._error_detail_input.setText(detail) + + self._set_visible_inputs(True) + + def _set_visible_inputs(self, visible): + self._error_detail_top.setVisible(visible) + self._error_detail_input.setVisible(visible) + + def _on_detail_toggle(self): + self._error_detail_expand_btn.set_collapsed() + self._error_detail_input.setVisible( + not self._error_detail_expand_btn.collapsed + ) + + class CrashWidget(QtWidgets.QWidget): """Widget shown when publishing crashes. @@ -1466,20 +1533,16 @@ def __init__( ): super().__init__(parent) - main_label = QtWidgets.QLabel("This is not your fault", self) - main_label.setAlignment(QtCore.Qt.AlignCenter) - main_label.setObjectName("PublishCrashMainLabel") + title_label = QtWidgets.QLabel("", self) + title_label.setAlignment(QtCore.Qt.AlignCenter) + title_label.setObjectName("PublishCrashMainLabel") - report_label = QtWidgets.QLabel( - ( - "Please report the error to your pipeline support" - " using one of the options below." - ), - self - ) - report_label.setAlignment(QtCore.Qt.AlignCenter) - report_label.setWordWrap(True) - report_label.setObjectName("PublishCrashReportLabel") + description_label = QtWidgets.QLabel("", self) + description_label.setAlignment(QtCore.Qt.AlignCenter) + description_label.setWordWrap(True) + description_label.setObjectName("PublishCrashReportLabel") + + detail_widget = ErrorDetailWidget(self) btns_widget = QtWidgets.QWidget(self) copy_clipboard_btn = QtWidgets.QPushButton( @@ -1488,6 +1551,8 @@ def __init__( "Save to disk", btns_widget) btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.setSpacing(0) btns_layout.addStretch(1) btns_layout.addWidget(copy_clipboard_btn, 0) btns_layout.addSpacing(20) @@ -1495,19 +1560,38 @@ def __init__( btns_layout.addStretch(1) layout = QtWidgets.QVBoxLayout(self) - layout.addStretch(1) - layout.addWidget(main_label, 0) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(0) + layout.addSpacing(20) + layout.addWidget(title_label, 0) layout.addSpacing(20) - layout.addWidget(report_label, 0) + layout.addWidget(description_label, 0) layout.addSpacing(20) + layout.addWidget(detail_widget, 1) + layout.addSpacing(10) layout.addWidget(btns_widget, 0) - layout.addStretch(2) + layout.addSpacing(10) copy_clipboard_btn.clicked.connect(self._on_copy_to_clipboard) save_to_disk_btn.clicked.connect(self._on_save_to_disk_click) + self._title_label = title_label + self._description_label = description_label + self._detail_widget = detail_widget self._controller: AbstractPublisherFrontend = controller + def update_error_info(self): + error_info = self._controller.get_publish_error_info() + if error_info is None: + self._title_label.setText("Placeholder title") + self._description_label.setText("A bug happened if you see this") + self._detail_widget.set_detail(None) + return + + self._title_label.setText(error_info.title) + self._description_label.setText(error_info.description) + self._detail_widget.set_detail(error_info.detail) + def _on_copy_to_clipboard(self): self._controller.emit_event( "copy_report.request", {}, "report_page") @@ -1517,7 +1601,7 @@ def _on_save_to_disk_click(self): "export_report.request", {}, "report_page") -class ErrorDetailsWidget(QtWidgets.QWidget): +class PublishFailWidget(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) @@ -1529,35 +1613,7 @@ def __init__(self, parent): QtCore.Qt.TextBrowserInteraction ) - # Error 'Details' widget -> Collapsible - error_details_widget = QtWidgets.QWidget(inputs_widget) - - error_details_top = ClickableFrame(error_details_widget) - - error_details_expand_btn = ClassicExpandBtn(error_details_top) - error_details_expand_label = QtWidgets.QLabel( - "Details", error_details_top) - - line_widget = SeparatorWidget(1, parent=error_details_top) - - error_details_top_l = QtWidgets.QHBoxLayout(error_details_top) - error_details_top_l.setContentsMargins(0, 0, 10, 0) - error_details_top_l.addWidget(error_details_expand_btn, 0) - error_details_top_l.addWidget(error_details_expand_label, 0) - error_details_top_l.addWidget(line_widget, 1) - - error_details_input = ExpandingTextEdit(error_details_widget) - error_details_input.setObjectName("InfoText") - error_details_input.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - error_details_input.setVisible(not error_details_expand_btn.collapsed) - - error_details_layout = QtWidgets.QVBoxLayout(error_details_widget) - error_details_layout.setContentsMargins(0, 0, 0, 0) - error_details_layout.addWidget(error_details_top, 0) - error_details_layout.addWidget(error_details_input, 0) - error_details_layout.addStretch(1) + error_details_widget = ErrorDetailWidget(inputs_widget) # Description and Details layout inputs_layout = QtWidgets.QVBoxLayout(inputs_widget) @@ -1570,17 +1626,8 @@ def __init__(self, parent): main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(inputs_widget, 1) - error_details_top.clicked.connect(self._on_detail_toggle) - - self._error_details_widget = error_details_widget self._error_description_input = error_description_input - self._error_details_expand_btn = error_details_expand_btn - self._error_details_input = error_details_input - - def _on_detail_toggle(self): - self._error_details_expand_btn.set_collapsed() - self._error_details_input.setVisible( - not self._error_details_expand_btn.collapsed) + self._error_details_widget = error_details_widget def set_error_item(self, error_item): detail = "" @@ -1589,23 +1636,18 @@ def set_error_item(self, error_item): description = error_item.description or description detail = error_item.detail or detail + self._error_details_widget.set_detail(detail) + if commonmark: self._error_description_input.setHtml( commonmark.commonmark(description) ) - self._error_details_input.setHtml( - commonmark.commonmark(detail) - ) - elif hasattr(self._error_details_input, "setMarkdown"): + elif hasattr(self._error_description_input, "setMarkdown"): self._error_description_input.setMarkdown(description) - self._error_details_input.setMarkdown(detail) else: self._error_description_input.setText(description) - self._error_details_input.setText(detail) - - self._error_details_widget.setVisible(bool(detail)) class ReportsWidget(QtWidgets.QWidget): @@ -1671,7 +1713,7 @@ def __init__( detail_input_scroll = QtWidgets.QScrollArea(pages_widget) - detail_inputs_widget = ErrorDetailsWidget(detail_input_scroll) + detail_inputs_widget = PublishFailWidget(detail_input_scroll) detail_inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) detail_input_scroll.setWidget(detail_inputs_widget) @@ -1768,6 +1810,8 @@ def update_data(self): self._crash_widget.setVisible(is_crashed) self._logs_view.setVisible(not is_crashed) + self._crash_widget.update_error_info() + # Instance view & logs update instance_items = self._get_instance_items() self._instances_view.update_instances(instance_items) From 2c362fb35fa73c35aa017402ded1619291e01d2c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:48:31 +0200 Subject: [PATCH 023/266] removed detail from default behavior --- client/ayon_core/tools/publisher/models/publish.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 4afcc5772d..020436a466 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -53,10 +53,6 @@ def from_exception(cls, exc): detail=exc.detail, ) title = "This is not your fault" - detail = ( - "Please report the error to your pipeline support" - " using one of the options below." - ) if isinstance(exc, KnownPublishError): msg = str(exc) else: @@ -64,7 +60,7 @@ def from_exception(cls, exc): "Something went wrong. Send report" " to your supervisor or Ynput team." ) - return cls(msg, title, detail) + return cls(msg, title) class PublishReportMaker: From a1ed54c44db24e9ff797c20eff5a6cb7372a5270 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:57:26 +0200 Subject: [PATCH 024/266] added some typehints --- .../tools/publisher/models/publish.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 020436a466..b01cfb30f9 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -27,12 +27,17 @@ class PublishErrorInfo: - def __init__(self, description, title, detail): - self.description = description - self.title = title - self.detail = detail + def __init__( + self, + description: str, + title: Optional[str], + detail: Optional[str] + ): + self.description: str = description + self.title: Optional[str] = title + self.detail: Optional[str] = detail - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: if not isinstance(other, PublishErrorInfo): return False return ( @@ -41,11 +46,11 @@ def __eq__(self, other): and self.detail == other.detail ) - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: return not self.__eq__(other) @classmethod - def from_exception(cls, exc): + def from_exception(cls, exc) -> "PublishErrorInfo": if isinstance(exc, PublishArtistError): return cls( exc.description or exc.message, @@ -60,7 +65,7 @@ def from_exception(cls, exc): "Something went wrong. Send report" " to your supervisor or Ynput team." ) - return cls(msg, title) + return cls(msg, title, None) class PublishReportMaker: From 82ac6b332ca0aedd28d8b6f6bb162482656a80c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:11:13 +0200 Subject: [PATCH 025/266] normalize collapse/expand pixmaps --- client/ayon_core/tools/utils/widgets.py | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 73c8819758..4c2b418c41 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -540,11 +540,38 @@ class ClassicExpandBtnLabel(ExpandBtnLabel): right_arrow_path = get_style_image_path("right_arrow") down_arrow_path = get_style_image_path("down_arrow") + def _normalize_pixmap(self, pixmap): + if pixmap.width() == pixmap.height(): + return pixmap + width = pixmap.width() + height = pixmap.height() + size = max(width, height) + pos_x = 0 + pos_y = 0 + if width > height: + pos_y = (size - height) // 2 + else: + pos_x = (size - width) // 2 + + new_pix = QtGui.QPixmap(size, size) + new_pix.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(new_pix) + render_hints = ( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + render_hints |= QtGui.QPainter.HighQualityAntialiasing + painter.setRenderHints(render_hints) + painter.drawPixmap(QtCore.QPoint(pos_x, pos_y), pixmap) + painter.end() + return new_pix + def _create_collapsed_pixmap(self): - return QtGui.QPixmap(self.right_arrow_path) + return self._normalize_pixmap(QtGui.QPixmap(self.right_arrow_path)) def _create_expanded_pixmap(self): - return QtGui.QPixmap(self.down_arrow_path) + return self._normalize_pixmap(QtGui.QPixmap(self.down_arrow_path)) class ClassicExpandBtn(ExpandBtn): From cd6739763c326a888dd1bef7e069340f29f7bc3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:21:41 +0200 Subject: [PATCH 026/266] fix hiding details --- .../ayon_core/tools/publisher/widgets/report_page.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index f63b58eab2..e9fbd13808 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1486,12 +1486,18 @@ def __init__(self, parent): error_detail_top.clicked.connect(self._on_detail_toggle) - error_detail_input.setVisible(not error_detail_expand_btn.collapsed) - self._error_detail_top = error_detail_top self._error_detail_expand_btn = error_detail_expand_btn self._error_detail_input = error_detail_input + def showEvent(self, event): + super().showEvent(event) + # Calling this in __init__ does not seem to propagate the visibility + # correctly + self._error_detail_input.setVisible( + not self._error_detail_expand_btn.collapsed + ) + def set_detail(self, detail): if not detail: self._set_visible_inputs(False) From 10684239e936ea88ade6c60d0e5c8e0adca6861f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:21:55 +0200 Subject: [PATCH 027/266] added line at the start of details too (shorter) --- client/ayon_core/tools/publisher/widgets/report_page.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index e9fbd13808..09b722ea54 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1460,17 +1460,19 @@ def __init__(self, parent): error_detail_top = ClickableFrame(self) + line_l_widget = SeparatorWidget(1, parent=error_detail_top) error_detail_expand_btn = ClassicExpandBtn(error_detail_top) error_detail_expand_label = QtWidgets.QLabel( "Details", error_detail_top) - line_widget = SeparatorWidget(1, parent=error_detail_top) + line_r_widget = SeparatorWidget(1, parent=error_detail_top) error_detail_top_l = QtWidgets.QHBoxLayout(error_detail_top) error_detail_top_l.setContentsMargins(0, 0, 10, 0) + error_detail_top_l.addWidget(line_l_widget, 1) error_detail_top_l.addWidget(error_detail_expand_btn, 0) error_detail_top_l.addWidget(error_detail_expand_label, 0) - error_detail_top_l.addWidget(line_widget, 1) + error_detail_top_l.addWidget(line_r_widget, 9) error_detail_input = ExpandingTextEdit(self) error_detail_input.setObjectName("InfoText") From 71623cd0bdbc2bf5aaa378b455d042dc95d6d27a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:32:52 +0200 Subject: [PATCH 028/266] fill title if is not avaialble in exception --- client/ayon_core/tools/publisher/models/publish.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index b01cfb30f9..7fc4ca8bb4 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -51,13 +51,13 @@ def __ne__(self, other: Any) -> bool: @classmethod def from_exception(cls, exc) -> "PublishErrorInfo": + title = "This is not your fault" if isinstance(exc, PublishArtistError): return cls( exc.description or exc.message, - title=exc.title, + title=exc.title or title, detail=exc.detail, ) - title = "This is not your fault" if isinstance(exc, KnownPublishError): msg = str(exc) else: From 47e921ac43cd51c684efff8da9538b6d1ef977e7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 15:31:15 +0200 Subject: [PATCH 029/266] renamed 'PublishArtistError' to 'PublishError' --- client/ayon_core/pipeline/__init__.py | 4 ++-- client/ayon_core/pipeline/publish/__init__.py | 4 ++-- client/ayon_core/pipeline/publish/publish_plugins.py | 4 ++-- client/ayon_core/tools/publisher/models/publish.py | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index cb9d4ee46a..e17ef66557 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -52,7 +52,7 @@ from .publish import ( KnownPublishError, - PublishArtistError, + PublishError, PublishValidationError, PublishXmlValidationError, AYONPyblishPluginMixin, @@ -166,7 +166,7 @@ # --- Publish --- "KnownPublishError", - "PublishArtistError", + "PublishError", "PublishValidationError", "PublishXmlValidationError", "AYONPyblishPluginMixin", diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index 3f0bc0bb85..80671a43ac 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -10,7 +10,7 @@ AbstractMetaContextPlugin, KnownPublishError, - PublishArtistError, + PublishError, PublishValidationError, PublishXmlValidationError, @@ -65,7 +65,7 @@ "AbstractMetaContextPlugin", "KnownPublishError", - "PublishArtistError", + "PublishError", "PublishValidationError", "PublishXmlValidationError", diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 70040d7395..4e7c5900c7 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -33,7 +33,7 @@ class KnownPublishError(Exception): pass -class PublishArtistError(Exception): +class PublishError(Exception): """Publishing crashed because of known error. Message will be shown in UI for artist. @@ -53,7 +53,7 @@ def __init__(self, message, title=None, description=None, detail=None): super().__init__(message) -class PublishValidationError(PublishArtistError): +class PublishValidationError(PublishError): """Validation error happened during publishing. This exception should be used when validation publishing failed. diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 7fc4ca8bb4..e0ceea825a 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -17,7 +17,7 @@ from ayon_core.pipeline.plugin_discover import DiscoverResult from ayon_core.pipeline.publish import ( get_publish_instance_label, - PublishArtistError, + PublishError, ) from ayon_core.tools.publisher.abstract import AbstractPublisherBackend @@ -52,7 +52,7 @@ def __ne__(self, other: Any) -> bool: @classmethod def from_exception(cls, exc) -> "PublishErrorInfo": title = "This is not your fault" - if isinstance(exc, PublishArtistError): + if isinstance(exc, PublishError): return cls( exc.description or exc.message, title=exc.title or title, From 4875cbe67f6f35cf7d4b169eba94772371b6b79a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 16:20:41 +0200 Subject: [PATCH 030/266] added deprecation warning --- client/ayon_core/pipeline/publish/publish_plugins.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 4e7c5900c7..d8ed4ec10c 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -29,6 +29,10 @@ class KnownPublishError(Exception): """Publishing crashed because of known error. Artist can't affect source of the error. + + Deprecated: + Please use `PublishError` instead. Marked as deprecated 24/09/02. + """ pass From c70a44f1f4ad784cdfe689e7941d0b4e73c17a43 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:11:31 +0200 Subject: [PATCH 031/266] updated readme --- client/ayon_core/pipeline/publish/README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/publish/README.md b/client/ayon_core/pipeline/publish/README.md index ee2124dfd3..316a0ee0f9 100644 --- a/client/ayon_core/pipeline/publish/README.md +++ b/client/ayon_core/pipeline/publish/README.md @@ -4,17 +4,19 @@ AYON is using `pyblish` for publishing process which is a little bit extented an ## Exceptions AYON define few specific exceptions that should be used in publish plugins. -### Validation exception -Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. +### Publish error +Exception `PublishError` can be raised on known error. The message is shown to artist. +- **message** Error message. +- **title** Short description of error (2-5 words). Title can be used for grouping of exceptions per plugin. +- **description** Override of 'message' for UI, you can add markdown and html. By default, is filled with 'message'. +- **detail** Additional detail message that is hidden under collapsed component. -Exception `PublishValidationError` 3 arguments: -- **message** Which is not used in UI but for headless publishing. -- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin. -- **description** Detailed description of happened issue where markdown and html can be used. +Arguments `title`, `description` and `detail` are optional. Title is filled with generic message "This is not your fault" if is not passed. +### Validation exception +Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. -### Known errors -When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. +Exception expect same arguments as `PublishError`. Value of `title` is filled with plugin label if is not passed. ## Plugin extension Publish plugins can be extended by additional logic when inherits from `AYONPyblishPluginMixin` which can be used as mixin (additional inheritance of class). From d90310079cbd7a81a032be3f3d50f7346805ff76 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 3 Sep 2024 15:13:36 +0300 Subject: [PATCH 032/266] refactor settings names --- .../plugins/publish/extract_color_transcode.py | 15 +++++++++------ server/settings/publish_plugins.py | 18 +++++------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index c5f90e4c99..77ab6d0428 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -123,15 +123,18 @@ def process(self, instance): target_colorspace = view = display = None # NOTE: we use colorspace_data as the fallback values for the target colorspace. - if transcoding_type == "use_colorspace": + if transcoding_type == "colorspace": # TODO: Should we fallback to the colorspace (which used as source above) ? # or should we compute the target colorspace from current view and display ? - target_colorspace = (output_def["use_colorspace"]["colorspace"] or + target_colorspace = (output_def["colorspace"] or colorspace_data.get("colorspace")) - elif transcoding_type == "use_display_view": - view = output_def["use_display_view"]["view"] or colorspace_data.get("view") - display = (output_def["use_display_view"]["display"] or - colorspace_data.get("display")) + elif transcoding_type == "display_view": + display_view = output_def["display_view"] + view = display_view["view"] or colorspace_data.get("view") + display = ( + display_view["display"] + or colorspace_data.get("display") + ) # both could be already collected by DCC, # but could be overwritten when transcoding diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index cbfab4c5c6..98a50e6101 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -267,8 +267,8 @@ class ExtractThumbnailModel(BaseSettingsModel): def _extract_oiio_transcoding_type(): return [ - {"value": "use_colorspace", "label": "Use Colorspace"}, - {"value": "use_display_view", "label": "Use Display&View"} + {"value": "colorspace", "label": "Use Colorspace"}, + {"value": "display_view", "label": "Use Display&View"} ] @@ -277,11 +277,6 @@ class OIIOToolArgumentsModel(BaseSettingsModel): default_factory=list, title="Arguments") -class UseColorspaceModel(BaseSettingsModel): - _layout = "expanded" - colorspace: str = SettingsField("", title="Target Colorspace") - - class UseDisplayViewModel(BaseSettingsModel): _layout = "expanded" display: str = SettingsField("", title="Target Display") @@ -298,16 +293,13 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): ) extension: str = SettingsField("", title="Extension") transcoding_type: str = SettingsField( - "use_colorspace", + "colorspace", title="Transcoding type", enum_resolver=_extract_oiio_transcoding_type, conditionalEnum=True ) - use_colorspace: UseColorspaceModel = SettingsField( - title="Use Colorspace", - default_factory=UseColorspaceModel - ) - use_display_view: UseDisplayViewModel = SettingsField( + colorspace: str = SettingsField("", title="Target Colorspace") + display_view: UseDisplayViewModel = SettingsField( title="Use Display&View", default_factory=UseDisplayViewModel ) From d970a1cf20d5440301c48203efa9e131e0c39bc9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 4 Sep 2024 10:25:37 +0200 Subject: [PATCH 033/266] remove line --- client/ayon_core/pipeline/publish/publish_plugins.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 6ea2cb1efa..147b1d3a6d 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -145,7 +145,6 @@ def get_attribute_defs_for_instance(cls, create_context, instance): Attributes available for all families in plugin's `families` attribute. - Args: create_context (CreateContext): Create context. instance (CreatedInstance): Instance for which attributes are From 49448fc3ffb13d04281e9bb691ab443f0ed2dbd2 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 5 Sep 2024 16:08:03 +0300 Subject: [PATCH 034/266] fix running builder on start if scene is the default scene --- .../workfile/workfile_template_builder.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7b15dff049..1fce07a722 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -533,29 +533,20 @@ def build_template( if create_first_version: created_version_workfile = self.create_first_workfile_version() - # if first version is created, import template - # and populate placeholders + # Build the template if ( create_first_version and workfile_creation_enabled - and created_version_workfile + and (created_version_workfile or self.host.get_current_workfile() is None) ): self.import_template(template_path) self.populate_scene_placeholders( level_limit, keep_placeholders) - # save workfile after template is populated + # save workfile if a workfile was created. + if created_version_workfile: self.save_workfile(created_version_workfile) - # ignore process if first workfile is enabled - # but a version is already created - if workfile_creation_enabled: - return - - self.import_template(template_path) - self.populate_scene_placeholders( - level_limit, keep_placeholders) - def rebuild_template(self): """Go through existing placeholders in scene and update them. From e4881e2fec7599863e311fd9124db89bc35fd2b8 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 5 Sep 2024 22:04:55 +0300 Subject: [PATCH 035/266] refactor and simplify `build_template` method --- .../workfile/workfile_template_builder.py | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 1fce07a722..283408452e 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -512,39 +512,28 @@ def build_template( process if version is created """ - if any( - value is None - for value in [ - template_path, - keep_placeholders, - create_first_version, - ] - ): - template_preset = self.get_template_preset() - if template_path is None: - template_path = template_preset["path"] + # Get default values if not provided + if template_path is None or keep_placeholders is None or create_first_version is None: + preset = self.get_template_preset() + template_path = template_path or preset["path"] if keep_placeholders is None: - keep_placeholders = template_preset["keep_placeholder"] + keep_placeholders = preset["keep_placeholder"] if create_first_version is None: - create_first_version = template_preset["create_first_version"] + create_first_version = preset["create_first_version"] # check if first version is created - created_version_workfile = False - if create_first_version: - created_version_workfile = self.create_first_workfile_version() + created_version_workfile = create_first_version and self.create_first_workfile_version() # Build the template if ( - create_first_version - and workfile_creation_enabled - and (created_version_workfile or self.host.get_current_workfile() is None) + not self.host.get_current_workfile() or created_version_workfile ): self.import_template(template_path) self.populate_scene_placeholders( level_limit, keep_placeholders) # save workfile if a workfile was created. - if created_version_workfile: + if workfile_creation_enabled and created_version_workfile: self.save_workfile(created_version_workfile) def rebuild_template(self): From 274585ced6e7a7c1c4582e0c28ddf0346b4a9039 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 6 Sep 2024 11:41:21 +0300 Subject: [PATCH 036/266] Optimize the code --- .../workfile/workfile_template_builder.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 283408452e..abee71f8ec 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -521,19 +521,26 @@ def build_template( if create_first_version is None: create_first_version = preset["create_first_version"] - # check if first version is created + # Create, open and return the first workfile version. + # This only works if there are no existent files and + # create_first_version is enabled. created_version_workfile = create_first_version and self.create_first_workfile_version() - # Build the template - if ( - not self.host.get_current_workfile() or created_version_workfile - ): + # # Abort the process if workfile_creation_enabled. + # if workfile_creation_enabled: + # return + + # Build the template if the current scene is empty + # or if we have created new file. + # which basically avoids any + if not self.host.get_current_workfile() or created_version_workfile: + self.log.info(f"Building the workfile template: {template_path}") self.import_template(template_path) self.populate_scene_placeholders( level_limit, keep_placeholders) # save workfile if a workfile was created. - if workfile_creation_enabled and created_version_workfile: + if created_version_workfile: self.save_workfile(created_version_workfile) def rebuild_template(self): @@ -833,11 +840,10 @@ def get_template_preset(self): keep_placeholder = True if not path: - raise TemplateLoadFailed(( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles" - ).format(host_name.title())) + self.log.info( + "Template path is not set." + ) + return # Try fill path with environments and anatomy roots anatomy = Anatomy(project_name) From df203db3d7fa5c9e2d8877f550372c96b8496616 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 17:16:28 +0200 Subject: [PATCH 037/266] Refactor the `build_template` function for readability --- .../workfile/workfile_template_builder.py | 68 +++++++++++-------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index abee71f8ec..c535173038 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -15,6 +15,7 @@ import re import collections import copy +import warnings from abc import ABC, abstractmethod from ayon_api import ( @@ -489,7 +490,7 @@ def build_template( level_limit=None, keep_placeholders=None, create_first_version=None, - workfile_creation_enabled=False + workfile_creation_enabled=None ): """Main callback for building workfile from template path. @@ -507,41 +508,49 @@ def build_template( hosts to decide if they want to remove placeholder after it is used. create_first_version (bool): create first version of a workfile - workfile_creation_enabled (bool): If True, it might create - first version but ignore - process if version is created + workfile_creation_enabled (Any): Deprecated unused variable. """ + + if workfile_creation_enabled is not None: + warnings.warn( + ( + "Using deprecated argument `workfile_creation_enabled`. " + "It has been removed because it remained unused." + ), + category=DeprecationWarning + ) + # Get default values if not provided - if template_path is None or keep_placeholders is None or create_first_version is None: + if ( + template_path is None + or keep_placeholders is None + or create_first_version is None + ): preset = self.get_template_preset() - template_path = template_path or preset["path"] + template_path: str = template_path or preset["path"] if keep_placeholders is None: - keep_placeholders = preset["keep_placeholder"] + keep_placeholders: bool = preset["keep_placeholder"] if create_first_version is None: - create_first_version = preset["create_first_version"] - - # Create, open and return the first workfile version. - # This only works if there are no existent files and - # create_first_version is enabled. - created_version_workfile = create_first_version and self.create_first_workfile_version() - - # # Abort the process if workfile_creation_enabled. - # if workfile_creation_enabled: - # return + create_first_version: bool = preset["create_first_version"] - # Build the template if the current scene is empty - # or if we have created new file. - # which basically avoids any - if not self.host.get_current_workfile() or created_version_workfile: + # Build the template if current workfile is a new unsaved file + # (that's detected by checking if it returns any current filepath) + if not self.host.get_current_workfile(): self.log.info(f"Building the workfile template: {template_path}") self.import_template(template_path) self.populate_scene_placeholders( level_limit, keep_placeholders) - # save workfile if a workfile was created. - if created_version_workfile: - self.save_workfile(created_version_workfile) + if not create_first_version: + # Do not save whatsoever + return + + # If there is no existing workfile, save the first version + workfile_path = self.get_first_workfile_version_to_create() + if workfile_path: + self.log.info("Saving first workfile: %s", workfile_path) + self.save_workfile(workfile_path) def rebuild_template(self): """Go through existing placeholders in scene and update them. @@ -595,7 +604,7 @@ def import_template(self, template_path): pass - def create_first_workfile_version(self): + def get_first_workfile_version_to_create(self): """ Create first version of workfile. @@ -605,7 +614,12 @@ def create_first_workfile_version(self): Args: template_path (str): Fullpath for current task and host's template file. + + Return: + Optional[str]: Filepath to save to, if we should save. """ + # AYON_LAST_WORKFILE will be set to the last existing workfile OR + # if none exist it will be set to the first version. last_workfile_path = os.environ.get("AYON_LAST_WORKFILE") self.log.info("__ last_workfile_path: {}".format(last_workfile_path)) if os.path.exists(last_workfile_path): @@ -613,10 +627,6 @@ def create_first_workfile_version(self): self.log.info("Workfile already exists, skipping creation.") return False - # Create first version - self.log.info("Creating first version of workfile.") - self.save_workfile(last_workfile_path) - # Confirm creation of first version return last_workfile_path From a653ed64e45a20f35f449e8d84a180d5651e4a78 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 17:31:24 +0200 Subject: [PATCH 038/266] :tada: `workfile_creation_enabled` argument was actually useful after all --- .../workfile/workfile_template_builder.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index c535173038..9986e8ddae 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -15,7 +15,6 @@ import re import collections import copy -import warnings from abc import ABC, abstractmethod from ayon_api import ( @@ -508,19 +507,12 @@ def build_template( hosts to decide if they want to remove placeholder after it is used. create_first_version (bool): create first version of a workfile - workfile_creation_enabled (Any): Deprecated unused variable. + workfile_creation_enabled (Any): Whether we are creating a new + workfile. If False, we assume we just want to build the + template in our current scene. """ - if workfile_creation_enabled is not None: - warnings.warn( - ( - "Using deprecated argument `workfile_creation_enabled`. " - "It has been removed because it remained unused." - ), - category=DeprecationWarning - ) - # Get default values if not provided if ( template_path is None @@ -542,6 +534,12 @@ def build_template( self.populate_scene_placeholders( level_limit, keep_placeholders) + if not workfile_creation_enabled: + # Do not consider saving a first workfile version, if this + # is not set to be a "workfile creation". Then we assume + # only the template should get build. + return + if not create_first_version: # Do not save whatsoever return From b470ff214d2c05dbd367e9bf6438caa768aaf39c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 17:33:55 +0200 Subject: [PATCH 039/266] Elaborate `create_first_version` argument to differentiate from `workfile_creation_enabled` --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 9986e8ddae..c28b156e46 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -506,7 +506,10 @@ def build_template( keep_placeholders (bool): Add flag to placeholder data for hosts to decide if they want to remove placeholder after it is used. - create_first_version (bool): create first version of a workfile + create_first_version (bool): Create first version of a workfile. + When set to True, this option initiates the saving of the + workfile for an initial version. It will skip saving if + a version already exists. workfile_creation_enabled (Any): Whether we are creating a new workfile. If False, we assume we just want to build the template in our current scene. From 0ca2e1044f0efdd3e25cde6ce391b2cd4ab6d8f7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 17:35:43 +0200 Subject: [PATCH 040/266] Fix argument signature --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index c28b156e46..e2c06db328 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -489,7 +489,7 @@ def build_template( level_limit=None, keep_placeholders=None, create_first_version=None, - workfile_creation_enabled=None + workfile_creation_enabled=False ): """Main callback for building workfile from template path. @@ -510,7 +510,7 @@ def build_template( When set to True, this option initiates the saving of the workfile for an initial version. It will skip saving if a version already exists. - workfile_creation_enabled (Any): Whether we are creating a new + workfile_creation_enabled (bool): Whether we are creating a new workfile. If False, we assume we just want to build the template in our current scene. From 0faaadab51b0e7b74c483a32e406f90b49f45636 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 6 Sep 2024 17:47:25 +0200 Subject: [PATCH 041/266] Fix case of explicit load import and improve docstring --- .../workfile/workfile_template_builder.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index b24a4ab653..8bd9c00773 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -511,8 +511,10 @@ def build_template( workfile for an initial version. It will skip saving if a version already exists. workfile_creation_enabled (bool): Whether we are creating a new - workfile. If False, we assume we just want to build the - template in our current scene. + workfile. If False, we assume we explicitly want to build the + template in our current scene. Otherwise we only build if the + current file is not an existing saved workfile but a "new" + file. """ @@ -531,7 +533,14 @@ def build_template( # Build the template if current workfile is a new unsaved file # (that's detected by checking if it returns any current filepath) - if not self.host.get_current_workfile(): + if ( + # If not a workfile creation, an explicit load template + # was requested, so we always want to build the template + not workfile_creation_enabled + # Or if workfile creation, but we're not in an active file + # we still need to build the "new workfile template" + or not self.host.get_current_workfile() + ): self.log.info(f"Building the workfile template: {template_path}") self.import_template(template_path) self.populate_scene_placeholders( From f03983dfdac916f1d5b4d1c74a4e63ba7e905e92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Sep 2024 18:50:09 +0200 Subject: [PATCH 042/266] use 'get_attr_defs_for_instance' after creation --- client/ayon_core/pipeline/create/structures.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 81b0ce7d6d..c725a98e51 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -475,12 +475,13 @@ def __init__( # {key: value} creator_values = copy.deepcopy(orig_creator_attributes) - if creator is not None: - creator_attr_defs = creator.get_attr_defs_for_instance(self) - + if creator_attr_defs is None: + _creator_attr_defs = [] + else: + _creator_attr_defs = list(creator_attr_defs) self._data["creator_attributes"] = CreatorAttributeValues( self, - list(creator_attr_defs), + _creator_attr_defs, creator_values, orig_creator_attributes ) @@ -499,6 +500,12 @@ def __init__( self._folder_is_valid = self.has_set_folder self._task_is_valid = self.has_set_task + if creator is not None: + creator_attr_defs = creator.get_attr_defs_for_instance(self) + self.update_create_attr_defs( + creator_attr_defs, creator_values + ) + def __str__(self): return ( " Date: Fri, 6 Sep 2024 18:51:25 +0200 Subject: [PATCH 043/266] implemented 'update_create_attr_defs' --- client/ayon_core/pipeline/create/structures.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index c725a98e51..7f31a72b0c 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -698,6 +698,17 @@ def data_to_store(self): return output + def update_create_attr_defs(self, attr_defs, value=None): + if value is not None: + value = self._data["creator_attributes"] + origin_data = self._data["creator_attributes"].origin_data + self._data["creator_attributes"] = CreatorAttributeValues( + self, + attr_defs, + value, + origin_data + ) + @classmethod def from_existing(cls, instance_data, creator): """Convert instance data from workfile to CreatedInstance. From b3f39ba41c20ebc10c365a389efa578d810188f9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 6 Sep 2024 18:59:28 +0200 Subject: [PATCH 044/266] data to store does not have to have 'AttributeValues' --- client/ayon_core/pipeline/create/structures.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 7f31a72b0c..afefb911cd 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -300,10 +300,12 @@ def mark_as_stored(self): def data_to_store(self): """Convert attribute values to "data to store".""" - output = {} for key, attr_value in self._data.items(): - output[key] = attr_value.data_to_store() + if isinstance(attr_value, AttributeValues): + output[key] = attr_value.data_to_store() + else: + output[key] = attr_value return output @property From a27157f46d90b8f08fc858583dc4dd85ffc15940 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 15:58:20 +0200 Subject: [PATCH 045/266] Please @fabiaserra's eyes with better indentation --- .../pipeline/workfile/workfile_template_builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 8bd9c00773..aaa8a951c2 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -520,9 +520,9 @@ def build_template( # Get default values if not provided if ( - template_path is None - or keep_placeholders is None - or create_first_version is None + template_path is None + or keep_placeholders is None + or create_first_version is None ): preset = self.get_template_preset() template_path: str = template_path or preset["path"] From 3df68ef2a5b1e6c8bda457b96ebfe153480e1bbe Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 15:59:32 +0200 Subject: [PATCH 046/266] `workfile_creation_enabled` docstring, first explain the `True` case. --- .../pipeline/workfile/workfile_template_builder.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index aaa8a951c2..9537096794 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -511,10 +511,11 @@ def build_template( workfile for an initial version. It will skip saving if a version already exists. workfile_creation_enabled (bool): Whether we are creating a new - workfile. If False, we assume we explicitly want to build the - template in our current scene. Otherwise we only build if the - current file is not an existing saved workfile but a "new" - file. + workfile. When True, we only build if the current file is not + an existing saved workfile but a "new" file. When False, the + default value, we assume we explicitly want to build the + template in our current scene regardless of current scene + state. """ From e7f7eaba680a779323f1a4cd1b28d68d8c346593 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 16:01:57 +0200 Subject: [PATCH 047/266] Elaborate more --- .../workfile/workfile_template_builder.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 9537096794..86cb4b3f86 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -510,12 +510,15 @@ def build_template( When set to True, this option initiates the saving of the workfile for an initial version. It will skip saving if a version already exists. - workfile_creation_enabled (bool): Whether we are creating a new - workfile. When True, we only build if the current file is not - an existing saved workfile but a "new" file. When False, the - default value, we assume we explicitly want to build the - template in our current scene regardless of current scene - state. + workfile_creation_enabled (bool): Whether the call is part of + creating a new workfile. + When True, we only build if the current file is not + an existing saved workfile but a "new" file. Basically when + enabled we assume the user tries to load it only into a + "New File" (unsaved empty workfile). + When False, the default value, we assume we explicitly want to + build the template in our current scene regardless of current + scene state. """ From 522b205a92625eb5c2188f8f6e0d5b6a7bb6e72b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 16:07:17 +0200 Subject: [PATCH 048/266] Refactor logic with better function names --- .../workfile/workfile_template_builder.py | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 86cb4b3f86..7ea69b3f8f 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -561,10 +561,14 @@ def build_template( return # If there is no existing workfile, save the first version - workfile_path = self.get_first_workfile_version_to_create() - if workfile_path: + workfile_path = self.get_workfile_path() + if not os.path.exists(workfile_path): self.log.info("Saving first workfile: %s", workfile_path) self.save_workfile(workfile_path) + else: + self.log.info( + "A workfile already exists. Skipping save of workfile as " + "initial version.") def rebuild_template(self): """Go through existing placeholders in scene and update them. @@ -618,30 +622,16 @@ def import_template(self, template_path): pass - def get_first_workfile_version_to_create(self): - """ - Create first version of workfile. - - Should load the content of template into scene so - 'populate_scene_placeholders' can be started. - - Args: - template_path (str): Fullpath for current task and - host's template file. + def get_workfile_path(self): + """Return last known workfile path or the first workfile path create. Return: - Optional[str]: Filepath to save to, if we should save. + str: Last workfile path, or first version to create if none exist. """ # AYON_LAST_WORKFILE will be set to the last existing workfile OR # if none exist it will be set to the first version. last_workfile_path = os.environ.get("AYON_LAST_WORKFILE") self.log.info("__ last_workfile_path: {}".format(last_workfile_path)) - if os.path.exists(last_workfile_path): - # ignore in case workfile existence - self.log.info("Workfile already exists, skipping creation.") - return False - - # Confirm creation of first version return last_workfile_path def save_workfile(self, workfile_path): From d65b84cf2fea40c615fdf04a14831175e1e37c59 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 16:09:21 +0200 Subject: [PATCH 049/266] Tweak comment to describe both the cases in the if statement --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7ea69b3f8f..8495529924 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -535,8 +535,8 @@ def build_template( if create_first_version is None: create_first_version: bool = preset["create_first_version"] - # Build the template if current workfile is a new unsaved file - # (that's detected by checking if it returns any current filepath) + # Build the template if we are explicitly requesting it or if it's + # an unsaved "new file". if ( # If not a workfile creation, an explicit load template # was requested, so we always want to build the template From cfcce2138347a0928e42021384960a87797bc567 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 16:14:25 +0200 Subject: [PATCH 050/266] Separate into variables for readability --- .../pipeline/workfile/workfile_template_builder.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 8495529924..673ca7922e 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -537,14 +537,9 @@ def build_template( # Build the template if we are explicitly requesting it or if it's # an unsaved "new file". - if ( - # If not a workfile creation, an explicit load template - # was requested, so we always want to build the template - not workfile_creation_enabled - # Or if workfile creation, but we're not in an active file - # we still need to build the "new workfile template" - or not self.host.get_current_workfile() - ): + is_new_file = not self.host.get_current_workfile() + explicit_build_requested = not workfile_creation_enabled + if is_new_file or explicit_build_requested: self.log.info(f"Building the workfile template: {template_path}") self.import_template(template_path) self.populate_scene_placeholders( From 057a5fffcf96a6c7dc48efb845e21c47de935d6a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 9 Sep 2024 17:23:26 +0200 Subject: [PATCH 051/266] Simplify logic --- .../pipeline/workfile/workfile_template_builder.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 673ca7922e..026eacc19f 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -545,14 +545,9 @@ def build_template( self.populate_scene_placeholders( level_limit, keep_placeholders) - if not workfile_creation_enabled: - # Do not consider saving a first workfile version, if this - # is not set to be a "workfile creation". Then we assume - # only the template should get build. - return - - if not create_first_version: - # Do not save whatsoever + # Do not consider saving a first workfile version, if this is not set + # to be a "workfile creation" or `create_first_version` is disabled. + if explicit_build_requested or not create_first_version: return # If there is no existing workfile, save the first version From 2a2b9563123818969bf9910bf19c826a7493d378 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:46:50 +0200 Subject: [PATCH 052/266] do not log into console during publishing --- .../tools/publisher/models/publish.py | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index a60ef69fac..c22f2d263c 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -1,6 +1,7 @@ import uuid import copy import inspect +import logging import traceback import collections from functools import partial @@ -23,6 +24,14 @@ PLUGIN_ORDER_OFFSET = 0.5 +class MessageHandler(logging.Handler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.records = [] + + def emit(self, record): + self.records.append(record) + class PublishReportMaker: """Report for single publishing process. @@ -869,6 +878,9 @@ def reset(self): # - pop the key after first collector using it would be safest option? self._publish_context.data["create_context"] = create_context publish_plugins = create_context.publish_plugins + for plugin in publish_plugins: + plugin.log.propagate = False + self._publish_plugins = publish_plugins self._publish_plugins_proxy = PublishPluginsProxy( publish_plugins @@ -1219,9 +1231,18 @@ def _process_and_continue( plugin: pyblish.api.Plugin, instance: pyblish.api.Instance ): - result = pyblish.plugin.process( - plugin, self._publish_context, instance - ) + handler = MessageHandler() + root = logging.getLogger() + plugin.log.addHandler(handler) + root.addHandler(handler) + try: + result = pyblish.plugin.process( + plugin, self._publish_context, instance + ) + result["records"] = handler.records + finally: + plugin.log.removeHandler(handler) + root.removeHandler(handler) exception = result.get("error") if exception: From 6099d4a5ae00b5c7a0ea20672eb61e8fe5b021ca Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:03:44 +0200 Subject: [PATCH 053/266] move propagate to publish iterator --- client/ayon_core/tools/publisher/models/publish.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index c22f2d263c..2041faab54 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -878,9 +878,6 @@ def reset(self): # - pop the key after first collector using it would be safest option? self._publish_context.data["create_context"] = create_context publish_plugins = create_context.publish_plugins - for plugin in publish_plugins: - plugin.log.propagate = False - self._publish_plugins = publish_plugins self._publish_plugins_proxy = PublishPluginsProxy( publish_plugins @@ -1233,6 +1230,8 @@ def _process_and_continue( ): handler = MessageHandler() root = logging.getLogger() + + plugin.log.propagate = False plugin.log.addHandler(handler) root.addHandler(handler) try: @@ -1241,6 +1240,7 @@ def _process_and_continue( ) result["records"] = handler.records finally: + plugin.log.propagate = True plugin.log.removeHandler(handler) root.removeHandler(handler) From 4c809f9278364057252e11a833d5e1f524a13c8a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:09:47 +0200 Subject: [PATCH 054/266] calculate message at the time of emit --- client/ayon_core/tools/publisher/models/publish.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 2041faab54..4c21a6d5b5 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -30,6 +30,7 @@ def __init__(self, *args, **kwargs): self.records = [] def emit(self, record): + record.msg = record.getMessage() self.records.append(record) From 17f76c761f93539f4d00b0fe9981755852fac5df Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:01:47 +0200 Subject: [PATCH 055/266] use explicit margins --- client/ayon_core/tools/publisher/widgets/report_page.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 09b722ea54..4eb71ccc99 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1740,11 +1740,8 @@ def __init__( pages_layout.addWidget(crash_widget, 1) details_layout = QtWidgets.QVBoxLayout(details_widget) - margins = details_layout.contentsMargins() - margins.setTop(margins.top() * 2) - margins.setBottom(margins.bottom() * 2) - details_layout.setContentsMargins(margins) - details_layout.setSpacing(margins.top()) + details_layout.setContentsMargins(8, 16, 8, 16) + details_layout.setSpacing(8) details_layout.addWidget(actions_widget, 0) details_layout.addWidget(pages_widget, 1) From 56b8ca9d38dbce36bf8a3dd6e47332b203305062 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:02:01 +0200 Subject: [PATCH 056/266] added is_unknown_error to error info --- client/ayon_core/tools/publisher/models/publish.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index e0ceea825a..b0a360fcda 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -30,10 +30,12 @@ class PublishErrorInfo: def __init__( self, description: str, + is_unknown_error: bool, title: Optional[str], - detail: Optional[str] + detail: Optional[str], ): self.description: str = description + self.is_unknown_error = is_unknown_error self.title: Optional[str] = title self.detail: Optional[str] = detail @@ -42,6 +44,7 @@ def __eq__(self, other: Any) -> bool: return False return ( self.description == other.description + and self.is_unknown_error == other.is_unknown_error and self.title == other.title and self.detail == other.detail ) @@ -55,6 +58,7 @@ def from_exception(cls, exc) -> "PublishErrorInfo": if isinstance(exc, PublishError): return cls( exc.description or exc.message, + is_unknown_error=False, title=exc.title or title, detail=exc.detail, ) @@ -65,7 +69,7 @@ def from_exception(cls, exc) -> "PublishErrorInfo": "Something went wrong. Send report" " to your supervisor or Ynput team." ) - return cls(msg, title, None) + return cls(msg, True, title, None) class PublishReportMaker: From 58fc67eed880d25a9a060d9e0a81b6480a3b08b9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:02:19 +0200 Subject: [PATCH 057/266] make logs visible based on unknown error --- .../tools/publisher/widgets/report_page.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 4eb71ccc99..d0af34035f 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1588,8 +1588,7 @@ def __init__( self._detail_widget = detail_widget self._controller: AbstractPublisherFrontend = controller - def update_error_info(self): - error_info = self._controller.get_publish_error_info() + def update_error_info(self, error_info): if error_info is None: self._title_label.setText("Placeholder title") self._description_label.setText("A bug happened if you see this") @@ -1799,8 +1798,9 @@ def _get_instance_items(self): def update_data(self): view = self._instances_view validation_error_mode = False + is_crashed = self._controller.publish_has_crashed() if ( - not self._controller.publish_has_crashed() + not is_crashed and self._controller.publish_has_validation_errors() ): view = self._validation_error_view @@ -1811,11 +1811,15 @@ def update_data(self): self._detail_input_scroll.setVisible(validation_error_mode) self._views_layout.setCurrentWidget(view) - is_crashed = self._controller.publish_has_crashed() + error_info = self._controller.get_publish_error_info() + logs_visible = True + if is_crashed and error_info.is_unknown_error: + logs_visible = False + self._crash_widget.setVisible(is_crashed) - self._logs_view.setVisible(not is_crashed) + self._logs_view.setVisible(logs_visible) - self._crash_widget.update_error_info() + self._crash_widget.update_error_info(error_info) # Instance view & logs update instance_items = self._get_instance_items() From 079a7967b784b59500eefb39d542534edf75e762 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:02:28 +0200 Subject: [PATCH 058/266] move crash widget to left --- client/ayon_core/tools/publisher/widgets/report_page.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index d0af34035f..ebe84457f9 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1708,6 +1708,9 @@ def __init__( pages_widget = QtWidgets.QWidget(details_widget) + # Crash information + crash_widget = CrashWidget(controller, details_widget) + # Logs view logs_view = InstancesLogsView(pages_widget) @@ -1727,16 +1730,13 @@ def __init__( detail_input_scroll.setWidgetResizable(True) detail_input_scroll.setViewportMargins(0, 0, 0, 0) - # Crash information - crash_widget = CrashWidget(controller, details_widget) - # Layout pages pages_layout = QtWidgets.QHBoxLayout(pages_widget) pages_layout.setContentsMargins(0, 0, 0, 0) + pages_layout.addWidget(crash_widget, 1) pages_layout.addWidget(logs_view, 1) pages_layout.addWidget(detail_inputs_spacer, 0) pages_layout.addWidget(detail_input_scroll, 1) - pages_layout.addWidget(crash_widget, 1) details_layout = QtWidgets.QVBoxLayout(details_widget) details_layout.setContentsMargins(8, 16, 8, 16) From e29ade4e7d36d3f0def6bf4b8c998b3fd3fb446d Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 11 Sep 2024 11:49:09 +0300 Subject: [PATCH 059/266] Add tool tips for `ExtractOIIOTranscode` settings --- server/settings/publish_plugins.py | 72 ++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 98a50e6101..cdcd28a9ce 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -274,13 +274,30 @@ def _extract_oiio_transcoding_type(): class OIIOToolArgumentsModel(BaseSettingsModel): additional_command_args: list[str] = SettingsField( - default_factory=list, title="Arguments") + default_factory=list, + title="Arguments", + description="Additional command line arguments for *oiiotool*." + ) class UseDisplayViewModel(BaseSettingsModel): _layout = "expanded" - display: str = SettingsField("", title="Target Display") - view: str = SettingsField("", title="Target View") + display: str = SettingsField( + "", + title="Target Display", + description=( + "Display of the target transform. If left empty, the" + " source Display value will be used." + ) + ) + view: str = SettingsField( + "", + title="Target View", + description=( + "View of the target transform. If left empty, the" + " source View value will be used." + ) + ) class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): @@ -291,14 +308,35 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): description="Output name (no space)", regex=r"[a-zA-Z0-9_]([a-zA-Z0-9_\.\-]*[a-zA-Z0-9_])?$", ) - extension: str = SettingsField("", title="Extension") + extension: str = SettingsField( + "", + title="Extension", + description=( + "Target extension. If left empty, original" + " extension is used." + ), + ) transcoding_type: str = SettingsField( "colorspace", title="Transcoding type", enum_resolver=_extract_oiio_transcoding_type, - conditionalEnum=True + conditionalEnum=True, + description=( + "Select the transcoding type for your output, choosing either " + "*Colorspace* or *Display&View* transform." + " Only one option can be applied per output definition." + ), + ) + colorspace: str = SettingsField( + "", + title="Target Colorspace", + description=( + "Choose the desired target colorspace, confirming its availability" + " in the active OCIO config. If left empty, the" + " source colorspace value will be used, resulting in no" + " colorspace conversion." + ) ) - colorspace: str = SettingsField("", title="Target Colorspace") display_view: UseDisplayViewModel = SettingsField( title="Use Display&View", default_factory=UseDisplayViewModel @@ -308,9 +346,19 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): default_factory=OIIOToolArgumentsModel, title="OIIOtool arguments") - tags: list[str] = SettingsField(default_factory=list, title="Tags") + tags: list[str] = SettingsField( + default_factory=list, + title="Tags", + description=( + "Additional tags that will be added to the created representation." + "\nAdd *review* tag to create review from the transcoded" + " representation instead of the original." + ) + ) custom_tags: list[str] = SettingsField( - default_factory=list, title="Custom Tags" + default_factory=list, + title="Custom Tags", + description="Additional custom tags that will be added to the created representation." ) @@ -338,7 +386,13 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): ) delete_original: bool = SettingsField( True, - title="Delete Original Representation" + title="Delete Original Representation", + description=( + "Choose to preserve or remove the original representation.\n" + "Keep in mind that if the transcoded representation includes" + " a `review` tag, it will take precedence over" + " the original for creating reviews." + ), ) outputs: list[ExtractOIIOTranscodeOutputModel] = SettingsField( default_factory=list, From ab498dd7e1edd5f4db0003dcb314aa016dfa889a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 12 Sep 2024 15:03:14 +0200 Subject: [PATCH 060/266] Update client/ayon_core/pipeline/workfile/workfile_template_builder.py Co-authored-by: Mustafa Jafar --- .../pipeline/workfile/workfile_template_builder.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index a908e76222..17debdb2e8 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -844,10 +844,11 @@ def get_template_preset(self): keep_placeholder = True if not path: - self.log.info( - "Template path is not set." - ) - return + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) # Try to fill path with environments and anatomy roots anatomy = Anatomy(project_name) From f5c26e3d6e54e2941331ccb0cb23361ab3a8edb6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:18:06 +0200 Subject: [PATCH 061/266] call getMessage in try block --- client/ayon_core/pipeline/context_tools.py | 5 ++++- client/ayon_core/tools/publisher/models/publish.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 8b72405048..5fb48cd79b 100644 --- a/client/ayon_core/pipeline/context_tools.py +++ b/client/ayon_core/pipeline/context_tools.py @@ -132,7 +132,10 @@ def install_host(host): def modified_emit(obj, record): """Method replacing `emit` in Pyblish's MessageHandler.""" - record.msg = record.getMessage() + try: + record.msg = record.getMessage() + except Exception: + record.msg = str(record.msg) obj.records.append(record) MessageHandler.emit = modified_emit diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 4c21a6d5b5..e7e765c4d2 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -30,7 +30,10 @@ def __init__(self, *args, **kwargs): self.records = [] def emit(self, record): - record.msg = record.getMessage() + try: + record.msg = record.getMessage() + except Exception: + record.msg = str(record.msg) self.records.append(record) From 0b352273959db9b1a6508d2f3821684664ad8b50 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:03:35 +0200 Subject: [PATCH 062/266] fix images used for collapsed widget --- client/ayon_core/tools/utils/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 4c2b418c41..9092283221 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -466,10 +466,10 @@ def __init__(self, parent): self._collapsed = True def _create_collapsed_pixmap(self): - return QtGui.QPixmap(self.branch_closed_path) + return QtGui.QPixmap(self.branch_open_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap(self.branch_open_path) + return QtGui.QPixmap(self.branch_closed_path) @property def collapsed(self): From a14df299c8f4fc1d8e8dde049eb62b5ae96f4804 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:04:06 +0200 Subject: [PATCH 063/266] don't show detail until it should be visible --- .../tools/publisher/widgets/report_page.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index ebe84457f9..cc0f425c30 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1486,19 +1486,14 @@ def __init__(self, parent): main_layout.addWidget(error_detail_input, 0) main_layout.addStretch(1) + error_detail_input.setVisible(not error_detail_expand_btn.collapsed) + error_detail_top.clicked.connect(self._on_detail_toggle) self._error_detail_top = error_detail_top self._error_detail_expand_btn = error_detail_expand_btn self._error_detail_input = error_detail_input - def showEvent(self, event): - super().showEvent(event) - # Calling this in __init__ does not seem to propagate the visibility - # correctly - self._error_detail_input.setVisible( - not self._error_detail_expand_btn.collapsed - ) def set_detail(self, detail): if not detail: @@ -1520,7 +1515,10 @@ def set_detail(self, detail): def _set_visible_inputs(self, visible): self._error_detail_top.setVisible(visible) - self._error_detail_input.setVisible(visible) + input_visible = visible + if input_visible: + input_visible = not self._error_detail_expand_btn.collapsed + self._error_detail_input.setVisible(input_visible) def _on_detail_toggle(self): self._error_detail_expand_btn.set_collapsed() From 9cbfecf92c643d3d2e5f40942ccb2d7ce944919d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:04:25 +0200 Subject: [PATCH 064/266] fill title in exception if is missing --- client/ayon_core/tools/publisher/models/publish.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index b0a360fcda..58598602d5 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -1282,6 +1282,11 @@ def _process_and_continue( self._add_validation_error(result) else: + if isinstance(exception, PublishError): + if not exception.title: + exception.title = plugin.label or plugin.__name__ + self._add_validation_error(result) + error_info = PublishErrorInfo.from_exception(exception) self._set_publish_error_info(error_info) self._set_is_crashed(True) From d3d2f836773210e4ef0d0b5e43d048af2d604cec Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:04:51 +0200 Subject: [PATCH 065/266] added message to error info and simplified initialization --- .../tools/publisher/models/publish.py | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index 58598602d5..a3bfad091a 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -29,14 +29,16 @@ class PublishErrorInfo: def __init__( self, - description: str, + message: str, is_unknown_error: bool, - title: Optional[str], - detail: Optional[str], + description: Optional[str] = None, + title: Optional[str] = None, + detail: Optional[str] = None, ): - self.description: str = description + self.message: str = message self.is_unknown_error = is_unknown_error - self.title: Optional[str] = title + self.description: str = description or message + self.title: Optional[str] = title or "Unknown error" self.detail: Optional[str] = detail def __eq__(self, other: Any) -> bool: @@ -54,12 +56,12 @@ def __ne__(self, other: Any) -> bool: @classmethod def from_exception(cls, exc) -> "PublishErrorInfo": - title = "This is not your fault" if isinstance(exc, PublishError): return cls( - exc.description or exc.message, - is_unknown_error=False, - title=exc.title or title, + exc.message, + False, + exc.description, + title=exc.title, detail=exc.detail, ) if isinstance(exc, KnownPublishError): @@ -69,7 +71,7 @@ def from_exception(cls, exc) -> "PublishErrorInfo": "Something went wrong. Send report" " to your supervisor or Ynput team." ) - return cls(msg, True, title, None) + return cls(msg, True) class PublishReportMaker: From d62b31c981632e2de591e71d247fd13d328de7d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:05:18 +0200 Subject: [PATCH 066/266] fix error message shown in progress bar --- client/ayon_core/tools/publisher/widgets/publish_frame.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/publish_frame.py b/client/ayon_core/tools/publisher/widgets/publish_frame.py index d9a9e501ef..55051d2c40 100644 --- a/client/ayon_core/tools/publisher/widgets/publish_frame.py +++ b/client/ayon_core/tools/publisher/widgets/publish_frame.py @@ -412,7 +412,12 @@ def _set_error_msg(self): self._set_main_label("Error happened") error_info = self._controller.get_publish_error_info() - self._message_label_top.setText(error_info.description) + + error_message = "Unknown error happened" + if error_info is not None: + error_message = error_info.message + + self._message_label_top.setText(error_message) self._set_success_property(1) From b4e0e32e1bfdd108a2aa134ae6963b608549579c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:05:39 +0200 Subject: [PATCH 067/266] show publish error as validation error --- .../tools/publisher/widgets/report_page.py | 75 +++++++++---------- 1 file changed, 35 insertions(+), 40 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index cc0f425c30..0580777669 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1539,16 +1539,20 @@ def __init__( ): super().__init__(parent) - title_label = QtWidgets.QLabel("", self) - title_label.setAlignment(QtCore.Qt.AlignCenter) - title_label.setObjectName("PublishCrashMainLabel") - - description_label = QtWidgets.QLabel("", self) - description_label.setAlignment(QtCore.Qt.AlignCenter) - description_label.setWordWrap(True) - description_label.setObjectName("PublishCrashReportLabel") - - detail_widget = ErrorDetailWidget(self) + main_label = QtWidgets.QLabel("This is not your fault", self) + main_label.setAlignment(QtCore.Qt.AlignCenter) + main_label.setObjectName("PublishCrashMainLabel") + + report_label = QtWidgets.QLabel( + ( + "Please report the error to your pipeline support" + " using one of the options below." + ), + self + ) + report_label.setAlignment(QtCore.Qt.AlignCenter) + report_label.setWordWrap(True) + report_label.setObjectName("PublishCrashReportLabel") btns_widget = QtWidgets.QWidget(self) copy_clipboard_btn = QtWidgets.QPushButton( @@ -1569,34 +1573,18 @@ def __init__( layout.setContentsMargins(5, 5, 5, 5) layout.setSpacing(0) layout.addSpacing(20) - layout.addWidget(title_label, 0) + layout.addWidget(main_label, 0) layout.addSpacing(20) - layout.addWidget(description_label, 0) + layout.addWidget(report_label, 0) layout.addSpacing(20) - layout.addWidget(detail_widget, 1) - layout.addSpacing(10) layout.addWidget(btns_widget, 0) layout.addSpacing(10) copy_clipboard_btn.clicked.connect(self._on_copy_to_clipboard) save_to_disk_btn.clicked.connect(self._on_save_to_disk_click) - self._title_label = title_label - self._description_label = description_label - self._detail_widget = detail_widget self._controller: AbstractPublisherFrontend = controller - def update_error_info(self, error_info): - if error_info is None: - self._title_label.setText("Placeholder title") - self._description_label.setText("A bug happened if you see this") - self._detail_widget.set_detail(None) - return - - self._title_label.setText(error_info.title) - self._description_label.setText(error_info.description) - self._detail_widget.set_detail(error_info.detail) - def _on_copy_to_clipboard(self): self._controller.emit_event( "copy_report.request", {}, "report_page") @@ -1618,6 +1606,7 @@ def __init__(self, parent): QtCore.Qt.TextBrowserInteraction ) + # Error 'Details' widget -> Collapsible error_details_widget = ErrorDetailWidget(inputs_widget) # Description and Details layout @@ -1794,30 +1783,36 @@ def _get_instance_items(self): return instance_items def update_data(self): - view = self._instances_view - validation_error_mode = False is_crashed = self._controller.publish_has_crashed() + error_info = None + if is_crashed: + error_info = self._controller.get_publish_error_info() + + validation_error_mode = False if ( + error_info is not None + and not error_info.is_unknown_error + ): + validation_error_mode = True + + elif ( not is_crashed and self._controller.publish_has_validation_errors() ): - view = self._validation_error_view validation_error_mode = True + if validation_error_mode: + view = self._validation_error_view + else: + view = self._instances_view + self._actions_widget.set_visible_mode(validation_error_mode) self._detail_inputs_spacer.setVisible(validation_error_mode) self._detail_input_scroll.setVisible(validation_error_mode) self._views_layout.setCurrentWidget(view) - error_info = self._controller.get_publish_error_info() - logs_visible = True - if is_crashed and error_info.is_unknown_error: - logs_visible = False - - self._crash_widget.setVisible(is_crashed) - self._logs_view.setVisible(logs_visible) - - self._crash_widget.update_error_info(error_info) + self._crash_widget.setVisible(not validation_error_mode) + self._logs_view.setVisible(validation_error_mode) # Instance view & logs update instance_items = self._get_instance_items() From 5be47d4f5b7a324d72c38447d8f8bc12e12aeb63 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:05:50 +0200 Subject: [PATCH 068/266] better typehint --- client/ayon_core/tools/publisher/abstract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index f5c8306631..04a1985cbb 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -543,11 +543,11 @@ def get_publish_max_progress(self) -> int: pass @abstractmethod - def get_publish_error_info(self) -> Union["PublishErrorInfo", None]: + def get_publish_error_info(self) -> Optional["PublishErrorInfo"]: """Current error message which cause fail of publishing. Returns: - Union[PublishErrorInfo, None]: Error info or None. + Optional[PublishErrorInfo]: Error info or None. """ pass From 7ca0a354cac0f8d5011ae280c97fb411a6215684 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:27:44 +0200 Subject: [PATCH 069/266] remove double line --- client/ayon_core/tools/publisher/widgets/report_page.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 0580777669..64281a7046 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1494,7 +1494,6 @@ def __init__(self, parent): self._error_detail_expand_btn = error_detail_expand_btn self._error_detail_input = error_detail_input - def set_detail(self, detail): if not detail: self._set_visible_inputs(False) From 882d61f2f34460ee13ccd621ee03103049645cd5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:53:39 +0200 Subject: [PATCH 070/266] refactored validation error handling to publish error handling --- client/ayon_core/style/style.css | 16 +- client/ayon_core/tools/publisher/abstract.py | 2 +- client/ayon_core/tools/publisher/control.py | 4 +- .../tools/publisher/models/publish.py | 82 ++++---- .../tools/publisher/widgets/report_page.py | 195 ++++++++++-------- 5 files changed, 159 insertions(+), 140 deletions(-) diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 8578522c79..aaaf358f97 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -1118,39 +1118,39 @@ ValidationArtistMessage QLabel { font-weight: bold; } -#ValidationActionButton { +#PublishActionButton { border-radius: 0.2em; padding: 4px 6px 4px 6px; background: {color:bg-buttons}; } -#ValidationActionButton:hover { +#PublishActionButton:hover { background: {color:bg-buttons-hover}; color: {color:font-hover}; } -#ValidationActionButton:disabled { +#PublishActionButton:disabled { background: {color:bg-buttons-disabled}; } -#ValidationErrorTitleFrame { +#PublishErrorTitleFrame { border-radius: 0.2em; background: {color:bg-buttons}; } -#ValidationErrorTitleFrame:hover { +#PublishErrorTitleFrame:hover { background: {color:bg-buttons-hover}; } -#ValidationErrorTitleFrame[selected="1"] { +#PublishErrorTitleFrame[selected="1"] { background: {color:bg-view-selection}; } -#ValidationErrorInstanceList { +#PublishErrorInstanceList { border-radius: 0; } -#ValidationErrorInstanceList::item { +#PublishErrorInstanceList::item { border-bottom: 1px solid {color:border}; border-left: 1px solid {color:border}; } diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 04a1985cbb..6bea4cc247 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -557,7 +557,7 @@ def get_publish_report(self) -> Dict[str, Any]: pass @abstractmethod - def get_validation_errors(self): + def get_publish_errors_report(self): pass @abstractmethod diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index bc7ed994bd..c7fd75b3c3 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -502,8 +502,8 @@ def get_publish_error_info(self): def get_publish_report(self): return self._publish_model.get_publish_report() - def get_validation_errors(self): - return self._publish_model.get_validation_errors() + def get_publish_errors_report(self): + return self._publish_model.get_publish_errors_report() def set_comment(self, comment): """Set comment from ui to pyblish context. diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index a3bfad091a..fbfc2dcdf1 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -529,10 +529,10 @@ def _create_action_item( ) -class ValidationErrorItem: - """Data driven validation error item. +class PublishErrorItem: + """Data driven publish error item. - Prepared data container with information about validation error and it's + Prepared data container with information about publish error and it's source plugin. Can be converted to raw data and recreated should be used for controller @@ -540,11 +540,11 @@ class ValidationErrorItem: Args: instance_id (Optional[str]): Pyblish instance id to which is - validation error connected. + publish error connected. instance_label (Optional[str]): Prepared instance label. - plugin_id (str): Pyblish plugin id which triggered the validation + plugin_id (str): Pyblish plugin id which triggered the publish error. Id is generated using 'PublishPluginsProxy'. - context_validation (bool): Error happened on context. + is_context_plugin (bool): Error happened on context. title (str): Error title. description (str): Error description. detail (str): Error detail. @@ -555,7 +555,8 @@ def __init__( instance_id: Optional[str], instance_label: Optional[str], plugin_id: str, - context_validation: bool, + is_context_plugin: bool, + is_validation_error: bool, title: str, description: str, detail: str @@ -563,7 +564,8 @@ def __init__( self.instance_id: Optional[str] = instance_id self.instance_label: Optional[str] = instance_label self.plugin_id: str = plugin_id - self.context_validation: bool = context_validation + self.is_context_plugin: bool = is_context_plugin + self.is_validation_error: bool = is_validation_error self.title: str = title self.description: str = description self.detail: str = detail @@ -579,7 +581,8 @@ def to_data(self) -> Dict[str, Any]: "instance_id": self.instance_id, "instance_label": self.instance_label, "plugin_id": self.plugin_id, - "context_validation": self.context_validation, + "is_context_plugin": self.is_context_plugin, + "is_validation_error": self.is_validation_error, "title": self.title, "description": self.description, "detail": self.detail, @@ -589,13 +592,13 @@ def to_data(self) -> Dict[str, Any]: def from_result( cls, plugin_id: str, - error: PublishValidationError, + error: PublishError, instance: Union[pyblish.api.Instance, None] ): """Create new object based on resukt from controller. Returns: - ValidationErrorItem: New object with filled data. + PublishErrorItem: New object with filled data. """ instance_label = None @@ -611,6 +614,7 @@ def from_result( instance_label, plugin_id, instance is None, + isinstance(error, PublishValidationError), error.title, error.description, error.detail, @@ -621,11 +625,11 @@ def from_data(cls, data): return cls(**data) -class PublishValidationErrorsReport: - """Publish validation errors report that can be parsed to raw data. +class PublishErrorsReport: + """Publish errors report that can be parsed to raw data. Args: - error_items (List[ValidationErrorItem]): List of validation errors. + error_items (List[PublishErrorItem]): List of publish errors. plugin_action_items (Dict[str, List[PublishPluginActionItem]]): Action items by plugin id. @@ -634,7 +638,7 @@ def __init__(self, error_items, plugin_action_items): self._error_items = error_items self._plugin_action_items = plugin_action_items - def __iter__(self) -> Iterable[ValidationErrorItem]: + def __iter__(self) -> Iterable[PublishErrorItem]: for item in self._error_items: yield item @@ -708,7 +712,7 @@ def to_data(self): @classmethod def from_data( cls, data: Dict[str, Any] - ) -> "PublishValidationErrorsReport": + ) -> "PublishErrorsReport": """Recreate object from data. Args: @@ -716,11 +720,11 @@ def from_data( using 'to_data' method. Returns: - PublishValidationErrorsReport: New object based on data. + PublishErrorsReport: New object based on data. """ error_items = [ - ValidationErrorItem.from_data(error_item) + PublishErrorItem.from_data(error_item) for error_item in data["error_items"] ] plugin_action_items = {} @@ -732,12 +736,12 @@ def from_data( return cls(error_items, plugin_action_items) -class PublishValidationErrors: - """Object to keep track about validation errors by plugin.""" +class PublishErrors: + """Object to keep track about publish errors by plugin.""" def __init__(self): self._plugins_proxy: Union[PublishPluginsProxy, None] = None - self._error_items: List[ValidationErrorItem] = [] + self._error_items: List[PublishErrorItem] = [] self._plugin_action_items: Dict[ str, List[PublishPluginActionItem] ] = {} @@ -763,29 +767,29 @@ def reset(self, plugins_proxy: PublishPluginsProxy): self._error_items = [] self._plugin_action_items = {} - def create_report(self) -> PublishValidationErrorsReport: + def create_report(self) -> PublishErrorsReport: """Create report based on currently existing errors. Returns: - PublishValidationErrorsReport: Validation error report with all + PublishErrorsReport: Publish error report with all error information and publish plugin action items. """ - return PublishValidationErrorsReport( + return PublishErrorsReport( self._error_items, self._plugin_action_items ) def add_error( self, plugin: pyblish.api.Plugin, - error: PublishValidationError, + error: PublishError, instance: Union[pyblish.api.Instance, None] ): """Add error from pyblish result. Args: plugin (pyblish.api.Plugin): Plugin which triggered error. - error (PublishValidationError): Validation error. + error (PublishError): Publish error. instance (Union[pyblish.api.Instance, None]): Instance on which was error raised or None if was raised on context. """ @@ -800,7 +804,7 @@ def add_error( error.title = plugin_label self._error_items.append( - ValidationErrorItem.from_result(plugin_id, error, instance) + PublishErrorItem.from_result(plugin_id, error, instance) ) if plugin_id in self._plugin_action_items: return @@ -874,10 +878,8 @@ def __init__(self, controller: AbstractPublisherBackend): self._publish_context = None # Pyblish report self._publish_report: PublishReportMaker = PublishReportMaker() - # Store exceptions of validation error - self._publish_validation_errors: PublishValidationErrors = ( - PublishValidationErrors() - ) + # Store exceptions of publish error + self._publish_errors: PublishErrors = PublishErrors() # This information is not much important for controller but for widget # which can change (and set) the comment. @@ -931,7 +933,7 @@ def reset(self): ) for plugin in create_context.publish_plugins_mismatch_targets: self._publish_report.set_plugin_skipped(plugin.id) - self._publish_validation_errors.reset(self._publish_plugins_proxy) + self._publish_errors.reset(self._publish_plugins_proxy) self._set_max_progress(len(publish_plugins)) @@ -1024,8 +1026,8 @@ def get_publish_report(self) -> Dict[str, Any]: self._publish_context ) - def get_validation_errors(self) -> PublishValidationErrorsReport: - return self._publish_validation_errors.create_report() + def get_publish_errors_report(self) -> PublishErrorsReport: + return self._publish_errors.create_report() def get_error_info(self) -> Optional[PublishErrorInfo]: return self._publish_error_info @@ -1275,31 +1277,33 @@ def _process_and_continue( exception = result.get("error") if exception: - has_validation_error = False if ( isinstance(exception, PublishValidationError) and not self._publish_has_validated ): - has_validation_error = True + result["is_validation_error"] = True self._add_validation_error(result) else: if isinstance(exception, PublishError): if not exception.title: exception.title = plugin.label or plugin.__name__ - self._add_validation_error(result) + self._add_publish_error_to_report(result) error_info = PublishErrorInfo.from_exception(exception) self._set_publish_error_info(error_info) self._set_is_crashed(True) - result["is_validation_error"] = has_validation_error + result["is_validation_error"] = False self._publish_report.add_result(plugin.id, result) def _add_validation_error(self, result: Dict[str, Any]): self._set_has_validation_errors(True) - self._publish_validation_errors.add_error( + self._add_publish_error_to_report(result) + + def _add_publish_error_to_report(self, result: Dict[str, Any]): + self._publish_errors.add_error( result["plugin"], result["error"], result["instance"] diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 64281a7046..d491c300d3 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -26,7 +26,7 @@ CONTEXT_LABEL, ) -from .widgets import IconValuePixmapLabel +from .widgets import PublishPixmapLabel, IconValuePixmapLabel from .icons import ( get_pixmap, get_image, @@ -42,7 +42,7 @@ class VerticalScrollArea(QtWidgets.QScrollArea): - """Scroll area for validation error titles. + """Scroll area for publish error titles. The biggest difference is that the scroll area has scroll bar on left side and resize of content will also resize scrollarea itself. @@ -126,7 +126,7 @@ class ActionButton(BaseClickableFrame): def __init__(self, plugin_action_item, parent): super().__init__(parent) - self.setObjectName("ValidationActionButton") + self.setObjectName("PublishActionButton") self.plugin_action_item = plugin_action_item @@ -155,10 +155,10 @@ def _mouse_release_callback(self): ) -class ValidateActionsWidget(QtWidgets.QFrame): +class PublishActionsWidget(QtWidgets.QFrame): """Wrapper widget for plugin actions. - Change actions based on selected validation error. + Change actions based on selected publish error. """ def __init__( @@ -243,16 +243,16 @@ def _on_action_click(self, plugin_id, action_id): self._controller.run_action(plugin_id, action_id) -# --- Validation error titles --- -class ValidationErrorInstanceList(QtWidgets.QListView): - """List of publish instances that caused a validation error. +# --- Publish error titles --- +class PublishErrorInstanceList(QtWidgets.QListView): + """List of publish instances that caused a publish error. - Instances are collected per plugin's validation error title. + Instances are collected per plugin's publish error title. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.setObjectName("ValidationErrorInstanceList") + self.setObjectName("PublishErrorInstanceList") self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) @@ -270,18 +270,19 @@ def sizeHint(self): return result -class ValidationErrorTitleWidget(QtWidgets.QWidget): - """Title of validation error. +class PublishErrorTitleWidget(QtWidgets.QWidget): + """Title of publish error. Widget is used as radio button so requires clickable functionality and changing style on selection/deselection. - Has toggle button to show/hide instances on which validation error happened + Has toggle button to show/hide instances on which publish error happened if there is a list (Valdation error may happen on context). """ selected = QtCore.Signal(str) instance_changed = QtCore.Signal(str) + _error_pixmap = None def __init__(self, title_id, error_info, parent): super().__init__(parent) @@ -290,30 +291,17 @@ def __init__(self, title_id, error_info, parent): self._error_info = error_info self._selected = False - title_frame = ClickableFrame(self) - title_frame.setObjectName("ValidationErrorTitleFrame") - - toggle_instance_btn = QtWidgets.QToolButton(title_frame) - toggle_instance_btn.setObjectName("ArrowBtn") - toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) - toggle_instance_btn.setMaximumWidth(14) - - label_widget = QtWidgets.QLabel(error_info["title"], title_frame) - - title_frame_layout = QtWidgets.QHBoxLayout(title_frame) - title_frame_layout.addWidget(label_widget, 1) - title_frame_layout.addWidget(toggle_instance_btn, 0) - instances_model = QtGui.QStandardItemModel() instance_ids = [] items = [] - context_validation = False + is_context_plugin = False + is_crashing_error = False for error_item in error_info["error_items"]: - context_validation = error_item.context_validation - if context_validation: - toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + is_crashing_error = not error_item.is_validation_error + is_context_plugin = error_item.is_context_plugin + if is_context_plugin: instance_ids.append(CONTEXT_ID) # Add fake item to have minimum size hint of view widget items.append(QtGui.QStandardItem(CONTEXT_LABEL)) @@ -333,7 +321,33 @@ def __init__(self, title_id, error_info, parent): root_item = instances_model.invisibleRootItem() root_item.appendRows(items) - instances_view = ValidationErrorInstanceList(self) + title_frame = ClickableFrame(self) + title_frame.setObjectName("PublishErrorTitleFrame") + + toggle_instance_btn = QtWidgets.QToolButton(title_frame) + toggle_instance_btn.setObjectName("ArrowBtn") + toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + toggle_instance_btn.setMaximumWidth(14) + if is_context_plugin: + toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + + icon_label = None + if is_crashing_error: + error_pixmap = self._get_error_pixmap() + icon_label = PublishPixmapLabel(error_pixmap, self) + + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) + + title_frame_layout = QtWidgets.QHBoxLayout(title_frame) + title_frame_layout.setContentsMargins(8, 8, 8, 8) + title_frame_layout.setSpacing(0) + if icon_label is not None: + title_frame_layout.addWidget(icon_label, 0) + title_frame_layout.addSpacing(6) + title_frame_layout.addWidget(label_widget, 1) + title_frame_layout.addWidget(toggle_instance_btn, 0) + + instances_view = PublishErrorInstanceList(self) instances_view.setModel(instances_model) self.setLayoutDirection(QtCore.Qt.LeftToRight) @@ -352,7 +366,7 @@ def __init__(self, title_id, error_info, parent): layout.addWidget(view_widget, 0) view_widget.setVisible(False) - if not context_validation: + if not is_context_plugin: toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) title_frame.clicked.connect(self._mouse_release_callback) @@ -369,7 +383,8 @@ def __init__(self, title_id, error_info, parent): self._instances_model = instances_model self._instances_view = instances_view - self._context_validation = context_validation + self._is_context_plugin = is_context_plugin + self._is_crashing_error = is_crashing_error self._instance_ids = instance_ids self._expanded = False @@ -411,6 +426,10 @@ def is_selected(self): def id(self): return self._title_id + @property + def is_crashing_error(self): + return self._is_crashing_error + def _change_style_property(self, selected): """Change style of widget based on selection.""" @@ -438,6 +457,12 @@ def set_selected(self, selected=None): self.selected.emit(self._title_id) self._set_expanded(True) + @classmethod + def _get_error_pixmap(cls): + if cls._error_pixmap is None: + cls._error_pixmap = get_pixmap("error") + return cls._error_pixmap + def _on_toggle_btn_click(self): """Show/hide instances list.""" @@ -450,7 +475,7 @@ def _set_expanded(self, expanded=None): elif expanded is self._expanded: return - if expanded and self._context_validation: + if expanded and self._is_context_plugin: return self._expanded = expanded @@ -464,7 +489,7 @@ def _on_selection_change(self): self.instance_changed.emit(self._title_id) def get_selected_instances(self): - if self._context_validation: + if self._is_context_plugin: return [CONTEXT_ID] sel_model = self._instances_view.selectionModel() return [ @@ -477,21 +502,7 @@ def get_available_instances(self): return list(self._instance_ids) -class ValidationArtistMessage(QtWidgets.QWidget): - def __init__(self, message, parent): - super().__init__(parent) - - artist_msg_label = QtWidgets.QLabel(message, self) - artist_msg_label.setAlignment(QtCore.Qt.AlignCenter) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget( - artist_msg_label, 1, QtCore.Qt.AlignCenter - ) - - -class ValidationErrorsView(QtWidgets.QWidget): +class PublishErrorsView(QtWidgets.QWidget): selection_changed = QtCore.Signal() def __init__(self, parent): @@ -510,8 +521,9 @@ def __init__(self, parent): # scroll widget errors_layout.setContentsMargins(5, 0, 0, 0) - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(errors_scroll, 1) + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.addWidget(errors_scroll, 1) self._errors_widget = errors_widget self._errors_layout = errors_layout @@ -533,28 +545,30 @@ def set_errors(self, grouped_error_items): """Set errors into context and created titles. Args: - validation_error_report (PublishValidationErrorsReport): Report - with information about validation errors and publish plugin + grouped_error_items (List[Dict[str, Any]]): Report + with information about publish errors and publish plugin actions. """ self._clear() - first_id = None + select_id = None for title_item in grouped_error_items: title_id = title_item["id"] - if first_id is None: - first_id = title_id - widget = ValidationErrorTitleWidget(title_id, title_item, self) + if select_id is None: + select_id = title_id + widget = PublishErrorTitleWidget(title_id, title_item, self) widget.selected.connect(self._on_select) widget.instance_changed.connect(self._on_instance_change) + if widget.is_crashing_error: + select_id = title_id self._errors_layout.addWidget(widget) self._title_widgets[title_id] = widget self._errors_layout.addStretch(1) - if first_id: - self._title_widgets[first_id].set_selected(True) + if select_id: + self._title_widgets[select_id].set_selected(True) else: self.selection_changed.emit() @@ -1319,6 +1333,7 @@ def __init__(self, parent): content_widget = QtWidgets.QWidget(content_wrap_widget) content_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(8, 8, 8, 8) content_layout.setSpacing(15) scroll_area.setWidget(content_wrap_widget) @@ -1657,7 +1672,7 @@ class ReportsWidget(QtWidgets.QWidget): │ │ │ │ │ │ └──────┴───────────────────┘ - # Validation errors layout + # Publish errors layout ┌──────┬─────────┬─────────┐ │Views │ Actions │ │ │ ├─────────┤ Details │ @@ -1676,12 +1691,12 @@ def __init__( instances_view = PublishInstancesViewWidget(controller, views_widget) - validation_error_view = ValidationErrorsView(views_widget) + publish_error_view = PublishErrorsView(views_widget) views_layout = QtWidgets.QStackedLayout(views_widget) views_layout.setContentsMargins(0, 0, 0, 0) views_layout.addWidget(instances_view) - views_layout.addWidget(validation_error_view) + views_layout.addWidget(publish_error_view) views_layout.setCurrentWidget(instances_view) @@ -1690,7 +1705,7 @@ def __init__( details_widget.setObjectName("PublishInstancesDetails") # Actions widget - actions_widget = ValidateActionsWidget(controller, details_widget) + actions_widget = PublishActionsWidget(controller, details_widget) pages_widget = QtWidgets.QWidget(details_widget) @@ -1736,12 +1751,12 @@ def __init__( content_layout.addWidget(details_widget, 1) instances_view.selection_changed.connect(self._on_instance_selection) - validation_error_view.selection_changed.connect( + publish_error_view.selection_changed.connect( self._on_error_selection) self._views_layout = views_layout self._instances_view = instances_view - self._validation_error_view = validation_error_view + self._publish_error_view = publish_error_view self._actions_widget = actions_widget self._detail_inputs_widget = detail_inputs_widget @@ -1752,7 +1767,7 @@ def __init__( self._controller: AbstractPublisherFrontend = controller - self._validation_errors_by_id = {} + self._publish_errors_by_id = {} def _get_instance_items(self): report = self._controller.get_publish_report() @@ -1787,48 +1802,48 @@ def update_data(self): if is_crashed: error_info = self._controller.get_publish_error_info() - validation_error_mode = False + publish_error_mode = False if ( error_info is not None and not error_info.is_unknown_error ): - validation_error_mode = True + publish_error_mode = True elif ( not is_crashed and self._controller.publish_has_validation_errors() ): - validation_error_mode = True + publish_error_mode = True - if validation_error_mode: - view = self._validation_error_view + if publish_error_mode: + view = self._publish_error_view else: view = self._instances_view - self._actions_widget.set_visible_mode(validation_error_mode) - self._detail_inputs_spacer.setVisible(validation_error_mode) - self._detail_input_scroll.setVisible(validation_error_mode) + self._actions_widget.set_visible_mode(publish_error_mode) + self._detail_inputs_spacer.setVisible(publish_error_mode) + self._detail_input_scroll.setVisible(publish_error_mode) self._views_layout.setCurrentWidget(view) - self._crash_widget.setVisible(not validation_error_mode) - self._logs_view.setVisible(validation_error_mode) + self._crash_widget.setVisible(not publish_error_mode) + self._logs_view.setVisible(publish_error_mode) # Instance view & logs update instance_items = self._get_instance_items() self._instances_view.update_instances(instance_items) self._logs_view.update_instances(instance_items) - # Validation errors - validation_errors = self._controller.get_validation_errors() - grouped_error_items = validation_errors.group_items_by_title() + # Publish errors + publish_errors_report = self._controller.get_publish_errors_report() + grouped_error_items = publish_errors_report.group_items_by_title() - validation_errors_by_id = { + publish_errors_by_id = { title_item["id"]: title_item for title_item in grouped_error_items } - self._validation_errors_by_id = validation_errors_by_id - self._validation_error_view.set_errors(grouped_error_items) + self._publish_errors_by_id = publish_errors_by_id + self._publish_error_view.set_errors(grouped_error_items) def _on_instance_selection(self): instance_ids = self._instances_view.get_selected_instance_ids() @@ -1836,8 +1851,8 @@ def _on_instance_selection(self): def _on_error_selection(self): title_id, instance_ids = ( - self._validation_error_view.get_selected_items()) - error_info = self._validation_errors_by_id.get(title_id) + self._publish_error_view.get_selected_items()) + error_info = self._publish_errors_by_id.get(title_id) if error_info is None: self._actions_widget.set_error_info(None) self._detail_inputs_widget.set_error_item(None) @@ -1865,12 +1880,12 @@ class ReportPageWidget(QtWidgets.QFrame): 2. Publishing is paused. ┐ 3. Publishing successfully finished. │> Instances with logs. 4. Publishing crashed. ┘ - 5. Crashed because of validation error. > Errors with logs. + 5. Crashed because of publish error. > Errors with logs. - This widget is shown if validation errors happened during validation part. + This widget is shown if publish errors happened. - Shows validation error titles with instances on which they happened - and validation error detail with possible actions (repair). + Shows publish error titles with instances on which they happened + and publish error detail with possible actions (repair). """ def __init__( From 2fd99f232aee15fdd8d683df552a66664deeaa3d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 13 Sep 2024 18:56:37 +0200 Subject: [PATCH 071/266] fix expand button --- client/ayon_core/tools/utils/widgets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 9092283221..4c2b418c41 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -466,10 +466,10 @@ def __init__(self, parent): self._collapsed = True def _create_collapsed_pixmap(self): - return QtGui.QPixmap(self.branch_open_path) + return QtGui.QPixmap(self.branch_closed_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap(self.branch_closed_path) + return QtGui.QPixmap(self.branch_open_path) @property def collapsed(self): From d01cde7051527c85738b13f1c3769d821e8b0f3a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Sep 2024 12:01:42 +0200 Subject: [PATCH 072/266] reverse disabled and hidden to positive names --- client/ayon_core/lib/attribute_definitions.py | 80 ++++++++++++++----- 1 file changed, 62 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 894b012d59..a882bee0d9 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -90,6 +90,26 @@ def __call__(cls, *args, **kwargs): 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"God invalid '{main_label}' and '{depr_label}' arguments." + f" Using '{main_label}' value." + ) + elif depr_value is not None: + print( + f"Using deprecated argument '{depr_label}'" + f" please use '{main_label}' instead." + ) + 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 +126,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 = [] @@ -125,22 +147,28 @@ def __init__( label=None, tooltip=None, is_label_horizontal=None, - hidden=False, - disabled=False + visible=None, + enabled=None, + hidden=None, + disabled=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.visible = visible + self.enabled = enabled self._id = uuid.uuid4().hex self.__init__class__ = AbstractAttrDef @@ -149,14 +177,30 @@ def __init__( def id(self): return self._id + @property + def hidden(self): + return not self.visible + + @hidden.setter + def hidden(self, value): + self.visible = not value + + @property + def disabled(self): + return not self.enabled + + @disabled.setter + def disabled(self, value): + self.enabled = not value + def __eq__(self, other): if not isinstance(other, self.__class__): return False return ( self.key == other.key - and self.hidden == other.hidden and self.default == other.default - and self.disabled == other.disabled + and self.visible == other.visible + and self.enabled == other.enabled ) def __ne__(self, other): @@ -198,8 +242,8 @@ def serialize(self): "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) @@ -279,8 +323,8 @@ 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 convert_value(self, value): return value From f174941e1d11fc2d04b85d1de69db8441ff9d6c7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Sep 2024 12:01:54 +0200 Subject: [PATCH 073/266] use new attribute names --- client/ayon_core/tools/attribute_defs/widgets.py | 6 +++--- client/ayon_core/tools/publisher/widgets/widgets.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) 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 @@ 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 @@ def add_attr_defs(self, attr_defs): 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/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 83a2d9e6c1..b0f32dfcfc 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1446,7 +1446,7 @@ def set_current_instances(self, instances): self._attr_def_id_to_instances[attr_def.id] = attr_instances 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 @@ -1585,7 +1585,7 @@ def set_current_instances(self, instances, context_selected): widget = create_widget_for_attr_def( attr_def, content_widget ) - hidden_widget = attr_def.hidden + hidden_widget = not attr_def.visible # Hide unknown values of publish plugins # - The keys in most of cases does not represent what would # label represent From 37697bc6ce62f320da88b3bea1f933e424714acd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Sep 2024 12:06:40 +0200 Subject: [PATCH 074/266] change print to warning --- client/ayon_core/lib/attribute_definitions.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index a882bee0d9..cffa424798 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -4,6 +4,7 @@ import uuid import json import copy +import warnings from abc import ABCMeta, abstractmethod import clique @@ -100,9 +101,13 @@ def _convert_reversed_attr( f" Using '{main_label}' value." ) elif depr_value is not None: - print( - f"Using deprecated argument '{depr_label}'" - f" please use '{main_label}' instead." + 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: From b6392a3e4201ca3f2dfc75a7a25bb1b3834783be Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:20:15 +0200 Subject: [PATCH 075/266] fix comment Co-authored-by: Roy Nieterau --- client/ayon_core/lib/attribute_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index cffa424798..639778b16d 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -97,7 +97,7 @@ def _convert_reversed_attr( if main_value is not None and depr_value is not None: if main_value == depr_value: print( - f"God invalid '{main_label}' and '{depr_label}' arguments." + f"Got invalid '{main_label}' and '{depr_label}' arguments." f" Using '{main_label}' value." ) elif depr_value is not None: From 3c5603c9241560b578b28d4b27ab8eb3da486085 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Mon, 16 Sep 2024 23:47:41 +0300 Subject: [PATCH 076/266] fix line lengths --- .../ayon_core/plugins/publish/extract_color_transcode.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index 77ab6d0428..3e54d324e3 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -122,10 +122,13 @@ def process(self, instance): transcoding_type = output_def["transcoding_type"] target_colorspace = view = display = None - # NOTE: we use colorspace_data as the fallback values for the target colorspace. + # NOTE: we use colorspace_data as the fallback values for + # the target colorspace. if transcoding_type == "colorspace": - # TODO: Should we fallback to the colorspace (which used as source above) ? - # or should we compute the target colorspace from current view and display ? + # TODO: Should we fallback to the colorspace + # (which used as source above) ? + # or should we compute the target colorspace from + # current view and display ? target_colorspace = (output_def["colorspace"] or colorspace_data.get("colorspace")) elif transcoding_type == "display_view": From 57266dc7b660226fc6fddddecb4b1b9e89bfde1b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 17 Sep 2024 09:59:48 +0200 Subject: [PATCH 077/266] use single object of handler --- .../tools/publisher/models/publish.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index e7e765c4d2..6848e27bc4 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -29,6 +29,9 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.records = [] + def clear_records(self): + self.records = [] + def emit(self, record): try: record.msg = record.getMessage() @@ -858,6 +861,8 @@ def __init__(self, controller: AbstractPublisherBackend): self._default_iterator() ) + self._log_handler: MessageHandler = MessageHandler() + def reset(self): create_context = self._controller.get_create_context() self._publish_up_validation = False @@ -1232,21 +1237,20 @@ def _process_and_continue( plugin: pyblish.api.Plugin, instance: pyblish.api.Instance ): - handler = MessageHandler() root = logging.getLogger() - + self._log_handler.clear_records() plugin.log.propagate = False - plugin.log.addHandler(handler) - root.addHandler(handler) + plugin.log.addHandler(self._log_handler) + root.addHandler(self._log_handler) try: result = pyblish.plugin.process( plugin, self._publish_context, instance ) - result["records"] = handler.records + result["records"] = self._log_handler.records finally: plugin.log.propagate = True - plugin.log.removeHandler(handler) - root.removeHandler(handler) + plugin.log.removeHandler(self._log_handler) + root.removeHandler(self._log_handler) exception = result.get("error") if exception: From 14244e184c932831b4efbecec37d6444087e13a5 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 17 Sep 2024 18:29:59 -0400 Subject: [PATCH 078/266] Fix wrong retimed detection on image sequence clip. --- client/ayon_core/pipeline/editorial.py | 42 ++++++++++++++++---------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 84bffbe1ec..2934e3073b 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -179,6 +179,24 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): media_in = available_range.start_time.value media_out = available_range.end_time_inclusive().value + # Ensure image sequence media_ref source and + # available range are absolute. + media_ref = otio_clip.media_reference + if ( + hasattr(otio.schema, "ImageSequenceReference") and + isinstance(media_ref, otio.schema.ImageSequenceReference) and + media_in != media_ref.start_frame + ): + media_in = media_ref.start_frame + media_out += media_ref.start_frame + source_range_start = otio.opentime.RationalTime( + media_in + source_range.start_time.value, + ) + source_range = otio.opentime.TimeRange( + start_time=source_range_start, + duration=source_range.duration + ) + # modifiers time_scalar = 1. offset_in = 0 @@ -224,38 +242,30 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): offset_in *= time_scalar offset_out *= time_scalar - # filip offset if reversed speed + # flip offset if reversed speed if time_scalar < 0: - _offset_in = offset_out - _offset_out = offset_in - offset_in = _offset_in - offset_out = _offset_out + offset_in, offset_out = offset_out, offset_in # scale handles handle_start *= abs(time_scalar) handle_end *= abs(time_scalar) - # filip handles if reversed speed + # flip handles if reversed speed if time_scalar < 0: - _handle_start = handle_end - _handle_end = handle_start - handle_start = _handle_start - handle_end = _handle_end + handle_start, handle_end = handle_end, handle_start source_in = source_range.start_time.value - media_in_trimmed = ( - media_in + source_in + offset_in) - media_out_trimmed = ( - media_in + source_in + ( + media_in_trimmed = (source_in + offset_in) + media_out_trimmed = ( media_in_trimmed + ( ((source_range.duration.value - 1) * abs( time_scalar)) + offset_out)) # calculate available handles if (media_in_trimmed - media_in) < handle_start: - handle_start = (media_in_trimmed - media_in) + handle_start = max(0, media_in_trimmed - media_in) if (media_out - media_out_trimmed) < handle_end: - handle_end = (media_out - media_out_trimmed) + handle_end = max(0, media_out - media_out_trimmed) # create version data version_data = { From 318ce6b32122afaf65701e505e842550ed769a23 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 17 Sep 2024 18:41:57 -0400 Subject: [PATCH 079/266] Fix lint. --- client/ayon_core/pipeline/editorial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 2934e3073b..83035616c6 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -179,7 +179,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): media_in = available_range.start_time.value media_out = available_range.end_time_inclusive().value - # Ensure image sequence media_ref source and + # Ensure image sequence media_ref source and # available range are absolute. media_ref = otio_clip.media_reference if ( From ac226a360aa7f75fc89f38676ecda916af8f966d Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Wed, 18 Sep 2024 08:14:59 -0400 Subject: [PATCH 080/266] Apply suggestions from code review Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/editorial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 83035616c6..d69ca8714a 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -256,8 +256,8 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): source_in = source_range.start_time.value - media_in_trimmed = (source_in + offset_in) - media_out_trimmed = ( media_in_trimmed + ( + media_in_trimmed = source_in + offset_in + media_out_trimmed = (media_in_trimmed + ( ((source_range.duration.value - 1) * abs( time_scalar)) + offset_out)) From 0836fb810e70220ac1a1dd38443de6c065d24740 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Wed, 18 Sep 2024 08:15:13 -0400 Subject: [PATCH 081/266] Apply suggestions from code review Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/editorial.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index d69ca8714a..ad56eb247f 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -183,9 +183,9 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # available range are absolute. media_ref = otio_clip.media_reference if ( - hasattr(otio.schema, "ImageSequenceReference") and - isinstance(media_ref, otio.schema.ImageSequenceReference) and - media_in != media_ref.start_frame + hasattr(otio.schema, "ImageSequenceReference") + and isinstance(media_ref, otio.schema.ImageSequenceReference) + and media_in != media_ref.start_frame ): media_in = media_ref.start_frame media_out += media_ref.start_frame From efc31f01443bf3b9747d72e77ac2fa3b1c5737d8 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 18 Sep 2024 15:45:45 -0400 Subject: [PATCH 082/266] Consolidate pipeline.editorial.get_media_range_with_retimes --- client/ayon_core/pipeline/editorial.py | 106 ++++++++++++------ .../publish/extract_otio_trimming_video.py | 7 +- 2 files changed, 74 insertions(+), 39 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 83035616c6..bc02ae9b00 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -176,26 +176,36 @@ def _sequence_resize(source, length): def get_media_range_with_retimes(otio_clip, handle_start, handle_end): source_range = otio_clip.source_range available_range = otio_clip.available_range() - media_in = available_range.start_time.value - media_out = available_range.end_time_inclusive().value - # Ensure image sequence media_ref source and - # available range are absolute. - media_ref = otio_clip.media_reference - if ( - hasattr(otio.schema, "ImageSequenceReference") and - isinstance(media_ref, otio.schema.ImageSequenceReference) and - media_in != media_ref.start_frame - ): - media_in = media_ref.start_frame - media_out += media_ref.start_frame - source_range_start = otio.opentime.RationalTime( - media_in + source_range.start_time.value, - ) - source_range = otio.opentime.TimeRange( - start_time=source_range_start, - duration=source_range.duration - ) + source_range_rate = source_range.start_time.rate + available_range_rate = available_range.start_time.rate + + # Conform source range bounds to available range rate + # .e.g. embedded TC of (3600 sec/ 1h), duration 100 frames + # + # available |----------------------------------------| 24fps + # 86400 86500 + # + # + # 90010 90060 + # src |-----|______duration 2s___|----| 25fps + # 90000 + # + # + # 86409.6 86466.8 + # conformed |-------|_____duration _2.38s____|-------| 24fps + # 86400 + # + # 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 + ) # modifiers time_scalar = 1. @@ -242,47 +252,75 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): offset_in *= time_scalar offset_out *= time_scalar - # flip offset if reversed speed - if time_scalar < 0: - offset_in, offset_out = offset_out, offset_in - # scale handles handle_start *= abs(time_scalar) handle_end *= abs(time_scalar) - # flip handles if reversed speed + # flip offset and handles if reversed speed if time_scalar < 0: + offset_in, offset_out = offset_out, offset_in handle_start, handle_end = handle_end, handle_start - source_in = source_range.start_time.value + # compute retimed range + media_in_trimmed = conformed_source_range.start_time.value + offset_in + media_out_trimmed = media_in_trimmed + ( + (conformed_source_range.duration.value * abs( + time_scalar) + offset_out) - 1) - media_in_trimmed = (source_in + offset_in) - media_out_trimmed = ( media_in_trimmed + ( - ((source_range.duration.value - 1) * abs( - time_scalar)) + offset_out)) + media_in = available_range.start_time.value + media_out = available_range.end_time_inclusive().value + + # If media source is an image sequence, returned + # mediaIn/mediaOut have to correspond + # to frame numbers from source sequence. + media_ref = otio_clip.media_reference + is_input_sequence = ( + hasattr(otio.schema, "ImageSequenceReference") and + isinstance(media_ref, otio.schema.ImageSequenceReference) + ) + + if is_input_sequence: + # preserve discreet frame numbers + media_in_trimmed = otio.opentime.RationalTime.from_frames( + media_in_trimmed - media_in + media_ref.start_frame, + rate=available_range_rate, + ).to_frames() + media_out_trimmed = otio.opentime.RationalTime.from_frames( + media_out_trimmed - media_in + media_ref.start_frame, + rate=available_range_rate, + ).to_frames() - # calculate available handles + media_in = media_ref.start_frame + media_out = media_in + available_range.duration.to_frames() - 1 + + # adjust available handles if needed if (media_in_trimmed - media_in) < handle_start: handle_start = max(0, media_in_trimmed - media_in) if (media_out - media_out_trimmed) < handle_end: handle_end = max(0, media_out - media_out_trimmed) + # FFmpeg extraction ignores embedded timecode + # so substract to get a (mediaIn-mediaOut) range from 0. + if not is_input_sequence: + media_in_trimmed -= media_in + media_out_trimmed -= media_in + # create version data version_data = { "versionData": { "retime": True, "speed": time_scalar, "timewarps": time_warp_nodes, - "handleStart": int(round(handle_start)), - "handleEnd": int(round(handle_end)) + "handleStart": int(handle_start), + "handleEnd": int(handle_end) } } returning_dict = { "mediaIn": media_in_trimmed, "mediaOut": media_out_trimmed, - "handleStart": int(round(handle_start)), - "handleEnd": int(round(handle_end)), + "handleStart": int(handle_start), + "handleEnd": int(handle_end), "speed": time_scalar } diff --git a/client/ayon_core/plugins/publish/extract_otio_trimming_video.py b/client/ayon_core/plugins/publish/extract_otio_trimming_video.py index 9736c30b73..0c77602681 100644 --- a/client/ayon_core/plugins/publish/extract_otio_trimming_video.py +++ b/client/ayon_core/plugins/publish/extract_otio_trimming_video.py @@ -84,11 +84,8 @@ def _ffmpeg_trim_seqment(self, input_file_path, otio_range): command = get_ffmpeg_tool_args("ffmpeg") video_path = input_file_path - frame_start = otio_range.start_time.value - input_fps = otio_range.start_time.rate - frame_duration = otio_range.duration.value - 1 - sec_start = frames_to_seconds(frame_start, input_fps) - sec_duration = frames_to_seconds(frame_duration, input_fps) + sec_start = otio_range.start_time.to_seconds() + sec_duration = otio_range.duration.to_seconds() # form command for rendering gap files command.extend([ From ef6693f8a0c53b418765a0b88f131fdd911d1e3b Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 18 Sep 2024 16:28:45 -0400 Subject: [PATCH 083/266] Add unit tests. --- .../resources/img_seq_embedded_tc.json | 363 +++++++++++++++++ .../resources/img_seq_no_handles.json | 363 +++++++++++++++++ .../resources/img_seq_with_handles.json | 363 +++++++++++++++++ .../resources/movie_with_handles.json | 358 +++++++++++++++++ .../editorial/resources/qt_embedded_tc.json | 356 +++++++++++++++++ .../editorial/resources/qt_retimed_speed.json | 365 ++++++++++++++++++ 6 files changed, 2168 insertions(+) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/img_seq_no_handles.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/img_seq_with_handles.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/movie_with_handles.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/qt_retimed_speed.json diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc.json new file mode 100644 index 0000000000..a7c3ee00cf --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc.json @@ -0,0 +1,363 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1000-1100].exr", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 74.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 91046.625 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-6": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "994": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-6": { + "Value": 0.8, + "Variant Type": "Double" + }, + "994": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"f2918dd7-a30b-4b7d-8ac1-7d5f400058bf\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"ade94deb-f104-47dc-b8e9-04943f900914\", \"reviewTrack\": null, \"label\": \"/shots/sq01/Video_1sq01sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"5109899f-d744-4ed3-8547-8585ef9b703b\", \"creator_attributes\": {\"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1075, \"clipIn\": 655, \"clipOut\": 729, \"clipDuration\": 74, \"sourceIn\": 6, \"sourceOut\": 80, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"f2918dd7-a30b-4b7d-8ac1-7d5f400058bf\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"ade94deb-f104-47dc-b8e9-04943f900914\", \"reviewTrack\": null, \"parent_instance_id\": \"5109899f-d744-4ed3-8547-8585ef9b703b\", \"label\": \"/shots/sq01/Video_1sq01sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"85c729e9-0503-4c3a-8d7f-be0920f047d8\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/Video_1sq01sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"f2918dd7-a30b-4b7d-8ac1-7d5f400058bf\", \"publish\": true}" + }, + "clip_index": "f2918dd7-a30b-4b7d-8ac1-7d5f400058bf", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "f2918dd7-a30b-4b7d-8ac1-7d5f400058bf", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq01/Video_1sq01sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "85c729e9-0503-4c3a-8d7f-be0920f047d8", + "label": "/shots/sq01/Video_1sq01sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "5109899f-d744-4ed3-8547-8585ef9b703b", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "ade94deb-f104-47dc-b8e9-04943f900914", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "f2918dd7-a30b-4b7d-8ac1-7d5f400058bf", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 74, + "clipIn": 655, + "clipOut": 729, + "folderPath": "/shots/sq01/Video_1sq01sh010", + "fps": "from_selection", + "frameEnd": 1075, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 6, + "sourceOut": 80, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "5109899f-d744-4ed3-8547-8585ef9b703b", + "label": "/shots/sq01/Video_1sq01sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "ade94deb-f104-47dc-b8e9-04943f900914", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 91083.625 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1000-1100].exr", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 87399.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\samples\\exr_embedded_tc", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "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/resources/img_seq_no_handles.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_no_handles.json new file mode 100644 index 0000000000..9dccb51197 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_no_handles.json @@ -0,0 +1,363 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1000-1100].tif", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "0": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "1000": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "0": { + "Value": 0.8, + "Variant Type": "Double" + }, + "1000": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"647b2ba6-6fca-4219-b163-cd321df9652f\", \"reviewTrack\": null, \"label\": \"/shots/sq01/Video_1sq01sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"2ab8f149-e32c-40f5-a6cb-ad1ca567ccc1\", \"creator_attributes\": {\"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1102, \"clipIn\": 509, \"clipOut\": 610, \"clipDuration\": 101, \"sourceIn\": 0, \"sourceOut\": 101, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"647b2ba6-6fca-4219-b163-cd321df9652f\", \"reviewTrack\": null, \"parent_instance_id\": \"2ab8f149-e32c-40f5-a6cb-ad1ca567ccc1\", \"label\": \"/shots/sq01/Video_1sq01sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"9f866936-966b-4a73-8e61-1a5b6e648a3f\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/Video_1sq01sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184\", \"publish\": true}" + }, + "clip_index": "cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq01/Video_1sq01sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "9f866936-966b-4a73-8e61-1a5b6e648a3f", + "label": "/shots/sq01/Video_1sq01sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "2ab8f149-e32c-40f5-a6cb-ad1ca567ccc1", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "647b2ba6-6fca-4219-b163-cd321df9652f", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 101, + "clipIn": 509, + "clipOut": 610, + "folderPath": "/shots/sq01/Video_1sq01sh010", + "fps": "from_selection", + "frameEnd": 1102, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 0, + "sourceOut": 101, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "2ab8f149-e32c-40f5-a6cb-ad1ca567ccc1", + "label": "/shots/sq01/Video_1sq01sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "647b2ba6-6fca-4219-b163-cd321df9652f", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 50.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1000-1100].tif", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\samples", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "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/resources/img_seq_with_handles.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_with_handles.json new file mode 100644 index 0000000000..eb8876354c --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_with_handles.json @@ -0,0 +1,363 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1000-1100].tif", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 39.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 34.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-34": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "966": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-34": { + "Value": 0.8, + "Variant Type": "Double" + }, + "966": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5ca7b240-ec4c-49d1-841d-a96c33a08b1b\", \"reviewTrack\": null, \"label\": \"/shots/sq01/Video_1sq01sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"5b526964-5805-4412-af09-2da696c4978b\", \"creator_attributes\": {\"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1040, \"clipIn\": 543, \"clipOut\": 582, \"clipDuration\": 39, \"sourceIn\": 34, \"sourceOut\": 73, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5ca7b240-ec4c-49d1-841d-a96c33a08b1b\", \"reviewTrack\": null, \"parent_instance_id\": \"5b526964-5805-4412-af09-2da696c4978b\", \"label\": \"/shots/sq01/Video_1sq01sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"992ab293-943b-4894-8a7f-c42b54b4d582\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/Video_1sq01sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184\", \"publish\": true}" + }, + "clip_index": "cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq01/Video_1sq01sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "992ab293-943b-4894-8a7f-c42b54b4d582", + "label": "/shots/sq01/Video_1sq01sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "5b526964-5805-4412-af09-2da696c4978b", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "5ca7b240-ec4c-49d1-841d-a96c33a08b1b", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "cfea9be4-3ecd-4253-a0a9-1a2bc9a4a184", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 39, + "clipIn": 543, + "clipOut": 582, + "folderPath": "/shots/sq01/Video_1sq01sh010", + "fps": "from_selection", + "frameEnd": 1040, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 34, + "sourceOut": 73, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "5b526964-5805-4412-af09-2da696c4978b", + "label": "/shots/sq01/Video_1sq01sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "5ca7b240-ec4c-49d1-841d-a96c33a08b1b", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 53.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1000-1100].tif", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\samples", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "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/resources/movie_with_handles.json b/tests/client/ayon_core/pipeline/editorial/resources/movie_with_handles.json new file mode 100644 index 0000000000..47b5772b49 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/movie_with_handles.json @@ -0,0 +1,358 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": { + "Link Group ID": 1 + } + }, + "name": "simple_editorial_setup.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 171.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "0": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "1000": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "0": { + "Value": 0.8, + "Variant Type": "Double" + }, + "1000": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cef0267f-bbf4-4959-9f22-d225e03f2532\", \"clip_source_resolution\": {\"width\": \"640\", \"height\": \"360\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"3af1b00a-5625-468d-af17-8ed29fa8608a\", \"reviewTrack\": null, \"label\": \"/shots/sq01/Video_1sq01sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"b6e33343-2410-4de4-935e-724bc74301e1\", \"creator_attributes\": {\"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1172, \"clipIn\": 1097, \"clipOut\": 1268, \"clipDuration\": 171, \"sourceIn\": 0, \"sourceOut\": 171, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cef0267f-bbf4-4959-9f22-d225e03f2532\", \"clip_source_resolution\": {\"width\": \"640\", \"height\": \"360\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"3af1b00a-5625-468d-af17-8ed29fa8608a\", \"reviewTrack\": null, \"parent_instance_id\": \"b6e33343-2410-4de4-935e-724bc74301e1\", \"label\": \"/shots/sq01/Video_1sq01sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"ef8fe238-c970-4a16-be67-76f446113c4b\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/Video_1sq01sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"cef0267f-bbf4-4959-9f22-d225e03f2532\", \"publish\": true}" + }, + "clip_index": "cef0267f-bbf4-4959-9f22-d225e03f2532", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "cef0267f-bbf4-4959-9f22-d225e03f2532", + "clip_source_resolution": { + "height": "360", + "pixelAspect": 1.0, + "width": "640" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq01/Video_1sq01sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "ef8fe238-c970-4a16-be67-76f446113c4b", + "label": "/shots/sq01/Video_1sq01sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "b6e33343-2410-4de4-935e-724bc74301e1", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "3af1b00a-5625-468d-af17-8ed29fa8608a", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "cef0267f-bbf4-4959-9f22-d225e03f2532", + "clip_source_resolution": { + "height": "360", + "pixelAspect": 1.0, + "width": "640" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 171, + "clipIn": 1097, + "clipOut": 1268, + "folderPath": "/shots/sq01/Video_1sq01sh010", + "fps": "from_selection", + "frameEnd": 1172, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 0, + "sourceOut": 171, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "b6e33343-2410-4de4-935e-724bc74301e1", + "label": "/shots/sq01/Video_1sq01sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "3af1b00a-5625-468d-af17-8ed29fa8608a", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 85.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": {}, + "name": "simple_editorial_setup.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 16450.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\data\\simple_editorial_setup.mp4" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc.json new file mode 100644 index 0000000000..1b74ea4f37 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc.json @@ -0,0 +1,356 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "qt_embedded_tc.mov", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 44.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 90032.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-32": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "1009": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-32": { + "Value": 0.8, + "Variant Type": "Double" + }, + "1009": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"297fbf7a-7636-44b5-a308-809098298fae\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"3e459c3f-cc87-42c6-95c0-f11435ec8ace\", \"reviewTrack\": null, \"label\": \"/shots/sq01/Video_1sq01sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"acebdee4-5f4a-4ebd-8c22-6ef2725c2070\", \"creator_attributes\": {\"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1045, \"clipIn\": 509, \"clipOut\": 553, \"clipDuration\": 44, \"sourceIn\": 32, \"sourceOut\": 76, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"297fbf7a-7636-44b5-a308-809098298fae\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"3e459c3f-cc87-42c6-95c0-f11435ec8ace\", \"reviewTrack\": null, \"parent_instance_id\": \"acebdee4-5f4a-4ebd-8c22-6ef2725c2070\", \"label\": \"/shots/sq01/Video_1sq01sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"ffd09d3c-227c-4be0-8788-dec30daf7f78\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/Video_1sq01sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"297fbf7a-7636-44b5-a308-809098298fae\", \"publish\": true}" + }, + "clip_index": "297fbf7a-7636-44b5-a308-809098298fae", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "297fbf7a-7636-44b5-a308-809098298fae", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq01/Video_1sq01sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "ffd09d3c-227c-4be0-8788-dec30daf7f78", + "label": "/shots/sq01/Video_1sq01sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "acebdee4-5f4a-4ebd-8c22-6ef2725c2070", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "3e459c3f-cc87-42c6-95c0-f11435ec8ace", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "297fbf7a-7636-44b5-a308-809098298fae", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 44, + "clipIn": 509, + "clipOut": 553, + "folderPath": "/shots/sq01/Video_1sq01sh010", + "fps": "from_selection", + "frameEnd": 1045, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 32, + "sourceOut": 76, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "acebdee4-5f4a-4ebd-8c22-6ef2725c2070", + "label": "/shots/sq01/Video_1sq01sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "3e459c3f-cc87-42c6-95c0-f11435ec8ace", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 90054.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": {}, + "name": "qt_embedded_tc.mov", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 100.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\data\\qt_embedded_tc.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_retimed_speed.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_retimed_speed.json new file mode 100644 index 0000000000..61838d2755 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_retimed_speed.json @@ -0,0 +1,365 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": { + "Link Group ID": 1 + } + }, + "name": "simple_editorial_setup.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 171.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "LinearTimeWarp.1", + "metadata": {}, + "name": "", + "effect_name": "", + "time_scalar": 2.5 + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "0": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "1000": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "0": { + "Value": 0.8, + "Variant Type": "Double" + }, + "1000": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cef0267f-bbf4-4959-9f22-d225e03f2532\", \"clip_source_resolution\": {\"width\": \"640\", \"height\": \"360\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"2a780b95-14cc-45de-acc0-3ecd1f504325\", \"reviewTrack\": null, \"label\": \"/shots/sq01/Video_1sq01sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"e8af785a-484f-452b-8c9c-ac31ef0696c4\", \"creator_attributes\": {\"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1172, \"clipIn\": 805, \"clipOut\": 976, \"clipDuration\": 171, \"sourceIn\": 0, \"sourceOut\": 171, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo_1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video_1\", \"folderPath\": \"/shots/sq01/Video_1sq01sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"cef0267f-bbf4-4959-9f22-d225e03f2532\", \"clip_source_resolution\": {\"width\": \"640\", \"height\": \"360\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/sq01\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"sq01\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"sq01\", \"track\": \"Video_1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"2a780b95-14cc-45de-acc0-3ecd1f504325\", \"reviewTrack\": null, \"parent_instance_id\": \"e8af785a-484f-452b-8c9c-ac31ef0696c4\", \"label\": \"/shots/sq01/Video_1sq01sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"a34e7048-3d86-4c29-88c7-f65b1ba3d777\", \"creator_attributes\": {\"parentInstance\": \"/shots/sq01/Video_1sq01sh010 shot\", \"vSyncOn\": false, \"vSyncTrack\": \"Video 1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"cef0267f-bbf4-4959-9f22-d225e03f2532\", \"publish\": true}" + }, + "clip_index": "cef0267f-bbf4-4959-9f22-d225e03f2532", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "cef0267f-bbf4-4959-9f22-d225e03f2532", + "clip_source_resolution": { + "height": "360", + "pixelAspect": 1.0, + "width": "640" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq01/Video_1sq01sh010 shot", + "vSyncOn": false, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "a34e7048-3d86-4c29-88c7-f65b1ba3d777", + "label": "/shots/sq01/Video_1sq01sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "e8af785a-484f-452b-8c9c-ac31ef0696c4", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "2a780b95-14cc-45de-acc0-3ecd1f504325", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "cef0267f-bbf4-4959-9f22-d225e03f2532", + "clip_source_resolution": { + "height": "360", + "pixelAspect": 1.0, + "width": "640" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 171, + "clipIn": 805, + "clipOut": 976, + "folderPath": "/shots/sq01/Video_1sq01sh010", + "fps": "from_selection", + "frameEnd": 1172, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 0, + "sourceOut": 171, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/sq01/Video_1sq01sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq01", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq01", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "e8af785a-484f-452b-8c9c-ac31ef0696c4", + "label": "/shots/sq01/Video_1sq01sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq01", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": null, + "sequence": "sq01", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "2a780b95-14cc-45de-acc0-3ecd1f504325", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 85.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": {}, + "name": "simple_editorial_setup.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 16450.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:\\Users\\robin\\OneDrive\\Bureau\\dev_ayon\\data\\simple_editorial_setup.mp4" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file From b0b509d431eee02e193211efd52a0126af18e476 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 18 Sep 2024 16:34:05 -0400 Subject: [PATCH 084/266] Add unit tests --- .../test_media_range_with_retimes.py | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py 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 new file mode 100644 index 0000000000..7dca2e087a --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py @@ -0,0 +1,150 @@ +import os +import pytest + +import opentimelineio as otio + +from ayon_core.pipeline.editorial import get_media_range_with_retimes + + +_RESOURCE_DIR = os.path.join( + os.path.dirname(__file__), + "resources" +) + + +def _check_expected_retimed_values( + file_name: str, + expected_retimed_data: dict, + handle_start: int = 10, + handle_end: int = 10, +): + file_path = os.path.join(_RESOURCE_DIR, file_name) + otio_clip = otio.schema.Clip.from_json_file(file_path) + + retimed_data = get_media_range_with_retimes( + otio_clip, handle_start, handle_end + ) + assert retimed_data == expected_retimed_data + + +def test_movie_with_end_handle_end_only(): + """ + Movie clip (no embedded timecode) + available_range = 0-171 25fps + source_range = 0-16450 25fps + """ + expected_data = { + 'mediaIn': 0.0, + 'mediaOut': 170.0, + 'handleStart': 0, + 'handleEnd': 10, + 'speed': 1.0 + } + _check_expected_retimed_values( + "movie_with_handles.json", + expected_data, + ) + + +def test_movie_embedded_tc_handle(): + """ + Movie clip (embedded timecode 1h) + available_range = 86400-86500 24fps + source_range = 90032-90076 25fps + """ + expected_data = { + 'mediaIn': 30.720000000001164, + 'mediaOut': 71.9600000000064, + 'handleStart': 10, + 'handleEnd': 10, + 'speed': 1.0 + } + _check_expected_retimed_values( + "qt_embedded_tc.json", + expected_data + ) + + +def test_movie_retime_effect(): + """ + Movie clip (embedded timecode 1h) + available_range = 0-171 25fps + source_range = 0-16450 25fps + retimed speed: 250% + """ + expected_data = { + 'mediaIn': 0.0, + 'mediaOut': 426.5, + 'handleStart': 0, + 'handleEnd': 25, + 'speed': 2.5, + 'versionData': { + 'retime': True, + 'speed': 2.5, + 'timewarps': [], + 'handleStart': 0, + 'handleEnd': 25 + } + } +# import rpdb ; rpdb.Rpdb().set_trace() + _check_expected_retimed_values( + "qt_retimed_speed.json", + expected_data + ) + + +def test_img_sequence_no_handles(): + """ + Img sequence clip (no embedded timecode) + available files = 1000-1100 + source_range = 0-100 25fps + """ + expected_data = { + 'mediaIn': 1000, + 'mediaOut': 1100, + 'handleStart': 0, + 'handleEnd': 0, + 'speed': 1.0 + } + _check_expected_retimed_values( + "img_seq_no_handles.json", + expected_data + ) + + +def test_img_sequence_with_handles(): + """ + Img sequence clip (no embedded timecode) + available files = 1000-1100 + source_range = 34-72 25fps + """ + expected_data = { + 'mediaIn': 1034, + 'mediaOut': 1072, + 'handleStart': 10, + 'handleEnd': 10, + 'speed': 1.0 + } + _check_expected_retimed_values( + "img_seq_with_handles.json", + expected_data + ) + + +def test_img_sequence_with_embedded_tc_and_handles(): + """ + Img sequence clip (embedded timecode 1h) + available files = 1000-1100 + source_range = 91046.625-91,120.625 25fps + """ + expected_data = { + 'mediaIn': 1005, + 'mediaOut': 1075, + 'handleStart': 5, + 'handleEnd': 10, + 'speed': 1.0 + } + _check_expected_retimed_values( + "img_seq_embedded_tc.json", + expected_data + ) From be2b8d5c60087d53e84f84bf9392f0ece2812c91 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 18 Sep 2024 16:46:49 -0400 Subject: [PATCH 085/266] Fix lint. --- client/ayon_core/pipeline/editorial.py | 8 +++----- .../plugins/publish/extract_otio_trimming_video.py | 3 --- .../pipeline/editorial/test_media_range_with_retimes.py | 1 - 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index bc02ae9b00..ca62c13e7d 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -176,8 +176,6 @@ def _sequence_resize(source, length): def get_media_range_with_retimes(otio_clip, handle_start, handle_end): source_range = otio_clip.source_range available_range = otio_clip.available_range() - - source_range_rate = source_range.start_time.rate available_range_rate = available_range.start_time.rate # Conform source range bounds to available range rate @@ -187,9 +185,9 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # 86400 86500 # # - # 90010 90060 - # src |-----|______duration 2s___|----| 25fps - # 90000 + # 90010 90060 + # src |-----|______duration 2s___|----| 25fps + # 90000 # # # 86409.6 86466.8 diff --git a/client/ayon_core/plugins/publish/extract_otio_trimming_video.py b/client/ayon_core/plugins/publish/extract_otio_trimming_video.py index 0c77602681..59b8a714f0 100644 --- a/client/ayon_core/plugins/publish/extract_otio_trimming_video.py +++ b/client/ayon_core/plugins/publish/extract_otio_trimming_video.py @@ -74,9 +74,6 @@ def _ffmpeg_trim_seqment(self, input_file_path, otio_range): otio_range (opentime.TimeRange): range to trim to """ - # Not all hosts can import this module. - from ayon_core.pipeline.editorial import frames_to_seconds - # create path to destination output_path = self._get_ffmpeg_output(input_file_path) 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 7dca2e087a..ea0b7fbf82 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 @@ -1,5 +1,4 @@ import os -import pytest import opentimelineio as otio From 688c25315834f8e7b86090fb19f3da3fe5ad25f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:26:00 +0200 Subject: [PATCH 086/266] modified signature of '_create_instance' --- .../pipeline/create/creator_plugins.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 3b90b11a51..66725e7026 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -364,8 +364,25 @@ def log(self): return self._log def _create_instance( - self, product_type: str, product_name: str, data: Dict[str, Any] + self, + product_name: str, + data: Dict[str, Any], + product_type: Optional[str] = None ) -> CreatedInstance: + """Create instance and add instance to context. + + Args: + product_name (str): Product name. + data (Dict[str, Any]): Instance data. + product_type (Optional[str]): Product type, object attribute + 'product_type' is used if not passed. + + Returns: + CreatedInstance: Created instance. + + """ + if product_type is None: + product_type = self.product_type instance = CreatedInstance( product_type, product_name, From ac8848dd1975e93847c0d0afc19eb51b403ca69b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:28:59 +0200 Subject: [PATCH 087/266] fix crash widget layour --- client/ayon_core/tools/publisher/widgets/report_page.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index d491c300d3..5e48cef0d2 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1586,13 +1586,13 @@ def __init__( layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) layout.setSpacing(0) - layout.addSpacing(20) + layout.addStretch(1) layout.addWidget(main_label, 0) - layout.addSpacing(20) + layout.addSpacing(30) layout.addWidget(report_label, 0) - layout.addSpacing(20) + layout.addSpacing(30) layout.addWidget(btns_widget, 0) - layout.addSpacing(10) + layout.addStretch(2) copy_clipboard_btn.clicked.connect(self._on_copy_to_clipboard) save_to_disk_btn.clicked.connect(self._on_save_to_disk_click) From f5182f277cb4e4472ad76f8fa14a3f8460fe4294 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 17:33:31 +0200 Subject: [PATCH 088/266] fix success publish page --- .../ayon_core/tools/publisher/widgets/report_page.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 5e48cef0d2..7ee7ad76f3 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1802,8 +1802,11 @@ def update_data(self): if is_crashed: error_info = self._controller.get_publish_error_info() + publish_finished = self._controller.publish_has_finished() publish_error_mode = False - if ( + if publish_finished: + publish_error_mode = False + elif ( error_info is not None and not error_info.is_unknown_error ): @@ -1819,14 +1822,15 @@ def update_data(self): view = self._publish_error_view else: view = self._instances_view + self._views_layout.setCurrentWidget(view) self._actions_widget.set_visible_mode(publish_error_mode) self._detail_inputs_spacer.setVisible(publish_error_mode) self._detail_input_scroll.setVisible(publish_error_mode) - self._views_layout.setCurrentWidget(view) - self._crash_widget.setVisible(not publish_error_mode) - self._logs_view.setVisible(publish_error_mode) + logs_visible = publish_error_mode or publish_finished + self._logs_view.setVisible(logs_visible) + self._crash_widget.setVisible(not logs_visible) # Instance view & logs update instance_items = self._get_instance_items() From b2b933bc029026034e374079a427514a5cb6a07e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 18:03:24 +0200 Subject: [PATCH 089/266] fix and simplify the visibility --- .../tools/publisher/widgets/report_page.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index 7ee7ad76f3..b7afcf470a 100644 --- a/client/ayon_core/tools/publisher/widgets/report_page.py +++ b/client/ayon_core/tools/publisher/widgets/report_page.py @@ -1797,38 +1797,31 @@ def _get_instance_items(self): return instance_items def update_data(self): - is_crashed = self._controller.publish_has_crashed() + has_validation_error = self._controller.publish_has_validation_errors() + has_finished = self._controller.publish_has_finished() + has_crashed = self._controller.publish_has_crashed() error_info = None - if is_crashed: + if has_crashed: error_info = self._controller.get_publish_error_info() - publish_finished = self._controller.publish_has_finished() publish_error_mode = False - if publish_finished: - publish_error_mode = False - elif ( - error_info is not None - and not error_info.is_unknown_error - ): - publish_error_mode = True - - elif ( - not is_crashed - and self._controller.publish_has_validation_errors() - ): + if error_info is not None: + publish_error_mode = not error_info.is_unknown_error + elif has_validation_error: publish_error_mode = True if publish_error_mode: view = self._publish_error_view else: view = self._instances_view + self._views_layout.setCurrentWidget(view) self._actions_widget.set_visible_mode(publish_error_mode) self._detail_inputs_spacer.setVisible(publish_error_mode) self._detail_input_scroll.setVisible(publish_error_mode) - logs_visible = publish_error_mode or publish_finished + logs_visible = publish_error_mode or has_finished or not has_crashed self._logs_view.setVisible(logs_visible) self._crash_widget.setVisible(not logs_visible) From 98918b1dd4e4140f93f23de8f16178d43494cd65 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Fri, 20 Sep 2024 00:23:28 +0300 Subject: [PATCH 090/266] Add `ExtractOIIOTranscode` settings override --- server/settings/conversion.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index f513738603..7eed13bb69 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -71,10 +71,36 @@ def _convert_validate_version_0_3_3(publish_overrides): validate_version["plugin_state_profiles"] = [profile] +def _convert_oiio_transcode_0_4_5(publish_overrides): + """ExtractOIIOTranscode plugin changed in 0.4.5.""" + if "ExtractOIIOTranscode" not in publish_overrides: + return + + transcode_profiles = publish_overrides["ExtractOIIOTranscode"]["profiles"] + + for profile in transcode_profiles: + for output in profile["outputs"]: + transcode_type = output["transcoding_type"] + if transcode_type == "display": + output["transcoding_type"] = "display_view" + # Already new settings + if "display_view" in output: + continue + + output["display_view"] = {} + if output["display"]: + output["display_view"].update({"display": output["display"]}) + output.pop("display") + if output["view"]: + output["display_view"].update({"view": output["view"]}) + output.pop("view") + + def _conver_publish_plugins(overrides): if "publish" not in overrides: return _convert_validate_version_0_3_3(overrides["publish"]) + _convert_oiio_transcode_0_4_5(overrides["publish"]) def convert_settings_overrides( From 9ea9e344a2e147e1681a291795f3352ea71cb5af Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 20 Sep 2024 12:28:34 +0200 Subject: [PATCH 091/266] Log the filepath it invalidated for clearer log --- client/ayon_core/plugins/publish/integrate.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index d3f6c04333..e8fe09bab7 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -509,8 +509,11 @@ def _validate_repre_files(self, files, is_sequence_representation): if not is_sequence_representation: files = [files] - if any(os.path.isabs(fname) for fname in files): - raise KnownPublishError("Given file names contain full paths") + for fname in files: + if os.path.isabs(fname): + raise KnownPublishError( + f"Representation file names contains full paths: {fname}" + ) if not is_sequence_representation: return From 451d5e77cd4edeadd5cc18bcf14fdc9d350c3a5c Mon Sep 17 00:00:00 2001 From: Mustafa Jafar Date: Mon, 23 Sep 2024 12:13:23 +0300 Subject: [PATCH 092/266] exit _convert_oiio_transcode_0_4_5 if there are no profiles Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/conversion.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 7eed13bb69..06a1c2c02b 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -76,7 +76,9 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): if "ExtractOIIOTranscode" not in publish_overrides: return - transcode_profiles = publish_overrides["ExtractOIIOTranscode"]["profiles"] + transcode_profiles = publish_overrides["ExtractOIIOTranscode"].get("profiles") + if not transcode_profiles: + return for profile in transcode_profiles: for output in profile["outputs"]: From 50428f1528a241176da08092eceeef746e8b4359 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Sep 2024 13:55:09 +0200 Subject: [PATCH 093/266] Fix delivering UDIMs using {udim} in delivery template --- client/ayon_core/lib/path_tools.py | 5 ++++- client/ayon_core/plugins/load/delivery.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/path_tools.py b/client/ayon_core/lib/path_tools.py index a65f0f8e13..5c81fbfebf 100644 --- a/client/ayon_core/lib/path_tools.py +++ b/client/ayon_core/lib/path_tools.py @@ -81,7 +81,10 @@ def collect_frames(files): dict: {'/folder/product_v001.0001.png': '0001', ....} """ - patterns = [clique.PATTERNS["frames"]] + # clique.PATTERNS["frames"] supports only `.1001.exr` not `_1001.exr` so + # we use a customized pattern. + pattern = "[_.](?P(?P0*)\\d+)\\.\\D+\\d?$" + patterns = [pattern] collections, remainder = clique.assemble( files, minimum_items=1, patterns=patterns) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index c7954a18b2..449d4f0554 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -231,6 +231,11 @@ def deliver(self): self.log ] + # TODO: This will currently incorrectly detect 'resources' + # that are published along with the publish, because those should + # not adhere to the template directly but are ingested in a + # customized way. For example, maya look textures or any publish + # that directly adds files into `instance.data["transfers"]`. if repre.get("files"): src_paths = [] for repre_file in repre["files"]: @@ -263,7 +268,18 @@ def deliver(self): frame = dst_frame if frame is not None: - anatomy_data["frame"] = frame + if repre["context"].get("frame"): + anatomy_data["frame"] = frame + elif repre["context"].get("udim"): + anatomy_data["udim"] = frame + else: + # Fallback + self.log.warning( + "Representation context has no frame or udim" + " data. Supplying sequence frame to '{frame}'" + " formatting data." + ) + anatomy_data["frame"] = frame new_report_items, uploaded = deliver_single_file(*args) report_items.update(new_report_items) self._update_progress(uploaded) From 4f2340adea40e8c2e589387a115ae1c083041d9a Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Mon, 23 Sep 2024 08:14:55 -0400 Subject: [PATCH 094/266] Update client/ayon_core/pipeline/editorial.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/editorial.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index ca62c13e7d..4b823f130f 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -262,8 +262,12 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # compute retimed range media_in_trimmed = conformed_source_range.start_time.value + offset_in media_out_trimmed = media_in_trimmed + ( - (conformed_source_range.duration.value * abs( - time_scalar) + offset_out) - 1) + ( + conformed_source_range.duration.value + * abs(time_scalar) + + offset_out + ) - 1 + ) media_in = available_range.start_time.value media_out = available_range.end_time_inclusive().value From f9ed6f58774cb14c132ed53856d8a966838131a1 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 23 Sep 2024 08:19:12 -0400 Subject: [PATCH 095/266] Fix typos. --- client/ayon_core/pipeline/editorial.py | 2 +- .../pipeline/editorial/test_media_range_with_retimes.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 4b823f130f..7d6d6f5882 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -282,7 +282,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): ) if is_input_sequence: - # preserve discreet frame numbers + # preserve discrete frame numbers media_in_trimmed = otio.opentime.RationalTime.from_frames( media_in_trimmed - media_in + media_ref.start_frame, rate=available_range_rate, 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 ea0b7fbf82..82512df7b8 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 @@ -85,7 +85,6 @@ def test_movie_retime_effect(): 'handleEnd': 25 } } -# import rpdb ; rpdb.Rpdb().set_trace() _check_expected_retimed_values( "qt_retimed_speed.json", expected_data From d2b933718d85d996fe4217f490e5fbd65a916a42 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 23 Sep 2024 08:22:54 -0400 Subject: [PATCH 096/266] Adjust test docstring. --- .../pipeline/editorial/test_media_range_with_retimes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 82512df7b8..270b01a799 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 @@ -133,7 +133,7 @@ def test_img_sequence_with_embedded_tc_and_handles(): """ Img sequence clip (embedded timecode 1h) available files = 1000-1100 - source_range = 91046.625-91,120.625 25fps + source_range = 91046.625-91120.625 25fps """ expected_data = { 'mediaIn': 1005, From cd5303ef7b09014115f56432337a4b461617d2df Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Sep 2024 14:42:01 +0200 Subject: [PATCH 097/266] Remove legacy code --- client/ayon_core/plugins/load/delivery.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index c7954a18b2..60d4c01258 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -267,17 +267,11 @@ def deliver(self): new_report_items, uploaded = deliver_single_file(*args) report_items.update(new_report_items) self._update_progress(uploaded) - else: # fallback for Pype2 and representations without files - frame = repre["context"].get("frame") - if frame: - repre["context"]["frame"] = len(str(frame)) * "#" - - if not frame: - new_report_items, uploaded = deliver_single_file(*args) - else: - new_report_items, uploaded = deliver_sequence(*args) - report_items.update(new_report_items) - self._update_progress(uploaded) + else: + raise ValueError( + "Representation entity is lacking `files`." + f" Unable to process entity: {repre}" + ) self.text_area.setText(self._format_report(report_items)) self.text_area.setVisible(True) From b85289080e4289810bdebbca9b87f490ddac19fa Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Sep 2024 15:02:12 +0200 Subject: [PATCH 098/266] Remove unused import --- client/ayon_core/plugins/load/delivery.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 60d4c01258..09c396ff6f 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -17,8 +17,7 @@ from ayon_core.pipeline.delivery import ( get_format_dict, check_destination_path, - deliver_single_file, - deliver_sequence, + deliver_single_file ) From ffd548201849c20436f147d109998e8cd1a98dbd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 23 Sep 2024 15:17:47 +0200 Subject: [PATCH 099/266] Fix not removing absolute path in Maya DL submission This occured only in Maya (or DCC with AOV), caused pasting whole absolute path to metadata.json which could caused `Given file names contain full paths` (if ValidateExpectedFiles was disabled). 'files' is expecting only file names, not full path. --- client/ayon_core/pipeline/farm/pyblish_functions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index b218dc78e5..af90903bd8 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -788,6 +788,11 @@ 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] + else: + files = os.path.basename(files) + rep = { "name": ext, "ext": ext, From e9b67edc0f16f972c6d0d53f056ff69c03f49653 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 23 Sep 2024 15:37:58 +0200 Subject: [PATCH 100/266] Remove legacy condition completely --- client/ayon_core/plugins/load/delivery.py | 76 +++++++++++------------ 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 09c396ff6f..6a0947cc42 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -230,47 +230,41 @@ def deliver(self): self.log ] - if repre.get("files"): - src_paths = [] - for repre_file in repre["files"]: - src_path = self.anatomy.fill_root(repre_file["path"]) - src_paths.append(src_path) - sources_and_frames = collect_frames(src_paths) - - frames = set(sources_and_frames.values()) - frames.discard(None) - first_frame = None - if frames: - first_frame = min(frames) - - for src_path, frame in sources_and_frames.items(): - args[0] = src_path - # Renumber frames - if renumber_frame and frame is not None: - # Calculate offset between - # first frame and current frame - # - '0' for first frame - offset = frame_offset - int(first_frame) - # Add offset to new frame start - dst_frame = int(frame) + offset - if dst_frame < 0: - msg = "Renumber frame has a smaller number than original frame" # noqa - report_items[msg].append(src_path) - self.log.warning("{} <{}>".format( - msg, dst_frame)) - continue - frame = dst_frame - - if frame is not None: - anatomy_data["frame"] = frame - new_report_items, uploaded = deliver_single_file(*args) - report_items.update(new_report_items) - self._update_progress(uploaded) - else: - raise ValueError( - "Representation entity is lacking `files`." - f" Unable to process entity: {repre}" - ) + src_paths = [] + for repre_file in repre["files"]: + src_path = self.anatomy.fill_root(repre_file["path"]) + src_paths.append(src_path) + sources_and_frames = collect_frames(src_paths) + + frames = set(sources_and_frames.values()) + frames.discard(None) + first_frame = None + if frames: + first_frame = min(frames) + + for src_path, frame in sources_and_frames.items(): + args[0] = src_path + # Renumber frames + if renumber_frame and frame is not None: + # Calculate offset between + # first frame and current frame + # - '0' for first frame + offset = frame_offset - int(first_frame) + # Add offset to new frame start + dst_frame = int(frame) + offset + if dst_frame < 0: + msg = "Renumber frame has a smaller number than original frame" # noqa + report_items[msg].append(src_path) + self.log.warning("{} <{}>".format( + msg, dst_frame)) + continue + frame = dst_frame + + if frame is not None: + anatomy_data["frame"] = frame + new_report_items, uploaded = deliver_single_file(*args) + report_items.update(new_report_items) + self._update_progress(uploaded) self.text_area.setText(self._format_report(report_items)) self.text_area.setVisible(True) From b905dfe4bbc68e32cd76bb9fdea226c9c68e2a29 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 24 Sep 2024 02:41:30 +0200 Subject: [PATCH 101/266] Support PySide6 --- .../tools/experimental_tools/pyblish_debug_stepper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py index 51b7ba2c06..1db0a3d9f4 100644 --- a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -165,9 +165,9 @@ def __init__(self, parent=None): text_edit = QtWidgets.QTextEdit() text_edit.setFixedHeight(65) font = QtGui.QFont("NONEXISTENTFONT") - font.setStyleHint(font.TypeWriter) + font.setStyleHint(QtGui.QFont.TypeWriter) text_edit.setFont(font) - text_edit.setLineWrapMode(text_edit.NoWrap) + text_edit.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) step = QtWidgets.QPushButton("Step") step.setEnabled(False) From c9ea3dddd06dcdb15714f977095f49b513ea3f66 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 24 Sep 2024 02:44:36 +0200 Subject: [PATCH 102/266] Cosmetics --- .../pyblish_debug_stepper.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py index 1db0a3d9f4..0f1120b8f6 100644 --- a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -82,8 +82,8 @@ def _update_recursive(self, data, parent, previous_data): 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)) - value_item = self.itemFromIndex(self.index(row, 2, parent_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) @@ -92,13 +92,16 @@ def _update_recursive(self, data, parent, previous_data): parent.appendRow([item, type_item, value_item]) # Key - key_color = NEW_KEY_COLOR if key not in previous_data else KEY_COLOR + 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: + if ( + key in previous_data + and type(previous_data[key]).__name__ != type_str + ): type_color = NEW_VALUE_TYPE_COLOR type_item.setText(type_str) @@ -116,23 +119,31 @@ def _update_recursive(self, data, parent, previous_data): 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 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) + # 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) + 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) + # 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 @@ -195,8 +206,6 @@ def __init__(self, parent=None): self._previous_data = {} - - def _set_window_title(self, plugin=None): title = "Pyblish Debug Stepper" if plugin is not None: @@ -227,7 +236,7 @@ def on_plugin_processed(self, result): self._set_window_title(plugin=result["plugin"]) - print(10*"<" ,result["plugin"].__name__, 10*">") + print(10*"<", result["plugin"].__name__, 10*">") plugin_order = result["plugin"].order plugin_name = result["plugin"].__name__ From b946ed64f33f3c1e19c351b44f8c1d55cf395738 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 24 Sep 2024 02:51:49 +0200 Subject: [PATCH 103/266] Add simple filter field to quickly filter to certain keys only --- .../tools/experimental_tools/pyblish_debug_stepper.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py index 0f1120b8f6..33de4bf036 100644 --- a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -185,12 +185,18 @@ def __init__(self, parent=None): 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) @@ -198,6 +204,7 @@ def __init__(self, parent=None): self._pause = False self.model = model + self.filter = filter_field self.proxy = proxy self.view = view self.text = text_edit From d93c114d14bab45a0406588414999844c5a6c62a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 12:45:18 +0200 Subject: [PATCH 104/266] Fix querying of loadedVersions It si only in context.data, without it 'reference' links (of loaded containers) won't be created. --- client/ayon_core/plugins/publish/integrate_inputlinks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index 16aef09a39..113d6144ce 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -97,7 +97,7 @@ def create_workfile_links( instance.data["versionEntity"]["id"], ) - loaded_versions = workfile_instance.context.get("loadedVersions") + loaded_versions = workfile_instance.context.data.get("loadedVersions") if not loaded_versions: return From 5189ee77243c7e6f4633f3b7a0e2251b0d88252f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 12:45:37 +0200 Subject: [PATCH 105/266] Added docstrings --- .../plugins/publish/integrate_inputlinks.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index 113d6144ce..912420e7b3 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -9,7 +9,14 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): - """Connecting version level dependency links""" + """Connecting version level dependency links + + Handles links: + - generative - what gets produced from workfile + - reference - what was loaded into workfile + + It expects workfile instance is being published. + """ order = pyblish.api.IntegratorOrder + 0.2 label = "Connect Dependency InputLinks AYON" @@ -47,6 +54,11 @@ def process(self, context): self.create_links_on_server(context, new_links_by_type) def split_instances(self, context): + """Separates published instances into workfile and other + + Returns: + (tuple(pyblish.plugin.Instance), list(pyblish.plugin.Instance)) + """ workfile_instance = None other_instances = [] @@ -83,6 +95,15 @@ def add_link(self, new_links_by_type, link_type, input_id, output_id): def create_workfile_links( self, workfile_instance, other_instances, new_links_by_type ): + """Adds links (generative and reference) for workfile. + + Args: + workfile_instance (pyblish.plugin.Instance): published workfile + other_instances (list[pyblish.plugin.Instance]): other published + instances + new_links_by_type (dict[str, list[str]): dictionary collecting new + created links by its type + """ if workfile_instance is None: self.log.warn("No workfile in this publish session.") return From ebbd65cc6902bde884c120f3219467ad1ff6a24a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 12:45:54 +0200 Subject: [PATCH 106/266] Refactor imports --- client/ayon_core/tools/loader/models/sitesync.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 02504c2ad3..753a2e4d5c 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -1,6 +1,9 @@ import collections -from ayon_api import get_representations, get_versions_links +from ayon_api import ( + get_representations, + get_versions_links, +) from ayon_core.lib import Logger, NestedCacheItem from ayon_core.addon import AddonsManager From 2bd7f814bfbedd8fa300d1f9c2f929a662ba6377 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 12:47:12 +0200 Subject: [PATCH 107/266] Fix directions of links Currently workfile instance is on 'outside' side of reference link, we must look for 'input' reference links for it --- client/ayon_core/tools/loader/models/sitesync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 753a2e4d5c..8d29845f5d 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -578,7 +578,7 @@ def _get_linked_representation_id( project_name, versions_to_check, link_types=link_types, - link_direction="out") + link_direction="in") # looking for 'in'puts for version versions_to_check = set() for links in versions_links.values(): From 2c673ea4c4563822292e77c4414750e141e5b249 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 12:48:34 +0200 Subject: [PATCH 108/266] Refactor adding site to linked representations Previously it was handled by ugly exception, not it actually checks if representation files are present and if not it forces redownload immediately. --- client/ayon_core/tools/loader/models/sitesync.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 8d29845f5d..375ef9aa26 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -512,18 +512,19 @@ def _add_site(self, project_name, repre_entity, site_name, product_type): "reference" ) for link_repre_id in links: - try: + if not self._sitesync_addon.is_representation_on_site( + project_name, + link_repre_id, + site_name + ): print("Adding {} to linked representation: {}".format( site_name, link_repre_id)) self._sitesync_addon.add_site( project_name, link_repre_id, site_name, - force=False + force=True ) - except Exception: - # do not add/reset working site for references - log.debug("Site present", exc_info=True) def _get_linked_representation_id( self, From 7106ff04416c11de216943ba330e45c92c3fe919 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 12:49:19 +0200 Subject: [PATCH 109/266] Refactor remove unneeded check for adding to set --- client/ayon_core/tools/loader/models/sitesync.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 375ef9aa26..c7f0038df4 100644 --- a/client/ayon_core/tools/loader/models/sitesync.py +++ b/client/ayon_core/tools/loader/models/sitesync.py @@ -588,9 +588,6 @@ def _get_linked_representation_id( if link["entityType"] != "version": continue entity_id = link["entityId"] - # Skip already found linked version ids - if entity_id in linked_version_ids: - continue linked_version_ids.add(entity_id) versions_to_check.add(entity_id) From 7c1602340949fa3576eec1eec49720d5a3e7fcb6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 24 Sep 2024 14:49:20 +0200 Subject: [PATCH 110/266] Fix docstring type definition Co-authored-by: Roy Nieterau --- client/ayon_core/plugins/publish/integrate_inputlinks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index 912420e7b3..a3b6a228d6 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -101,7 +101,7 @@ def create_workfile_links( workfile_instance (pyblish.plugin.Instance): published workfile other_instances (list[pyblish.plugin.Instance]): other published instances - new_links_by_type (dict[str, list[str]): dictionary collecting new + new_links_by_type (dict[str, list[str]]): dictionary collecting new created links by its type """ if workfile_instance is None: From 77fac00ecc68be9189dc321381880a432c7b617c Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 24 Sep 2024 12:50:52 -0400 Subject: [PATCH 111/266] Make it work even with image sequence and embedded timecodes. --- client/ayon_core/pipeline/editorial.py | 80 +++++-- .../plugins/publish/extract_otio_review.py | 204 +++++++++++------- 2 files changed, 187 insertions(+), 97 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 7d6d6f5882..23c49154ac 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -88,7 +88,7 @@ def trim_media_range(media_range, source_range): """ rw_media_start = _ot.RationalTime( - media_range.start_time.value + source_range.start_time.value, + source_range.start_time.value, media_range.start_time.rate ) rw_media_duration = _ot.RationalTime( @@ -173,6 +173,66 @@ def _sequence_resize(source, length): yield (1 - ratio) * source[int(low)] + ratio * source[int(high)] +def is_clip_from_media_sequence(otio_clip): + """ + Args: + otio_clip (otio.schema.Clip): The OTIO clip to check. + + Returns: + bool. Is the provided clip from an input media sequence ? + """ + media_ref = otio_clip.media_reference + metadata = media_ref.metadata + + # OpenTimelineIO 0.13 and newer + is_input_sequence = ( + hasattr(otio.schema, "ImageSequenceReference") and + isinstance(media_ref, otio.schema.ImageSequenceReference) + ) + + # OpenTimelineIO 0.12 and older + is_input_sequence_legacy = bool(metadata.get("padding")) + + return is_input_sequence or is_input_sequence_legacy + + +def remap_range_on_file_sequence(otio_clip, in_out_range): + """ + Args: + otio_clip (otio.schema.Clip): The OTIO clip to check. + in_out_range (tuple[float, float]): The in-out range to remap. + + Returns: + tuple(int, int): The remapped range as discrete frame number. + + Raises: + ValueError. When the otio_clip or provided range is invalid. + """ + if not is_clip_from_media_sequence(otio_clip): + raise ValueError(f"Cannot map on non-file sequence clip {otio_clip}.") + + try: + media_in_trimmed, media_out_trimmed = in_out_range + + except ValueError as error: + raise ValueError("Invalid in_out_range provided.") from error + + media_ref = otio_clip.media_reference + media_in = otio_clip.available_range().start_time.value + available_range_rate = otio_clip.available_range().start_time.rate + + frame_in = otio.opentime.RationalTime.from_frames( + media_in_trimmed - media_in + media_ref.start_frame, + rate=available_range_rate, + ).to_frames() + frame_out = otio.opentime.RationalTime.from_frames( + media_out_trimmed - media_in + media_ref.start_frame, + rate=available_range_rate, + ).to_frames() + + return frame_in, frame_out + + def get_media_range_with_retimes(otio_clip, handle_start, handle_end): source_range = otio_clip.source_range available_range = otio_clip.available_range() @@ -276,22 +336,14 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # mediaIn/mediaOut have to correspond # to frame numbers from source sequence. media_ref = otio_clip.media_reference - is_input_sequence = ( - hasattr(otio.schema, "ImageSequenceReference") and - isinstance(media_ref, otio.schema.ImageSequenceReference) - ) + is_input_sequence = is_clip_from_media_sequence(otio_clip) if is_input_sequence: # preserve discrete frame numbers - media_in_trimmed = otio.opentime.RationalTime.from_frames( - media_in_trimmed - media_in + media_ref.start_frame, - rate=available_range_rate, - ).to_frames() - media_out_trimmed = otio.opentime.RationalTime.from_frames( - media_out_trimmed - media_in + media_ref.start_frame, - rate=available_range_rate, - ).to_frames() - + media_in_trimmed, media_out_trimmed = remap_range_on_file_sequence( + otio_clip, + (media_in_trimmed, media_out_trimmed) + ) media_in = media_ref.start_frame media_out = media_in + available_range.duration.to_frames() - 1 diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 64c73adbd5..b96f716ac9 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -58,7 +58,9 @@ def process(self, instance): import opentimelineio as otio from ayon_core.pipeline.editorial import ( otio_range_to_frame_range, - make_sequence_collection + make_sequence_collection, + remap_range_on_file_sequence, + is_clip_from_media_sequence ) # TODO refactore from using instance variable @@ -105,42 +107,48 @@ def process(self, instance): otio_media = r_otio_cl.media_reference media_metadata = otio_media.metadata + variables = (self.to_width, self.to_height) + keys = ("width", "height") + # get from media reference metadata source # TODO 'openpype' prefix should be removed (added 24/09/03) # NOTE it looks like it is set only in hiero integration - for key in {"ayon.source.width", "openpype.source.width"}: - value = media_metadata.get(key) - if value is not None: - width = int(value) - break - - for key in {"ayon.source.height", "openpype.source.height"}: - value = media_metadata.get(key) - if value is not None: - height = int(value) - break - - # compare and reset - if width != self.to_width: - self.to_width = width - if height != self.to_height: - self.to_height = height + for variable, key in zip(variables, keys): + for meta_prefix in ("ayon.source.", "openpype.source."): + meta_key = f"{meta_prefix}.{key}" + if media_metadata.get(meta_key): + variable = media_metadata[meta_key] + break self.log.debug("> self.to_width x self.to_height: {} x {}".format( self.to_width, self.to_height )) - # get frame range values + # Clip: compute process range from available media range. src_range = r_otio_cl.source_range - start = src_range.start_time.value - duration = src_range.duration.value - available_range = None - self.actual_fps = src_range.duration.rate - - # add available range only if not gap if isinstance(r_otio_cl, otio.schema.Clip): available_range = r_otio_cl.available_range() + processing_range = None self.actual_fps = available_range.duration.rate + start = src_range.start_time.rescaled_to(self.actual_fps) + duration = src_range.duration.rescaled_to(self.actual_fps) + + # Gap: no media, generate range based on source range + else: + available_range = processing_range = None + self.actual_fps = src_range.duration.rate + start = src_range.start_time + duration = src_range.duration + + # Create handle offsets. + handle_start = otio.opentime.RationalTime( + handle_start, + rate=self.actual_fps, + ) + handle_end = otio.opentime.RationalTime( + handle_end, + rate=self.actual_fps, + ) # reframing handles conditions if (len(otio_review_clips) > 1) and (index == 0): @@ -157,35 +165,33 @@ def process(self, instance): duration += (handle_start + handle_end) if available_range: - available_range = self._trim_available_range( - available_range, start, duration, self.actual_fps) + processing_range = self._trim_available_range( + available_range, start, duration) # process all track items of the track if isinstance(r_otio_cl, otio.schema.Clip): # process Clip media_ref = r_otio_cl.media_reference metadata = media_ref.metadata - is_sequence = None - - # check in two way if it is sequence - if hasattr(otio.schema, "ImageSequenceReference"): - # for OpenTimelineIO 0.13 and newer - if isinstance(media_ref, - otio.schema.ImageSequenceReference): - is_sequence = True - else: - # for OpenTimelineIO 0.12 and older - if metadata.get("padding"): - is_sequence = True + is_sequence = is_clip_from_media_sequence(r_otio_cl) + # File sequence way if is_sequence: - # file sequence way + # Remap processing range to input file sequence. + processing_range_as_frames = ( + processing_range.start_time.to_frames(), + processing_range.end_time_inclusive().to_frames() + ) + first, last = remap_range_on_file_sequence( + r_otio_cl, + processing_range_as_frames, + ) + input_fps = processing_range.start_time.rate + if hasattr(media_ref, "target_url_base"): dirname = media_ref.target_url_base head = media_ref.name_prefix tail = media_ref.name_suffix - first, last = otio_range_to_frame_range( - available_range) collection = clique.Collection( head=head, tail=tail, @@ -195,7 +201,7 @@ def process(self, instance): [i for i in range(first, (last + 1))]) # render segment self._render_seqment( - sequence=[dirname, collection]) + sequence=[dirname, collection, input_fps]) # generate used frames self._generate_used_frames( len(collection.indexes)) @@ -204,24 +210,38 @@ def process(self, instance): # `ImageSequenceReference` path = media_ref.target_url collection_data = make_sequence_collection( - path, available_range, metadata) + path, processing_range, metadata) dir_path, collection = collection_data # render segment self._render_seqment( - sequence=[dir_path, collection]) + sequence=[dir_path, collection, input_fps]) # generate used frames self._generate_used_frames( len(collection.indexes)) + + # Single video way. + # Extraction via FFmpeg. else: - # single video file way path = media_ref.target_url + # Set extract range from 0 (FFmpeg ignores embedded timecode). + extract_range = otio.opentime.TimeRange( + otio.opentime.RationalTime( + ( + processing_range.start_time.value + - available_range.start_time.value + ), + rate=available_range.start_time.rate, + ), + duration=processing_range.duration, + ) # render video file to sequence self._render_seqment( - video=[path, available_range]) + video=[path, extract_range]) # generate used frames self._generate_used_frames( - available_range.duration.value) + processing_range.duration.value) + # QUESTION: what if nested track composition is in place? else: # at last process a Gap @@ -276,7 +296,7 @@ def _create_representation(self, start, duration): }) return representation_data - def _trim_available_range(self, avl_range, start, duration, fps): + def _trim_available_range(self, avl_range, start, duration): """ Trim available media range to source range. @@ -285,57 +305,62 @@ def _trim_available_range(self, avl_range, start, duration, fps): Args: avl_range (otio.time.TimeRange): media available time range - start (int): start frame - duration (int): duration frames - fps (float): frame rate + start (otio.time.RationalTime): start + duration (otio.time.RationalTime): duration Returns: otio.time.TimeRange: trimmed available range """ # Not all hosts can import these modules. + import opentimelineio as otio from ayon_core.pipeline.editorial import ( trim_media_range, - range_from_frames ) - avl_start = int(avl_range.start_time.value) - src_start = int(avl_start + start) - avl_durtation = int(avl_range.duration.value) + avl_start = avl_range.start_time + avl_duration = avl_range.duration - self.need_offset = bool(avl_start != 0 and src_start != 0) + # TODO investigate + #self.need_offset = bool(avl_start != 0 and src_start != 0) - # if media start is les then clip requires - if src_start < avl_start: - # calculate gap - gap_duration = avl_start - src_start + # A gap is required before available range + # range to conform start point. + if start < avl_start: + gap_duration = avl_start - start + start = avl_start + duration -= gap_duration # create gap data to disk - self._render_seqment(gap=gap_duration) + self._render_seqment(gap=gap_duration.to_frames()) # generate used frames - self._generate_used_frames(gap_duration) - - # fix start and end to correct values - start = 0 - duration -= gap_duration + self._generate_used_frames(gap_duration.to_frames()) + # A gap is required after available range # if media duration is shorter then clip requirement - if duration > avl_durtation: - # calculate gap - gap_start = int(src_start + avl_durtation) - gap_end = int(src_start + duration) - gap_duration = gap_end - gap_start + end_point = start + duration + avl_end_point = avl_range.end_time_exclusive() + if end_point > avl_end_point: + gap_duration = end_point - avl_end_point + duration -= gap_duration # create gap data to disk - self._render_seqment(gap=gap_duration, end_offset=avl_durtation) + self._render_seqment( + gap=gap_duration.to_frames(), + end_offset=avl_duration.to_frames() + ) # generate used frames - self._generate_used_frames(gap_duration, end_offset=avl_durtation) - - # fix duration lenght - duration = avl_durtation + self._generate_used_frames( + gap_duration.to_frames(), + end_offset=avl_duration.to_frames() + ) # return correct trimmed range return trim_media_range( - avl_range, range_from_frames(start, duration, fps) + avl_range, + otio.opentime.TimeRange( + start, + duration + ) ) def _render_seqment(self, sequence=None, @@ -347,7 +372,7 @@ def _render_seqment(self, sequence=None, to defined image sequence format. Args: - sequence (list): input dir path string, collection object in list + sequence (list): input dir path string, collection object, fps in list video (list)[optional]: video_path string, otio_range in list gap (int)[optional]: gap duration end_offset (int)[optional]: offset gap frame start in frames @@ -369,7 +394,7 @@ def _render_seqment(self, sequence=None, input_extension = None if sequence: - input_dir, collection = sequence + input_dir, collection, sequence_fps = sequence in_frame_start = min(collection.indexes) # converting image sequence to image sequence @@ -377,9 +402,22 @@ def _render_seqment(self, sequence=None, input_path = os.path.join(input_dir, input_file) input_extension = os.path.splitext(input_path)[-1] - # form command for rendering gap files + # form command for rendering sequence files + # (need to explicit set the input frame range + # if case input sequence has framerate metadata + # to preserve frame range and avoid silent dropped + # frames caused by input mismatch with FFmpeg default + # rate 25.0 fps) more info refer to FFmpeg image2 demuxer + # + # Implicit + # [Input 100 frames (24fps from metadata)] -> [Demuxer video 25fps] -> [Output 98 frames, dropped 2] + # + # Explicit with "-framerate" + # [Input 100 frames (24fps from metadata)] -> [Demuxer video 24fps] -> [Output 100 frames] + command.extend([ "-start_number", str(in_frame_start), + "-framerate", str(sequence_fps), "-i", input_path ]) @@ -456,8 +494,8 @@ def _generate_used_frames(self, duration, end_offset=None): # create frame offset offset = 0 - if self.need_offset: - offset = 1 +# if self.need_offset: +# offset = 1 if end_offset: new_frames = list() From 6d31cd723c0043e12a5f64219a39cda981332f5e Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 24 Sep 2024 17:14:42 -0400 Subject: [PATCH 112/266] Add unit tests. --- .../plugins/publish/extract_otio_review.py | 20 +- .../resources/img_seq_embedded_tc_review.json | 363 ++++++++++++++++++ .../editorial/resources/img_seq_review.json | 363 ++++++++++++++++++ .../resources/qt_embedded_tc_review.json | 356 +++++++++++++++++ .../editorial/resources/qt_review.json | 356 +++++++++++++++++ .../editorial/test_extract_otio_review.py | 180 +++++++++ 6 files changed, 1624 insertions(+), 14 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc_review.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/img_seq_review.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc_review.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/qt_review.json create mode 100644 tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index b96f716ac9..d1ac019c3a 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -320,9 +320,6 @@ def _trim_available_range(self, avl_range, start, duration): avl_start = avl_range.start_time avl_duration = avl_range.duration - # TODO investigate - #self.need_offset = bool(avl_start != 0 and src_start != 0) - # A gap is required before available range # range to conform start point. if start < avl_start: @@ -331,9 +328,9 @@ def _trim_available_range(self, avl_range, start, duration): duration -= gap_duration # create gap data to disk - self._render_seqment(gap=gap_duration.to_frames()) + self._render_seqment(gap=gap_duration.round().to_frames()) # generate used frames - self._generate_used_frames(gap_duration.to_frames()) + self._generate_used_frames(gap_duration.round().to_frames()) # A gap is required after available range # if media duration is shorter then clip requirement @@ -345,12 +342,12 @@ def _trim_available_range(self, avl_range, start, duration): # create gap data to disk self._render_seqment( - gap=gap_duration.to_frames(), + gap=gap_duration.round().to_frames(), end_offset=avl_duration.to_frames() ) # generate used frames self._generate_used_frames( - gap_duration.to_frames(), + gap_duration.round().to_frames(), end_offset=avl_duration.to_frames() ) @@ -492,16 +489,11 @@ def _generate_used_frames(self, duration, end_offset=None): padding = "{{:0{}d}}".format(self.padding) - # create frame offset - offset = 0 -# if self.need_offset: -# offset = 1 - if end_offset: new_frames = list() start_frame = self.used_frames[-1] - for index in range((end_offset + offset), - (int(end_offset + duration) + offset)): + for index in range(end_offset, + (int(end_offset + duration))): seq_number = padding.format(start_frame + index) self.log.debug( "index: `{}` | seq_number: `{}`".format(index, seq_number)) diff --git a/tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc_review.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc_review.json new file mode 100644 index 0000000000..3437692155 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_embedded_tc_review.json @@ -0,0 +1,363 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1000-1100].exr", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 87399.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "0": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "1000": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "0": { + "Value": 0.8, + "Variant Type": "Double" + }, + "1000": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/seq_img_tc_handles_out/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"adca7e5b-b53c-48ab-8469-abe4db3c276a\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_tc_handles_out\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_img_tc_handles_out\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_img_tc_handles_out\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_tc_handles_out\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"fca94ed7-1e74-4ddc-8d56-05696e8c472a\", \"reviewTrack\": \"Video1\", \"label\": \"/shots/seq_img_tc_handles_out/sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"6c2baba3-183c-41f0-b9a9-596d315fd162\", \"creator_attributes\": {\"folderPath\": \"/shots/seq_img_tc_handles_out/sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1102, \"clipIn\": 86524, \"clipOut\": 86625, \"clipDuration\": 101, \"sourceIn\": 0, \"sourceOut\": 101, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video1\", \"folderPath\": \"/shots/seq_img_tc_handles_out/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"adca7e5b-b53c-48ab-8469-abe4db3c276a\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_tc_handles_out\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_img_tc_handles_out\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_img_tc_handles_out\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_tc_handles_out\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"fca94ed7-1e74-4ddc-8d56-05696e8c472a\", \"reviewTrack\": \"Video1\", \"parent_instance_id\": \"6c2baba3-183c-41f0-b9a9-596d315fd162\", \"label\": \"/shots/seq_img_tc_handles_out/sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"8b1f1e6f-699a-4481-b9be-92d819bc4096\", \"creator_attributes\": {\"parentInstance\": \"/shots/seq_img_tc_handles_out/sh010 shot\", \"vSyncOn\": true, \"vSyncTrack\": \"Video1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"adca7e5b-b53c-48ab-8469-abe4db3c276a\", \"publish\": true}" + }, + "clip_index": "adca7e5b-b53c-48ab-8469-abe4db3c276a", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "adca7e5b-b53c-48ab-8469-abe4db3c276a", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/seq_img_tc_handles_out/sh010 shot", + "vSyncOn": true, + "vSyncTrack": "Video1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_img_tc_handles_out/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_img_tc_handles_out", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_img_tc_handles_out", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "8b1f1e6f-699a-4481-b9be-92d819bc4096", + "label": "/shots/seq_img_tc_handles_out/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "6c2baba3-183c-41f0-b9a9-596d315fd162", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_img_tc_handles_out", + "folder_type": "sequence" + } + ], + "productName": "plateVideo1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_img_tc_handles_out", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "fca94ed7-1e74-4ddc-8d56-05696e8c472a", + "variant": "Video1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "adca7e5b-b53c-48ab-8469-abe4db3c276a", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 101, + "clipIn": 86524, + "clipOut": 86625, + "folderPath": "/shots/seq_img_tc_handles_out/sh010", + "fps": "from_selection", + "frameEnd": 1102, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 0, + "sourceOut": 101, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_img_tc_handles_out/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_img_tc_handles_out", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_img_tc_handles_out", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "6c2baba3-183c-41f0-b9a9-596d315fd162", + "label": "/shots/seq_img_tc_handles_out/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_img_tc_handles_out", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_img_tc_handles_out", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "fca94ed7-1e74-4ddc-8d56-05696e8c472a", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 87449.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1000-1100].exr", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 87399.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\exr_embedded_tc", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "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/resources/img_seq_review.json b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_review.json new file mode 100644 index 0000000000..ed19d65744 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/img_seq_review.json @@ -0,0 +1,363 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "output.[1000-1100].tif", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 91.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 5.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-5": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "955": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-5": { + "Value": 0.8, + "Variant Type": "Double" + }, + "955": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/seq_img_notc_blackhandles/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"a82520bd-f231-4a23-9cb7-8823141232db\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_notc_blackhandles\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_img_notc_blackhandles\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_img_notc_blackhandles\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_notc_blackhandles\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5d6be326-f1d0-4416-b6aa-780d05a8dd6d\", \"reviewTrack\": \"Video1\", \"label\": \"/shots/seq_img_notc_blackhandles/sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"e196263f-c584-40b4-bc27-018051a3bc92\", \"creator_attributes\": {\"folderPath\": \"/shots/seq_img_notc_blackhandles/sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1092, \"clipIn\": 86511, \"clipOut\": 86602, \"clipDuration\": 91, \"sourceIn\": 5, \"sourceOut\": 96, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video1\", \"folderPath\": \"/shots/seq_img_notc_blackhandles/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"a82520bd-f231-4a23-9cb7-8823141232db\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_notc_blackhandles\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_img_notc_blackhandles\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_img_notc_blackhandles\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_img_notc_blackhandles\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5d6be326-f1d0-4416-b6aa-780d05a8dd6d\", \"reviewTrack\": \"Video1\", \"parent_instance_id\": \"e196263f-c584-40b4-bc27-018051a3bc92\", \"label\": \"/shots/seq_img_notc_blackhandles/sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"ced7e9b8-721a-4377-a827-15fbf7f2831a\", \"creator_attributes\": {\"parentInstance\": \"/shots/seq_img_notc_blackhandles/sh010 shot\", \"vSyncOn\": true, \"vSyncTrack\": \"Video1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"a82520bd-f231-4a23-9cb7-8823141232db\", \"publish\": true}" + }, + "clip_index": "a82520bd-f231-4a23-9cb7-8823141232db", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "a82520bd-f231-4a23-9cb7-8823141232db", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/seq_img_notc_blackhandles/sh010 shot", + "vSyncOn": true, + "vSyncTrack": "Video1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_img_notc_blackhandles/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_img_notc_blackhandles", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_img_notc_blackhandles", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "ced7e9b8-721a-4377-a827-15fbf7f2831a", + "label": "/shots/seq_img_notc_blackhandles/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "e196263f-c584-40b4-bc27-018051a3bc92", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_img_notc_blackhandles", + "folder_type": "sequence" + } + ], + "productName": "plateVideo1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_img_notc_blackhandles", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "5d6be326-f1d0-4416-b6aa-780d05a8dd6d", + "variant": "Video1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "a82520bd-f231-4a23-9cb7-8823141232db", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 91, + "clipIn": 86511, + "clipOut": 86602, + "folderPath": "/shots/seq_img_notc_blackhandles/sh010", + "fps": "from_selection", + "frameEnd": 1092, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 5, + "sourceOut": 96, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_img_notc_blackhandles/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_img_notc_blackhandles", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_img_notc_blackhandles", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "e196263f-c584-40b4-bc27-018051a3bc92", + "label": "/shots/seq_img_notc_blackhandles/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_img_notc_blackhandles", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_img_notc_blackhandles", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "5d6be326-f1d0-4416-b6aa-780d05a8dd6d", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 50.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": {}, + "name": "output.[1000-1100].tif", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\tif_seq", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "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/resources/qt_embedded_tc_review.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc_review.json new file mode 100644 index 0000000000..629e9e04af --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_embedded_tc_review.json @@ -0,0 +1,356 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "qt_embedded_tc.mov", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 68.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 86414.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-14": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "986": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-14": { + "Value": 0.8, + "Variant Type": "Double" + }, + "986": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/seq_qt_tc/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"12cce00c-eadf-4abd-ac80-0816a24506ab\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_tc\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_qt_tc\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_qt_tc\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_tc\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5dc397e0-1142-4a35-969d-d4c35c512f0f\", \"reviewTrack\": \"Video1\", \"label\": \"/shots/seq_qt_tc/sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"6f4bbf76-6638-4645-9059-0f516c0c12c2\", \"creator_attributes\": {\"folderPath\": \"/shots/seq_qt_tc/sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1069, \"clipIn\": 86516, \"clipOut\": 86584, \"clipDuration\": 68, \"sourceIn\": 14, \"sourceOut\": 82, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video1\", \"folderPath\": \"/shots/seq_qt_tc/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"12cce00c-eadf-4abd-ac80-0816a24506ab\", \"clip_source_resolution\": {\"width\": \"956\", \"height\": \"720\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_tc\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_qt_tc\", \"sourceResolution\": false, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_qt_tc\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_tc\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5dc397e0-1142-4a35-969d-d4c35c512f0f\", \"reviewTrack\": \"Video1\", \"parent_instance_id\": \"6f4bbf76-6638-4645-9059-0f516c0c12c2\", \"label\": \"/shots/seq_qt_tc/sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"1d11a6b5-cc2b-49d8-8bcb-35187c785b22\", \"creator_attributes\": {\"parentInstance\": \"/shots/seq_qt_tc/sh010 shot\", \"vSyncOn\": true, \"vSyncTrack\": \"Video1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"12cce00c-eadf-4abd-ac80-0816a24506ab\", \"publish\": true}" + }, + "clip_index": "12cce00c-eadf-4abd-ac80-0816a24506ab", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "12cce00c-eadf-4abd-ac80-0816a24506ab", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/seq_qt_tc/sh010 shot", + "vSyncOn": true, + "vSyncTrack": "Video1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_qt_tc/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_qt_tc", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_qt_tc", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "1d11a6b5-cc2b-49d8-8bcb-35187c785b22", + "label": "/shots/seq_qt_tc/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "6f4bbf76-6638-4645-9059-0f516c0c12c2", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_qt_tc", + "folder_type": "sequence" + } + ], + "productName": "plateVideo1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_qt_tc", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "5dc397e0-1142-4a35-969d-d4c35c512f0f", + "variant": "Video1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "12cce00c-eadf-4abd-ac80-0816a24506ab", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 68, + "clipIn": 86516, + "clipOut": 86584, + "folderPath": "/shots/seq_qt_tc/sh010", + "fps": "from_selection", + "frameEnd": 1069, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 14, + "sourceOut": 82, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_qt_tc/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_qt_tc", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_qt_tc", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "6f4bbf76-6638-4645-9059-0f516c0c12c2", + "label": "/shots/seq_qt_tc/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_qt_tc", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_qt_tc", + "shot": "sh###", + "sourceResolution": false, + "task": "Generic", + "track": "{_track_}", + "uuid": "5dc397e0-1142-4a35-969d-d4c35c512f0f", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 86448.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": {}, + "name": "qt_embedded_tc.mov", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 100.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 86400.0 + } + }, + "available_image_bounds": null, + "target_url": "C:\\data\\qt_embedded_tc.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_review.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_review.json new file mode 100644 index 0000000000..4dabb7d58f --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_review.json @@ -0,0 +1,356 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "3 jours dans les coulisses du ZEvent 2024.mp4", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 50.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "0": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "1000": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "0": { + "Value": 0.8, + "Variant Type": "Double" + }, + "1000": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/seq_qt_no_tc/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"c3d9fb4f-afdf-49e3-9733-bf80e40e0de3\", \"clip_source_resolution\": {\"width\": \"640\", \"height\": \"360\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_no_tc\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_qt_no_tc\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_qt_no_tc\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_no_tc\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5ab44838-a173-422a-8750-d5265e5a4ab5\", \"reviewTrack\": \"Video1\", \"label\": \"/shots/seq_qt_no_tc/sh010 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"ba8e76cd-7319-449d-93b5-93fd65cf3e83\", \"creator_attributes\": {\"folderPath\": \"/shots/seq_qt_no_tc/sh010\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1051, \"clipIn\": 86477, \"clipOut\": 86527, \"clipDuration\": 50, \"sourceIn\": 0, \"sourceOut\": 50, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"plateVideo1\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"Video1\", \"folderPath\": \"/shots/seq_qt_no_tc/sh010\", \"task\": \"Generic\", \"clip_variant\": \"\", \"clip_index\": \"c3d9fb4f-afdf-49e3-9733-bf80e40e0de3\", \"clip_source_resolution\": {\"width\": \"640\", \"height\": \"360\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_no_tc\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_qt_no_tc\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_qt_no_tc\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_qt_no_tc\", \"track\": \"Video1\", \"shot\": \"sh010\"}, \"heroTrack\": true, \"uuid\": \"5ab44838-a173-422a-8750-d5265e5a4ab5\", \"reviewTrack\": \"Video1\", \"parent_instance_id\": \"ba8e76cd-7319-449d-93b5-93fd65cf3e83\", \"label\": \"/shots/seq_qt_no_tc/sh010 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"4a1cd220-c638-4e77-855c-cebd43b5dbc3\", \"creator_attributes\": {\"parentInstance\": \"/shots/seq_qt_no_tc/sh010 shot\", \"vSyncOn\": true, \"vSyncTrack\": \"Video1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"c3d9fb4f-afdf-49e3-9733-bf80e40e0de3\", \"publish\": true}" + }, + "clip_index": "c3d9fb4f-afdf-49e3-9733-bf80e40e0de3", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "c3d9fb4f-afdf-49e3-9733-bf80e40e0de3", + "clip_source_resolution": { + "height": "360", + "pixelAspect": 1.0, + "width": "640" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/seq_qt_no_tc/sh010 shot", + "vSyncOn": true, + "vSyncTrack": "Video1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_qt_no_tc/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_qt_no_tc", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_qt_no_tc", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "4a1cd220-c638-4e77-855c-cebd43b5dbc3", + "label": "/shots/seq_qt_no_tc/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "ba8e76cd-7319-449d-93b5-93fd65cf3e83", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_qt_no_tc", + "folder_type": "sequence" + } + ], + "productName": "plateVideo1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_qt_no_tc", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "5ab44838-a173-422a-8750-d5265e5a4ab5", + "variant": "Video1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "c3d9fb4f-afdf-49e3-9733-bf80e40e0de3", + "clip_source_resolution": { + "height": "360", + "pixelAspect": 1.0, + "width": "640" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 50, + "clipIn": 86477, + "clipOut": 86527, + "folderPath": "/shots/seq_qt_no_tc/sh010", + "fps": "from_selection", + "frameEnd": 1051, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 0, + "sourceOut": 50, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_qt_no_tc/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_qt_no_tc", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_qt_no_tc", + "shot": "sh010", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "ba8e76cd-7319-449d-93b5-93fd65cf3e83", + "label": "/shots/seq_qt_no_tc/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_qt_no_tc", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_qt_no_tc", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "5ab44838-a173-422a-8750-d5265e5a4ab5", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 25.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": {}, + "name": "3 jours dans les coulisses du ZEvent 2024.mp4", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 30822.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:\\data\\movie.mp4" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py new file mode 100644 index 0000000000..3623f6129d --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -0,0 +1,180 @@ +import mock +import os +import pytest +from typing import NamedTuple + +import opentimelineio as otio + +import ayon_core.lib +from ayon_core.plugins.publish import extract_otio_review + + +_RESOURCE_DIR = os.path.join( + os.path.dirname(__file__), + "resources" +) + + +class MockInstance(): + """ Mock pyblish instance for testing purpose. + """ + def __init__(self, data: dict): + self.data = data + self.context = self + + +class CaptureFFmpegCalls(): + """ Mock calls made to ffmpeg subprocess. + """ + def __init__(self): + self.calls = [] + + def append_call(self, *args, **kwargs): + ffmpeg_args_list, = args + self.calls.append(" ".join(ffmpeg_args_list)) + return True + + def get_fmpeg_executable(self, _): + return ["/path/to/ffmpeg"] + + +def run_process(file_name: str): + """ + """ + # Get OTIO review data from serialized file_name + file_path = os.path.join(_RESOURCE_DIR, file_name) + clip = otio.schema.Clip.from_json_file(file_path) + + # Prepare dummy instance and capture call object + capture_call = CaptureFFmpegCalls() + processor = extract_otio_review.ExtractOTIOReview() + instance = MockInstance({ + "otioReviewClips": [clip], + "handleStart": 10, + "handleEnd": 10, + "workfileFrameStart": 1001, + "folderPath": "/dummy/path", + "anatomy": NamedTuple("Anatomy", [('project_name', "test_project")]) + }) + + # Mock calls to extern and run plugins. + with mock.patch.object( + extract_otio_review, + "get_ffmpeg_tool_args", + side_effect=capture_call.get_fmpeg_executable, + ): + with mock.patch.object( + extract_otio_review, + "run_subprocess", + side_effect=capture_call.append_call, + ): + with mock.patch.object( + processor, + "_get_folder_name_based_prefix", + return_value="C:/result/output." + ): + processor.process(instance) + + # return all calls made to ffmpeg subprocess + return capture_call.calls + + +def test_image_sequence_with_embedded_tc_and_handles_out_of_range(): + """ + Img sequence clip (embedded timecode 1h/24fps) + available_files = 1000-1100 + available_range = 87399-87500 24fps + source_range = 87399-87500 24fps + """ + calls = run_process("img_seq_embedded_tc_review.json") + + expected = [ + # 10 head black handles generated from gap (991-1000) + "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " + "color=c=black:s=1280x720 -tune stillimage -start_number 991 " + "C:/result/output.%03d.jpg", + + # 10 tail black handles generated from gap (1102-1111) + "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " + "color=c=black:s=1280x720 -tune stillimage -start_number 1102 " + "C:/result/output.%03d.jpg", + + # Report from source exr (1001-1101) with enforce framerate + "/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i " + "C:\\exr_embedded_tc\\output.%04d.exr -start_number 1001 " + "C:/result/output.%03d.jpg" + ] + + assert calls == expected + + +def test_image_sequence_and_handles_out_of_range(): + """ + Img sequence clip (no timecode) + available_files = 1000-1100 + available_range = 0-101 25fps + source_range = 5-91 24fps + """ + calls = run_process("img_seq_review.json") + + expected = [ + # 5 head black frames generated from gap (991-995) + "/path/to/ffmpeg -t 0.2 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune " + "stillimage -start_number 991 C:/result/output.%03d.jpg", + + # 9 tail back frames generated from gap (1097-1105) + "/path/to/ffmpeg -t 0.36 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune " + "stillimage -start_number 1097 C:/result/output.%03d.jpg", + + # Report from source tiff (996-1096) + # 996-1000 = additional 5 head frames + # 1001-1095 = source range conformed to 25fps + # 1096-1096 = additional 1 tail frames + "/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i " + "C:\\tif_seq\\output.%04d.tif -start_number 996 C:/result/output.%03d.jpg" + ] + + assert calls == expected + + +def test_movie_with_embedded_tc_no_gap_handles(): + """ + Qt movie clip (embedded timecode 1h/24fps) + available_range = 86400-86500 24fps + source_range = 86414-86482 24fps + """ + calls = run_process("qt_embedded_tc_review.json") + + expected = [ + # Handles are all included in media available range. + # Extract source range from Qt + # - first_frame = 14 src - 10 (head tail) = frame 4 = 0.1666s + # - duration = 68fr (source) + 20fr (handles) = 88frames = 3.666s + "/path/to/ffmpeg -ss 0.16666666666666666 -t 3.6666666666666665 " + "-i C:\\data\\qt_embedded_tc.mov -start_number 991 " + "C:/result/output.%03d.jpg" + ] + + assert calls == expected + + +def test_movie_tail_gap_handles(): + """ + Qt movie clip (embedded timecode 1h/24fps) + available_range = 0-30822 25fps + source_range = 0-50 24fps + """ + calls = run_process("qt_review.json") + + expected = [ + # 10 head black frames generated from gap (991-1000) + "/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune " + "stillimage -start_number 991 C:/result/output.%03d.jpg", + + # source range + 10 tail frames + # duration = 50fr (source) + 10fr (tail handle) = 60 fr = 2.4s + "/path/to/ffmpeg -ss 0.0 -t 2.4 -i C:\\data\\movie.mp4 -start_number 1001 " + "C:/result/output.%03d.jpg" + ] + + assert calls == expected From 4b27971a8e6d292d4f06c21733dca33be40a017d Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 24 Sep 2024 17:26:37 -0400 Subject: [PATCH 113/266] Adjust comment. --- client/ayon_core/plugins/publish/extract_otio_review.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index d1ac019c3a..01cd974dad 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -320,8 +320,8 @@ def _trim_available_range(self, avl_range, start, duration): avl_start = avl_range.start_time avl_duration = avl_range.duration - # A gap is required before available range - # range to conform start point. + # An additional gap is required before the available + # range to conform source start point and head handles. if start < avl_start: gap_duration = avl_start - start start = avl_start @@ -332,8 +332,9 @@ def _trim_available_range(self, avl_range, start, duration): # generate used frames self._generate_used_frames(gap_duration.round().to_frames()) - # A gap is required after available range - # if media duration is shorter then clip requirement + # An additional gap is required after the available + # range to conform to source end point + tail handles + # (media duration is shorter then clip requirement). end_point = start + duration avl_end_point = avl_range.end_time_exclusive() if end_point > avl_end_point: From 885f8acd2b59748facb35b2b64b244bd4d670493 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Tue, 24 Sep 2024 17:33:17 -0400 Subject: [PATCH 114/266] Update client/ayon_core/pipeline/editorial.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/editorial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 7d6d6f5882..2dc15bd645 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -277,8 +277,8 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # to frame numbers from source sequence. media_ref = otio_clip.media_reference is_input_sequence = ( - hasattr(otio.schema, "ImageSequenceReference") and - isinstance(media_ref, otio.schema.ImageSequenceReference) + hasattr(otio.schema, "ImageSequenceReference") + and isinstance(media_ref, otio.schema.ImageSequenceReference) ) if is_input_sequence: From 453995ac5795489bd5eb7390717021c832a321b1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 25 Sep 2024 14:16:39 +0200 Subject: [PATCH 115/266] Update imageio config conversion to 0.4.5 version Adjust imageio config conversion function to handle changes in settings from 0.4.4 to 0.4.5, ensuring proper profile usage and plugin conversion consistency. --- server/settings/conversion.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index d99483d21f..634e5ab438 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -4,8 +4,8 @@ from .publish_plugins import DEFAULT_PUBLISH_VALUES -def _convert_imageio_configs_0_4_4(overrides): - """Imageio config settings did change to profiles since 0.4.4.""" +def _convert_imageio_configs_0_4_5(overrides): + """Imageio config settings did change to profiles since 0.4.5.""" imageio_overrides = overrides.get("imageio") or {} # make sure settings are already converted to profiles @@ -119,6 +119,6 @@ def convert_settings_overrides( overrides: dict[str, Any], ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) - _convert_imageio_configs_0_4_4(overrides) + _convert_imageio_configs_0_4_5(overrides) _conver_publish_plugins(overrides) return overrides From 6085b6bd8237b7049714ecf5f03c20cfd738d086 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 25 Sep 2024 14:17:26 +0200 Subject: [PATCH 116/266] Refactor imageio settings conversion logic Simplified conditional check for ocio_config_profiles presence. --- server/settings/conversion.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 634e5ab438..3b51e46dba 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -9,9 +9,7 @@ def _convert_imageio_configs_0_4_5(overrides): imageio_overrides = overrides.get("imageio") or {} # make sure settings are already converted to profiles - if ( - "ocio_config_profiles" not in imageio_overrides - ): + if "ocio_config_profiles" not in imageio_overrides: return ocio_config_profiles = imageio_overrides["ocio_config_profiles"] From 07ea80d2d70fd611b7687eba45e8d9a3fe294405 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 25 Sep 2024 14:23:31 +0200 Subject: [PATCH 117/266] Update fallback type field names in colorspace and settings modules The commit updates the field name from "type" to "fallback_type" for consistency in the colorspace and settings modules. --- client/ayon_core/pipeline/colorspace.py | 2 +- server/settings/main.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 1e6f98f272..8c4f97ab1c 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -806,7 +806,7 @@ def _get_global_config_data( log.info("Using fallback data for ocio config path.") # in case no product was found we need to use fallback - fallback_type = fallback_data["type"] + fallback_type = fallback_data["fallback_type"] return _get_config_path_from_profile_data( fallback_data, fallback_type, template_data ) diff --git a/server/settings/main.py b/server/settings/main.py index 09c9bf0065..249bab85fd 100644 --- a/server/settings/main.py +++ b/server/settings/main.py @@ -85,7 +85,7 @@ def _ocio_built_in_paths(): class FallbackProductModel(BaseSettingsModel): _layout = "expanded" - type: str = SettingsField( + fallback_type: str = SettingsField( title="Fallback config type", enum_resolver=_fallback_ocio_config_profile_types, conditionalEnum=True, @@ -347,7 +347,7 @@ def validate_json(cls, value): "published_product": { "product_name": "", "fallback": { - "type": "builtin_path", + "fallback_type": "builtin_path", "builtin_path": "ACES 1.2", "custom_path": "" } From 6aa31e8788dbeb316b2c3efcc480675aa9386cc0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 25 Sep 2024 14:24:52 +0200 Subject: [PATCH 118/266] fixing typo Changed function name from '_conver_publish_plugins' to '_convert_publish_plugins' for consistency and clarity. Updated the function call accordingly in 'convert_settings_overrides'. --- server/settings/conversion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 3b51e46dba..b933d5856f 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -106,7 +106,7 @@ def _convert_validate_version_0_3_3(publish_overrides): validate_version["plugin_state_profiles"] = [profile] -def _conver_publish_plugins(overrides): +def _convert_publish_plugins(overrides): if "publish" not in overrides: return _convert_validate_version_0_3_3(overrides["publish"]) @@ -118,5 +118,5 @@ def convert_settings_overrides( ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) _convert_imageio_configs_0_4_5(overrides) - _conver_publish_plugins(overrides) + _convert_publish_plugins(overrides) return overrides From 2980f100400ca3951cd82c8fd6c70b346733e1fa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 25 Sep 2024 16:06:24 +0200 Subject: [PATCH 119/266] Add client path to sys.path and run repository from code - Added client path to sys.path in conftest.py - Implemented function to run the repository from code in manage.ps1 --- tests/conftest.py | 9 +++++++++ tools/manage.ps1 | 11 +++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..a3c46a9dd7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +import sys +from pathlib import Path + +client_path = Path(__file__).resolve().parent.parent / "client" + +# add client path to sys.path +sys.path.append(str(client_path)) + +print(f"Added {client_path} to sys.path") diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 23c52d57be..1fb57fe445 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -233,6 +233,13 @@ function Invoke-Codespell { & $Poetry $CodespellArgs } +function Run-From-Code { + $Poetry = "$RepoRoot\.poetry\bin\poetry.exe" + $RunArgs = @( "run") + + & $Poetry $RunArgs @arguments +} + function Write-Help { <# .SYNOPSIS @@ -248,6 +255,7 @@ function Write-Help { Write-Info -Text " ruff-check ", "Run Ruff check for the repository" -Color White, Cyan Write-Info -Text " ruff-fix ", "Run Ruff fix for the repository" -Color White, Cyan Write-Info -Text " codespell ", "Run codespell check for the repository" -Color White, Cyan + Write-Info -Text " run ", "Run the repository" -Color White, Cyan Write-Host "" } @@ -269,6 +277,9 @@ function Resolve-Function { } elseif ($FunctionName -eq "codespell") { Set-Cwd Invoke-CodeSpell + } elseif ($FunctionName -eq "run") { + Set-Cwd + Run-From-Code } else { Write-Host "Unknown function ""$FunctionName""" Write-Help From 7a83b8ec97d36ed489b3b7b1ac7e8013c287aa79 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Wed, 25 Sep 2024 13:06:28 -0400 Subject: [PATCH 120/266] Add test for tail handles only. --- .../plugins/publish/extract_otio_review.py | 4 +- .../resources/qt_handle_tail_review.json | 417 ++++++++++++++++++ .../editorial/test_extract_otio_review.py | 27 +- 3 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/qt_handle_tail_review.json diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 01cd974dad..dfc028a785 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -344,12 +344,12 @@ def _trim_available_range(self, avl_range, start, duration): # create gap data to disk self._render_seqment( gap=gap_duration.round().to_frames(), - end_offset=avl_duration.to_frames() + end_offset=duration.to_frames() ) # generate used frames self._generate_used_frames( gap_duration.round().to_frames(), - end_offset=avl_duration.to_frames() + end_offset=duration.to_frames() ) # return correct trimmed range diff --git a/tests/client/ayon_core/pipeline/editorial/resources/qt_handle_tail_review.json b/tests/client/ayon_core/pipeline/editorial/resources/qt_handle_tail_review.json new file mode 100644 index 0000000000..5d97628c47 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/qt_handle_tail_review.json @@ -0,0 +1,417 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": { + "Resolve_OTIO": {} + }, + "name": "qt_no_tc_24fps.mov", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 66.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 35.0 + } + }, + "effects": [ + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Transform", + "Enabled": true, + "Name": "Transform", + "Parameters": [], + "Type": 2 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Cropping", + "Enabled": true, + "Name": "Cropping", + "Parameters": [], + "Type": 3 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Dynamic Zoom", + "Enabled": false, + "Name": "Dynamic Zoom", + "Parameters": [ + { + "Default Parameter Value": [ + 0.0, + 0.0 + ], + "Key Frames": { + "-35": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + "965": { + "Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + } + }, + "Parameter ID": "dynamicZoomCenter", + "Parameter Value": [ + 0.0, + 0.0 + ], + "Variant Type": "POINTF" + }, + { + "Default Parameter Value": 1.0, + "Key Frames": { + "-35": { + "Value": 0.8, + "Variant Type": "Double" + }, + "965": { + "Value": 1.0, + "Variant Type": "Double" + } + }, + "Parameter ID": "dynamicZoomScale", + "Parameter Value": 1.0, + "Variant Type": "Double", + "maxValue": 100.0, + "minValue": 0.01 + } + ], + "Type": 59 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Composite", + "Enabled": true, + "Name": "Composite", + "Parameters": [], + "Type": 1 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Lens Correction", + "Enabled": true, + "Name": "Lens Correction", + "Parameters": [], + "Type": 43 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Retime and Scaling", + "Enabled": true, + "Name": "Retime and Scaling", + "Parameters": [], + "Type": 22 + } + }, + "name": "", + "effect_name": "Resolve Effect" + }, + { + "OTIO_SCHEMA": "Effect.1", + "metadata": { + "Resolve_OTIO": { + "Effect Name": "Video Faders", + "Enabled": true, + "Name": "Video Faders", + "Parameters": [], + "Type": 36 + } + }, + "name": "", + "effect_name": "Resolve Effect" + } + ], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "Resolve_OTIO": { + "Keywords": [], + "Note": "{\"resolve_sub_products\": {\"io.ayon.creators.resolve.shot\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"shot\", \"productName\": \"shotMain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.shot\", \"variant\": \"Main\", \"folderPath\": \"/shots/seq_native_otio_resolve/sh040\", \"task\": \"Generic\", \"clip_variant\": \"main\", \"clip_index\": \"1c8b84d2-4cf0-4528-9854-5c13a7ab64f7\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_native_otio_resolve\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_native_otio_resolve\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_native_otio_resolve\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_native_otio_resolve\", \"track\": \"Video1\", \"shot\": \"sh040\"}, \"heroTrack\": true, \"uuid\": \"6259d185-d57e-444f-b667-b5970a67a655\", \"reviewTrack\": \"Video1\", \"label\": \"/shots/seq_native_otio_resolve/sh040 shot\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"24c94533-8ae5-490c-98cf-cd3a27183d3e\", \"creator_attributes\": {\"folderPath\": \"/shots/seq_native_otio_resolve/sh040\", \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"frameStart\": 1001, \"frameEnd\": 1067, \"clipIn\": 87088, \"clipOut\": 87154, \"clipDuration\": 66, \"sourceIn\": 35, \"sourceOut\": 101, \"fps\": \"from_selection\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.plate\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"plate\", \"productName\": \"platemain\", \"active\": true, \"creator_identifier\": \"io.ayon.creators.resolve.plate\", \"variant\": \"main\", \"folderPath\": \"/shots/seq_native_otio_resolve/sh040\", \"task\": \"Generic\", \"clip_variant\": \"main\", \"clip_index\": \"1c8b84d2-4cf0-4528-9854-5c13a7ab64f7\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_native_otio_resolve\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_native_otio_resolve\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_native_otio_resolve\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_native_otio_resolve\", \"track\": \"Video1\", \"shot\": \"sh040\"}, \"heroTrack\": true, \"uuid\": \"6259d185-d57e-444f-b667-b5970a67a655\", \"reviewTrack\": \"Video1\", \"parent_instance_id\": \"24c94533-8ae5-490c-98cf-cd3a27183d3e\", \"label\": \"/shots/seq_native_otio_resolve/sh040 plate\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"92adedc5-4e65-4a0a-9f09-e6522f2327d2\", \"creator_attributes\": {\"parentInstance\": \"/shots/seq_native_otio_resolve/sh040 shot\", \"vSyncOn\": true, \"vSyncTrack\": \"Video1\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}, \"io.ayon.creators.resolve.audio\": {\"id\": \"pyblish.avalon.instance\", \"productType\": \"audio\", \"productName\": \"audioMain\", \"active\": false, \"creator_identifier\": \"io.ayon.creators.resolve.audio\", \"variant\": \"Main\", \"folderPath\": \"/shots/seq_native_otio_resolve/sh040\", \"task\": \"Generic\", \"clip_variant\": \"main\", \"clip_index\": \"1c8b84d2-4cf0-4528-9854-5c13a7ab64f7\", \"clip_source_resolution\": {\"width\": \"1920\", \"height\": \"1080\", \"pixelAspect\": 1.0}, \"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_native_otio_resolve\", \"track\": \"{_track_}\", \"shot\": \"sh###\", \"hierarchy\": \"shots/seq_native_otio_resolve\", \"sourceResolution\": true, \"workfileFrameStart\": 1001, \"handleStart\": 10, \"handleEnd\": 10, \"parents\": [{\"folder_type\": \"folder\", \"entity_name\": \"shots\"}, {\"folder_type\": \"sequence\", \"entity_name\": \"seq_native_otio_resolve\"}], \"hierarchyData\": {\"folder\": \"shots\", \"episode\": \"ep01\", \"sequence\": \"seq_native_otio_resolve\", \"track\": \"Video1\", \"shot\": \"sh040\"}, \"heroTrack\": true, \"uuid\": \"6259d185-d57e-444f-b667-b5970a67a655\", \"reviewTrack\": \"Video1\", \"parent_instance_id\": \"24c94533-8ae5-490c-98cf-cd3a27183d3e\", \"label\": \"/shots/seq_native_otio_resolve/sh040 audio\", \"has_promised_context\": true, \"newHierarchyIntegration\": true, \"newAssetPublishing\": true, \"instance_id\": \"f22878b9-e9d2-415f-93f7-784474d2ff2f\", \"creator_attributes\": {\"parentInstance\": \"/shots/seq_native_otio_resolve/sh040 shot\"}, \"publish_attributes\": {\"CollectSlackFamilies\": {\"additional_message\": \"\"}}}}, \"clip_index\": \"1c8b84d2-4cf0-4528-9854-5c13a7ab64f7\", \"publish\": true}" + }, + "clip_index": "1c8b84d2-4cf0-4528-9854-5c13a7ab64f7", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.audio": { + "active": false, + "clip_index": "1c8b84d2-4cf0-4528-9854-5c13a7ab64f7", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "main", + "creator_attributes": { + "parentInstance": "/shots/seq_native_otio_resolve/sh040 shot" + }, + "creator_identifier": "io.ayon.creators.resolve.audio", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_native_otio_resolve/sh040", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_native_otio_resolve", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_native_otio_resolve", + "shot": "sh040", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "f22878b9-e9d2-415f-93f7-784474d2ff2f", + "label": "/shots/seq_native_otio_resolve/sh040 audio", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "24c94533-8ae5-490c-98cf-cd3a27183d3e", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_native_otio_resolve", + "folder_type": "sequence" + } + ], + "productName": "audioMain", + "productType": "audio", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_native_otio_resolve", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "6259d185-d57e-444f-b667-b5970a67a655", + "variant": "Main", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "1c8b84d2-4cf0-4528-9854-5c13a7ab64f7", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "main", + "creator_attributes": { + "parentInstance": "/shots/seq_native_otio_resolve/sh040 shot", + "vSyncOn": true, + "vSyncTrack": "Video1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_native_otio_resolve/sh040", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_native_otio_resolve", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_native_otio_resolve", + "shot": "sh040", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "92adedc5-4e65-4a0a-9f09-e6522f2327d2", + "label": "/shots/seq_native_otio_resolve/sh040 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "24c94533-8ae5-490c-98cf-cd3a27183d3e", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_native_otio_resolve", + "folder_type": "sequence" + } + ], + "productName": "platemain", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_native_otio_resolve", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "6259d185-d57e-444f-b667-b5970a67a655", + "variant": "main", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "1c8b84d2-4cf0-4528-9854-5c13a7ab64f7", + "clip_source_resolution": { + "height": "1080", + "pixelAspect": 1.0, + "width": "1920" + }, + "clip_variant": "main", + "creator_attributes": { + "clipDuration": 66, + "clipIn": 87088, + "clipOut": 87154, + "folderPath": "/shots/seq_native_otio_resolve/sh040", + "fps": "from_selection", + "frameEnd": 1067, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 35, + "sourceOut": 101, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "shots", + "folderPath": "/shots/seq_native_otio_resolve/sh040", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/seq_native_otio_resolve", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "seq_native_otio_resolve", + "shot": "sh040", + "track": "Video1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "24c94533-8ae5-490c-98cf-cd3a27183d3e", + "label": "/shots/seq_native_otio_resolve/sh040 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "seq_native_otio_resolve", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video1", + "sequence": "seq_native_otio_resolve", + "shot": "sh###", + "sourceResolution": true, + "task": "Generic", + "track": "{_track_}", + "uuid": "6259d185-d57e-444f-b667-b5970a67a655", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AyonData", + "color": "GREEN", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 68.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ExternalReference.1", + "metadata": {}, + "name": "qt_no_tc_24fps.mov", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "available_image_bounds": null, + "target_url": "C:\\data\\qt_no_tc_24fps.mov" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 3623f6129d..f266a40f50 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -158,9 +158,9 @@ def test_movie_with_embedded_tc_no_gap_handles(): assert calls == expected -def test_movie_tail_gap_handles(): +def test_short_movie_head_gap_handles(): """ - Qt movie clip (embedded timecode 1h/24fps) + Qt movie clip. available_range = 0-30822 25fps source_range = 0-50 24fps """ @@ -178,3 +178,26 @@ def test_movie_tail_gap_handles(): ] assert calls == expected + + +def test_short_movie_tail_gap_handles(): + """ + Qt movie clip. + available_range = 0-101 24fps + source_range = 35-101 24fps + """ + calls = run_process("qt_handle_tail_review.json") + + expected = [ + # 10 tail black frames generated from gap (1067-1076) + "/path/to/ffmpeg -t 0.4166666666666667 -r 24.0 -f lavfi -i " + "color=c=black:s=1280x720 -tune stillimage -start_number 1067 " + "C:/result/output.%03d.jpg", + + # 10 head frames + source range + # duration = 10fr (head handle) + 66fr (source) = 76fr = 3.16s + "/path/to/ffmpeg -ss 1.0416666666666667 -t 3.1666666666666665 -i " + "C:\\data\\qt_no_tc_24fps.mov -start_number 991 C:/result/output.%03d.jpg" + ] + + assert calls == expected From a10f0e5968068d4c747f52669c623fd5b8c86c65 Mon Sep 17 00:00:00 2001 From: robin Date: Wed, 25 Sep 2024 17:52:46 -0400 Subject: [PATCH 121/266] Implement linux counterpart to manage.sh --- tools/manage.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/manage.sh b/tools/manage.sh index 923953bf96..02648e3775 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -157,6 +157,7 @@ default_help() { echo -e " ${BWhite}ruff-check${RST} ${BCyan}Run Ruff check for the repository${RST}" echo -e " ${BWhite}ruff-fix${RST} ${BCyan}Run Ruff fix for the repository${RST}" echo -e " ${BWhite}codespell${RST} ${BCyan}Run codespell check for the repository${RST}" + echo -e " ${BWhite}run${RST} ${BCyan}Run the repository${RST}" echo "" } @@ -175,6 +176,12 @@ run_codespell () { "$POETRY_HOME/bin/poetry" run codespell } +run_command () { + echo -e "${BIGreen}>>>${RST} Running ..." + shift; # will remove first arg ("run") from the "$@" + "$POETRY_HOME/bin/poetry" run "$@" +} + main () { detect_python || return 1 @@ -207,6 +214,10 @@ main () { run_codespell || return_code=$? exit $return_code ;; + "run") + run_command "$@" || return_code=$? + exit $return_code + ;; esac if [ "$function_name" != "" ]; then From 166ac0a5f75821e9331c0cdb57169fafc7af38d3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 25 Sep 2024 23:57:12 +0200 Subject: [PATCH 122/266] Add cinema4d to OCIO prelaunch hook --- client/ayon_core/hooks/pre_ocio_hook.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 6c30b267bc..7406aa42cf 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -19,7 +19,8 @@ class OCIOEnvHook(PreLaunchHook): "nuke", "hiero", "resolve", - "openrv" + "openrv", + "cinema4d" } launch_types = set() From f9b962d233639067436e21bab54c9f4a5b1a6817 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 26 Sep 2024 00:07:26 +0200 Subject: [PATCH 123/266] Add `cinema4d` to `HOST_WORKFILE_EXTENSIONS` constants --- client/ayon_core/pipeline/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/constants.py b/client/ayon_core/pipeline/constants.py index 7a08cbb3aa..88cddc6e1a 100644 --- a/client/ayon_core/pipeline/constants.py +++ b/client/ayon_core/pipeline/constants.py @@ -9,6 +9,7 @@ HOST_WORKFILE_EXTENSIONS = { "blender": [".blend"], "celaction": [".scn"], + "cinema4d": [".c4d"], "tvpaint": [".tvpp"], "fusion": [".comp"], "harmony": [".zip"], From 7d9390e9d5e3167ccca5063a3231e8381d0feff5 Mon Sep 17 00:00:00 2001 From: ReeceMulley <153881471+ReeceMulley@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:34:31 +1000 Subject: [PATCH 124/266] improved OIIO subimages handling --- client/ayon_core/lib/transcoding.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index ead8b621b9..e9750864ac 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -1152,9 +1152,7 @@ def convert_colorspace( input_arg, input_path, # Tell oiiotool which channels should be put to top stack # (and output) - "--ch", channels_arg, - # Use first subimage - "--subimage", "0" + "--ch", channels_arg ]) if all([target_colorspace, view, display]): @@ -1168,12 +1166,12 @@ def convert_colorspace( oiio_cmd.extend(additional_command_args) if target_colorspace: - oiio_cmd.extend(["--colorconvert", + oiio_cmd.extend(["--colorconvert:subimages=0", source_colorspace, target_colorspace]) if view and display: oiio_cmd.extend(["--iscolorspace", source_colorspace]) - oiio_cmd.extend(["--ociodisplay", display, view]) + oiio_cmd.extend(["--ociodisplay:subimages=0", display, view]) oiio_cmd.extend(["-o", output_path]) From 85752510f0fe90a91e5f6a1c774264a8b788e64f Mon Sep 17 00:00:00 2001 From: Mustafa Jafar Date: Thu, 26 Sep 2024 11:57:49 +0300 Subject: [PATCH 125/266] optimize `_convert_oiio_transcode_0_4_5` Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/conversion.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 06a1c2c02b..0a345e2059 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -81,21 +81,25 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): return for profile in transcode_profiles: - for output in profile["outputs"]: - transcode_type = output["transcoding_type"] + outputs = profile.get("outputs") + if outputs is None: + return + + for output in outputs : + # Already new settings + if "display" not in output and "view" not in output: + break + + # Fix 'display' -> 'display_view' in 'transcoding_type' + transcode_type = output.get("transcoding_type") if transcode_type == "display": output["transcoding_type"] = "display_view" - # Already new settings - if "display_view" in output: - continue - - output["display_view"] = {} - if output["display"]: - output["display_view"].update({"display": output["display"]}) - output.pop("display") - if output["view"]: - output["display_view"].update({"view": output["view"]}) - output.pop("view") + + # Convert 'display' and 'view' to new values + output["display_view"] = { + "display": output.pop("display", ""), + "view": output.pop("view", ""), + } def _conver_publish_plugins(overrides): From 6f2e69c3f7f24a0499a430d6f282647a672d4428 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Thu, 26 Sep 2024 12:02:39 +0300 Subject: [PATCH 126/266] fix code linting --- server/settings/conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 0a345e2059..2337492ef2 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -94,7 +94,7 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): transcode_type = output.get("transcoding_type") if transcode_type == "display": output["transcoding_type"] = "display_view" - + # Convert 'display' and 'view' to new values output["display_view"] = { "display": output.pop("display", ""), From fa54805d94152b92bcb3c0a7903f2a10528e6409 Mon Sep 17 00:00:00 2001 From: Mustafa Jafar Date: Thu, 26 Sep 2024 12:06:28 +0300 Subject: [PATCH 127/266] enhance a condition in `_convert_oiio_transcode_0_4_5` Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 2337492ef2..c855863591 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -87,7 +87,7 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): for output in outputs : # Already new settings - if "display" not in output and "view" not in output: + if "display_view" in output: break # Fix 'display' -> 'display_view' in 'transcoding_type' From ceea08636bb9f951246c408b79fb51dfcd0f4e0e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 26 Sep 2024 17:06:34 +0200 Subject: [PATCH 128/266] Add validate file saved for Cinema4D --- client/ayon_core/plugins/publish/validate_file_saved.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index d132ba8d3a..f52998cef3 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -36,7 +36,8 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 - hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter"] + hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter", + "cinema4d"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): From f17529b0510704d29e1ae83c784f553e71060e22 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 27 Sep 2024 13:05:31 +0200 Subject: [PATCH 129/266] Make Publisher UI raised to the front when clicking `AYON > Create...` or `AYON > Publish...` in host integrations if it was already opened. --- client/ayon_core/tools/utils/host_tools.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 1eff746b9e..3cddb69eae 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -252,6 +252,9 @@ def show_publisher_tool(self, parent=None, controller=None, tab=None): if tab: window.set_current_tab(tab) window.make_sure_is_visible() + window.raise_() + window.activateWindow() + window.showNormal() def get_tool_by_name(self, tool_name, parent=None, *args, **kwargs): """Show tool by it's name. From 28d30bc4ba7d9cbc95e5cde311db963d36645e66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:11:11 +0200 Subject: [PATCH 130/266] added release trigger action --- .github/workflows/release_trigger.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/workflows/release_trigger.yml diff --git a/.github/workflows/release_trigger.yml b/.github/workflows/release_trigger.yml new file mode 100644 index 0000000000..01a3b3a682 --- /dev/null +++ b/.github/workflows/release_trigger.yml @@ -0,0 +1,12 @@ +name: 🚀 Release Trigger + +on: + workflow_dispatch: + +jobs: + call-release-trigger: + uses: ynput/ops-repo-automation/.github/workflows/release_trigger.yml@main + secrets: + token: ${{ secrets.YNPUT_BOT_TOKEN }} + email: ${{ secrets.CI_EMAIL }} + user: ${{ secrets.CI_USER }} From beee0efab3593416d261c81451ae828d104f9c13 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 09:59:01 -0400 Subject: [PATCH 131/266] Adjust feedback from PR. --- client/ayon_core/plugins/publish/extract_otio_review.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index dfc028a785..30c6dfd424 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -113,13 +113,16 @@ def process(self, instance): # get from media reference metadata source # TODO 'openpype' prefix should be removed (added 24/09/03) # NOTE it looks like it is set only in hiero integration - for variable, key in zip(variables, keys): + res_data = {"width": self.to_width, "height": self.to_height} + for key in res_data: for meta_prefix in ("ayon.source.", "openpype.source."): meta_key = f"{meta_prefix}.{key}" - if media_metadata.get(meta_key): - variable = media_metadata[meta_key] + value = media_metadata.get(meta_key) + if value is not None: + res_data[key] = value break + self.to_width, self.to_height = res_data["width"], res_data["height"] self.log.debug("> self.to_width x self.to_height: {} x {}".format( self.to_width, self.to_height )) From 3712152cb7d5d45abad425d97e235b234f93d797 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Sep 2024 16:52:48 +0200 Subject: [PATCH 132/266] removed 'HOST_WORKFILE_EXTENSIONS' --- client/ayon_core/pipeline/__init__.py | 2 -- client/ayon_core/pipeline/constants.py | 18 ------------------ 2 files changed, 20 deletions(-) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 4fcea60d5e..8e89029e7b 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -3,7 +3,6 @@ AVALON_INSTANCE_ID, AYON_CONTAINER_ID, AYON_INSTANCE_ID, - HOST_WORKFILE_EXTENSIONS, ) from .anatomy import Anatomy @@ -114,7 +113,6 @@ "AVALON_INSTANCE_ID", "AYON_CONTAINER_ID", "AYON_INSTANCE_ID", - "HOST_WORKFILE_EXTENSIONS", # --- Anatomy --- "Anatomy", diff --git a/client/ayon_core/pipeline/constants.py b/client/ayon_core/pipeline/constants.py index 88cddc6e1a..e6156b3138 100644 --- a/client/ayon_core/pipeline/constants.py +++ b/client/ayon_core/pipeline/constants.py @@ -4,21 +4,3 @@ # Backwards compatibility AVALON_CONTAINER_ID = "pyblish.avalon.container" AVALON_INSTANCE_ID = "pyblish.avalon.instance" - -# TODO get extensions from host implementations -HOST_WORKFILE_EXTENSIONS = { - "blender": [".blend"], - "celaction": [".scn"], - "cinema4d": [".c4d"], - "tvpaint": [".tvpp"], - "fusion": [".comp"], - "harmony": [".zip"], - "houdini": [".hip", ".hiplc", ".hipnc"], - "maya": [".ma", ".mb"], - "nuke": [".nk"], - "hiero": [".hrox"], - "photoshop": [".psd", ".psb"], - "premiere": [".prproj"], - "resolve": [".drp"], - "aftereffects": [".aep"] -} From 3a75ebcc8935ae5fd0f0dd69044e9ea272296e59 Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Mon, 30 Sep 2024 15:29:57 -0400 Subject: [PATCH 133/266] Adjust manage.sh and manage.ps1 run documentation. --- tools/manage.ps1 | 2 +- tools/manage.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/manage.ps1 b/tools/manage.ps1 index 1fb57fe445..9a9a9a2eff 100755 --- a/tools/manage.ps1 +++ b/tools/manage.ps1 @@ -255,7 +255,7 @@ function Write-Help { Write-Info -Text " ruff-check ", "Run Ruff check for the repository" -Color White, Cyan Write-Info -Text " ruff-fix ", "Run Ruff fix for the repository" -Color White, Cyan Write-Info -Text " codespell ", "Run codespell check for the repository" -Color White, Cyan - Write-Info -Text " run ", "Run the repository" -Color White, Cyan + Write-Info -Text " run ", "Run a poetry command in the repository environment" -Color White, Cyan Write-Host "" } diff --git a/tools/manage.sh b/tools/manage.sh index 02648e3775..6b0a4d6978 100755 --- a/tools/manage.sh +++ b/tools/manage.sh @@ -157,7 +157,7 @@ default_help() { echo -e " ${BWhite}ruff-check${RST} ${BCyan}Run Ruff check for the repository${RST}" echo -e " ${BWhite}ruff-fix${RST} ${BCyan}Run Ruff fix for the repository${RST}" echo -e " ${BWhite}codespell${RST} ${BCyan}Run codespell check for the repository${RST}" - echo -e " ${BWhite}run${RST} ${BCyan}Run the repository${RST}" + echo -e " ${BWhite}run${RST} ${BCyan}Run a poetry command in the repository environment${RST}" echo "" } From d667f21625bdd043a3642fad5f36e7f0b6357ec0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 1 Oct 2024 10:15:23 +0200 Subject: [PATCH 134/266] Move logic to `make_sure_is_visible` --- client/ayon_core/tools/publisher/window.py | 5 ++++- client/ayon_core/tools/utils/host_tools.py | 3 --- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index a8ca605ecb..434c2ca602 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -439,10 +439,13 @@ def set_comment(self, comment): def make_sure_is_visible(self): if self._window_is_visible: self.setWindowState(QtCore.Qt.WindowActive) - else: self.show() + self.raise_() + self.activateWindow() + self.showNormal() + def showEvent(self, event): self._window_is_visible = True super().showEvent(event) diff --git a/client/ayon_core/tools/utils/host_tools.py b/client/ayon_core/tools/utils/host_tools.py index 3cddb69eae..1eff746b9e 100644 --- a/client/ayon_core/tools/utils/host_tools.py +++ b/client/ayon_core/tools/utils/host_tools.py @@ -252,9 +252,6 @@ def show_publisher_tool(self, parent=None, controller=None, tab=None): if tab: window.set_current_tab(tab) window.make_sure_is_visible() - window.raise_() - window.activateWindow() - window.showNormal() def get_tool_by_name(self, tool_name, parent=None, *args, **kwargs): """Show tool by it's name. From 93793655ea2e29ade756bbc66c4087280beeecd4 Mon Sep 17 00:00:00 2001 From: Robin De Lillo Date: Tue, 1 Oct 2024 08:00:43 -0400 Subject: [PATCH 135/266] Update client/ayon_core/plugins/publish/extract_otio_review.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/plugins/publish/extract_otio_review.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 30c6dfd424..a7ef819c5d 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -107,9 +107,6 @@ def process(self, instance): otio_media = r_otio_cl.media_reference media_metadata = otio_media.metadata - variables = (self.to_width, self.to_height) - keys = ("width", "height") - # get from media reference metadata source # TODO 'openpype' prefix should be removed (added 24/09/03) # NOTE it looks like it is set only in hiero integration From 93033e58a42761bd188a139bd2e27291b185b91a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:39:37 +0200 Subject: [PATCH 136/266] move private method to the bottom --- client/ayon_core/pipeline/create/context.py | 80 ++++++++++----------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 57d24db7db..85ff0557f1 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -914,46 +914,6 @@ def create( _pre_create_data ) - def _create_with_unified_error( - self, identifier, creator, *args, **kwargs - ): - error_message = "Failed to run Creator with identifier \"{}\". {}" - - label = None - add_traceback = False - result = None - fail_info = None - exc_info = None - success = False - - try: - # Try to get creator and his label - if creator is None: - creator = self._get_creator_in_create(identifier) - label = getattr(creator, "label", label) - - # Run create - result = creator.create(*args, **kwargs) - success = True - - except CreatorError: - exc_info = sys.exc_info() - self.log.warning(error_message.format(identifier, exc_info[1])) - - except: # noqa: E722 - add_traceback = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, ""), - exc_info=True - ) - - if not success: - fail_info = prepare_failed_creator_operation_info( - identifier, label, exc_info, add_traceback - ) - return result, fail_info - def create_with_unified_error(self, identifier, *args, **kwargs): """Trigger create but raise only one error if anything fails. @@ -1460,3 +1420,43 @@ def run_convertors(self, convertor_identifiers): if failed_info: raise ConvertorsConversionFailed(failed_info) + + def _create_with_unified_error( + self, identifier, creator, *args, **kwargs + ): + error_message = "Failed to run Creator with identifier \"{}\". {}" + + label = None + add_traceback = False + result = None + fail_info = None + exc_info = None + success = False + + try: + # Try to get creator and his label + if creator is None: + creator = self._get_creator_in_create(identifier) + label = getattr(creator, "label", label) + + # Run create + result = creator.create(*args, **kwargs) + success = True + + except CreatorError: + exc_info = sys.exc_info() + self.log.warning(error_message.format(identifier, exc_info[1])) + + except: # noqa: E722 + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if not success: + fail_info = prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + return result, fail_info From 124cf7b6f689f85990a6098354cc425b091f277b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:42:15 +0200 Subject: [PATCH 137/266] replaced '_remove_instance' with '_remove_instances' --- client/ayon_core/pipeline/create/context.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 85ff0557f1..0a89af1d0d 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -938,9 +938,6 @@ def create_with_unified_error(self, identifier, *args, **kwargs): raise CreatorsCreateFailed([fail_info]) return result - def _remove_instance(self, instance): - self._instances_by_id.pop(instance.id, None) - def creator_removed_instance(self, instance: "CreatedInstance"): """When creator removes instance context should be acknowledged. @@ -952,7 +949,7 @@ def creator_removed_instance(self, instance: "CreatedInstance"): from scene metadata. """ - self._remove_instance(instance) + self._remove_instances([instance]) def add_convertor_item(self, convertor_identifier, label): self.convertor_items_by_id[convertor_identifier] = ConvertorItem( @@ -1310,9 +1307,14 @@ def remove_instances(self, instances): # Just remove instances from context if creator is not available missing_creators = set(instances_by_identifier) - set(self.creators) + instances = [] for identifier in missing_creators: - for instance in instances_by_identifier[identifier]: - self._remove_instance(instance) + instances.extend( + instance + for instance in instances_by_identifier[identifier] + ) + + self._remove_instances(instances) error_message = "Instances removement of creator \"{}\" failed. {}" failed_info = [] @@ -1421,6 +1423,11 @@ def run_convertors(self, convertor_identifiers): if failed_info: raise ConvertorsConversionFailed(failed_info) + def _remove_instances(self, instances): + removed_instances = [] + for instance in instances: + obj = self._instances_by_id.pop(instance.id, None) + def _create_with_unified_error( self, identifier, creator, *args, **kwargs ): From 3e37f6ef1592461bf26dc56d22989125aea55a54 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:42:59 +0200 Subject: [PATCH 138/266] added missing imports --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0a89af1d0d..ca27ac32ae 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -7,7 +7,7 @@ import inspect from contextlib import contextmanager import typing -from typing import Optional, Iterable, Dict +from typing import Optional, Iterable, Dict, Any, Callable import pyblish.logic import pyblish.api From 5d982ab5e218f045cf8cdeb63d3c4a34e3b47644 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 15:44:27 +0200 Subject: [PATCH 139/266] added event hub to context --- client/ayon_core/pipeline/create/context.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index ca27ac32ae..176f3825f1 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -15,6 +15,7 @@ from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported +from ayon_core.lib.events import QueuedEventSystem from ayon_core.lib.attribute_definitions import get_default_values from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy @@ -117,6 +118,7 @@ def __init__( # Prepare attribute for logger (Created on demand in `log` property) self._log = None + self._event_hub = QueuedEventSystem() # Publish context plugins attributes and it's values self._publish_attributes = PublishAttributes(self, {}) @@ -1423,6 +1425,20 @@ def run_convertors(self, convertor_identifiers): if failed_info: raise ConvertorsConversionFailed(failed_info) + def _register_event_callback(self, topic: str, callback: Callable): + return self._event_hub.add_callback(topic, callback) + + def _emit_event( + self, + topic: str, + data: Optional[Dict[str, Any]] = None, + sender: Optional[str] = None, + ): + if data is None: + data = {} + data.setdefault("create_context", self) + return self._event_hub.emit(topic, data, sender) + def _remove_instances(self, instances): removed_instances = [] for instance in instances: From febeff61e73a74db4bdece12754d8173e3a7b20d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 17:26:22 +0200 Subject: [PATCH 140/266] added option to clear callbacks --- client/ayon_core/lib/events.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/lib/events.py b/client/ayon_core/lib/events.py index 2601bc1cf4..1965906dda 100644 --- a/client/ayon_core/lib/events.py +++ b/client/ayon_core/lib/events.py @@ -566,6 +566,10 @@ def emit_event(self, event): self._process_event(event) + def clear_callbacks(self): + """Clear all registered callbacks.""" + self._registered_callbacks = [] + def _process_event(self, event): """Process event topic and trigger callbacks. From a8c842be4ff504df3b11446328ef542c364002d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 17:26:45 +0200 Subject: [PATCH 141/266] clear callbacks on reset --- client/ayon_core/pipeline/create/context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 176f3825f1..3238c70d89 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -503,6 +503,7 @@ def reset_preparation(self): self._collection_shared_data = {} self._folder_id_by_folder_path = {} self._task_names_by_folder_path = {} + self._event_hub.clear_callbacks() def reset_finalization(self): """Cleanup of attributes after reset.""" From 6af9c62d1629ec5111974533f64250658885c5b0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 17:33:18 +0200 Subject: [PATCH 142/266] 'set_publish_plugin_attr_defs' allows to pass in value --- client/ayon_core/pipeline/create/structures.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 18d6d55cfe..a7e5e197d4 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -313,16 +313,24 @@ def data_to_store(self): def origin_data(self): return copy.deepcopy(self._origin_data) - def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): + def set_publish_plugin_attr_defs( + self, + plugin_name: str, + attr_defs: List[AbstractAttrDef], + value: Optional[Dict[str, Any]] = None + ): """Set attribute definitions for plugin. Args: - plugin_name(str): Name of plugin. - attr_defs(Optional[List[AbstractAttrDef]]): Attribute definitions. + plugin_name (str): Name of plugin. + attr_defs (List[AbstractAttrDef]): Attribute definitions. + value (Optional[Dict[str, Any]]): Attribute values. """ # TODO what if 'attr_defs' is 'None'? - value = self._data.get(plugin_name) + if value is None: + value = self._data.get(plugin_name) + if value is None: value = {} From d18079dc2b4fa09b038da008941af08b30d72329 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 18 Sep 2024 17:33:38 +0200 Subject: [PATCH 143/266] trigger event on removed instances --- client/ayon_core/pipeline/create/context.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 3238c70d89..95e08bcada 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1444,6 +1444,19 @@ def _remove_instances(self, instances): removed_instances = [] for instance in instances: obj = self._instances_by_id.pop(instance.id, None) + if obj is not None: + removed_instances.append(instance) + + if not removed_instances: + return + + self._emit_event( + "instances.removed", + { + "instances": removed_instances, + "create_context": self, + } + ) def _create_with_unified_error( self, identifier, creator, *args, **kwargs From 921edf213b1ce16ba05e2ac6afb87e4143ea010e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:16:34 +0200 Subject: [PATCH 144/266] moved bulk finishing logic to separate method --- client/ayon_core/pipeline/create/context.py | 29 ++++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 95e08bcada..2cf3410b20 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -977,19 +977,22 @@ def bulk_instances_collection(self): finally: self._bulk_counter -= 1 - # Trigger validation if there is no more context manager for bulk - # instance validation - if self._bulk_counter != 0: - return - - ( - self._bulk_instances_to_process, - instances_to_validate - ) = ( - [], - self._bulk_instances_to_process - ) - self.get_instances_context_info(instances_to_validate) + self._on_bulk_finished() + + def _on_bulk_finished(self): + # Trigger validation if there is no more context manager for bulk + # instance validation + if self._bulk_counter != 0: + return + + ( + self._bulk_instances_to_process, instances_to_validate + ) = ( + [], self._bulk_instances_to_process + ) + + # Cache folder and task entities for all instances at once + self.get_instances_context_info(instances_to_validate) def reset_instances(self): """Reload instances""" From 352d40f757e319e127b1b67b6da19b4d878a6e36 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:18:31 +0200 Subject: [PATCH 145/266] move publish plugin attributes assignment to bulk finish logic --- client/ayon_core/pipeline/create/context.py | 44 ++++++++++++--------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 2cf3410b20..277eebe7b1 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -763,25 +763,6 @@ def creator_adds_instance(self, instance: "CreatedInstance"): self._instances_by_id[instance.id] = instance - publish_attributes = instance.publish_attributes - # Prepare publish plugin attributes and set it on instance - for plugin in self.plugins_with_defs: - if is_func_signature_supported( - plugin.convert_attribute_values, self, instance - ): - plugin.convert_attribute_values(self, instance) - - elif plugin.__instanceEnabled__: - output = plugin.convert_attribute_values(publish_attributes) - if output: - publish_attributes.update(output) - - for plugin in self.plugins_with_defs: - attr_defs = plugin.get_attribute_defs_for_instance(self, instance) - if not attr_defs: - continue - instance.set_publish_plugin_attr_defs(plugin.__name__, attr_defs) - # Add instance to be validated inside 'bulk_instances_collection' # context manager if is inside bulk with self.bulk_instances_collection(): @@ -994,6 +975,31 @@ def _on_bulk_finished(self): # Cache folder and task entities for all instances at once self.get_instances_context_info(instances_to_validate) + for instance in instances_to_validate: + publish_attributes = instance.publish_attributes + # Prepare publish plugin attributes and set it on instance + for plugin in self.plugins_with_defs: + if is_func_signature_supported( + plugin.convert_attribute_values, self, instance + ): + plugin.convert_attribute_values(self, instance) + + elif plugin.__instanceEnabled__: + output = plugin.convert_attribute_values( + publish_attributes) + if output: + publish_attributes.update(output) + + for plugin in self.plugins_with_defs: + attr_defs = plugin.get_attribute_defs_for_instance( + self, instance + ) + if not attr_defs: + continue + instance.set_publish_plugin_attr_defs( + plugin.__name__, attr_defs + ) + def reset_instances(self): """Reload instances""" self._instances_by_id = collections.OrderedDict() From 503b2f579a67ed9bd8407fb6f55e24c7b945282f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:19:00 +0200 Subject: [PATCH 146/266] safe call of methods --- client/ayon_core/pipeline/create/context.py | 42 +++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 277eebe7b1..0082d5c207 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -979,21 +979,39 @@ def _on_bulk_finished(self): publish_attributes = instance.publish_attributes # Prepare publish plugin attributes and set it on instance for plugin in self.plugins_with_defs: - if is_func_signature_supported( + try: + if is_func_signature_supported( plugin.convert_attribute_values, self, instance - ): - plugin.convert_attribute_values(self, instance) - - elif plugin.__instanceEnabled__: - output = plugin.convert_attribute_values( - publish_attributes) - if output: - publish_attributes.update(output) + ): + plugin.convert_attribute_values(self, instance) + + elif plugin.__instanceEnabled__: + output = plugin.convert_attribute_values( + publish_attributes + ) + if output: + publish_attributes.update(output) + + except Exception: + self.log.error( + "Failed to convert attribute values of" + f" plugin '{plugin.__name__}'", + exc_info=True + ) for plugin in self.plugins_with_defs: - attr_defs = plugin.get_attribute_defs_for_instance( - self, instance - ) + attr_defs = None + try: + attr_defs = plugin.get_attribute_defs_for_instance( + self, instance + ) + except Exception: + self.log.error( + "Failed to get attribute definitions" + f" from plugin '{plugin.__name__}'.", + exc_info=True + ) + if not attr_defs: continue instance.set_publish_plugin_attr_defs( From e379f18620ce83c05493713d76d68f672f108bc4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:19:16 +0200 Subject: [PATCH 147/266] emit instance added event --- client/ayon_core/pipeline/create/context.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0082d5c207..56d427a6de 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -975,6 +975,14 @@ def _on_bulk_finished(self): # Cache folder and task entities for all instances at once self.get_instances_context_info(instances_to_validate) + self._emit_event( + "instances.added", + { + "instances": instances_to_validate, + "create_context": self, + } + ) + for instance in instances_to_validate: publish_attributes = instance.publish_attributes # Prepare publish plugin attributes and set it on instance From da8c349aed9792ab603b217fe08e1bb38e67b3eb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 19 Sep 2024 19:09:40 +0200 Subject: [PATCH 148/266] removed serialization methods from CreatedInstance --- .../ayon_core/pipeline/create/structures.py | 57 ------------------- 1 file changed, 57 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index a7e5e197d4..bbcce54c25 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -791,60 +791,3 @@ def add_members(self, members): if member not in self._members: self._members.append(member) - def serialize_for_remote(self): - """Serialize object into data to be possible recreated object. - - Returns: - Dict[str, Any]: Serialized data. - """ - - creator_attr_defs = self.creator_attributes.get_serialized_attr_defs() - publish_attributes = self.publish_attributes.serialize_attributes() - return { - "data": self.data_to_store(), - "orig_data": self.origin_data, - "creator_attr_defs": creator_attr_defs, - "publish_attributes": publish_attributes, - "creator_label": self._creator_label, - "group_label": self._group_label, - } - - @classmethod - def deserialize_on_remote(cls, serialized_data): - """Convert instance data to CreatedInstance. - - This is fake instance in remote process e.g. in UI process. The creator - is not a full creator and should not be used for calling methods when - instance is created from this method (matters on implementation). - - Args: - serialized_data (Dict[str, Any]): Serialized data for remote - recreating. Should contain 'data' and 'orig_data'. - """ - - instance_data = copy.deepcopy(serialized_data["data"]) - creator_identifier = instance_data["creator_identifier"] - - product_type = instance_data["productType"] - product_name = instance_data.get("productName", None) - - creator_label = serialized_data["creator_label"] - group_label = serialized_data["group_label"] - creator_attr_defs = deserialize_attr_defs( - serialized_data["creator_attr_defs"] - ) - publish_attributes = serialized_data["publish_attributes"] - - obj = cls( - product_type, - product_name, - instance_data, - creator_identifier=creator_identifier, - creator_label=creator_label, - group_label=group_label, - creator_attr_defs=creator_attr_defs - ) - obj._orig_data = serialized_data["orig_data"] - obj.publish_attributes.deserialize_attributes(publish_attributes) - - return obj From 159b1ae9113b4cddaa7b5d65ca30f64f14d5c011 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:35:29 +0200 Subject: [PATCH 149/266] allow only creator passed to created instance --- .../ayon_core/pipeline/create/structures.py | 41 +++++-------------- 1 file changed, 11 insertions(+), 30 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index bbcce54c25..c65616d3d4 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -409,12 +409,7 @@ class CreatedInstance: product_name (str): Name of product that will be created. data (Dict[str, Any]): Data used for filling product name or override data from already existing instance. - creator (Union[BaseCreator, None]): Creator responsible for instance. - creator_identifier (str): Identifier of creator plugin. - creator_label (str): Creator plugin label. - group_label (str): Default group label from creator plugin. - creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from - creator. + creator (BaseCreator): Creator responsible for instance. """ # Keys that can't be changed or removed from data after loading using @@ -435,16 +430,12 @@ def __init__( product_type, product_name, data, - creator=None, - creator_identifier=None, - creator_label=None, - group_label=None, - creator_attr_defs=None, + creator, ): - if creator is not None: - creator_identifier = creator.identifier - group_label = creator.get_group_label() - creator_label = creator.label + self._creator = creator + creator_identifier = creator.identifier + group_label = creator.get_group_label() + creator_label = creator.label self._creator_label = creator_label self._group_label = group_label or creator_identifier @@ -504,16 +495,7 @@ def __init__( # {key: value} creator_values = copy.deepcopy(orig_creator_attributes) - if creator_attr_defs is None: - _creator_attr_defs = [] - else: - _creator_attr_defs = list(creator_attr_defs) - self._data["creator_attributes"] = CreatorAttributeValues( - self, - _creator_attr_defs, - creator_values, - orig_creator_attributes - ) + self._data["creator_attributes"] = creator_values # Stored publish specific attribute values # {: {key: value}} @@ -526,11 +508,10 @@ def __init__( if not self._data.get("instance_id"): self._data["instance_id"] = str(uuid4()) - if creator is not None: - creator_attr_defs = creator.get_attr_defs_for_instance(self) - self.update_create_attr_defs( - creator_attr_defs, creator_values - ) + creator_attr_defs = creator.get_attr_defs_for_instance(self) + self.update_create_attr_defs( + creator_attr_defs, creator_values + ) def __str__(self): return ( From 40708dd7cefb9343f519dbc2d40471d673a48e59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:36:16 +0200 Subject: [PATCH 150/266] added more methods to trigger events --- client/ayon_core/pipeline/create/context.py | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 56d427a6de..da1ea7b9e8 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -960,6 +960,45 @@ def bulk_instances_collection(self): self._on_bulk_finished() + def publish_attribute_value_changed(self, plugin_name, value): + self._emit_event( + "context.values.changed", + { + "publish_attributes": {plugin_name: value} + }, + ) + + # --- instance change callbacks --- + def instance_create_attr_defs_changed(self, instance_id): + # TODO allow bulk changes + self._emit_event( + "instances.create.attr.defs.changed", + { + "instance_ids": [instance_id] + } + ) + + def instance_publish_attr_defs_changed( + self, instance_id, plugin_name + ): + # TODO allow bulk changes + self._emit_event( + "instances.publish.attr.defs.changed", + { + plugin_name: [instance_id], + } + ) + + def instance_values_changed( + self, instance_id, new_values + ): + self._emit_event( + "instances.values.changed", + { + instance_id: new_values + } + ) + def _on_bulk_finished(self): # Trigger validation if there is no more context manager for bulk # instance validation From ca9e016693044c8e0aef35cc9cd281f0c3bdea7a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 23 Sep 2024 18:02:58 +0200 Subject: [PATCH 151/266] structures are triggering events in create context --- .../ayon_core/pipeline/create/structures.py | 126 ++++++++++++------ 1 file changed, 88 insertions(+), 38 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index c65616d3d4..56995ed60b 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -1,9 +1,10 @@ import copy import collections from uuid import uuid4 -from typing import Optional +from typing import Optional, Dict, List, Any from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, UnknownDef, UIDef, serialize_attr_defs, @@ -80,12 +81,17 @@ class AttributeValues: Has dictionary like methods. Not all of them are allowed all the time. Args: - attr_defs(AbstractAttrDef): Definitions of value type and properties. - values(dict): Values after possible conversion. - origin_data(dict): Values loaded from host before conversion. - """ + parent (Union[CreatedInstance, PublishAttributes]): Parent object. + key (str): Key of attribute values. + attr_defs (List[AbstractAttrDef]): Definitions of value type + and properties. + values (dict): Values after possible conversion. + origin_data (dict): Values loaded from host before conversion. - def __init__(self, attr_defs, values, origin_data=None): + """ + def __init__(self, parent, key, attr_defs, values, origin_data=None): + self._parent = parent + self._key = key if origin_data is None: origin_data = copy.deepcopy(values) self._origin_data = origin_data @@ -148,7 +154,11 @@ def update(self, value): self._data[_key] = _value changes[_key] = _value + if changes: + self._parent.attribute_value_changed(self._key, changes) + def pop(self, key, default=None): + has_key = key in self._data value = self._data.pop(key, default) # Remove attribute definition if is 'UnknownDef' # - gives option to get rid of unknown values @@ -156,6 +166,8 @@ def pop(self, key, default=None): if isinstance(attr_def, UnknownDef): self._attr_defs_by_key.pop(key) self._attr_defs.remove(attr_def) + elif has_key: + self._parent.attribute_value_changed(self._key, {key: None}) return value def reset_values(self): @@ -205,15 +217,11 @@ def get_serialized_attr_defs(self): class CreatorAttributeValues(AttributeValues): - """Creator specific attribute values of an instance. - - Args: - instance (CreatedInstance): Instance for which are values hold. - """ + """Creator specific attribute values of an instance.""" - def __init__(self, instance, *args, **kwargs): - self.instance = instance - super().__init__(*args, **kwargs) + @property + def instance(self): + return self._parent class PublishAttributeValues(AttributeValues): @@ -221,19 +229,11 @@ class PublishAttributeValues(AttributeValues): Values are for single plugin which can be on `CreatedInstance` or context values stored on `CreateContext`. - - Args: - publish_attributes(PublishAttributes): Wrapper for multiple publish - attributes is used as parent object. """ - def __init__(self, publish_attributes, *args, **kwargs): - self.publish_attributes = publish_attributes - super().__init__(*args, **kwargs) - @property - def parent(self): - return self.publish_attributes.parent + def publish_attributes(self): + return self._parent class PublishAttributes: @@ -246,12 +246,10 @@ class PublishAttributes: parent(CreatedInstance, CreateContext): Parent for which will be data stored and from which are data loaded. origin_data(dict): Loaded data by plugin class name. - attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish - plugins that may have defined attribute definitions. - """ + """ def __init__(self, parent, origin_data): - self.parent = parent + self._parent = parent self._origin_data = copy.deepcopy(origin_data) self._data = copy.deepcopy(origin_data) @@ -287,6 +285,7 @@ def pop(self, key, default=None): value = self._data[key] if not isinstance(value, AttributeValues): + self._parent.publish_attribute_value_changed(key, None) return self._data.pop(key) value_item = self._data[key] @@ -294,6 +293,9 @@ def pop(self, key, default=None): output = value_item.data_to_store() # Reset values value_item.reset_values() + self._parent.publish_attribute_value_changed( + key, value_item.data_to_store() + ) return output def mark_as_stored(self): @@ -313,6 +315,9 @@ def data_to_store(self): def origin_data(self): return copy.deepcopy(self._origin_data) + def attribute_value_changed(self, key, changes): + self._parent.publish_attribute_value_changed(key, changes) + def set_publish_plugin_attr_defs( self, plugin_name: str, @@ -342,7 +347,7 @@ def set_publish_plugin_attr_defs( value[key] = attr_def.convert_value(value[key]) self._data[plugin_name] = PublishAttributeValues( - self, attr_defs, value, value + self, plugin_name, attr_defs, value, value ) def serialize_attributes(self): @@ -366,7 +371,7 @@ def deserialize_attributes(self, data): value = data.get(plugin_name) or {} orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) self._data[plugin_name] = PublishAttributeValues( - self, attr_defs, value, orig_value + self, plugin_name, attr_defs, value, orig_value ) for key, value in data.items(): @@ -532,13 +537,20 @@ def __contains__(self, key): def __setitem__(self, key, value): # Validate immutable keys - if key not in self.__immutable_keys: - self._data[key] = value - - elif value != self._data.get(key): + if key in self.__immutable_keys: + if value == self._data.get(key): + return # Raise exception if key is immutable and value has changed raise ImmutableKeyError(key) + if key in self._data and self._data[key] == value: + return + + self._data[key] = value + self._create_context.instance_values_changed( + self.id, {key: value} + ) + def get(self, key, default=None): return self._data.get(key, default) @@ -547,7 +559,13 @@ def pop(self, key, *args, **kwargs): if key in self.__immutable_keys: raise ImmutableKeyError(key) - self._data.pop(key, *args, **kwargs) + has_key = key in self._data + output = self._data.pop(key, *args, **kwargs) + if has_key: + self._create_context.instance_values_changed( + self.id, {key: None} + ) + return output def keys(self): return self._data.keys() @@ -594,7 +612,7 @@ def creator_identifier(self): @property def creator_label(self): - return self._creator_label or self.creator_identifier + return self._creator.label or self.creator_identifier @property def id(self): @@ -711,7 +729,11 @@ def data_to_store(self): continue output[key] = value - output["creator_attributes"] = self.creator_attributes.data_to_store() + if isinstance(self.creator_attributes, AttributeValues): + creator_attributes = self.creator_attributes.data_to_store() + else: + creator_attributes = copy.deepcopy(self.creator_attributes) + output["creator_attributes"] = creator_attributes output["publish_attributes"] = self.publish_attributes.data_to_store() return output @@ -719,13 +741,22 @@ def data_to_store(self): def update_create_attr_defs(self, attr_defs, value=None): if value is not None: value = self._data["creator_attributes"] - origin_data = self._data["creator_attributes"].origin_data + + if isinstance(value, AttributeValues): + value = value.data_to_store() + + if isinstance(self._data["creator_attributes"], AttributeValues): + origin_data = self._data["creator_attributes"].origin_data + else: + origin_data = self._data["creator_attributes"] self._data["creator_attributes"] = CreatorAttributeValues( self, + "creator_attributes", attr_defs, value, origin_data ) + self._create_context.instance_create_attr_defs_changed(self.id) @classmethod def from_existing(cls, instance_data, creator): @@ -753,6 +784,9 @@ def from_existing(cls, instance_data, creator): product_type, product_name, instance_data, creator ) + def attribute_value_changed(self, key, changes): + self._create_context.instance_values_changed(self.id, {key: changes}) + def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): """Set attribute definitions for publish plugin. @@ -764,6 +798,19 @@ def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): self.publish_attributes.set_publish_plugin_attr_defs( plugin_name, attr_defs ) + self._create_context.instance_publish_attr_defs_changed( + self.id, plugin_name + ) + + def publish_attribute_value_changed(self, plugin_name, value): + self._create_context.instance_values_changed( + self.id, + { + "publish_attributes": { + plugin_name: value, + }, + }, + ) def add_members(self, members): """Currently unused method.""" @@ -772,3 +819,6 @@ def add_members(self, members): if member not in self._members: self._members.append(member) + @property + def _create_context(self): + return self._creator.create_context From 5c0458599d09b862054a4a5069771c05809353fe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:42:01 +0200 Subject: [PATCH 152/266] don't use CreatedInstance in publisher UI --- client/ayon_core/tools/publisher/abstract.py | 35 +++- client/ayon_core/tools/publisher/control.py | 50 +++-- .../tools/publisher/models/create.py | 196 ++++++++++++++++-- .../publisher/widgets/card_view_widgets.py | 18 +- .../publisher/widgets/list_view_widgets.py | 22 +- .../tools/publisher/widgets/widgets.py | 72 ++++--- client/ayon_core/tools/publisher/window.py | 4 +- 7 files changed, 287 insertions(+), 110 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 6bea4cc247..196c1c938e 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -15,7 +15,6 @@ from ayon_core.host import HostBase from ayon_core.pipeline.create import ( CreateContext, - CreatedInstance, ConvertorItem, ) from ayon_core.tools.common_models import ( @@ -26,7 +25,7 @@ ) if TYPE_CHECKING: - from .models import CreatorItem, PublishErrorInfo + from .models import CreatorItem, PublishErrorInfo, InstanceItem class CardMessageTypes: @@ -307,19 +306,19 @@ def get_convertor_items(self) -> Dict[str, ConvertorItem]: pass @abstractmethod - def get_instances(self) -> List[CreatedInstance]: + def get_instance_items(self) -> List["InstanceItem"]: """Collected/created instances. Returns: - List[CreatedInstance]: List of created instances. + List[InstanceItem]: List of created instances. """ pass @abstractmethod - def get_instances_by_id( + def get_instance_items_by_id( self, instance_ids: Optional[Iterable[str]] = None - ) -> Dict[str, Union[CreatedInstance, None]]: + ) -> Dict[str, Union["InstanceItem", None]]: pass @abstractmethod @@ -334,22 +333,38 @@ def get_existing_product_names(self, folder_path: str) -> List[str]: @abstractmethod def get_creator_attribute_definitions( - self, instances: List[CreatedInstance] - ) -> List[Tuple[AbstractAttrDef, List[CreatedInstance], List[Any]]]: + self, instance_ids: List[str] + ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: + pass + + @abstractmethod + def set_instances_create_attr_values( + self, instance_ids: Iterable[str], key: str, value: Any + ): pass @abstractmethod def get_publish_attribute_definitions( self, - instances: List[CreatedInstance], + instance_ids: List[str], include_context: bool ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[CreatedInstance, Any]]] + Dict[str, List[Tuple[str, Any]]] ]]: pass + @abstractmethod + def set_instances_publish_attr_values( + self, + instance_ids: Iterable[str], + plugin_name: str, + key: str, + value: Any + ): + pass + @abstractmethod def get_product_name( self, diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index c7fd75b3c3..8664cfe605 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -172,23 +172,25 @@ def get_creator_icon(self, identifier): """ return self._create_model.get_creator_icon(identifier) + def get_instance_items(self): + """Current instances in create context.""" + return self._create_model.get_instance_items() + + # --- Legacy for TrayPublisher --- @property def instances(self): - """Current instances in create context. + return self.get_instance_items() - Deprecated: - Use 'get_instances' instead. Kept for backwards compatibility with - traypublisher. + def get_instances(self): + return self.get_instance_items() - """ - return self.get_instances() + def get_instances_by_id(self, *args, **kwargs): + return self.get_instance_items_by_id(*args, **kwargs) - def get_instances(self): - """Current instances in create context.""" - return self._create_model.get_instances() + # --- - def get_instances_by_id(self, instance_ids=None): - return self._create_model.get_instances_by_id(instance_ids) + def get_instance_items_by_id(self, instance_ids=None): + return self._create_model.get_instance_items_by_id(instance_ids) def get_instances_context_info(self, instance_ids=None): return self._create_model.get_instances_context_info(instance_ids) @@ -365,29 +367,41 @@ def clear_thumbnail_temp_dir_path(self): if os.path.exists(dirpath): shutil.rmtree(dirpath) - def get_creator_attribute_definitions(self, instances): + def get_creator_attribute_definitions(self, instance_ids): """Collect creator attribute definitions for multuple instances. Args: - instances(List[CreatedInstance]): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. """ return self._create_model.get_creator_attribute_definitions( - instances + instance_ids + ) + + def set_instances_create_attr_values(self, instance_ids, key, value): + return self._create_model.set_instances_create_attr_values( + instance_ids, key, value ) - def get_publish_attribute_definitions(self, instances, include_context): + def get_publish_attribute_definitions(self, instance_ids, include_context): """Collect publish attribute definitions for passed instances. Args: - instances(list): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. - include_context(bool): Add context specific attribute definitions. + include_context (bool): Add context specific attribute definitions. """ return self._create_model.get_publish_attribute_definitions( - instances, include_context + instance_ids, include_context + ) + + def set_instances_publish_attr_values( + self, instance_ids, plugin_name, key, value + ): + return self._create_model.set_instances_publish_attr_values( + instance_ids, plugin_name, key, value ) def get_product_name( diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index dcd2ce4acc..9d9d13fda6 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -192,6 +192,110 @@ def from_data(cls, data: Dict[str, Any]) -> "CreatorItem": return cls(**data) +class InstanceItem: + def __init__( + self, + instance_id: str, + creator_identifier: str, + label: str, + group_label: str, + product_type: str, + product_name: str, + variant: str, + folder_path: Optional[str], + task_name: Optional[str], + is_active: bool, + has_promised_context: bool, + ): + self._instance_id: str = instance_id + self._creator_identifier: str = creator_identifier + self._label: str = label + self._group_label: str = group_label + self._product_type: str = product_type + self._product_name: str = product_name + self._variant: str = variant + self._folder_path: Optional[str] = folder_path + self._task_name: Optional[str] = task_name + self._is_active: bool = is_active + self._has_promised_context: bool = has_promised_context + + @property + def id(self): + return self._instance_id + + @property + def creator_identifier(self): + return self._creator_identifier + + @property + def label(self): + return self._label + + @property + def group_label(self): + return self._group_label + + @property + def product_type(self): + return self._product_type + + @property + def has_promised_context(self): + return self._has_promised_context + + def get_variant(self): + return self._variant + + def set_variant(self, variant): + self._variant = variant + + def get_product_name(self): + return self._product_name + + def set_product_name(self, product_name): + self._product_name = product_name + + def get_folder_path(self): + return self._folder_path + + def set_folder_path(self, folder_path): + self._folder_path = folder_path + + def get_task_name(self): + return self._task_name + + def set_task_name(self, task_name): + self._task_name = task_name + + def get_is_active(self): + return self._is_active + + def set_is_active(self, is_active): + self._is_active = is_active + + product_name = property(get_product_name, set_product_name) + variant = property(get_variant, set_variant) + folder_path = property(get_folder_path, set_folder_path) + task_name = property(get_task_name, set_task_name) + is_active = property(get_is_active, set_is_active) + + @classmethod + def from_instance(cls, instance: CreatedInstance): + return InstanceItem( + instance.id, + instance.creator_identifier, + instance.label, + instance.group_label, + instance.product_type, + instance.product_name, + instance["variant"], + instance["folderPath"], + instance["task"], + instance["active"], + instance.has_promised_context, + ) + + class CreateModel: def __init__(self, controller: AbstractPublisherBackend): self._log = None @@ -287,29 +391,36 @@ def get_creator_icon( return creator_item.icon return None - def get_instances(self) -> List[CreatedInstance]: + def get_instance_items(self) -> List[InstanceItem]: """Current instances in create context.""" - return list(self._create_context.instances_by_id.values()) + return [ + InstanceItem.from_instance(instance) + for instance in self._create_context.instances_by_id.values() + ] - def get_instance_by_id( + def get_instance_item_by_id( self, instance_id: str - ) -> Union[CreatedInstance, None]: - return self._create_context.instances_by_id.get(instance_id) + ) -> Union[InstanceItem, None]: + instance = self._create_context.instances_by_id.get(instance_id) + if instance is None: + return None - def get_instances_by_id( + return InstanceItem.from_instance(instance) + + def get_instance_items_by_id( self, instance_ids: Optional[Iterable[str]] = None - ) -> Dict[str, Union[CreatedInstance, None]]: + ) -> Dict[str, Union[InstanceItem, None]]: if instance_ids is None: instance_ids = self._create_context.instances_by_id.keys() return { - instance_id: self.get_instance_by_id(instance_id) + instance_id: self.get_instance_item_by_id(instance_id) for instance_id in instance_ids } def get_instances_context_info( self, instance_ids: Optional[Iterable[str]] = None ): - instances = self.get_instances_by_id(instance_ids).values() + instances = self._get_instances_by_id(instance_ids).values() return self._create_context.get_instances_context_info( instances ) @@ -341,7 +452,7 @@ def get_product_name( instance = None if instance_id: - instance = self.get_instance_by_id(instance_id) + instance = self._get_instance_by_id(instance_id) project_name = self._controller.get_current_project_name() folder_item = self._controller.get_folder_item_by_path( @@ -500,21 +611,30 @@ def remove_instances(self, instance_ids: List[str]): self._on_create_instance_change() + def set_instances_create_attr_values(self, instance_ids, key, value): + # TODO set bulk change context + 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 + def get_creator_attribute_definitions( - self, instances: List[CreatedInstance] - ) -> List[Tuple[AbstractAttrDef, List[CreatedInstance], List[Any]]]: + self, instance_ids: List[str] + ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: """Collect creator attribute definitions for multuple instances. Args: - instances (List[CreatedInstance]): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. - """ + """ # NOTE it would be great if attrdefs would have hash method implemented # so they could be used as keys in dictionary output = [] _attr_defs = {} - for instance in instances: + for instance_id in instance_ids: + instance = self._get_instance_by_id(instance_id) for attr_def in instance.creator_attribute_defs: found_idx = None for idx, _attr_def in _attr_defs.items(): @@ -527,27 +647,39 @@ def get_creator_attribute_definitions( value = instance.creator_attributes[attr_def.key] if found_idx is None: idx = len(output) - output.append((attr_def, [instance], [value])) + output.append((attr_def, [instance_id], [value])) _attr_defs[idx] = attr_def else: item = output[found_idx] - item[1].append(instance) + item[1].append(instance_id) item[2].append(value) return output + def set_instances_publish_attr_values( + self, instance_ids, plugin_name, key, value + ): + # TODO set bulk change context + for instance_id in instance_ids: + if instance_id is None: + instance = self._create_context + else: + instance = self._get_instance_by_id(instance_id) + plugin_val = instance.publish_attributes[plugin_name] + plugin_val[key] = value + def get_publish_attribute_definitions( self, - instances: List[CreatedInstance], + instance_ids: List[str], include_context: bool ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[CreatedInstance, Any]]] + Dict[str, List[Tuple[str, Any]]] ]]: """Collect publish attribute definitions for passed instances. Args: - instances (list[CreatedInstance]): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. include_context (bool): Add context specific attribute definitions. @@ -556,12 +688,15 @@ def get_publish_attribute_definitions( if include_context: _tmp_items.append(self._create_context) - for instance in instances: - _tmp_items.append(instance) + for instance_id in instance_ids: + _tmp_items.append(self._get_instance_by_id(instance_id)) all_defs_by_plugin_name = {} all_plugin_values = {} for item in _tmp_items: + item_id = None + if isinstance(item, CreatedInstance): + item_id = item.id for plugin_name, attr_val in item.publish_attributes.items(): attr_defs = attr_val.attr_defs if not attr_defs: @@ -579,7 +714,7 @@ def get_publish_attribute_definitions( attr_values = plugin_values.setdefault(attr_def.key, []) value = attr_val[attr_def.key] - attr_values.append((item, value)) + attr_values.append((item_id, value)) output = [] for plugin in self._create_context.plugins_with_defs: @@ -638,6 +773,21 @@ def _creators(self) -> Dict[str, BaseCreator]: return self._create_context.creators + def _get_instance_by_id( + self, instance_id: str + ) -> Union[CreatedInstance, None]: + return self._create_context.instances_by_id.get(instance_id) + + def _get_instances_by_id( + self, instance_ids: Optional[Iterable[str]] + ) -> Dict[str, Union[CreatedInstance, None]]: + if instance_ids is None: + instance_ids = self._create_context.instances_by_id.keys() + return { + instance_id: self._get_instance_by_id(instance_id) + for instance_id in instance_ids + } + def _reset_instances(self): """Reset create instances.""" 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 c0e27d9c60..6ef34b86f8 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -227,7 +227,7 @@ def update_instances(self, instances, context_info_by_id): """Update instances for the group. Args: - instances (list[CreatedInstance]): List of instances in + instances (list[InstanceItem]): List of instances in CreateContext. context_info_by_id (Dict[str, InstanceContextInfo]): Instance context info by instance id. @@ -238,7 +238,7 @@ def update_instances(self, instances, context_info_by_id): instances_by_product_name = collections.defaultdict(list) for instance in instances: instances_by_id[instance.id] = instance - product_name = instance["productName"] + product_name = instance.product_name instances_by_product_name[product_name].append(instance) # Remove instance widgets that are not in passed instances @@ -473,12 +473,12 @@ def is_active(self): def set_active(self, new_value): """Set instance as active.""" checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance["active"] + 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["active"] = new_value + self.instance.is_active = new_value if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) @@ -494,8 +494,8 @@ def _validate_context(self, context_info): self._context_warning.setVisible(not valid) def _update_product_name(self): - variant = self.instance["variant"] - product_name = self.instance["productName"] + variant = self.instance.variant + product_name = self.instance.product_name label = self.instance.label if ( variant == self._last_variant @@ -525,7 +525,7 @@ def _update_product_name(self): def update_instance_values(self, context_info): """Update instance data""" self._update_product_name() - self.set_active(self.instance["active"]) + self.set_active(self.instance.is_active) self._validate_context(context_info) def _set_expanded(self, expanded=None): @@ -535,11 +535,11 @@ def _set_expanded(self, expanded=None): def _on_active_change(self): new_value = self._active_checkbox.isChecked() - old_value = self.instance["active"] + old_value = self.instance.is_active if new_value == old_value: return - self.instance["active"] = new_value + self.instance.is_active = new_value self.active_changed.emit(self._id, new_value) def _on_expend_clicked(self): 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 ab9f2db52c..14814a4aa6 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -131,7 +131,7 @@ def __init__(self, instance, context_info, parent): product_name_label.setObjectName("ListViewProductName") active_checkbox = NiceCheckbox(parent=self) - active_checkbox.setChecked(instance["active"]) + active_checkbox.setChecked(instance.is_active) layout = QtWidgets.QHBoxLayout(self) content_margins = layout.contentsMargins() @@ -171,19 +171,19 @@ def _set_valid_property(self, valid): def is_active(self): """Instance is activated.""" - return self.instance["active"] + return self.instance.is_active def set_active(self, new_value): """Change active state of instance and checkbox.""" checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance["active"] + instance_value = self.instance.is_active if new_value is None: new_value = not instance_value # First change instance value and them change checkbox # - prevent to trigger `active_changed` signal if instance_value != new_value: - self.instance["active"] = new_value + self.instance.is_active = new_value if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) @@ -200,17 +200,17 @@ def update_instance_values(self, context_info): if label != self._instance_label_widget.text(): self._instance_label_widget.setText(html_escape(label)) # Check active state - self.set_active(self.instance["active"]) + self.set_active(self.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["active"] + old_value = self.instance.is_active if new_value == old_value: return - self.instance["active"] = new_value + self.instance.is_active = new_value self.active_changed.emit(self.instance.id, new_value) def set_active_toggle_enabled(self, enabled): @@ -639,10 +639,10 @@ def refresh(self): instance_id = instance.id # Handle group activity if activity is None: - activity = int(instance["active"]) + activity = int(instance.is_active) elif activity == -1: pass - elif activity != instance["active"]: + elif activity != instance.is_active: activity = -1 context_info = context_info_by_id[instance_id] @@ -658,8 +658,8 @@ def refresh(self): # Create new item and store it as new item = QtGui.QStandardItem() - item.setData(instance["productName"], SORT_VALUE_ROLE) - item.setData(instance["productName"], GROUP_ROLE) + item.setData(instance.product_name, SORT_VALUE_ROLE) + item.setData(instance.product_name, GROUP_ROLE) item.setData(instance_id, INSTANCE_ID_ROLE) new_items.append(item) new_items_with_instance.append((item, instance)) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 83a2d9e6c1..429f7e0472 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1186,9 +1186,9 @@ def _on_submit(self): if instance.has_promised_context: continue - new_variant_value = instance.get("variant") - new_folder_path = instance.get("folderPath") - new_task_name = instance.get("task") + new_variant_value = instance.variant + new_folder_path = instance.folder_path + new_task_name = instance.task_name if variant_value is not None: new_variant_value = variant_value @@ -1210,20 +1210,20 @@ def _on_submit(self): except TaskNotSetError: invalid_tasks = True - product_names.add(instance["productName"]) + product_names.add(instance.product_name) continue product_names.add(new_product_name) if variant_value is not None: - instance["variant"] = variant_value + instance.variant = variant_value if folder_path is not None: - instance["folderPath"] = folder_path + instance.folder_path = folder_path if task_name is not None: - instance["task"] = task_name or None + instance.task_name = task_name or None - instance["productName"] = new_product_name + instance.product_name = new_product_name if invalid_tasks: self.task_value_widget.set_invalid_empty_task() @@ -1290,7 +1290,7 @@ def set_current_instances(self, instances): """Set currently selected instances. Args: - instances(List[CreatedInstance]): List of selected instances. + instances (List[InstanceItem]): List of selected instances. Empty instances tells that nothing or context is selected. """ self._set_btns_visible(False) @@ -1318,13 +1318,13 @@ def set_current_instances(self, instances): if instance.creator_identifier is None: editable = False - variants.add(instance.get("variant") or self.unknown_value) - product_types.add(instance.get("productType") or self.unknown_value) - folder_path = instance.get("folderPath") or self.unknown_value - task_name = instance.get("task") or "" + variants.add(instance.variant or self.unknown_value) + product_types.add(instance.product_type or self.unknown_value) + folder_path = instance.folder_path or self.unknown_value + task_name = instance.task_name or "" folder_paths.add(folder_path) folder_task_combinations.append((folder_path, task_name)) - product_names.add(instance.get("productName") or self.unknown_value) + product_names.add(instance.product_name or self.unknown_value) if not editable: context_editable = False @@ -1406,7 +1406,7 @@ def set_instances_valid(self, valid): ): self._content_widget.setEnabled(valid) - def set_current_instances(self, instances): + def set_current_instances(self, instance_ids): """Set current instances for which are attribute definitions shown.""" prev_content_widget = self._scroll_area.widget() @@ -1420,7 +1420,7 @@ def set_current_instances(self, instances): self._attr_def_id_to_attr_def = {} result = self._controller.get_creator_attribute_definitions( - instances + instance_ids ) content_widget = QtWidgets.QWidget(self._scroll_area) @@ -1432,7 +1432,7 @@ def set_current_instances(self, instances): content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) row = 0 - for attr_def, attr_instances, values in result: + for attr_def, instance_ids, values in result: widget = create_widget_for_attr_def(attr_def, content_widget) if attr_def.is_value_def: if len(values) == 1: @@ -1443,7 +1443,7 @@ def set_current_instances(self, instances): widget.set_value(values, True) widget.value_changed.connect(self._input_value_changed) - self._attr_def_id_to_instances[attr_def.id] = attr_instances + 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: @@ -1483,15 +1483,13 @@ def set_current_instances(self, instances): self._content_widget = content_widget def _input_value_changed(self, value, attr_id): - instances = self._attr_def_id_to_instances.get(attr_id) + instance_ids = self._attr_def_id_to_instances.get(attr_id) attr_def = self._attr_def_id_to_attr_def.get(attr_id) - if not instances or not attr_def: + if not instance_ids or not attr_def: return - - for instance in instances: - creator_attributes = instance["creator_attributes"] - if attr_def.key in creator_attributes: - creator_attributes[attr_def.key] = value + self._controller.set_instances_create_attr_values( + instance_ids, attr_def.key, value + ) class PublishPluginAttrsWidget(QtWidgets.QWidget): @@ -1546,7 +1544,7 @@ def set_instances_valid(self, valid): ): self._content_widget.setEnabled(valid) - def set_current_instances(self, instances, context_selected): + def set_current_instances(self, instance_ids, context_selected): """Set current instances for which are attribute definitions shown.""" prev_content_widget = self._scroll_area.widget() @@ -1562,7 +1560,7 @@ def set_current_instances(self, instances, context_selected): self._attr_def_id_to_plugin_name = {} result = self._controller.get_publish_attribute_definitions( - instances, context_selected + instance_ids, context_selected ) content_widget = QtWidgets.QWidget(self._scroll_area) @@ -1648,15 +1646,15 @@ def set_current_instances(self, instances, context_selected): self._content_widget = content_widget def _input_value_changed(self, value, attr_id): - instances = self._attr_def_id_to_instances.get(attr_id) + instance_ids = self._attr_def_id_to_instances.get(attr_id) attr_def = self._attr_def_id_to_attr_def.get(attr_id) plugin_name = self._attr_def_id_to_plugin_name.get(attr_id) - if not instances or not attr_def or not plugin_name: + if not instance_ids or not attr_def or not plugin_name: return - for instance in instances: - plugin_val = instance.publish_attributes[plugin_name] - plugin_val[attr_def.key] = value + self._controller.set_instances_publish_attr_values( + instance_ids, plugin_name, attr_def.key, value + ) class ProductAttributesWidget(QtWidgets.QWidget): @@ -1821,10 +1819,10 @@ def set_current_instances( """Change currently selected items. Args: - instances(List[CreatedInstance]): List of currently selected + instances (List[InstanceItem]): List of currently selected instances. - context_selected(bool): Is context selected. - convertor_identifiers(List[str]): Identifiers of convert items. + context_selected (bool): Is context selected. + convertor_identifiers (List[str]): Identifiers of convert items. """ instance_ids = { @@ -1849,9 +1847,9 @@ def set_current_instances( self._convert_widget.setVisible(len(s_convertor_identifiers) > 0) self.global_attrs_widget.set_current_instances(instances) - self.creator_attrs_widget.set_current_instances(instances) + self.creator_attrs_widget.set_current_instances(instance_ids) self.publish_attrs_widget.set_current_instances( - instances, context_selected + instance_ids, context_selected ) self.creator_attrs_widget.set_instances_valid(all_valid) self.publish_attrs_widget.set_instances_valid(all_valid) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 434c2ca602..e4da71b3d6 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -918,8 +918,8 @@ def _validate_create_instances(self): active_instances_by_id = { instance.id: instance - for instance in self._controller.get_instances() - if instance["active"] + for instance in self._controller.get_instance_items() + if instance.is_active } context_info_by_id = self._controller.get_instances_context_info( active_instances_by_id.keys() From 50305d7004991858d74956a65e7136f4a756b5e9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:19:53 +0200 Subject: [PATCH 153/266] use bulk event emit for any changes --- client/ayon_core/pipeline/create/context.py | 256 ++++++++++++++---- .../ayon_core/pipeline/create/structures.py | 4 +- .../tools/publisher/models/create.py | 32 ++- 3 files changed, 222 insertions(+), 70 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index da1ea7b9e8..921ff9d2ad 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -7,7 +7,16 @@ import inspect from contextlib import contextmanager import typing -from typing import Optional, Iterable, Dict, Any, Callable +from typing import ( + Optional, + Iterable, + Tuple, + List, + Dict, + Any, + Callable, + Union, +) import pyblish.logic import pyblish.api @@ -92,6 +101,38 @@ def prepare_failed_creator_operation_info( } +class BulkInfo: + def __init__(self): + self._count = 0 + self._data = [] + self._sender = None + + def __bool__(self): + return self._count == 0 + + def get_sender(self): + return self._sender + + def set_sender(self, sender): + if sender is not None: + self._sender = sender + + def increase(self): + self._count += 1 + + def decrease(self): + self._count -= 1 + + def append(self, item): + self._data.append(item) + + def pop_data(self): + data = self._data + self._data = [] + self._sender = None + return data + + class CreateContext: """Context of instance creation. @@ -181,8 +222,17 @@ def __init__( # - they can be validation for multiple instances at one time # using context manager which will trigger validation # after leaving of last context manager scope - self._bulk_counter = 0 - self._bulk_instances_to_process = [] + self._bulk_info = { + # Collect instances + "collect": BulkInfo(), + # Change values of instances or create context + "change": BulkInfo(), + # Create attribute definitions changed + "create_attrs_change": BulkInfo(), + # Publish attribute definitions changed + "publish_attrs_change": BulkInfo(), + } + self._bulk_order = [] # Shared data across creators during collection phase self._collection_shared_data = None @@ -765,8 +815,8 @@ def creator_adds_instance(self, instance: "CreatedInstance"): # Add instance to be validated inside 'bulk_instances_collection' # context manager if is inside bulk - with self.bulk_instances_collection(): - self._bulk_instances_to_process.append(instance) + with self.bulk_instances_collection() as bulk_info: + bulk_info.append(instance) def _get_creator_in_create(self, identifier): """Creator by identifier with unified error. @@ -944,72 +994,98 @@ def remove_convertor_item(self, convertor_identifier): self.convertor_items_by_id.pop(convertor_identifier, None) @contextmanager - def bulk_instances_collection(self): - """Validate context of instances in bulk. - - This can be used for single instance or for adding multiple instances - which is helpfull on reset. + def bulk_instances_collection(self, sender=None): + with self._bulk_context("collect", sender) as bulk_info: + yield bulk_info - Should not be executed from multiple threads. - """ - self._bulk_counter += 1 - try: - yield - finally: - self._bulk_counter -= 1 + @contextmanager + def bulk_value_changes(self, sender=None): + with self._bulk_context("change", sender) as bulk_info: + yield bulk_info - self._on_bulk_finished() + @contextmanager + def bulk_create_attr_defs_change(self, sender=None): + with self._bulk_context("create_attrs_change", sender) as bulk_info: + yield bulk_info - def publish_attribute_value_changed(self, plugin_name, value): - self._emit_event( - "context.values.changed", - { - "publish_attributes": {plugin_name: value} - }, - ) + @contextmanager + def bulk_publish_attr_defs_change(self, sender=None): + with self._bulk_context("publish_attrs_change", sender) as bulk_info: + yield bulk_info # --- instance change callbacks --- def instance_create_attr_defs_changed(self, instance_id): - # TODO allow bulk changes - self._emit_event( - "instances.create.attr.defs.changed", - { - "instance_ids": [instance_id] - } - ) + with self.bulk_create_attr_defs_change() as bulk_item: + bulk_item.append(instance_id) def instance_publish_attr_defs_changed( self, instance_id, plugin_name ): - # TODO allow bulk changes - self._emit_event( - "instances.publish.attr.defs.changed", - { - plugin_name: [instance_id], - } - ) + with self.bulk_publish_attr_defs_change() as bulk_item: + bulk_item.append((instance_id, plugin_name)) def instance_values_changed( self, instance_id, new_values ): - self._emit_event( - "instances.values.changed", + with self.bulk_value_changes() as bulk_item: + bulk_item.append((instance_id, new_values)) + + # --- context change callbacks --- + def publish_attribute_value_changed(self, plugin_name, value): + self.instance_values_changed( + None, { - instance_id: new_values - } + "publish_attributes": { + plugin_name: value, + }, + }, ) - def _on_bulk_finished(self): - # Trigger validation if there is no more context manager for bulk - # instance validation - if self._bulk_counter != 0: + @contextmanager + def _bulk_context(self, key, sender): + bulk_info = self._bulk_info[key] + bulk_info.set_sender(sender) + + bulk_info.increase() + if key not in self._bulk_order: + self._bulk_order.append(key +) + try: + yield bulk_info + finally: + bulk_info.decrease() + if bulk_info: + self._bulk_finished(key) + + def _bulk_finished(self, key): + if self._bulk_order[0] != key: return - ( - self._bulk_instances_to_process, instances_to_validate - ) = ( - [], self._bulk_instances_to_process - ) + self._bulk_finish(key) + self._bulk_order.pop(0) + + for key in tuple(self._bulk_order): + if not self._bulk_info[key]: + return + self._bulk_finish(key) + self._bulk_order.pop(0) + + def _bulk_finish(self, key): + bulk_info = self._bulk_info[key] + sender = bulk_info.get_sender() + data = bulk_info.pop_data() + if key == "collect": + self._bulk_instances_collection(data, sender) + elif key == "change": + self._bulk_values_change(data, sender) + elif key == "create_attrs_change": + self._bulk_create_attrs_change(data, sender) + elif key == "publish_attrs_change": + self._bulk_publish_attrs_change(data, sender) + + def _bulk_instances_collection(self, instances_to_validate, sender): + if not instances_to_validate: + return # Cache folder and task entities for all instances at once self.get_instances_context_info(instances_to_validate) @@ -1019,7 +1095,8 @@ def _on_bulk_finished(self): { "instances": instances_to_validate, "create_context": self, - } + }, + sender, ) for instance in instances_to_validate: @@ -1065,6 +1142,76 @@ def _on_bulk_finished(self): plugin.__name__, attr_defs ) + def _bulk_values_change( + self, + changes: Tuple[Union[str, None], Dict[str, Any]], + sender: Optional[str], + ): + if not changes: + return + event_data = {} + for item_id, item_changes in changes: + item_values = event_data.setdefault(item_id, {}) + if "creator_attributes" in item_changes: + current_value = item_values.setdefault( + "creator_attributes", {} + ) + current_value.update( + item_changes.pop("creator_attributes") + ) + + if "publish_attributes" in item_changes: + current_publish = item_values.setdefault( + "publish_attributes", {} + ) + for plugin_name, plugin_value in item_changes.pop( + "publish_attributes" + ).items(): + plugin_changes = current_publish.setdefault( + plugin_name, {} + ) + plugin_changes.update(plugin_value) + + item_values.update(item_changes) + + self._emit_event( + "values.changed", + event_data, + sender + ) + + def _bulk_create_attrs_change( + self, data: List[str], sender: Optional[str] + ): + if not data: + return + + self._emit_event( + "create.attr.defs.changed", + { + "instance_ids": set(data) + }, + sender, + ) + + def _bulk_publish_attrs_change( + self, + attr_info: Tuple[str, Union[str, None]], + sender: Optional[str], + ): + if not attr_info: + return + event_data = {} + for plugin_name, instance_id in attr_info: + instance_ids = event_data.setdefault(plugin_name, set()) + instance_ids.add(instance_id) + + self._emit_event( + "publish.attr.defs.changed", + event_data, + sender, + ) + def reset_instances(self): """Reload instances""" self._instances_by_id = collections.OrderedDict() @@ -1416,6 +1563,9 @@ def remove_instances(self, instances): error_message.format(identifier, exc_info[1]) ) + except (KeyboardInterrupt, SystemExit): + raise + except: # noqa: E722 failed = True add_traceback = True diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 56995ed60b..79a1e61a55 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -285,7 +285,7 @@ def pop(self, key, default=None): value = self._data[key] if not isinstance(value, AttributeValues): - self._parent.publish_attribute_value_changed(key, None) + self.attribute_value_changed(key, None) return self._data.pop(key) value_item = self._data[key] @@ -293,7 +293,7 @@ def pop(self, key, default=None): output = value_item.data_to_store() # Reset values value_item.reset_values() - self._parent.publish_attribute_value_changed( + self.attribute_value_changed( key, value_item.data_to_store() ) return output diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 9d9d13fda6..a00688a3fc 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -612,12 +612,12 @@ def remove_instances(self, instance_ids: List[str]): self._on_create_instance_change() def set_instances_create_attr_values(self, instance_ids, key, value): - # TODO set bulk change context - 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 + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + 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 def get_creator_attribute_definitions( self, instance_ids: List[str] @@ -658,14 +658,14 @@ def get_creator_attribute_definitions( def set_instances_publish_attr_values( self, instance_ids, plugin_name, key, value ): - # TODO set bulk change context - for instance_id in instance_ids: - if instance_id is None: - instance = self._create_context - else: - instance = self._get_instance_by_id(instance_id) - plugin_val = instance.publish_attributes[plugin_name] - plugin_val[key] = value + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id in instance_ids: + if instance_id is None: + instance = self._create_context + else: + instance = self._get_instance_by_id(instance_id) + plugin_val = instance.publish_attributes[plugin_name] + plugin_val[key] = value def get_publish_attribute_definitions( self, @@ -792,7 +792,9 @@ def _reset_instances(self): """Reset create instances.""" self._create_context.reset_context_data() - with self._create_context.bulk_instances_collection(): + with self._create_context.bulk_instances_collection( + CREATE_EVENT_SOURCE + ): try: self._create_context.reset_instances() except CreatorsOperationFailed as exc: From bad93d35324ab66a70ef3dbcfd23d8c165bfabea Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:40:41 +0200 Subject: [PATCH 154/266] use constants for topics --- client/ayon_core/pipeline/create/context.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 921ff9d2ad..ec859c3743 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -69,6 +69,12 @@ UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) _NOT_SET = object() +INSTANCE_ADDED_TOPIC = "instances.added" +INSTANCE_REMOVED_TOPIC = "instances.removed" +VALUE_CHANGED_TOPIC = "values.changed" +CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed" +PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed" + def prepare_failed_convertor_operation_info(identifier, exc_info): exc_type, exc_value, exc_traceback = exc_info @@ -1091,7 +1097,7 @@ def _bulk_instances_collection(self, instances_to_validate, sender): self.get_instances_context_info(instances_to_validate) self._emit_event( - "instances.added", + INSTANCE_ADDED_TOPIC, { "instances": instances_to_validate, "create_context": self, @@ -1175,7 +1181,7 @@ def _bulk_values_change( item_values.update(item_changes) self._emit_event( - "values.changed", + VALUE_CHANGED_TOPIC, event_data, sender ) @@ -1187,7 +1193,7 @@ def _bulk_create_attrs_change( return self._emit_event( - "create.attr.defs.changed", + CREATE_ATTR_DEFS_CHANGED_TOPIC, { "instance_ids": set(data) }, @@ -1207,7 +1213,7 @@ def _bulk_publish_attrs_change( instance_ids.add(instance_id) self._emit_event( - "publish.attr.defs.changed", + PUBLISH_ATTR_DEFS_CHANGED_TOPIC, event_data, sender, ) @@ -1519,11 +1525,11 @@ def remove_instances(self, instances): creator are just removed from context. Args: - instances(List[CreatedInstance]): Instances that should be removed. + instances (List[CreatedInstance]): Instances that should be removed. Remove logic is done using creator, which may require to do other cleanup than just remove instance from context. - """ + """ instances_by_identifier = collections.defaultdict(list) for instance in instances: identifier = instance.creator_identifier @@ -1675,7 +1681,7 @@ def _remove_instances(self, instances): return self._emit_event( - "instances.removed", + INSTANCE_REMOVED_TOPIC, { "instances": removed_instances, "create_context": self, From 813afc164fd99f09fe7e9267f539238c6d03c127 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:41:06 +0200 Subject: [PATCH 155/266] allow to pass sender to some methods --- client/ayon_core/pipeline/create/context.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index ec859c3743..fc2218dee6 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1518,7 +1518,7 @@ def _save_instance_changes(self): if failed_info: raise CreatorsSaveFailed(failed_info) - def remove_instances(self, instances): + def remove_instances(self, instances, sender=None): """Remove instances from context. All instances that don't have creator identifier leading to existing @@ -1528,6 +1528,7 @@ def remove_instances(self, instances): instances (List[CreatedInstance]): Instances that should be removed. Remove logic is done using creator, which may require to do other cleanup than just remove instance from context. + sender (Optional[str]): Sender of the event. """ instances_by_identifier = collections.defaultdict(list) @@ -1544,7 +1545,7 @@ def remove_instances(self, instances): for instance in instances_by_identifier[identifier] ) - self._remove_instances(instances) + self._remove_instances(instances, sender) error_message = "Instances removement of creator \"{}\" failed. {}" failed_info = [] @@ -1670,7 +1671,7 @@ def _emit_event( data.setdefault("create_context", self) return self._event_hub.emit(topic, data, sender) - def _remove_instances(self, instances): + def _remove_instances(self, instances, sender=None): removed_instances = [] for instance in instances: obj = self._instances_by_id.pop(instance.id, None) @@ -1685,7 +1686,8 @@ def _remove_instances(self, instances): { "instances": removed_instances, "create_context": self, - } + }, + sender, ) def _create_with_unified_error( From f19c2d46c033e385d35e358a25f3ae3b0a4810bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Sep 2024 12:25:45 +0200 Subject: [PATCH 156/266] added helper methods to register listening to events --- client/ayon_core/pipeline/create/context.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index fc2218dee6..776edee30e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -783,6 +783,23 @@ def reset_context_data(self): plugin.__name__, attr_defs ) + def listen_to_added_instances(self, callback): + self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) + + def listen_to_removed_instances(self, callback): + self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) + + def listen_to_value_changes(self, callback): + self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) + + def listen_to_create_attr_defs_change(self, callback): + self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) + + def listen_to_publish_attr_defs_change(self, callback): + self._event_hub.add_callback( + PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback + ) + def context_data_to_store(self): """Data that should be stored by host function. From fd575a41809087d29f906c1b9c55745bd9fc76be Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:06:28 +0200 Subject: [PATCH 157/266] refresh instances based on create context events --- .../tools/publisher/models/create.py | 88 ++++++++++++++++--- .../publisher/widgets/overview_widget.py | 29 ++++-- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index a00688a3fc..08fd984721 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -362,6 +362,25 @@ def reset(self): self._creator_items = None self._reset_instances() + + self._emit_event("create.model.reset") + + self._create_context.listen_to_added_instances( + self._cc_added_instance + ) + self._create_context.listen_to_removed_instances( + self._cc_removed_instance + ) + self._create_context.listen_to_value_changes( + self._cc_value_changed + ) + self._create_context.listen_to_create_attr_defs_change( + self._cc_create_attr_changed + ) + self._create_context.listen_to_publish_attr_defs_change( + self._cc_publish_attr_changed + ) + self._create_context.reset_finalization() def get_creator_items(self) -> Dict[str, CreatorItem]: @@ -521,7 +540,6 @@ def create( } ) - self._on_create_instance_change() return success def trigger_convertor_items(self, convertor_identifiers: List[str]): @@ -609,8 +627,6 @@ def remove_instances(self, instance_ids: List[str]): # is not required. self._remove_instances_from_context(instance_ids) - self._on_create_instance_change() - def set_instances_create_attr_values(self, instance_ids, key, value): with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): for instance_id in instance_ids: @@ -792,9 +808,7 @@ def _reset_instances(self): """Reset create instances.""" self._create_context.reset_context_data() - with self._create_context.bulk_instances_collection( - CREATE_EVENT_SOURCE - ): + with self._create_context.bulk_instances_collection(): try: self._create_context.reset_instances() except CreatorsOperationFailed as exc: @@ -829,8 +843,6 @@ def _reset_instances(self): } ) - self._on_create_instance_change() - def _remove_instances_from_context(self, instance_ids: List[str]): instances_by_id = self._create_context.instances_by_id instances = [ @@ -848,9 +860,6 @@ def _remove_instances_from_context(self, instance_ids: List[str]): } ) - def _on_create_instance_change(self): - self._emit_event("instances.refresh.finished") - def _collect_creator_items(self) -> Dict[str, CreatorItem]: # TODO add crashed initialization of create plugins to report output = {} @@ -872,6 +881,63 @@ def _collect_creator_items(self) -> Dict[str, CreatorItem]: return output + def _cc_added_instance(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.added.instance", + {"instance_ids": instance_ids}, + ) + + def _cc_removed_instance(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.removed.instance", + {"instance_ids": instance_ids}, + ) + + def _cc_value_changed(self, event): + if event.source != CREATE_EVENT_SOURCE: + return + + instance_ids = { + item["instance"].id + for item in event.data["changes"] + } + self._emit_event( + "create.context.value.changed", + {"instance_ids": instance_ids}, + ) + + def _cc_create_attr_changed(self, event): + if event.source != CREATE_EVENT_SOURCE: + return + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.create.attrs.changed", + {"instance_ids": instance_ids}, + ) + + def _cc_publish_attr_changed(self, event): + if event.source != CREATE_EVENT_SOURCE: + return + event_data = { + instance_id: instance_data["plugin_names"] + for instance_id, instance_data in event.data.items() + } + self._emit_event( + "create.context.publish.attrs.changed", + event_data, + ) + def _get_allowed_creators_pattern(self) -> Union[Pattern, None]: """Provide regex pattern for configured creator labels in this context diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index d00edb9883..8ddd9bad7b 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -152,7 +152,16 @@ def __init__( "publish.reset.finished", self._on_publish_reset ) 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._on_instances_added + ) + controller.register_event_callback( + "create.context.removed.instance", + self._on_instances_removed ) self._product_content_widget = product_content_widget @@ -436,6 +445,12 @@ def _refresh_instances(self): # Force to change instance and refresh details self._on_product_change() + # Give a change to process Resize Request + QtWidgets.QApplication.processEvents() + # Trigger update geometry of + widget = self._product_views_layout.currentWidget() + widget.updateGeometry() + def _on_publish_start(self): """Publish started.""" @@ -461,13 +476,11 @@ def _on_publish_reset(self): self._controller.is_host_valid() ) - def _on_instances_refresh(self): - """Controller refreshed instances.""" + def _on_create_model_reset(self): + self._refresh_instances() + def _on_instances_added(self): self._refresh_instances() - # Give a change to process Resize Request - QtWidgets.QApplication.processEvents() - # Trigger update geometry of - widget = self._product_views_layout.currentWidget() - widget.updateGeometry() + def _on_instances_removed(self): + self._refresh_instances() From db0bc46a054240570989dfc763275bd219e2a8d3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 26 Sep 2024 17:05:17 +0200 Subject: [PATCH 158/266] modified event data and avoid unnecesasry triggers --- client/ayon_core/pipeline/create/context.py | 185 +++++++++++------- .../tools/publisher/models/create.py | 22 +-- 2 files changed, 127 insertions(+), 80 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 776edee30e..a6c42ef70a 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -132,6 +132,10 @@ def decrease(self): def append(self, item): self._data.append(item) + def get_data(self): + """Use this method for read-only.""" + return self._data + def pop_data(self): data = self._data self._data = [] @@ -898,8 +902,8 @@ def create( Raises: CreatorError: If creator was not found or folder is empty. - """ + """ creator = self._get_creator_in_create(creator_identifier) project_name = self.project_name @@ -965,11 +969,12 @@ def create( active = bool(active) instance_data["active"] = active - return creator.create( - product_name, - instance_data, - _pre_create_data - ) + with self.bulk_instances_collection(): + return creator.create( + product_name, + instance_data, + _pre_create_data + ) def create_with_unified_error(self, identifier, *args, **kwargs): """Trigger create but raise only one error if anything fails. @@ -986,8 +991,8 @@ def create_with_unified_error(self, identifier, *args, **kwargs): CreatorsCreateFailed: When creation fails due to any possible reason. If anything goes wrong this is only possible exception the method should raise. - """ + """ result, fail_info = self._create_with_unified_error( identifier, None, *args, **kwargs ) @@ -1021,6 +1026,50 @@ def bulk_instances_collection(self, sender=None): with self._bulk_context("collect", sender) as bulk_info: yield bulk_info + # Set publish attributes before bulk context is exited + for instance in bulk_info.get_data(): + publish_attributes = instance.publish_attributes + # Prepare publish plugin attributes and set it on instance + for plugin in self.plugins_with_defs: + try: + if is_func_signature_supported( + plugin.convert_attribute_values, self, instance + ): + plugin.convert_attribute_values(self, instance) + + elif plugin.__instanceEnabled__: + output = plugin.convert_attribute_values( + publish_attributes + ) + if output: + publish_attributes.update(output) + + except Exception: + self.log.error( + "Failed to convert attribute values of" + f" plugin '{plugin.__name__}'", + exc_info=True + ) + + for plugin in self.plugins_with_defs: + attr_defs = None + try: + attr_defs = plugin.get_attribute_defs_for_instance( + self, instance + ) + except Exception: + self.log.error( + "Failed to get attribute definitions" + f" from plugin '{plugin.__name__}'.", + exc_info=True + ) + + if not attr_defs: + continue + instance.set_publish_plugin_attr_defs( + plugin.__name__, attr_defs + ) + @contextmanager def bulk_value_changes(self, sender=None): with self._bulk_context("change", sender) as bulk_info: @@ -1037,21 +1086,38 @@ def bulk_publish_attr_defs_change(self, sender=None): yield bulk_info # --- instance change callbacks --- + def _is_instance_events_ready(self, instance_id): + # Context is ready + if instance_id is None: + return True + # Instance is not in yet in context + if instance_id not in self._instances_by_id: + return False + + # Instance in 'collect' bulk will be ignored + for instance in self._bulk_info["collect"].get_data(): + if instance.id == instance_id: + return False + return True + def instance_create_attr_defs_changed(self, instance_id): - with self.bulk_create_attr_defs_change() as bulk_item: - bulk_item.append(instance_id) + if self._is_instance_events_ready(instance_id): + with self.bulk_create_attr_defs_change() as bulk_item: + bulk_item.append(instance_id) def instance_publish_attr_defs_changed( self, instance_id, plugin_name ): - with self.bulk_publish_attr_defs_change() as bulk_item: - bulk_item.append((instance_id, plugin_name)) + if self._is_instance_events_ready(instance_id): + with self.bulk_publish_attr_defs_change() as bulk_item: + bulk_item.append((instance_id, plugin_name)) def instance_values_changed( self, instance_id, new_values ): - with self.bulk_value_changes() as bulk_item: - bulk_item.append((instance_id, new_values)) + if self._is_instance_events_ready(instance_id): + with self.bulk_value_changes() as bulk_item: + bulk_item.append((instance_id, new_values)) # --- context change callbacks --- def publish_attribute_value_changed(self, plugin_name, value): @@ -1117,54 +1183,10 @@ def _bulk_instances_collection(self, instances_to_validate, sender): INSTANCE_ADDED_TOPIC, { "instances": instances_to_validate, - "create_context": self, }, sender, ) - for instance in instances_to_validate: - publish_attributes = instance.publish_attributes - # Prepare publish plugin attributes and set it on instance - for plugin in self.plugins_with_defs: - try: - if is_func_signature_supported( - plugin.convert_attribute_values, self, instance - ): - plugin.convert_attribute_values(self, instance) - - elif plugin.__instanceEnabled__: - output = plugin.convert_attribute_values( - publish_attributes - ) - if output: - publish_attributes.update(output) - - except Exception: - self.log.error( - "Failed to convert attribute values of" - f" plugin '{plugin.__name__}'", - exc_info=True - ) - - for plugin in self.plugins_with_defs: - attr_defs = None - try: - attr_defs = plugin.get_attribute_defs_for_instance( - self, instance - ) - except Exception: - self.log.error( - "Failed to get attribute definitions" - f" from plugin '{plugin.__name__}'.", - exc_info=True - ) - - if not attr_defs: - continue - instance.set_publish_plugin_attr_defs( - plugin.__name__, attr_defs - ) - def _bulk_values_change( self, changes: Tuple[Union[str, None], Dict[str, Any]], @@ -1172,9 +1194,9 @@ def _bulk_values_change( ): if not changes: return - event_data = {} + item_data_by_id = {} for item_id, item_changes in changes: - item_values = event_data.setdefault(item_id, {}) + item_values = item_data_by_id.setdefault(item_id, {}) if "creator_attributes" in item_changes: current_value = item_values.setdefault( "creator_attributes", {} @@ -1197,6 +1219,18 @@ def _bulk_values_change( item_values.update(item_changes) + event_changes = [] + for item_id, item_changes in item_data_by_id.items(): + instance = self.get_instance_by_id(item_id) + event_changes.append({ + "instance": instance, + "changes": item_changes, + }) + + event_data = { + "changes": event_changes, + } + self._emit_event( VALUE_CHANGED_TOPIC, event_data, @@ -1204,15 +1238,19 @@ def _bulk_values_change( ) def _bulk_create_attrs_change( - self, data: List[str], sender: Optional[str] + self, instance_ids: List[str], sender: Optional[str] ): - if not data: + if not instance_ids: return + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] self._emit_event( CREATE_ATTR_DEFS_CHANGED_TOPIC, { - "instance_ids": set(data) + "instances": instances, }, sender, ) @@ -1224,14 +1262,23 @@ def _bulk_publish_attrs_change( ): if not attr_info: return - event_data = {} - for plugin_name, instance_id in attr_info: - instance_ids = event_data.setdefault(plugin_name, set()) - instance_ids.add(instance_id) + + instance_changes = {} + for instance_id, plugin_name in attr_info: + instance_data = instance_changes.setdefault( + instance_id, + { + "instance": None, + "plugin_names": set(), + } + ) + instance = self.get_instance_by_id(instance_id) + instance_data["instance"] = instance + instance_data["plugin_names"].add(plugin_name) self._emit_event( PUBLISH_ATTR_DEFS_CHANGED_TOPIC, - event_data, + {"instance_changes": instance_changes}, sender, ) @@ -1702,7 +1749,6 @@ def _remove_instances(self, instances, sender=None): INSTANCE_REMOVED_TOPIC, { "instances": removed_instances, - "create_context": self, }, sender, ) @@ -1726,7 +1772,8 @@ def _create_with_unified_error( label = getattr(creator, "label", label) # Run create - result = creator.create(*args, **kwargs) + with self.bulk_instances_collection(): + result = creator.create(*args, **kwargs) success = True except CreatorError: diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 08fd984721..f7e8aaa503 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -902,21 +902,22 @@ def _cc_removed_instance(self, event): ) def _cc_value_changed(self, event): - if event.source != CREATE_EVENT_SOURCE: + if event.source == CREATE_EVENT_SOURCE: return - instance_ids = { - item["instance"].id - for item in event.data["changes"] - } + instance_changes = {} + for item in event.data["changes"]: + instance_id = None + if item["instance"]: + instance_id = item["instance"].id + instance_changes[instance_id] = item["changes"] + self._emit_event( "create.context.value.changed", - {"instance_ids": instance_ids}, + {"instance_changes": instance_changes}, ) def _cc_create_attr_changed(self, event): - if event.source != CREATE_EVENT_SOURCE: - return instance_ids = { instance.id for instance in event.data["instances"] @@ -927,11 +928,10 @@ def _cc_create_attr_changed(self, event): ) def _cc_publish_attr_changed(self, event): - if event.source != CREATE_EVENT_SOURCE: - return + instance_changes = event.data["instance_changes"] event_data = { instance_id: instance_data["plugin_names"] - for instance_id, instance_data in event.data.items() + for instance_id, instance_data in instance_changes.items() } self._emit_event( "create.context.publish.attrs.changed", From 8a3cbb5f94069a8eabc2caac5027c55c48e45421 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:21:42 +0200 Subject: [PATCH 159/266] create plugin can register callbacks --- client/ayon_core/pipeline/create/creator_plugins.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 66725e7026..8d7ede1fa6 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -205,6 +205,7 @@ def __init__( self.headless = headless self.apply_settings(project_settings) + self.register_callbacks() @staticmethod def _get_settings_values(project_settings, category_name, plugin_name): @@ -290,6 +291,14 @@ def apply_settings(self, project_settings): )) setattr(self, key, value) + def register_callbacks(self): + """Register callbacks for creator. + + Default implementation does nothing. It can be overridden to register + callbacks for creator. + """ + pass + @property def identifier(self): """Identifier of creator (must be unique). From d382343c12ff12efa06312b6f0fabeab5d2d3ae0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:22:53 +0200 Subject: [PATCH 160/266] update attr defs --- .../tools/publisher/widgets/widgets.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 429f7e0472..9fd687faf2 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1386,6 +1386,11 @@ def __init__( main_layout.setSpacing(0) main_layout.addWidget(scroll_area, 1) + controller.register_event_callback( + "create.context.create.attrs.changed", + self._on_instance_attr_defs_change + ) + self._main_layout = main_layout self._controller: AbstractPublisherFrontend = controller @@ -1393,6 +1398,7 @@ def __init__( self._attr_def_id_to_instances = {} self._attr_def_id_to_attr_def = {} + self._current_instance_ids = set() # To store content of scroll area to prevent garbage collection self._content_widget = None @@ -1409,6 +1415,7 @@ def set_instances_valid(self, valid): def set_current_instances(self, instance_ids): """Set current instances for which are attribute definitions shown.""" + self._current_instance_ids = set(instance_ids) prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() @@ -1482,6 +1489,16 @@ def set_current_instances(self, instance_ids): self._scroll_area.setWidget(content_widget) self._content_widget = content_widget + def _on_instance_attr_defs_change(self, event): + update = False + for instance_id in event.data["instance_ids"]: + if instance_id in self._current_instance_ids: + update = True + break + + if update: + self.set_current_instances(self._current_instance_ids) + def _input_value_changed(self, value, attr_id): instance_ids = self._attr_def_id_to_instances.get(attr_id) attr_def = self._attr_def_id_to_attr_def.get(attr_id) From e2ad2ce9c52e7cf72ca1f7724654c959ab79fd3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:31:04 +0200 Subject: [PATCH 161/266] autoconvert values --- client/ayon_core/pipeline/create/structures.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 79a1e61a55..9a89cb2e4d 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -113,7 +113,10 @@ def __init__(self, parent, key, attr_defs, values, origin_data=None): self._data = {} for attr_def in attr_defs: value = values.get(attr_def.key) - if value is not None: + if value is None: + continue + converted_value = attr_def.convert_value(value) + if converted_value == value: self._data[attr_def.key] = value def __setitem__(self, key, value): @@ -339,13 +342,6 @@ def set_publish_plugin_attr_defs( if value is None: value = {} - for attr_def in attr_defs: - if isinstance(attr_def, (UIDef, UnknownDef)): - continue - key = attr_def.key - if key in value: - value[key] = attr_def.convert_value(value[key]) - self._data[plugin_name] = PublishAttributeValues( self, plugin_name, attr_defs, value, value ) From 354bcf3122a194d458b523279cf37610199fddf7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 30 Sep 2024 18:31:16 +0200 Subject: [PATCH 162/266] fix 'update_create_attr_defs' --- client/ayon_core/pipeline/create/structures.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 9a89cb2e4d..2bbd6dabc5 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -735,7 +735,7 @@ def data_to_store(self): return output def update_create_attr_defs(self, attr_defs, value=None): - if value is not None: + if value is None: value = self._data["creator_attributes"] if isinstance(value, AttributeValues): @@ -744,7 +744,7 @@ def update_create_attr_defs(self, attr_defs, value=None): if isinstance(self._data["creator_attributes"], AttributeValues): origin_data = self._data["creator_attributes"].origin_data else: - origin_data = self._data["creator_attributes"] + origin_data = copy.deepcopy(self._data["creator_attributes"]) self._data["creator_attributes"] = CreatorAttributeValues( self, "creator_attributes", From c8eb232461b3090acd6e3fb90f90b8225937b56d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:20:13 +0200 Subject: [PATCH 163/266] remove product widgets into more files --- .../publisher/widgets/overview_widget.py | 4 +- .../publisher/widgets/product_attributes.py | 325 ++++ .../publisher/widgets/product_context.py | 881 ++++++++++ .../tools/publisher/widgets/product_info.py | 284 ++++ .../tools/publisher/widgets/widgets.py | 1466 +---------------- 5 files changed, 1493 insertions(+), 1467 deletions(-) create mode 100644 client/ayon_core/tools/publisher/widgets/product_attributes.py create mode 100644 client/ayon_core/tools/publisher/widgets/product_context.py create mode 100644 client/ayon_core/tools/publisher/widgets/product_info.py diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 8ddd9bad7b..beefa1ca98 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -6,12 +6,12 @@ from .card_view_widgets import InstanceCardView from .list_view_widgets import InstanceListView from .widgets import ( - ProductAttributesWidget, CreateInstanceBtn, RemoveInstanceBtn, ChangeViewBtn, ) from .create_widget import CreateWidget +from .product_info import ProductInfoWidget class OverviewWidget(QtWidgets.QFrame): @@ -61,7 +61,7 @@ def __init__( product_attributes_wrap = BorderedLabelWidget( "Publish options", product_content_widget ) - product_attributes_widget = ProductAttributesWidget( + product_attributes_widget = ProductInfoWidget( controller, product_attributes_wrap ) product_attributes_wrap.set_center_widget(product_attributes_widget) diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py new file mode 100644 index 0000000000..a696907a72 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -0,0 +1,325 @@ +from qtpy import QtWidgets, QtCore + +from ayon_core.lib.attribute_definitions import UnknownDef +from ayon_core.tools.attribute_defs import create_widget_for_attr_def +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.constants import ( + INPUTS_LAYOUT_HSPACING, + INPUTS_LAYOUT_VSPACING, +) + + +class CreatorAttrsWidget(QtWidgets.QWidget): + """Widget showing creator specific attributes for selected instances. + + Attributes are defined on creator so are dynamic. Their look and type is + based on attribute definitions that are defined in + `~/ayon_core/lib/attribute_definitions.py` and their widget + representation in `~/ayon_core/tools/attribute_defs/*`. + + Widgets are disabled if context of instance is not valid. + + Definitions are shown for all instance no matter if they are created with + different creators. If creator have same (similar) definitions their + widgets are merged into one (different label does not count). + """ + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(scroll_area, 1) + + controller.register_event_callback( + "create.context.create.attrs.changed", + self._on_instance_attr_defs_change + ) + + self._main_layout = main_layout + + self._controller: AbstractPublisherFrontend = controller + self._scroll_area = scroll_area + + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + self._current_instance_ids = set() + + # To store content of scroll area to prevent garbage collection + self._content_widget = None + + def set_instances_valid(self, valid): + """Change valid state of current instances.""" + + if ( + self._content_widget is not None + and self._content_widget.isEnabled() != valid + ): + self._content_widget.setEnabled(valid) + + def set_current_instances(self, instance_ids): + """Set current instances for which are attribute definitions shown.""" + + self._current_instance_ids = set(instance_ids) + prev_content_widget = self._scroll_area.widget() + if prev_content_widget: + self._scroll_area.takeWidget() + prev_content_widget.hide() + prev_content_widget.deleteLater() + + self._content_widget = None + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + + result = self._controller.get_creator_attribute_definitions( + instance_ids + ) + + content_widget = QtWidgets.QWidget(self._scroll_area) + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + content_layout.setAlignment(QtCore.Qt.AlignTop) + content_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) + content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) + + row = 0 + for attr_def, instance_ids, values in result: + widget = create_widget_for_attr_def(attr_def, content_widget) + if attr_def.is_value_def: + if len(values) == 1: + value = values[0] + if value is not None: + widget.set_value(values[0]) + else: + widget.set_value(values, True) + + widget.value_changed.connect(self._input_value_changed) + 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: + continue + + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + + label = None + if attr_def.is_value_def: + label = attr_def.label or attr_def.key + if label: + label_widget = QtWidgets.QLabel(label, self) + tooltip = attr_def.tooltip + if tooltip: + label_widget.setToolTip(tooltip) + if attr_def.is_label_horizontal: + label_widget.setAlignment( + QtCore.Qt.AlignRight + | QtCore.Qt.AlignVCenter + ) + content_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + content_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + row += 1 + + self._scroll_area.setWidget(content_widget) + self._content_widget = content_widget + + def _on_instance_attr_defs_change(self, event): + update = False + for instance_id in event.data["instance_ids"]: + if instance_id in self._current_instance_ids: + update = True + break + + if update: + self.set_current_instances(self._current_instance_ids) + + def _input_value_changed(self, value, attr_id): + instance_ids = self._attr_def_id_to_instances.get(attr_id) + attr_def = self._attr_def_id_to_attr_def.get(attr_id) + if not instance_ids or not attr_def: + return + self._controller.set_instances_create_attr_values( + instance_ids, attr_def.key, value + ) + + +class PublishPluginAttrsWidget(QtWidgets.QWidget): + """Widget showing publsish plugin attributes for selected instances. + + Attributes are defined on publish plugins. Publihs plugin may define + attribute definitions but must inherit `AYONPyblishPluginMixin` + (~/ayon_core/pipeline/publish). At the moment requires to implement + `get_attribute_defs` and `convert_attribute_values` class methods. + + Look and type of attributes is based on attribute definitions that are + defined in `~/ayon_core/lib/attribute_definitions.py` and their + widget representation in `~/ayon_core/tools/attribute_defs/*`. + + Widgets are disabled if context of instance is not valid. + + Definitions are shown for all instance no matter if they have different + product types. Similar definitions are merged into one (different label + does not count). + """ + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(scroll_area, 1) + + self._main_layout = main_layout + + self._controller: AbstractPublisherFrontend = controller + self._scroll_area = scroll_area + + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + self._attr_def_id_to_plugin_name = {} + + # Store content of scroll area to prevent garbage collection + self._content_widget = None + + def set_instances_valid(self, valid): + """Change valid state of current instances.""" + if ( + self._content_widget is not None + and self._content_widget.isEnabled() != valid + ): + self._content_widget.setEnabled(valid) + + def set_current_instances(self, instance_ids, context_selected): + """Set current instances for which are attribute definitions shown.""" + + prev_content_widget = self._scroll_area.widget() + if prev_content_widget: + self._scroll_area.takeWidget() + prev_content_widget.hide() + prev_content_widget.deleteLater() + + self._content_widget = None + + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + self._attr_def_id_to_plugin_name = {} + + result = self._controller.get_publish_attribute_definitions( + instance_ids, context_selected + ) + + content_widget = QtWidgets.QWidget(self._scroll_area) + attr_def_widget = QtWidgets.QWidget(content_widget) + attr_def_layout = QtWidgets.QGridLayout(attr_def_widget) + attr_def_layout.setColumnStretch(0, 0) + attr_def_layout.setColumnStretch(1, 1) + attr_def_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) + attr_def_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.addWidget(attr_def_widget, 0) + content_layout.addStretch(1) + + row = 0 + for plugin_name, attr_defs, all_plugin_values in result: + plugin_values = all_plugin_values[plugin_name] + + for attr_def in attr_defs: + widget = create_widget_for_attr_def( + attr_def, content_widget + ) + hidden_widget = attr_def.hidden + # Hide unknown values of publish plugins + # - The keys in most of cases does not represent what would + # label represent + if isinstance(attr_def, UnknownDef): + widget.setVisible(False) + hidden_widget = True + + if not hidden_widget: + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + label = None + if attr_def.is_value_def: + label = attr_def.label or attr_def.key + if label: + label_widget = QtWidgets.QLabel(label, content_widget) + tooltip = attr_def.tooltip + if tooltip: + label_widget.setToolTip(tooltip) + if attr_def.is_label_horizontal: + label_widget.setAlignment( + QtCore.Qt.AlignRight + | QtCore.Qt.AlignVCenter + ) + attr_def_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + attr_def_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + row += 1 + + if not attr_def.is_value_def: + continue + + widget.value_changed.connect(self._input_value_changed) + + attr_values = plugin_values[attr_def.key] + multivalue = len(attr_values) > 1 + values = [] + instances = [] + for instance, value in attr_values: + values.append(value) + instances.append(instance) + + self._attr_def_id_to_attr_def[attr_def.id] = attr_def + self._attr_def_id_to_instances[attr_def.id] = instances + self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name + + if multivalue: + widget.set_value(values, multivalue) + else: + widget.set_value(values[0]) + + self._scroll_area.setWidget(content_widget) + self._content_widget = content_widget + + def _input_value_changed(self, value, attr_id): + instance_ids = self._attr_def_id_to_instances.get(attr_id) + attr_def = self._attr_def_id_to_attr_def.get(attr_id) + plugin_name = self._attr_def_id_to_plugin_name.get(attr_id) + if not instance_ids or not attr_def or not plugin_name: + return + + self._controller.set_instances_publish_attr_values( + instance_ids, plugin_name, attr_def.key, value + ) diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py new file mode 100644 index 0000000000..b66d67717c --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -0,0 +1,881 @@ +import re +import copy +import collections + +from qtpy import QtWidgets, QtCore, QtGui +import qtawesome + +from ayon_core.pipeline.create import ( + PRODUCT_NAME_ALLOWED_SYMBOLS, + TaskNotSetError, +) +from ayon_core.tools.utils import ( + PlaceholderLineEdit, + BaseClickableFrame, + set_style_property, +) +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.constants import ( + VARIANT_TOOLTIP, + INPUTS_LAYOUT_HSPACING, + INPUTS_LAYOUT_VSPACING, +) + +from .folders_dialog import FoldersDialog +from .tasks_model import TasksModel +from .widgets import ClickableLineEdit, MultipleItemWidget + + +class FoldersFields(BaseClickableFrame): + """Field where folder path of selected instance/s is showed. + + Click on the field will trigger `FoldersDialog`. + """ + value_changed = QtCore.Signal() + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + self.setObjectName("FolderPathInputWidget") + + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = FoldersDialog(controller, parent) + + name_input = ClickableLineEdit(self) + name_input.setObjectName("FolderPathInput") + + icon_name = "fa.window-maximize" + icon = qtawesome.icon(icon_name, color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("FolderPathInputButton") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + + # Make sure all widgets are vertically extended to highest widget + for widget in ( + name_input, + icon_btn + ): + size_policy = widget.sizePolicy() + size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(size_policy) + name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) + dialog.finished.connect(self._on_dialog_finish) + + self._controller: AbstractPublisherFrontend = controller + self._dialog = dialog + self._name_input = name_input + self._icon_btn = icon_btn + + self._origin_value = [] + self._origin_selection = [] + self._selected_items = [] + self._has_value_changed = False + self._is_valid = True + self._multiselection_text = None + + def _on_dialog_finish(self, result): + if not result: + return + + folder_path = self._dialog.get_selected_folder_path() + if folder_path is None: + return + + self._selected_items = [folder_path] + self._has_value_changed = ( + self._origin_value != self._selected_items + ) + self.set_text(folder_path) + self._set_is_valid(True) + + self.value_changed.emit() + + def _mouse_release_callback(self): + self._dialog.set_selected_folders(self._selected_items) + self._dialog.open() + + def set_multiselection_text(self, text): + """Change text for multiselection of different folders. + + When there are selected multiple instances at once and they don't have + same folder in context. + """ + self._multiselection_text = text + + def _set_is_valid(self, valid): + if valid == self._is_valid: + return + self._is_valid = valid + state = "" + if not valid: + state = "invalid" + self._set_state_property(state) + + def _set_state_property(self, state): + set_style_property(self, "state", state) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) + + def is_valid(self): + """Is folder valid.""" + return self._is_valid + + def has_value_changed(self): + """Value of folder has changed.""" + return self._has_value_changed + + def get_selected_items(self): + """Selected folder paths.""" + return list(self._selected_items) + + def set_text(self, text): + """Set text in text field. + + Does not change selected items (folders). + """ + self._name_input.setText(text) + self._name_input.end(False) + + def set_selected_items(self, folder_paths=None): + """Set folder paths for selection of instances. + + Passed folder paths are validated and if there are 2 or more different + folder paths then multiselection text is shown. + + Args: + folder_paths (list, tuple, set, NoneType): List of folder paths. + + """ + if folder_paths is None: + folder_paths = [] + + self._has_value_changed = False + self._origin_value = list(folder_paths) + self._selected_items = list(folder_paths) + is_valid = self._controller.are_folder_paths_valid(folder_paths) + if not folder_paths: + self.set_text("") + + elif len(folder_paths) == 1: + folder_path = tuple(folder_paths)[0] + self.set_text(folder_path) + else: + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(folder_paths) + self.set_text(multiselection_text) + + self._set_is_valid(is_valid) + + def reset_to_origin(self): + """Change to folder paths set with last `set_selected_items` call.""" + self.set_selected_items(self._origin_value) + + def confirm_value(self): + self._origin_value = copy.deepcopy(self._selected_items) + self._has_value_changed = False + + +class TasksComboboxProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._filter_empty = False + + def set_filter_empty(self, filter_empty): + if self._filter_empty is filter_empty: + return + self._filter_empty = filter_empty + self.invalidate() + + def filterAcceptsRow(self, source_row, parent_index): + if self._filter_empty: + model = self.sourceModel() + source_index = model.index( + source_row, self.filterKeyColumn(), parent_index + ) + if not source_index.data(QtCore.Qt.DisplayRole): + return False + return True + + +class TasksCombobox(QtWidgets.QComboBox): + """Combobox to show tasks for selected instances. + + Combobox gives ability to select only from intersection of task names for + folder paths in selected instances. + + If folder paths in selected instances does not have same tasks then combobox + will be empty. + """ + value_changed = QtCore.Signal() + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + self.setObjectName("TasksCombobox") + + # Set empty delegate to propagate stylesheet to a combobox + delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(delegate) + + model = TasksModel(controller, True) + proxy_model = TasksComboboxProxy() + proxy_model.setSourceModel(model) + self.setModel(proxy_model) + + self.currentIndexChanged.connect(self._on_index_change) + + self._delegate = delegate + self._model = model + self._proxy_model = proxy_model + self._origin_value = [] + self._origin_selection = [] + self._selected_items = [] + self._has_value_changed = False + self._ignore_index_change = False + self._multiselection_text = None + self._is_valid = True + + self._text = None + + # Make sure combobox is extended horizontally + size_policy = self.sizePolicy() + size_policy.setHorizontalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + self.setSizePolicy(size_policy) + + def set_invalid_empty_task(self, invalid=True): + self._proxy_model.set_filter_empty(invalid) + if invalid: + self._set_is_valid(False) + self.set_text( + "< One or more products require Task selected >" + ) + else: + self.set_text(None) + + def set_multiselection_text(self, text): + """Change text shown when multiple different tasks are in context.""" + self._multiselection_text = text + + def _on_index_change(self): + if self._ignore_index_change: + return + + self.set_text(None) + text = self.currentText() + idx = self.findText(text) + if idx < 0: + return + + self._set_is_valid(True) + self._selected_items = [text] + self._has_value_changed = ( + self._origin_selection != self._selected_items + ) + + self.value_changed.emit() + + def set_text(self, text): + """Set context shown in combobox without changing selected items.""" + if text == self._text: + return + + self._text = text + self.repaint() + + def paintEvent(self, event): + """Paint custom text without using QLineEdit. + + The easiest way how to draw custom text in combobox and keep combobox + properties and event handling. + """ + painter = QtGui.QPainter(self) + painter.setPen(self.palette().color(QtGui.QPalette.Text)) + opt = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(opt) + if self._text is not None: + opt.currentText = self._text + + style = self.style() + style.drawComplexControl( + QtWidgets.QStyle.CC_ComboBox, opt, painter, self + ) + style.drawControl( + QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self + ) + painter.end() + + def is_valid(self): + """Are all selected items valid.""" + return self._is_valid + + def has_value_changed(self): + """Did selection of task changed.""" + return self._has_value_changed + + def _set_is_valid(self, valid): + if valid == self._is_valid: + return + self._is_valid = valid + state = "" + if not valid: + state = "invalid" + self._set_state_property(state) + + def _set_state_property(self, state): + current_value = self.property("state") + if current_value != state: + self.setProperty("state", state) + self.style().polish(self) + + def get_selected_items(self): + """Get selected tasks. + + If value has changed then will return list with single item. + + Returns: + list: Selected tasks. + """ + return list(self._selected_items) + + def set_folder_paths(self, folder_paths): + """Set folder paths for which should show tasks.""" + self._ignore_index_change = True + + self._model.set_folder_paths(folder_paths) + self._proxy_model.set_filter_empty(False) + self._proxy_model.sort(0) + + self._ignore_index_change = False + + # It is a bug if not exactly one folder got here + if len(folder_paths) != 1: + self.set_selected_item("") + self._set_is_valid(False) + return + + folder_path = tuple(folder_paths)[0] + + is_valid = False + if self._selected_items: + is_valid = True + + valid_task_names = [] + for task_name in self._selected_items: + _is_valid = self._model.is_task_name_valid(folder_path, task_name) + if _is_valid: + valid_task_names.append(task_name) + else: + is_valid = _is_valid + + self._selected_items = valid_task_names + if len(self._selected_items) == 0: + self.set_selected_item("") + + elif len(self._selected_items) == 1: + self.set_selected_item(self._selected_items[0]) + + else: + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(self._selected_items) + self.set_selected_item(multiselection_text) + + self._set_is_valid(is_valid) + + def confirm_value(self, folder_paths): + new_task_name = self._selected_items[0] + self._origin_value = [ + (folder_path, new_task_name) + for folder_path in folder_paths + ] + self._origin_selection = copy.deepcopy(self._selected_items) + self._has_value_changed = False + + def set_selected_items(self, folder_task_combinations=None): + """Set items for selected instances. + + Args: + folder_task_combinations (list): List of tuples. Each item in + the list contain folder path and task name. + """ + self._proxy_model.set_filter_empty(False) + self._proxy_model.sort(0) + + if folder_task_combinations is None: + folder_task_combinations = [] + + task_names = set() + task_names_by_folder_path = collections.defaultdict(set) + for folder_path, task_name in folder_task_combinations: + task_names.add(task_name) + task_names_by_folder_path[folder_path].add(task_name) + folder_paths = set(task_names_by_folder_path.keys()) + + self._ignore_index_change = True + + self._model.set_folder_paths(folder_paths) + + self._has_value_changed = False + + self._origin_value = copy.deepcopy(folder_task_combinations) + + self._origin_selection = list(task_names) + self._selected_items = list(task_names) + # Reset current index + self.setCurrentIndex(-1) + is_valid = True + if not task_names: + self.set_selected_item("") + + elif len(task_names) == 1: + task_name = tuple(task_names)[0] + idx = self.findText(task_name) + is_valid = not idx < 0 + if not is_valid and len(folder_paths) > 1: + is_valid = self._validate_task_names_by_folder_paths( + task_names_by_folder_path + ) + self.set_selected_item(task_name) + + else: + for task_name in task_names: + idx = self.findText(task_name) + is_valid = not idx < 0 + if not is_valid: + break + + if not is_valid and len(folder_paths) > 1: + is_valid = self._validate_task_names_by_folder_paths( + task_names_by_folder_path + ) + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(task_names) + self.set_selected_item(multiselection_text) + + self._set_is_valid(is_valid) + + self._ignore_index_change = False + + self.value_changed.emit() + + def _validate_task_names_by_folder_paths(self, task_names_by_folder_path): + for folder_path, task_names in task_names_by_folder_path.items(): + for task_name in task_names: + if not self._model.is_task_name_valid(folder_path, task_name): + return False + return True + + def set_selected_item(self, item_name): + """Set task which is set on selected instance. + + Args: + item_name(str): Task name which should be selected. + """ + idx = self.findText(item_name) + # Set current index (must be set to -1 if is invalid) + self.setCurrentIndex(idx) + self.set_text(item_name) + + def reset_to_origin(self): + """Change to task names set with last `set_selected_items` call.""" + self.set_selected_items(self._origin_value) + + +class VariantInputWidget(PlaceholderLineEdit): + """Input widget for variant.""" + value_changed = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + self.setObjectName("VariantInput") + self.setToolTip(VARIANT_TOOLTIP) + + name_pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS) + self._name_pattern = name_pattern + self._compiled_name_pattern = re.compile(name_pattern) + + self._origin_value = [] + self._current_value = [] + + self._ignore_value_change = False + self._has_value_changed = False + self._multiselection_text = None + + self._is_valid = True + + self.textChanged.connect(self._on_text_change) + + def is_valid(self): + """Is variant text valid.""" + return self._is_valid + + def has_value_changed(self): + """Value of variant has changed.""" + return self._has_value_changed + + def _set_state_property(self, state): + current_value = self.property("state") + if current_value != state: + self.setProperty("state", state) + self.style().polish(self) + + def set_multiselection_text(self, text): + """Change text of multiselection.""" + self._multiselection_text = text + + def confirm_value(self): + self._origin_value = copy.deepcopy(self._current_value) + self._has_value_changed = False + + def _set_is_valid(self, valid): + if valid == self._is_valid: + return + self._is_valid = valid + state = "" + if not valid: + state = "invalid" + self._set_state_property(state) + + def _on_text_change(self): + if self._ignore_value_change: + return + + is_valid = bool(self._compiled_name_pattern.match(self.text())) + self._set_is_valid(is_valid) + + self._current_value = [self.text()] + self._has_value_changed = self._current_value != self._origin_value + + self.value_changed.emit() + + def reset_to_origin(self): + """Set origin value of selected instances.""" + self.set_value(self._origin_value) + + def get_value(self): + """Get current value. + + Origin value returned if didn't change. + """ + return copy.deepcopy(self._current_value) + + def set_value(self, variants=None): + """Set value of currently selected instances.""" + if variants is None: + variants = [] + + self._ignore_value_change = True + + self._has_value_changed = False + + self._origin_value = list(variants) + self._current_value = list(variants) + + self.setPlaceholderText("") + if not variants: + self.setText("") + + elif len(variants) == 1: + self.setText(self._current_value[0]) + + else: + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(variants) + self.setText("") + self.setPlaceholderText(multiselection_text) + + self._ignore_value_change = False + + +class GlobalAttrsWidget(QtWidgets.QWidget): + """Global attributes mainly to define context and product name of instances. + + product name is or may be affected on context. Gives abiity to modify + context and product name of instance. This change is not autopromoted but + must be submitted. + + Warning: Until artist hit `Submit` changes must not be propagated to + instance data. + + Global attributes contain these widgets: + Variant: [ text input ] + Folder: [ folder dialog ] + Task: [ combobox ] + Product type: [ immutable ] + product name: [ immutable ] + [Submit] [Cancel] + """ + instance_context_changed = QtCore.Signal() + + multiselection_text = "< Multiselection >" + unknown_value = "N/A" + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + self._controller: AbstractPublisherFrontend = controller + self._current_instances = [] + + variant_input = VariantInputWidget(self) + folder_value_widget = FoldersFields(controller, self) + task_value_widget = TasksCombobox(controller, self) + product_type_value_widget = MultipleItemWidget(self) + product_value_widget = MultipleItemWidget(self) + + variant_input.set_multiselection_text(self.multiselection_text) + folder_value_widget.set_multiselection_text(self.multiselection_text) + task_value_widget.set_multiselection_text(self.multiselection_text) + + variant_input.set_value() + folder_value_widget.set_selected_items() + task_value_widget.set_selected_items() + product_type_value_widget.set_value() + product_value_widget.set_value() + + submit_btn = QtWidgets.QPushButton("Confirm", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + submit_btn.setEnabled(False) + cancel_btn.setEnabled(False) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.setSpacing(5) + btns_layout.addWidget(submit_btn) + btns_layout.addWidget(cancel_btn) + + main_layout = QtWidgets.QFormLayout(self) + main_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) + main_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) + main_layout.addRow("Variant", variant_input) + main_layout.addRow("Folder", folder_value_widget) + main_layout.addRow("Task", task_value_widget) + main_layout.addRow("Product type", product_type_value_widget) + main_layout.addRow("Product name", product_value_widget) + main_layout.addRow(btns_layout) + + variant_input.value_changed.connect(self._on_variant_change) + folder_value_widget.value_changed.connect(self._on_folder_change) + task_value_widget.value_changed.connect(self._on_task_change) + submit_btn.clicked.connect(self._on_submit) + cancel_btn.clicked.connect(self._on_cancel) + + self.variant_input = variant_input + self.folder_value_widget = folder_value_widget + self.task_value_widget = task_value_widget + self.product_type_value_widget = product_type_value_widget + self.product_value_widget = product_value_widget + self.submit_btn = submit_btn + self.cancel_btn = cancel_btn + + def _on_submit(self): + """Commit changes for selected instances.""" + + variant_value = None + folder_path = None + task_name = None + if self.variant_input.has_value_changed(): + variant_value = self.variant_input.get_value()[0] + + if self.folder_value_widget.has_value_changed(): + folder_path = self.folder_value_widget.get_selected_items()[0] + + if self.task_value_widget.has_value_changed(): + task_name = self.task_value_widget.get_selected_items()[0] + + product_names = set() + invalid_tasks = False + folder_paths = [] + for instance in self._current_instances: + # Ignore instances that have promised context + if instance.has_promised_context: + continue + + new_variant_value = instance.variant + new_folder_path = instance.folder_path + new_task_name = instance.task_name + if variant_value is not None: + new_variant_value = variant_value + + if folder_path is not None: + new_folder_path = folder_path + + if task_name is not None: + new_task_name = task_name + + folder_paths.append(new_folder_path) + try: + new_product_name = self._controller.get_product_name( + instance.creator_identifier, + new_variant_value, + new_task_name, + new_folder_path, + instance.id, + ) + + except TaskNotSetError: + invalid_tasks = True + product_names.add(instance.product_name) + continue + + product_names.add(new_product_name) + if variant_value is not None: + instance.variant = variant_value + + if folder_path is not None: + instance.folder_path = folder_path + + if task_name is not None: + instance.task_name = task_name or None + + instance.product_name = new_product_name + + if invalid_tasks: + self.task_value_widget.set_invalid_empty_task() + + self.product_value_widget.set_value(product_names) + + self._set_btns_enabled(False) + self._set_btns_visible(invalid_tasks) + + if variant_value is not None: + self.variant_input.confirm_value() + + if folder_path is not None: + self.folder_value_widget.confirm_value() + + if task_name is not None: + self.task_value_widget.confirm_value(folder_paths) + + self.instance_context_changed.emit() + + def _on_cancel(self): + """Cancel changes and set back to their irigin value.""" + + self.variant_input.reset_to_origin() + self.folder_value_widget.reset_to_origin() + self.task_value_widget.reset_to_origin() + self._set_btns_enabled(False) + + def _on_value_change(self): + any_invalid = ( + not self.variant_input.is_valid() + or not self.folder_value_widget.is_valid() + or not self.task_value_widget.is_valid() + ) + any_changed = ( + self.variant_input.has_value_changed() + or self.folder_value_widget.has_value_changed() + or self.task_value_widget.has_value_changed() + ) + self._set_btns_visible(any_changed or any_invalid) + self.cancel_btn.setEnabled(any_changed) + self.submit_btn.setEnabled(not any_invalid) + + def _on_variant_change(self): + self._on_value_change() + + def _on_folder_change(self): + folder_paths = self.folder_value_widget.get_selected_items() + self.task_value_widget.set_folder_paths(folder_paths) + self._on_value_change() + + def _on_task_change(self): + self._on_value_change() + + def _set_btns_visible(self, visible): + self.cancel_btn.setVisible(visible) + self.submit_btn.setVisible(visible) + + def _set_btns_enabled(self, enabled): + self.cancel_btn.setEnabled(enabled) + self.submit_btn.setEnabled(enabled) + + def set_current_instances(self, instances): + """Set currently selected instances. + + Args: + instances (List[InstanceItem]): List of selected instances. + Empty instances tells that nothing or context is selected. + """ + self._set_btns_visible(False) + + self._current_instances = instances + + folder_paths = set() + variants = set() + product_types = set() + product_names = set() + + editable = True + if len(instances) == 0: + editable = False + + folder_task_combinations = [] + context_editable = None + for instance in instances: + if not instance.has_promised_context: + context_editable = True + elif context_editable is None: + context_editable = False + + # NOTE I'm not sure how this can even happen? + if instance.creator_identifier is None: + editable = False + + variants.add(instance.variant or self.unknown_value) + product_types.add(instance.product_type or self.unknown_value) + folder_path = instance.folder_path or self.unknown_value + task_name = instance.task_name or "" + folder_paths.add(folder_path) + folder_task_combinations.append((folder_path, task_name)) + product_names.add(instance.product_name or self.unknown_value) + + if not editable: + context_editable = False + elif context_editable is None: + context_editable = True + + self.variant_input.set_value(variants) + + # Set context of folder widget + self.folder_value_widget.set_selected_items(folder_paths) + # Set context of task widget + self.task_value_widget.set_selected_items(folder_task_combinations) + self.product_type_value_widget.set_value(product_types) + self.product_value_widget.set_value(product_names) + + self.variant_input.setEnabled(editable) + self.folder_value_widget.setEnabled(context_editable) + self.task_value_widget.setEnabled(context_editable) + + if not editable: + folder_tooltip = "Select instances to change folder path." + task_tooltip = "Select instances to change task name." + elif not context_editable: + folder_tooltip = "Folder path is defined by Create plugin." + task_tooltip = "Task is defined by Create plugin." + else: + folder_tooltip = "Change folder path of selected instances." + task_tooltip = "Change task of selected instances." + + self.folder_value_widget.setToolTip(folder_tooltip) + self.task_value_widget.setToolTip(task_tooltip) diff --git a/client/ayon_core/tools/publisher/widgets/product_info.py b/client/ayon_core/tools/publisher/widgets/product_info.py new file mode 100644 index 0000000000..c1d2037bd3 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_info.py @@ -0,0 +1,284 @@ +import os +import uuid +import shutil + +from qtpy import QtWidgets, QtCore + +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend + +from .thumbnail_widget import ThumbnailWidget +from .product_context import GlobalAttrsWidget +from .product_attributes import ( + CreatorAttrsWidget, + PublishPluginAttrsWidget, +) + + +class ProductInfoWidget(QtWidgets.QWidget): + """Wrapper widget where attributes of instance/s are modified. + ┌─────────────────┬─────────────┐ + │ Global │ │ + │ attributes │ Thumbnail │ TOP + │ │ │ + ├─────────────┬───┴─────────────┤ + │ Creator │ Publish │ + │ attributes │ plugin │ BOTTOM + │ │ attributes │ + └───────────────────────────────┘ + """ + instance_context_changed = QtCore.Signal() + convert_requested = QtCore.Signal() + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + # TOP PART + top_widget = QtWidgets.QWidget(self) + + # Global attributes + global_attrs_widget = GlobalAttrsWidget(controller, top_widget) + thumbnail_widget = ThumbnailWidget(controller, top_widget) + + top_layout = QtWidgets.QHBoxLayout(top_widget) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget(global_attrs_widget, 7) + top_layout.addWidget(thumbnail_widget, 3) + + # BOTTOM PART + bottom_widget = QtWidgets.QWidget(self) + + # Wrap Creator attributes to widget to be able add convert button + creator_widget = QtWidgets.QWidget(bottom_widget) + + # Convert button widget (with layout to handle stretch) + convert_widget = QtWidgets.QWidget(creator_widget) + convert_label = QtWidgets.QLabel(creator_widget) + # Set the label text with 'setText' to apply html + convert_label.setText( + ( + "Found old publishable products" + " incompatible with new publisher." + "

Press the update products button" + " to automatically update them" + " to be able to publish again." + ) + ) + convert_label.setWordWrap(True) + convert_label.setAlignment(QtCore.Qt.AlignCenter) + + convert_btn = QtWidgets.QPushButton( + "Update products", convert_widget + ) + convert_separator = QtWidgets.QFrame(convert_widget) + convert_separator.setObjectName("Separator") + convert_separator.setMinimumHeight(1) + convert_separator.setMaximumHeight(1) + + convert_layout = QtWidgets.QGridLayout(convert_widget) + convert_layout.setContentsMargins(5, 0, 5, 0) + convert_layout.setVerticalSpacing(10) + convert_layout.addWidget(convert_label, 0, 0, 1, 3) + convert_layout.addWidget(convert_btn, 1, 1) + convert_layout.addWidget(convert_separator, 2, 0, 1, 3) + convert_layout.setColumnStretch(0, 1) + convert_layout.setColumnStretch(1, 0) + convert_layout.setColumnStretch(2, 1) + + # Creator attributes widget + creator_attrs_widget = CreatorAttrsWidget( + controller, creator_widget + ) + creator_layout = QtWidgets.QVBoxLayout(creator_widget) + creator_layout.setContentsMargins(0, 0, 0, 0) + creator_layout.addWidget(convert_widget, 0) + creator_layout.addWidget(creator_attrs_widget, 1) + + publish_attrs_widget = PublishPluginAttrsWidget( + controller, bottom_widget + ) + + bottom_separator = QtWidgets.QWidget(bottom_widget) + bottom_separator.setObjectName("Separator") + bottom_separator.setMinimumWidth(1) + + bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) + bottom_layout.setContentsMargins(0, 0, 0, 0) + bottom_layout.addWidget(creator_widget, 1) + bottom_layout.addWidget(bottom_separator, 0) + bottom_layout.addWidget(publish_attrs_widget, 1) + + top_bottom = QtWidgets.QWidget(self) + top_bottom.setObjectName("Separator") + top_bottom.setMinimumHeight(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(top_widget, 0) + layout.addWidget(top_bottom, 0) + layout.addWidget(bottom_widget, 1) + + self._convertor_identifiers = None + self._current_instances = None + 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( + "instance.thumbnail.changed", self._on_thumbnail_changed + ) + + self._controller: AbstractPublisherFrontend = controller + + self._convert_widget = convert_widget + + self.global_attrs_widget = global_attrs_widget + + self.creator_attrs_widget = creator_attrs_widget + self.publish_attrs_widget = publish_attrs_widget + self._thumbnail_widget = thumbnail_widget + + self.top_bottom = top_bottom + self.bottom_separator = bottom_separator + + def _on_instance_context_changed(self): + instance_ids = { + instance.id + for instance in self._current_instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + all_valid = True + for instance_id, context_info in context_info_by_id.items(): + if not context_info.is_valid: + all_valid = False + break + + self._all_instances_valid = all_valid + 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() + + def set_current_instances( + self, instances, context_selected, convertor_identifiers + ): + """Change currently selected items. + + Args: + instances (List[InstanceItem]): List of currently selected + instances. + context_selected (bool): Is context selected. + convertor_identifiers (List[str]): Identifiers of convert items. + """ + + instance_ids = { + instance.id + for instance in instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + + all_valid = True + for context_info in context_info_by_id.values(): + if not context_info.is_valid: + all_valid = False + break + + s_convertor_identifiers = set(convertor_identifiers) + self._convertor_identifiers = s_convertor_identifiers + self._current_instances = instances + self._context_selected = context_selected + self._all_instances_valid = all_valid + + self._convert_widget.setVisible(len(s_convertor_identifiers) > 0) + self.global_attrs_widget.set_current_instances(instances) + self.creator_attrs_widget.set_current_instances(instance_ids) + self.publish_attrs_widget.set_current_instances( + instance_ids, context_selected + ) + self.creator_attrs_widget.set_instances_valid(all_valid) + self.publish_attrs_widget.set_instances_valid(all_valid) + + self._update_thumbnails() + + def _on_thumbnail_create(self, path): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + return + + mapping = {} + if len(instance_ids) == 1: + mapping[instance_ids[0]] = path + + else: + for instance_id in instance_ids: + root = os.path.dirname(path) + ext = os.path.splitext(path)[-1] + dst_path = os.path.join(root, str(uuid.uuid4()) + ext) + shutil.copy(path, dst_path) + mapping[instance_id] = dst_path + + self._controller.set_thumbnail_paths_for_instances(mapping) + + def _on_thumbnail_clear(self): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + return + + mapping = { + instance_id: None + for instance_id in instance_ids + } + self._controller.set_thumbnail_paths_for_instances(mapping) + + def _on_thumbnail_changed(self, event): + self._update_thumbnails() + + def _update_thumbnails(self): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + self._thumbnail_widget.setVisible(False) + self._thumbnail_widget.set_current_thumbnails(None) + return + + mapping = self._controller.get_thumbnail_paths_for_instances( + instance_ids + ) + thumbnail_paths = [] + for instance_id in instance_ids: + path = mapping[instance_id] + if path: + thumbnail_paths.append(path) + + self._thumbnail_widget.setVisible(True) + self._thumbnail_widget.set_current_thumbnails(thumbnail_paths) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 9fd687faf2..00c87ac249 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1,41 +1,18 @@ # -*- coding: utf-8 -*- import os -import re -import copy import functools -import uuid -import shutil -import collections from qtpy import QtWidgets, QtCore, QtGui import qtawesome -from ayon_core.lib.attribute_definitions import UnknownDef from ayon_core.style import get_objected_colors -from ayon_core.pipeline.create import ( - PRODUCT_NAME_ALLOWED_SYMBOLS, - TaskNotSetError, -) -from ayon_core.tools.attribute_defs import create_widget_for_attr_def from ayon_core.tools import resources from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( - PlaceholderLineEdit, IconButton, PixmapLabel, - BaseClickableFrame, - set_style_property, -) -from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend -from ayon_core.tools.publisher.constants import ( - VARIANT_TOOLTIP, - ResetKeySequence, - INPUTS_LAYOUT_HSPACING, - INPUTS_LAYOUT_VSPACING, ) +from ayon_core.tools.publisher.constants import ResetKeySequence -from .thumbnail_widget import ThumbnailWidget -from .folders_dialog import FoldersDialog -from .tasks_model import TasksModel from .icons import ( get_pixmap, get_icon_path @@ -426,583 +403,6 @@ def mouseDoubleClickEvent(self, event): event.accept() -class FoldersFields(BaseClickableFrame): - """Field where folder path of selected instance/s is showed. - - Click on the field will trigger `FoldersDialog`. - """ - value_changed = QtCore.Signal() - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - self.setObjectName("FolderPathInputWidget") - - # Don't use 'self' for parent! - # - this widget has specific styles - dialog = FoldersDialog(controller, parent) - - name_input = ClickableLineEdit(self) - name_input.setObjectName("FolderPathInput") - - icon_name = "fa.window-maximize" - icon = qtawesome.icon(icon_name, color="white") - icon_btn = QtWidgets.QPushButton(self) - icon_btn.setIcon(icon) - icon_btn.setObjectName("FolderPathInputButton") - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(name_input, 1) - layout.addWidget(icon_btn, 0) - - # Make sure all widgets are vertically extended to highest widget - for widget in ( - name_input, - icon_btn - ): - size_policy = widget.sizePolicy() - size_policy.setVerticalPolicy( - QtWidgets.QSizePolicy.MinimumExpanding) - widget.setSizePolicy(size_policy) - name_input.clicked.connect(self._mouse_release_callback) - icon_btn.clicked.connect(self._mouse_release_callback) - dialog.finished.connect(self._on_dialog_finish) - - self._controller: AbstractPublisherFrontend = controller - self._dialog = dialog - self._name_input = name_input - self._icon_btn = icon_btn - - self._origin_value = [] - self._origin_selection = [] - self._selected_items = [] - self._has_value_changed = False - self._is_valid = True - self._multiselection_text = None - - def _on_dialog_finish(self, result): - if not result: - return - - folder_path = self._dialog.get_selected_folder_path() - if folder_path is None: - return - - self._selected_items = [folder_path] - self._has_value_changed = ( - self._origin_value != self._selected_items - ) - self.set_text(folder_path) - self._set_is_valid(True) - - self.value_changed.emit() - - def _mouse_release_callback(self): - self._dialog.set_selected_folders(self._selected_items) - self._dialog.open() - - def set_multiselection_text(self, text): - """Change text for multiselection of different folders. - - When there are selected multiple instances at once and they don't have - same folder in context. - """ - self._multiselection_text = text - - def _set_is_valid(self, valid): - if valid == self._is_valid: - return - self._is_valid = valid - state = "" - if not valid: - state = "invalid" - self._set_state_property(state) - - def _set_state_property(self, state): - set_style_property(self, "state", state) - set_style_property(self._name_input, "state", state) - set_style_property(self._icon_btn, "state", state) - - def is_valid(self): - """Is folder valid.""" - return self._is_valid - - def has_value_changed(self): - """Value of folder has changed.""" - return self._has_value_changed - - def get_selected_items(self): - """Selected folder paths.""" - return list(self._selected_items) - - def set_text(self, text): - """Set text in text field. - - Does not change selected items (folders). - """ - self._name_input.setText(text) - self._name_input.end(False) - - def set_selected_items(self, folder_paths=None): - """Set folder paths for selection of instances. - - Passed folder paths are validated and if there are 2 or more different - folder paths then multiselection text is shown. - - Args: - folder_paths (list, tuple, set, NoneType): List of folder paths. - - """ - if folder_paths is None: - folder_paths = [] - - self._has_value_changed = False - self._origin_value = list(folder_paths) - self._selected_items = list(folder_paths) - is_valid = self._controller.are_folder_paths_valid(folder_paths) - if not folder_paths: - self.set_text("") - - elif len(folder_paths) == 1: - folder_path = tuple(folder_paths)[0] - self.set_text(folder_path) - else: - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(folder_paths) - self.set_text(multiselection_text) - - self._set_is_valid(is_valid) - - def reset_to_origin(self): - """Change to folder paths set with last `set_selected_items` call.""" - self.set_selected_items(self._origin_value) - - def confirm_value(self): - self._origin_value = copy.deepcopy(self._selected_items) - self._has_value_changed = False - - -class TasksComboboxProxy(QtCore.QSortFilterProxyModel): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._filter_empty = False - - def set_filter_empty(self, filter_empty): - if self._filter_empty is filter_empty: - return - self._filter_empty = filter_empty - self.invalidate() - - def filterAcceptsRow(self, source_row, parent_index): - if self._filter_empty: - model = self.sourceModel() - source_index = model.index( - source_row, self.filterKeyColumn(), parent_index - ) - if not source_index.data(QtCore.Qt.DisplayRole): - return False - return True - - -class TasksCombobox(QtWidgets.QComboBox): - """Combobox to show tasks for selected instances. - - Combobox gives ability to select only from intersection of task names for - folder paths in selected instances. - - If folder paths in selected instances does not have same tasks then combobox - will be empty. - """ - value_changed = QtCore.Signal() - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - self.setObjectName("TasksCombobox") - - # Set empty delegate to propagate stylesheet to a combobox - delegate = QtWidgets.QStyledItemDelegate() - self.setItemDelegate(delegate) - - model = TasksModel(controller, True) - proxy_model = TasksComboboxProxy() - proxy_model.setSourceModel(model) - self.setModel(proxy_model) - - self.currentIndexChanged.connect(self._on_index_change) - - self._delegate = delegate - self._model = model - self._proxy_model = proxy_model - self._origin_value = [] - self._origin_selection = [] - self._selected_items = [] - self._has_value_changed = False - self._ignore_index_change = False - self._multiselection_text = None - self._is_valid = True - - self._text = None - - # Make sure combobox is extended horizontally - size_policy = self.sizePolicy() - size_policy.setHorizontalPolicy( - QtWidgets.QSizePolicy.MinimumExpanding) - self.setSizePolicy(size_policy) - - def set_invalid_empty_task(self, invalid=True): - self._proxy_model.set_filter_empty(invalid) - if invalid: - self._set_is_valid(False) - self.set_text( - "< One or more products require Task selected >" - ) - else: - self.set_text(None) - - def set_multiselection_text(self, text): - """Change text shown when multiple different tasks are in context.""" - self._multiselection_text = text - - def _on_index_change(self): - if self._ignore_index_change: - return - - self.set_text(None) - text = self.currentText() - idx = self.findText(text) - if idx < 0: - return - - self._set_is_valid(True) - self._selected_items = [text] - self._has_value_changed = ( - self._origin_selection != self._selected_items - ) - - self.value_changed.emit() - - def set_text(self, text): - """Set context shown in combobox without changing selected items.""" - if text == self._text: - return - - self._text = text - self.repaint() - - def paintEvent(self, event): - """Paint custom text without using QLineEdit. - - The easiest way how to draw custom text in combobox and keep combobox - properties and event handling. - """ - painter = QtGui.QPainter(self) - painter.setPen(self.palette().color(QtGui.QPalette.Text)) - opt = QtWidgets.QStyleOptionComboBox() - self.initStyleOption(opt) - if self._text is not None: - opt.currentText = self._text - - style = self.style() - style.drawComplexControl( - QtWidgets.QStyle.CC_ComboBox, opt, painter, self - ) - style.drawControl( - QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self - ) - painter.end() - - def is_valid(self): - """Are all selected items valid.""" - return self._is_valid - - def has_value_changed(self): - """Did selection of task changed.""" - return self._has_value_changed - - def _set_is_valid(self, valid): - if valid == self._is_valid: - return - self._is_valid = valid - state = "" - if not valid: - state = "invalid" - self._set_state_property(state) - - def _set_state_property(self, state): - current_value = self.property("state") - if current_value != state: - self.setProperty("state", state) - self.style().polish(self) - - def get_selected_items(self): - """Get selected tasks. - - If value has changed then will return list with single item. - - Returns: - list: Selected tasks. - """ - return list(self._selected_items) - - def set_folder_paths(self, folder_paths): - """Set folder paths for which should show tasks.""" - self._ignore_index_change = True - - self._model.set_folder_paths(folder_paths) - self._proxy_model.set_filter_empty(False) - self._proxy_model.sort(0) - - self._ignore_index_change = False - - # It is a bug if not exactly one folder got here - if len(folder_paths) != 1: - self.set_selected_item("") - self._set_is_valid(False) - return - - folder_path = tuple(folder_paths)[0] - - is_valid = False - if self._selected_items: - is_valid = True - - valid_task_names = [] - for task_name in self._selected_items: - _is_valid = self._model.is_task_name_valid(folder_path, task_name) - if _is_valid: - valid_task_names.append(task_name) - else: - is_valid = _is_valid - - self._selected_items = valid_task_names - if len(self._selected_items) == 0: - self.set_selected_item("") - - elif len(self._selected_items) == 1: - self.set_selected_item(self._selected_items[0]) - - else: - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(self._selected_items) - self.set_selected_item(multiselection_text) - - self._set_is_valid(is_valid) - - def confirm_value(self, folder_paths): - new_task_name = self._selected_items[0] - self._origin_value = [ - (folder_path, new_task_name) - for folder_path in folder_paths - ] - self._origin_selection = copy.deepcopy(self._selected_items) - self._has_value_changed = False - - def set_selected_items(self, folder_task_combinations=None): - """Set items for selected instances. - - Args: - folder_task_combinations (list): List of tuples. Each item in - the list contain folder path and task name. - """ - self._proxy_model.set_filter_empty(False) - self._proxy_model.sort(0) - - if folder_task_combinations is None: - folder_task_combinations = [] - - task_names = set() - task_names_by_folder_path = collections.defaultdict(set) - for folder_path, task_name in folder_task_combinations: - task_names.add(task_name) - task_names_by_folder_path[folder_path].add(task_name) - folder_paths = set(task_names_by_folder_path.keys()) - - self._ignore_index_change = True - - self._model.set_folder_paths(folder_paths) - - self._has_value_changed = False - - self._origin_value = copy.deepcopy(folder_task_combinations) - - self._origin_selection = list(task_names) - self._selected_items = list(task_names) - # Reset current index - self.setCurrentIndex(-1) - is_valid = True - if not task_names: - self.set_selected_item("") - - elif len(task_names) == 1: - task_name = tuple(task_names)[0] - idx = self.findText(task_name) - is_valid = not idx < 0 - if not is_valid and len(folder_paths) > 1: - is_valid = self._validate_task_names_by_folder_paths( - task_names_by_folder_path - ) - self.set_selected_item(task_name) - - else: - for task_name in task_names: - idx = self.findText(task_name) - is_valid = not idx < 0 - if not is_valid: - break - - if not is_valid and len(folder_paths) > 1: - is_valid = self._validate_task_names_by_folder_paths( - task_names_by_folder_path - ) - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(task_names) - self.set_selected_item(multiselection_text) - - self._set_is_valid(is_valid) - - self._ignore_index_change = False - - self.value_changed.emit() - - def _validate_task_names_by_folder_paths(self, task_names_by_folder_path): - for folder_path, task_names in task_names_by_folder_path.items(): - for task_name in task_names: - if not self._model.is_task_name_valid(folder_path, task_name): - return False - return True - - def set_selected_item(self, item_name): - """Set task which is set on selected instance. - - Args: - item_name(str): Task name which should be selected. - """ - idx = self.findText(item_name) - # Set current index (must be set to -1 if is invalid) - self.setCurrentIndex(idx) - self.set_text(item_name) - - def reset_to_origin(self): - """Change to task names set with last `set_selected_items` call.""" - self.set_selected_items(self._origin_value) - - -class VariantInputWidget(PlaceholderLineEdit): - """Input widget for variant.""" - value_changed = QtCore.Signal() - - def __init__(self, parent): - super().__init__(parent) - - self.setObjectName("VariantInput") - self.setToolTip(VARIANT_TOOLTIP) - - name_pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS) - self._name_pattern = name_pattern - self._compiled_name_pattern = re.compile(name_pattern) - - self._origin_value = [] - self._current_value = [] - - self._ignore_value_change = False - self._has_value_changed = False - self._multiselection_text = None - - self._is_valid = True - - self.textChanged.connect(self._on_text_change) - - def is_valid(self): - """Is variant text valid.""" - return self._is_valid - - def has_value_changed(self): - """Value of variant has changed.""" - return self._has_value_changed - - def _set_state_property(self, state): - current_value = self.property("state") - if current_value != state: - self.setProperty("state", state) - self.style().polish(self) - - def set_multiselection_text(self, text): - """Change text of multiselection.""" - self._multiselection_text = text - - def confirm_value(self): - self._origin_value = copy.deepcopy(self._current_value) - self._has_value_changed = False - - def _set_is_valid(self, valid): - if valid == self._is_valid: - return - self._is_valid = valid - state = "" - if not valid: - state = "invalid" - self._set_state_property(state) - - def _on_text_change(self): - if self._ignore_value_change: - return - - is_valid = bool(self._compiled_name_pattern.match(self.text())) - self._set_is_valid(is_valid) - - self._current_value = [self.text()] - self._has_value_changed = self._current_value != self._origin_value - - self.value_changed.emit() - - def reset_to_origin(self): - """Set origin value of selected instances.""" - self.set_value(self._origin_value) - - def get_value(self): - """Get current value. - - Origin value returned if didn't change. - """ - return copy.deepcopy(self._current_value) - - def set_value(self, variants=None): - """Set value of currently selected instances.""" - if variants is None: - variants = [] - - self._ignore_value_change = True - - self._has_value_changed = False - - self._origin_value = list(variants) - self._current_value = list(variants) - - self.setPlaceholderText("") - if not variants: - self.setText("") - - elif len(variants) == 1: - self.setText(self._current_value[0]) - - else: - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(variants) - self.setText("") - self.setPlaceholderText(multiselection_text) - - self._ignore_value_change = False - - class MultipleItemWidget(QtWidgets.QWidget): """Widget for immutable text which can have more than one value. @@ -1080,870 +480,6 @@ def set_value(self, value=None): self._model.appendRow(item) -class GlobalAttrsWidget(QtWidgets.QWidget): - """Global attributes mainly to define context and product name of instances. - - product name is or may be affected on context. Gives abiity to modify - context and product name of instance. This change is not autopromoted but - must be submitted. - - Warning: Until artist hit `Submit` changes must not be propagated to - instance data. - - Global attributes contain these widgets: - Variant: [ text input ] - Folder: [ folder dialog ] - Task: [ combobox ] - Product type: [ immutable ] - product name: [ immutable ] - [Submit] [Cancel] - """ - instance_context_changed = QtCore.Signal() - - multiselection_text = "< Multiselection >" - unknown_value = "N/A" - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - self._controller: AbstractPublisherFrontend = controller - self._current_instances = [] - - variant_input = VariantInputWidget(self) - folder_value_widget = FoldersFields(controller, self) - task_value_widget = TasksCombobox(controller, self) - product_type_value_widget = MultipleItemWidget(self) - product_value_widget = MultipleItemWidget(self) - - variant_input.set_multiselection_text(self.multiselection_text) - folder_value_widget.set_multiselection_text(self.multiselection_text) - task_value_widget.set_multiselection_text(self.multiselection_text) - - variant_input.set_value() - folder_value_widget.set_selected_items() - task_value_widget.set_selected_items() - product_type_value_widget.set_value() - product_value_widget.set_value() - - submit_btn = QtWidgets.QPushButton("Confirm", self) - cancel_btn = QtWidgets.QPushButton("Cancel", self) - submit_btn.setEnabled(False) - cancel_btn.setEnabled(False) - - btns_layout = QtWidgets.QHBoxLayout() - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addStretch(1) - btns_layout.setSpacing(5) - btns_layout.addWidget(submit_btn) - btns_layout.addWidget(cancel_btn) - - main_layout = QtWidgets.QFormLayout(self) - main_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) - main_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) - main_layout.addRow("Variant", variant_input) - main_layout.addRow("Folder", folder_value_widget) - main_layout.addRow("Task", task_value_widget) - main_layout.addRow("Product type", product_type_value_widget) - main_layout.addRow("Product name", product_value_widget) - main_layout.addRow(btns_layout) - - variant_input.value_changed.connect(self._on_variant_change) - folder_value_widget.value_changed.connect(self._on_folder_change) - task_value_widget.value_changed.connect(self._on_task_change) - submit_btn.clicked.connect(self._on_submit) - cancel_btn.clicked.connect(self._on_cancel) - - self.variant_input = variant_input - self.folder_value_widget = folder_value_widget - self.task_value_widget = task_value_widget - self.product_type_value_widget = product_type_value_widget - self.product_value_widget = product_value_widget - self.submit_btn = submit_btn - self.cancel_btn = cancel_btn - - def _on_submit(self): - """Commit changes for selected instances.""" - - variant_value = None - folder_path = None - task_name = None - if self.variant_input.has_value_changed(): - variant_value = self.variant_input.get_value()[0] - - if self.folder_value_widget.has_value_changed(): - folder_path = self.folder_value_widget.get_selected_items()[0] - - if self.task_value_widget.has_value_changed(): - task_name = self.task_value_widget.get_selected_items()[0] - - product_names = set() - invalid_tasks = False - folder_paths = [] - for instance in self._current_instances: - # Ignore instances that have promised context - if instance.has_promised_context: - continue - - new_variant_value = instance.variant - new_folder_path = instance.folder_path - new_task_name = instance.task_name - if variant_value is not None: - new_variant_value = variant_value - - if folder_path is not None: - new_folder_path = folder_path - - if task_name is not None: - new_task_name = task_name - - folder_paths.append(new_folder_path) - try: - new_product_name = self._controller.get_product_name( - instance.creator_identifier, - new_variant_value, - new_task_name, - new_folder_path, - instance.id, - ) - - except TaskNotSetError: - invalid_tasks = True - product_names.add(instance.product_name) - continue - - product_names.add(new_product_name) - if variant_value is not None: - instance.variant = variant_value - - if folder_path is not None: - instance.folder_path = folder_path - - if task_name is not None: - instance.task_name = task_name or None - - instance.product_name = new_product_name - - if invalid_tasks: - self.task_value_widget.set_invalid_empty_task() - - self.product_value_widget.set_value(product_names) - - self._set_btns_enabled(False) - self._set_btns_visible(invalid_tasks) - - if variant_value is not None: - self.variant_input.confirm_value() - - if folder_path is not None: - self.folder_value_widget.confirm_value() - - if task_name is not None: - self.task_value_widget.confirm_value(folder_paths) - - self.instance_context_changed.emit() - - def _on_cancel(self): - """Cancel changes and set back to their irigin value.""" - - self.variant_input.reset_to_origin() - self.folder_value_widget.reset_to_origin() - self.task_value_widget.reset_to_origin() - self._set_btns_enabled(False) - - def _on_value_change(self): - any_invalid = ( - not self.variant_input.is_valid() - or not self.folder_value_widget.is_valid() - or not self.task_value_widget.is_valid() - ) - any_changed = ( - self.variant_input.has_value_changed() - or self.folder_value_widget.has_value_changed() - or self.task_value_widget.has_value_changed() - ) - self._set_btns_visible(any_changed or any_invalid) - self.cancel_btn.setEnabled(any_changed) - self.submit_btn.setEnabled(not any_invalid) - - def _on_variant_change(self): - self._on_value_change() - - def _on_folder_change(self): - folder_paths = self.folder_value_widget.get_selected_items() - self.task_value_widget.set_folder_paths(folder_paths) - self._on_value_change() - - def _on_task_change(self): - self._on_value_change() - - def _set_btns_visible(self, visible): - self.cancel_btn.setVisible(visible) - self.submit_btn.setVisible(visible) - - def _set_btns_enabled(self, enabled): - self.cancel_btn.setEnabled(enabled) - self.submit_btn.setEnabled(enabled) - - def set_current_instances(self, instances): - """Set currently selected instances. - - Args: - instances (List[InstanceItem]): List of selected instances. - Empty instances tells that nothing or context is selected. - """ - self._set_btns_visible(False) - - self._current_instances = instances - - folder_paths = set() - variants = set() - product_types = set() - product_names = set() - - editable = True - if len(instances) == 0: - editable = False - - folder_task_combinations = [] - context_editable = None - for instance in instances: - if not instance.has_promised_context: - context_editable = True - elif context_editable is None: - context_editable = False - - # NOTE I'm not sure how this can even happen? - if instance.creator_identifier is None: - editable = False - - variants.add(instance.variant or self.unknown_value) - product_types.add(instance.product_type or self.unknown_value) - folder_path = instance.folder_path or self.unknown_value - task_name = instance.task_name or "" - folder_paths.add(folder_path) - folder_task_combinations.append((folder_path, task_name)) - product_names.add(instance.product_name or self.unknown_value) - - if not editable: - context_editable = False - elif context_editable is None: - context_editable = True - - self.variant_input.set_value(variants) - - # Set context of folder widget - self.folder_value_widget.set_selected_items(folder_paths) - # Set context of task widget - self.task_value_widget.set_selected_items(folder_task_combinations) - self.product_type_value_widget.set_value(product_types) - self.product_value_widget.set_value(product_names) - - self.variant_input.setEnabled(editable) - self.folder_value_widget.setEnabled(context_editable) - self.task_value_widget.setEnabled(context_editable) - - if not editable: - folder_tooltip = "Select instances to change folder path." - task_tooltip = "Select instances to change task name." - elif not context_editable: - folder_tooltip = "Folder path is defined by Create plugin." - task_tooltip = "Task is defined by Create plugin." - else: - folder_tooltip = "Change folder path of selected instances." - task_tooltip = "Change task of selected instances." - - self.folder_value_widget.setToolTip(folder_tooltip) - self.task_value_widget.setToolTip(task_tooltip) - - -class CreatorAttrsWidget(QtWidgets.QWidget): - """Widget showing creator specific attributes for selected instances. - - Attributes are defined on creator so are dynamic. Their look and type is - based on attribute definitions that are defined in - `~/ayon_core/lib/attribute_definitions.py` and their widget - representation in `~/ayon_core/tools/attribute_defs/*`. - - Widgets are disabled if context of instance is not valid. - - Definitions are shown for all instance no matter if they are created with - different creators. If creator have same (similar) definitions their - widgets are merged into one (different label does not count). - """ - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - scroll_area = QtWidgets.QScrollArea(self) - scroll_area.setWidgetResizable(True) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main_layout.addWidget(scroll_area, 1) - - controller.register_event_callback( - "create.context.create.attrs.changed", - self._on_instance_attr_defs_change - ) - - self._main_layout = main_layout - - self._controller: AbstractPublisherFrontend = controller - self._scroll_area = scroll_area - - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._current_instance_ids = set() - - # To store content of scroll area to prevent garbage collection - self._content_widget = None - - def set_instances_valid(self, valid): - """Change valid state of current instances.""" - - if ( - self._content_widget is not None - and self._content_widget.isEnabled() != valid - ): - self._content_widget.setEnabled(valid) - - def set_current_instances(self, instance_ids): - """Set current instances for which are attribute definitions shown.""" - - self._current_instance_ids = set(instance_ids) - prev_content_widget = self._scroll_area.widget() - if prev_content_widget: - self._scroll_area.takeWidget() - prev_content_widget.hide() - prev_content_widget.deleteLater() - - self._content_widget = None - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - - result = self._controller.get_creator_attribute_definitions( - instance_ids - ) - - content_widget = QtWidgets.QWidget(self._scroll_area) - content_layout = QtWidgets.QGridLayout(content_widget) - content_layout.setColumnStretch(0, 0) - content_layout.setColumnStretch(1, 1) - content_layout.setAlignment(QtCore.Qt.AlignTop) - content_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) - content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) - - row = 0 - for attr_def, instance_ids, values in result: - widget = create_widget_for_attr_def(attr_def, content_widget) - if attr_def.is_value_def: - if len(values) == 1: - value = values[0] - if value is not None: - widget.set_value(values[0]) - else: - widget.set_value(values, True) - - widget.value_changed.connect(self._input_value_changed) - 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: - continue - - expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - - label = None - if attr_def.is_value_def: - label = attr_def.label or attr_def.key - if label: - label_widget = QtWidgets.QLabel(label, self) - tooltip = attr_def.tooltip - if tooltip: - label_widget.setToolTip(tooltip) - if attr_def.is_label_horizontal: - label_widget.setAlignment( - QtCore.Qt.AlignRight - | QtCore.Qt.AlignVCenter - ) - content_layout.addWidget( - label_widget, row, 0, 1, expand_cols - ) - if not attr_def.is_label_horizontal: - row += 1 - - content_layout.addWidget( - widget, row, col_num, 1, expand_cols - ) - row += 1 - - self._scroll_area.setWidget(content_widget) - self._content_widget = content_widget - - def _on_instance_attr_defs_change(self, event): - update = False - for instance_id in event.data["instance_ids"]: - if instance_id in self._current_instance_ids: - update = True - break - - if update: - self.set_current_instances(self._current_instance_ids) - - def _input_value_changed(self, value, attr_id): - instance_ids = self._attr_def_id_to_instances.get(attr_id) - attr_def = self._attr_def_id_to_attr_def.get(attr_id) - if not instance_ids or not attr_def: - return - self._controller.set_instances_create_attr_values( - instance_ids, attr_def.key, value - ) - - -class PublishPluginAttrsWidget(QtWidgets.QWidget): - """Widget showing publsish plugin attributes for selected instances. - - Attributes are defined on publish plugins. Publihs plugin may define - attribute definitions but must inherit `AYONPyblishPluginMixin` - (~/ayon_core/pipeline/publish). At the moment requires to implement - `get_attribute_defs` and `convert_attribute_values` class methods. - - Look and type of attributes is based on attribute definitions that are - defined in `~/ayon_core/lib/attribute_definitions.py` and their - widget representation in `~/ayon_core/tools/attribute_defs/*`. - - Widgets are disabled if context of instance is not valid. - - Definitions are shown for all instance no matter if they have different - product types. Similar definitions are merged into one (different label - does not count). - """ - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - scroll_area = QtWidgets.QScrollArea(self) - scroll_area.setWidgetResizable(True) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main_layout.addWidget(scroll_area, 1) - - self._main_layout = main_layout - - self._controller: AbstractPublisherFrontend = controller - self._scroll_area = scroll_area - - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._attr_def_id_to_plugin_name = {} - - # Store content of scroll area to prevent garbage collection - self._content_widget = None - - def set_instances_valid(self, valid): - """Change valid state of current instances.""" - if ( - self._content_widget is not None - and self._content_widget.isEnabled() != valid - ): - self._content_widget.setEnabled(valid) - - def set_current_instances(self, instance_ids, context_selected): - """Set current instances for which are attribute definitions shown.""" - - prev_content_widget = self._scroll_area.widget() - if prev_content_widget: - self._scroll_area.takeWidget() - prev_content_widget.hide() - prev_content_widget.deleteLater() - - self._content_widget = None - - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._attr_def_id_to_plugin_name = {} - - result = self._controller.get_publish_attribute_definitions( - instance_ids, context_selected - ) - - content_widget = QtWidgets.QWidget(self._scroll_area) - attr_def_widget = QtWidgets.QWidget(content_widget) - attr_def_layout = QtWidgets.QGridLayout(attr_def_widget) - attr_def_layout.setColumnStretch(0, 0) - attr_def_layout.setColumnStretch(1, 1) - attr_def_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) - attr_def_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) - - content_layout = QtWidgets.QVBoxLayout(content_widget) - content_layout.addWidget(attr_def_widget, 0) - content_layout.addStretch(1) - - row = 0 - for plugin_name, attr_defs, all_plugin_values in result: - plugin_values = all_plugin_values[plugin_name] - - for attr_def in attr_defs: - widget = create_widget_for_attr_def( - attr_def, content_widget - ) - hidden_widget = attr_def.hidden - # Hide unknown values of publish plugins - # - The keys in most of cases does not represent what would - # label represent - if isinstance(attr_def, UnknownDef): - widget.setVisible(False) - hidden_widget = True - - if not hidden_widget: - expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - label = None - if attr_def.is_value_def: - label = attr_def.label or attr_def.key - if label: - label_widget = QtWidgets.QLabel(label, content_widget) - tooltip = attr_def.tooltip - if tooltip: - label_widget.setToolTip(tooltip) - if attr_def.is_label_horizontal: - label_widget.setAlignment( - QtCore.Qt.AlignRight - | QtCore.Qt.AlignVCenter - ) - attr_def_layout.addWidget( - label_widget, row, 0, 1, expand_cols - ) - if not attr_def.is_label_horizontal: - row += 1 - attr_def_layout.addWidget( - widget, row, col_num, 1, expand_cols - ) - row += 1 - - if not attr_def.is_value_def: - continue - - widget.value_changed.connect(self._input_value_changed) - - attr_values = plugin_values[attr_def.key] - multivalue = len(attr_values) > 1 - values = [] - instances = [] - for instance, value in attr_values: - values.append(value) - instances.append(instance) - - self._attr_def_id_to_attr_def[attr_def.id] = attr_def - self._attr_def_id_to_instances[attr_def.id] = instances - self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name - - if multivalue: - widget.set_value(values, multivalue) - else: - widget.set_value(values[0]) - - self._scroll_area.setWidget(content_widget) - self._content_widget = content_widget - - def _input_value_changed(self, value, attr_id): - instance_ids = self._attr_def_id_to_instances.get(attr_id) - attr_def = self._attr_def_id_to_attr_def.get(attr_id) - plugin_name = self._attr_def_id_to_plugin_name.get(attr_id) - if not instance_ids or not attr_def or not plugin_name: - return - - self._controller.set_instances_publish_attr_values( - instance_ids, plugin_name, attr_def.key, value - ) - - -class ProductAttributesWidget(QtWidgets.QWidget): - """Wrapper widget where attributes of instance/s are modified. - ┌─────────────────┬─────────────┐ - │ Global │ │ - │ attributes │ Thumbnail │ TOP - │ │ │ - ├─────────────┬───┴─────────────┤ - │ Creator │ Publish │ - │ attributes │ plugin │ BOTTOM - │ │ attributes │ - └───────────────────────────────┘ - """ - instance_context_changed = QtCore.Signal() - convert_requested = QtCore.Signal() - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - # TOP PART - top_widget = QtWidgets.QWidget(self) - - # Global attributes - global_attrs_widget = GlobalAttrsWidget(controller, top_widget) - thumbnail_widget = ThumbnailWidget(controller, top_widget) - - top_layout = QtWidgets.QHBoxLayout(top_widget) - top_layout.setContentsMargins(0, 0, 0, 0) - top_layout.addWidget(global_attrs_widget, 7) - top_layout.addWidget(thumbnail_widget, 3) - - # BOTTOM PART - bottom_widget = QtWidgets.QWidget(self) - - # Wrap Creator attributes to widget to be able add convert button - creator_widget = QtWidgets.QWidget(bottom_widget) - - # Convert button widget (with layout to handle stretch) - convert_widget = QtWidgets.QWidget(creator_widget) - convert_label = QtWidgets.QLabel(creator_widget) - # Set the label text with 'setText' to apply html - convert_label.setText( - ( - "Found old publishable products" - " incompatible with new publisher." - "

Press the update products button" - " to automatically update them" - " to be able to publish again." - ) - ) - convert_label.setWordWrap(True) - convert_label.setAlignment(QtCore.Qt.AlignCenter) - - convert_btn = QtWidgets.QPushButton( - "Update products", convert_widget - ) - convert_separator = QtWidgets.QFrame(convert_widget) - convert_separator.setObjectName("Separator") - convert_separator.setMinimumHeight(1) - convert_separator.setMaximumHeight(1) - - convert_layout = QtWidgets.QGridLayout(convert_widget) - convert_layout.setContentsMargins(5, 0, 5, 0) - convert_layout.setVerticalSpacing(10) - convert_layout.addWidget(convert_label, 0, 0, 1, 3) - convert_layout.addWidget(convert_btn, 1, 1) - convert_layout.addWidget(convert_separator, 2, 0, 1, 3) - convert_layout.setColumnStretch(0, 1) - convert_layout.setColumnStretch(1, 0) - convert_layout.setColumnStretch(2, 1) - - # Creator attributes widget - creator_attrs_widget = CreatorAttrsWidget( - controller, creator_widget - ) - creator_layout = QtWidgets.QVBoxLayout(creator_widget) - creator_layout.setContentsMargins(0, 0, 0, 0) - creator_layout.addWidget(convert_widget, 0) - creator_layout.addWidget(creator_attrs_widget, 1) - - publish_attrs_widget = PublishPluginAttrsWidget( - controller, bottom_widget - ) - - bottom_separator = QtWidgets.QWidget(bottom_widget) - bottom_separator.setObjectName("Separator") - bottom_separator.setMinimumWidth(1) - - bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) - bottom_layout.setContentsMargins(0, 0, 0, 0) - bottom_layout.addWidget(creator_widget, 1) - bottom_layout.addWidget(bottom_separator, 0) - bottom_layout.addWidget(publish_attrs_widget, 1) - - top_bottom = QtWidgets.QWidget(self) - top_bottom.setObjectName("Separator") - top_bottom.setMinimumHeight(1) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(top_widget, 0) - layout.addWidget(top_bottom, 0) - layout.addWidget(bottom_widget, 1) - - self._convertor_identifiers = None - self._current_instances = None - 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( - "instance.thumbnail.changed", self._on_thumbnail_changed - ) - - self._controller: AbstractPublisherFrontend = controller - - self._convert_widget = convert_widget - - self.global_attrs_widget = global_attrs_widget - - self.creator_attrs_widget = creator_attrs_widget - self.publish_attrs_widget = publish_attrs_widget - self._thumbnail_widget = thumbnail_widget - - self.top_bottom = top_bottom - self.bottom_separator = bottom_separator - - def _on_instance_context_changed(self): - instance_ids = { - instance.id - for instance in self._current_instances - } - context_info_by_id = self._controller.get_instances_context_info( - instance_ids - ) - all_valid = True - for instance_id, context_info in context_info_by_id.items(): - if not context_info.is_valid: - all_valid = False - break - - self._all_instances_valid = all_valid - 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() - - def set_current_instances( - self, instances, context_selected, convertor_identifiers - ): - """Change currently selected items. - - Args: - instances (List[InstanceItem]): List of currently selected - instances. - context_selected (bool): Is context selected. - convertor_identifiers (List[str]): Identifiers of convert items. - """ - - instance_ids = { - instance.id - for instance in instances - } - context_info_by_id = self._controller.get_instances_context_info( - instance_ids - ) - - all_valid = True - for context_info in context_info_by_id.values(): - if not context_info.is_valid: - all_valid = False - break - - s_convertor_identifiers = set(convertor_identifiers) - self._convertor_identifiers = s_convertor_identifiers - self._current_instances = instances - self._context_selected = context_selected - self._all_instances_valid = all_valid - - self._convert_widget.setVisible(len(s_convertor_identifiers) > 0) - self.global_attrs_widget.set_current_instances(instances) - self.creator_attrs_widget.set_current_instances(instance_ids) - self.publish_attrs_widget.set_current_instances( - instance_ids, context_selected - ) - self.creator_attrs_widget.set_instances_valid(all_valid) - self.publish_attrs_widget.set_instances_valid(all_valid) - - self._update_thumbnails() - - def _on_thumbnail_create(self, path): - instance_ids = [ - instance.id - for instance in self._current_instances - ] - if self._context_selected: - instance_ids.append(None) - - if not instance_ids: - return - - mapping = {} - if len(instance_ids) == 1: - mapping[instance_ids[0]] = path - - else: - for instance_id in instance_ids: - root = os.path.dirname(path) - ext = os.path.splitext(path)[-1] - dst_path = os.path.join(root, str(uuid.uuid4()) + ext) - shutil.copy(path, dst_path) - mapping[instance_id] = dst_path - - self._controller.set_thumbnail_paths_for_instances(mapping) - - def _on_thumbnail_clear(self): - instance_ids = [ - instance.id - for instance in self._current_instances - ] - if self._context_selected: - instance_ids.append(None) - - if not instance_ids: - return - - mapping = { - instance_id: None - for instance_id in instance_ids - } - self._controller.set_thumbnail_paths_for_instances(mapping) - - def _on_thumbnail_changed(self, event): - self._update_thumbnails() - - def _update_thumbnails(self): - instance_ids = [ - instance.id - for instance in self._current_instances - ] - if self._context_selected: - instance_ids.append(None) - - if not instance_ids: - self._thumbnail_widget.setVisible(False) - self._thumbnail_widget.set_current_thumbnails(None) - return - - mapping = self._controller.get_thumbnail_paths_for_instances( - instance_ids - ) - thumbnail_paths = [] - for instance_id in instance_ids: - path = mapping[instance_id] - if path: - thumbnail_paths.append(path) - - self._thumbnail_widget.setVisible(True) - self._thumbnail_widget.set_current_thumbnails(thumbnail_paths) - - class CreateNextPageOverlay(QtWidgets.QWidget): clicked = QtCore.Signal() From ec5f4abcd79456f5da3eade3b67df5d17061660c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 12:49:56 +0200 Subject: [PATCH 164/266] removemend happens in bulk --- client/ayon_core/pipeline/create/context.py | 41 +++++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a6c42ef70a..29cb9d3d4b 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -235,6 +235,7 @@ def __init__( self._bulk_info = { # Collect instances "collect": BulkInfo(), + "remove": BulkInfo(), # Change values of instances or create context "change": BulkInfo(), # Create attribute definitions changed @@ -1070,6 +1071,11 @@ def bulk_instances_collection(self, sender=None): plugin.__name__, attr_defs ) + @contextmanager + def bulk_remove_instances(self, sender=None): + with self._bulk_context("remove", sender) as bulk_info: + yield bulk_info + @contextmanager def bulk_value_changes(self, sender=None): with self._bulk_context("change", sender) as bulk_info: @@ -1165,6 +1171,8 @@ def _bulk_finish(self, key): data = bulk_info.pop_data() if key == "collect": self._bulk_instances_collection(data, sender) + elif key == "remove": + self._bulk_remove_instances_finished(data, sender) elif key == "change": self._bulk_values_change(data, sender) elif key == "create_attrs_change": @@ -1187,6 +1195,18 @@ def _bulk_instances_collection(self, instances_to_validate, sender): sender, ) + def _bulk_remove_instances_finished(self, instances_to_remove, sender): + if not instances_to_remove: + return + + self._emit_event( + INSTANCE_REMOVED_TOPIC, + { + "instances": instances_to_remove, + }, + sender, + ) + def _bulk_values_change( self, changes: Tuple[Union[str, None], Dict[str, Any]], @@ -1736,22 +1756,11 @@ def _emit_event( return self._event_hub.emit(topic, data, sender) def _remove_instances(self, instances, sender=None): - removed_instances = [] - for instance in instances: - obj = self._instances_by_id.pop(instance.id, None) - if obj is not None: - removed_instances.append(instance) - - if not removed_instances: - return - - self._emit_event( - INSTANCE_REMOVED_TOPIC, - { - "instances": removed_instances, - }, - sender, - ) + with self.bulk_remove_instances(sender) as bulk_info: + for instance in instances: + obj = self._instances_by_id.pop(instance.id, None) + if obj is not None: + bulk_info.append(obj) def _create_with_unified_error( self, identifier, creator, *args, **kwargs From cadc2e6551bdf929b3001725584a319f1cf3da51 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:07:21 +0200 Subject: [PATCH 165/266] change collect to add --- client/ayon_core/pipeline/create/context.py | 31 ++++++++++++------- .../tools/publisher/models/create.py | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 29cb9d3d4b..6872632a57 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -234,7 +234,7 @@ def __init__( # after leaving of last context manager scope self._bulk_info = { # Collect instances - "collect": BulkInfo(), + "add": BulkInfo(), "remove": BulkInfo(), # Change values of instances or create context "change": BulkInfo(), @@ -527,7 +527,7 @@ def reset(self, discover_publish_plugins=True): self.reset_plugins(discover_publish_plugins) self.reset_context_data() - with self.bulk_instances_collection(): + with self.bulk_add_instances(): self.reset_instances() self.find_convertor_items() self.execute_autocreators() @@ -841,9 +841,9 @@ def creator_adds_instance(self, instance: "CreatedInstance"): self._instances_by_id[instance.id] = instance - # Add instance to be validated inside 'bulk_instances_collection' + # Add instance to be validated inside 'bulk_add_instances' # context manager if is inside bulk - with self.bulk_instances_collection() as bulk_info: + with self.bulk_add_instances() as bulk_info: bulk_info.append(instance) def _get_creator_in_create(self, identifier): @@ -970,7 +970,7 @@ def create( active = bool(active) instance_data["active"] = active - with self.bulk_instances_collection(): + with self.bulk_add_instances(): return creator.create( product_name, instance_data, @@ -1023,8 +1023,8 @@ def remove_convertor_item(self, convertor_identifier): self.convertor_items_by_id.pop(convertor_identifier, None) @contextmanager - def bulk_instances_collection(self, sender=None): - with self._bulk_context("collect", sender) as bulk_info: + def bulk_add_instances(self, sender=None): + with self._bulk_context("add", sender) as bulk_info: yield bulk_info # Set publish attributes before bulk context is exited @@ -1071,6 +1071,13 @@ def bulk_instances_collection(self, sender=None): plugin.__name__, attr_defs ) + @contextmanager + def bulk_instances_collection(self, sender=None): + """DEPRECATED use 'bulk_add_instances' instead.""" + # TODO add warning + with self.bulk_add_instances(sender) as bulk_info: + yield bulk_info + @contextmanager def bulk_remove_instances(self, sender=None): with self._bulk_context("remove", sender) as bulk_info: @@ -1101,7 +1108,7 @@ def _is_instance_events_ready(self, instance_id): return False # Instance in 'collect' bulk will be ignored - for instance in self._bulk_info["collect"].get_data(): + for instance in self._bulk_info["add"].get_data(): if instance.id == instance_id: return False return True @@ -1169,8 +1176,8 @@ def _bulk_finish(self, key): bulk_info = self._bulk_info[key] sender = bulk_info.get_sender() data = bulk_info.pop_data() - if key == "collect": - self._bulk_instances_collection(data, sender) + if key == "add": + self._bulk_add_instances_finished(data, sender) elif key == "remove": self._bulk_remove_instances_finished(data, sender) elif key == "change": @@ -1180,7 +1187,7 @@ def _bulk_finish(self, key): elif key == "publish_attrs_change": self._bulk_publish_attrs_change(data, sender) - def _bulk_instances_collection(self, instances_to_validate, sender): + def _bulk_add_instances_finished(self, instances_to_validate, sender): if not instances_to_validate: return @@ -1781,7 +1788,7 @@ def _create_with_unified_error( label = getattr(creator, "label", label) # Run create - with self.bulk_instances_collection(): + with self.bulk_add_instances(): result = creator.create(*args, **kwargs) success = True diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index f7e8aaa503..e1eac3ae5b 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -808,7 +808,7 @@ def _reset_instances(self): """Reset create instances.""" self._create_context.reset_context_data() - with self._create_context.bulk_instances_collection(): + with self._create_context.bulk_add_instances(): try: self._create_context.reset_instances() except CreatorsOperationFailed as exc: From adcdab0b0340e62304c4b8154a783957c6738673 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:07:35 +0200 Subject: [PATCH 166/266] run create with in bulk mode --- client/ayon_core/tools/publisher/models/create.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index e1eac3ae5b..8f18e5f523 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -526,9 +526,10 @@ def create( success = True try: - self._create_context.create_with_unified_error( - creator_identifier, product_name, instance_data, options - ) + with self._create_context.bulk_add_instances(): + self._create_context.create_with_unified_error( + creator_identifier, product_name, instance_data, options + ) except CreatorsOperationFailed as exc: success = False @@ -661,6 +662,7 @@ def get_creator_attribute_definitions( value = None if attr_def.is_value_def: value = instance.creator_attributes[attr_def.key] + if found_idx is None: idx = len(output) output.append((attr_def, [instance_id], [value])) From 903c18c3e1e4dd999c9d15b35153a91fc56e4694 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:07:47 +0200 Subject: [PATCH 167/266] formatting fix --- client/ayon_core/pipeline/create/context.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 6872632a57..693a11db18 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1150,8 +1150,7 @@ def _bulk_context(self, key, sender): bulk_info.increase() if key not in self._bulk_order: - self._bulk_order.append(key -) + self._bulk_order.append(key) try: yield bulk_info finally: From cdc8e7c7a1449ad39a533db15086360b39731aa3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:08:00 +0200 Subject: [PATCH 168/266] added finished suffix to finishing methods --- client/ayon_core/pipeline/create/context.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 693a11db18..66c27f7159 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1180,11 +1180,11 @@ def _bulk_finish(self, key): elif key == "remove": self._bulk_remove_instances_finished(data, sender) elif key == "change": - self._bulk_values_change(data, sender) + self._bulk_values_change_finished(data, sender) elif key == "create_attrs_change": - self._bulk_create_attrs_change(data, sender) + self._bulk_create_attrs_change_finished(data, sender) elif key == "publish_attrs_change": - self._bulk_publish_attrs_change(data, sender) + self._bulk_publish_attrs_change_finished(data, sender) def _bulk_add_instances_finished(self, instances_to_validate, sender): if not instances_to_validate: @@ -1213,7 +1213,7 @@ def _bulk_remove_instances_finished(self, instances_to_remove, sender): sender, ) - def _bulk_values_change( + def _bulk_values_change_finished( self, changes: Tuple[Union[str, None], Dict[str, Any]], sender: Optional[str], @@ -1263,7 +1263,7 @@ def _bulk_values_change( sender ) - def _bulk_create_attrs_change( + def _bulk_create_attrs_change_finished( self, instance_ids: List[str], sender: Optional[str] ): if not instance_ids: @@ -1281,7 +1281,7 @@ def _bulk_create_attrs_change( sender, ) - def _bulk_publish_attrs_change( + def _bulk_publish_attrs_change_finished( self, attr_info: Tuple[str, Union[str, None]], sender: Optional[str], From 7338cffba3a05b4f7af64be0c880325f72120891 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:08:17 +0200 Subject: [PATCH 169/266] fix bulk order processing --- client/ayon_core/pipeline/create/context.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 66c27f7159..89f69bc452 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1162,14 +1162,15 @@ def _bulk_finished(self, key): if self._bulk_order[0] != key: return - self._bulk_finish(key) self._bulk_order.pop(0) + self._bulk_finish(key) - for key in tuple(self._bulk_order): + while self._bulk_order: + key = self._bulk_order[0] if not self._bulk_info[key]: - return - self._bulk_finish(key) + break self._bulk_order.pop(0) + self._bulk_finish(key) def _bulk_finish(self, key): bulk_info = self._bulk_info[key] From c6043316a70c69185dce3c2d20556796a58daa6a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:08:24 +0200 Subject: [PATCH 170/266] fix typo --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 89f69bc452..d86dbea9fa 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1004,7 +1004,7 @@ def create_with_unified_error(self, identifier, *args, **kwargs): def creator_removed_instance(self, instance: "CreatedInstance"): """When creator removes instance context should be acknowledged. - If creator removes instance conext should know about it to avoid + If creator removes instance context should know about it to avoid possible issues in the session. Args: From 092eacf534e6de6dd01637460807db89aedc0858 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:08:38 +0200 Subject: [PATCH 171/266] unpack tuple to variables --- client/ayon_core/tools/publisher/models/create.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 8f18e5f523..a2530cacc6 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -668,9 +668,9 @@ def get_creator_attribute_definitions( output.append((attr_def, [instance_id], [value])) _attr_defs[idx] = attr_def else: - item = output[found_idx] - item[1].append(instance_id) - item[2].append(value) + _, ids, values = output[found_idx] + ids.append(instance_id) + values.append(value) return output def set_instances_publish_attr_values( From dcbdfa09bddd98d2f7a3c2b59972a535cd1f8963 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 15:09:11 +0200 Subject: [PATCH 172/266] move private methods lower --- .../tools/publisher/widgets/product_info.py | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_info.py b/client/ayon_core/tools/publisher/widgets/product_info.py index c1d2037bd3..abd1408747 100644 --- a/client/ayon_core/tools/publisher/widgets/product_info.py +++ b/client/ayon_core/tools/publisher/widgets/product_info.py @@ -147,29 +147,6 @@ def __init__( self.top_bottom = top_bottom self.bottom_separator = bottom_separator - def _on_instance_context_changed(self): - instance_ids = { - instance.id - for instance in self._current_instances - } - context_info_by_id = self._controller.get_instances_context_info( - instance_ids - ) - all_valid = True - for instance_id, context_info in context_info_by_id.items(): - if not context_info.is_valid: - all_valid = False - break - - self._all_instances_valid = all_valid - 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() - def set_current_instances( self, instances, context_selected, convertor_identifiers ): @@ -213,6 +190,29 @@ def set_current_instances( self._update_thumbnails() + def _on_instance_context_changed(self): + instance_ids = { + instance.id + for instance in self._current_instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + all_valid = True + for instance_id, context_info in context_info_by_id.items(): + if not context_info.is_valid: + all_valid = False + break + + self._all_instances_valid = all_valid + 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() + def _on_thumbnail_create(self, path): instance_ids = [ instance.id From b68287f69defaff6848d2d4d6ab2c80e348eb286 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:35:18 +0200 Subject: [PATCH 173/266] refresh instances in separate method --- .../tools/publisher/widgets/product_info.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_info.py b/client/ayon_core/tools/publisher/widgets/product_info.py index abd1408747..9a7700d73d 100644 --- a/client/ayon_core/tools/publisher/widgets/product_info.py +++ b/client/ayon_core/tools/publisher/widgets/product_info.py @@ -119,7 +119,7 @@ def __init__( layout.addWidget(bottom_widget, 1) self._convertor_identifiers = None - self._current_instances = None + self._current_instances = [] self._context_selected = False self._all_instances_valid = True @@ -131,7 +131,8 @@ def __init__( thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear) controller.register_event_callback( - "instance.thumbnail.changed", self._on_thumbnail_changed + "instance.thumbnail.changed", + self._on_thumbnail_changed ) self._controller: AbstractPublisherFrontend = controller @@ -157,11 +158,18 @@ def set_current_instances( instances. context_selected (bool): Is context selected. convertor_identifiers (List[str]): Identifiers of convert items. + """ + s_convertor_identifiers = set(convertor_identifiers) + self._current_instances = instances + self._context_selected = context_selected + self._convertor_identifiers = s_convertor_identifiers + self._refresh_instances() + def _refresh_instances(self): instance_ids = { instance.id - for instance in instances + for instance in self._current_instances } context_info_by_id = self._controller.get_instances_context_info( instance_ids @@ -173,17 +181,15 @@ def set_current_instances( all_valid = False break - s_convertor_identifiers = set(convertor_identifiers) - self._convertor_identifiers = s_convertor_identifiers - self._current_instances = instances - self._context_selected = context_selected self._all_instances_valid = all_valid - self._convert_widget.setVisible(len(s_convertor_identifiers) > 0) - self.global_attrs_widget.set_current_instances(instances) + self._convert_widget.setVisible(len(self._convertor_identifiers) > 0) + self.global_attrs_widget.set_current_instances( + self._current_instances + ) self.creator_attrs_widget.set_current_instances(instance_ids) self.publish_attrs_widget.set_current_instances( - instance_ids, context_selected + instance_ids, self._context_selected ) self.creator_attrs_widget.set_instances_valid(all_valid) self.publish_attrs_widget.set_instances_valid(all_valid) From dd276c230a636c72e6ba1261eba01c0848363bd7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:47:47 +0200 Subject: [PATCH 174/266] fix typehings and costrings --- client/ayon_core/tools/publisher/abstract.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 196c1c938e..9a81fa0ac0 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -77,7 +77,7 @@ def emit_card_message( in future e.g. different message timeout or type (color). Args: - message (str): Message that will be showed. + message (str): Message that will be shown. message_type (Optional[str]): Message type. """ @@ -202,7 +202,7 @@ def register_event_callback(self, topic: str, callback: Callable): def is_host_valid(self) -> bool: """Host is valid for creation part. - Host must have implemented certain functionality to be able create + Host must have implemented certain functionality to be able to create in Publisher tool. Returns: @@ -333,7 +333,7 @@ def get_existing_product_names(self, folder_path: str) -> List[str]: @abstractmethod def get_creator_attribute_definitions( - self, instance_ids: List[str] + self, instance_ids: Iterable[str] ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: pass @@ -346,7 +346,7 @@ def set_instances_create_attr_values( @abstractmethod def get_publish_attribute_definitions( self, - instance_ids: List[str], + instance_ids: Iterable[str], include_context: bool ) -> List[Tuple[ str, @@ -398,7 +398,7 @@ def create( ): """Trigger creation by creator identifier. - Should also trigger refresh of instanes. + Should also trigger refresh of instances. Args: creator_identifier (str): Identifier of Creator plugin. @@ -461,8 +461,8 @@ def run_action(self, plugin_id: str, action_id: str): """Trigger pyblish action on a plugin. Args: - plugin_id (str): Id of publish plugin. - action_id (str): Id of publish action. + plugin_id (str): Publish plugin id. + action_id (str): Publish action id. """ pass @@ -601,7 +601,7 @@ def set_thumbnail_paths_for_instances( @abstractmethod def get_thumbnail_temp_dir_path(self) -> str: - """Return path to directory where thumbnails can be temporary stored. + """Path to directory where thumbnails can be temporarily stored. Returns: str: Path to a directory. From dec2fb740de0a31041bf2628f213285de014194d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:51:46 +0200 Subject: [PATCH 175/266] refresh create and publish attributes on change --- .../publisher/widgets/product_attributes.py | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py index a696907a72..6478748f7a 100644 --- a/client/ayon_core/tools/publisher/widgets/product_attributes.py +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -41,6 +41,10 @@ def __init__( "create.context.create.attrs.changed", self._on_instance_attr_defs_change ) + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) self._main_layout = main_layout @@ -67,6 +71,9 @@ def set_current_instances(self, instance_ids): """Set current instances for which are attribute definitions shown.""" self._current_instance_ids = set(instance_ids) + self._refresh_content() + + def _refresh_content(self): prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() @@ -78,7 +85,7 @@ def set_current_instances(self, instance_ids): self._attr_def_id_to_attr_def = {} result = self._controller.get_creator_attribute_definitions( - instance_ids + self._current_instance_ids ) content_widget = QtWidgets.QWidget(self._scroll_area) @@ -141,14 +148,21 @@ def set_current_instances(self, instance_ids): self._content_widget = content_widget def _on_instance_attr_defs_change(self, event): - update = False for instance_id in event.data["instance_ids"]: if instance_id in self._current_instance_ids: - update = True + self._refresh_content() break - if update: - self.set_current_instances(self._current_instance_ids) + def _on_instance_value_change(self, event): + # TODO try to find more optimized way to update values instead of + # force refresh of all of them. + for instance_id, changes in event["instance_changes"].items(): + if ( + instance_id in self._current_instance_ids + and "creator_attributes" not in changes + ): + self._refresh_content() + break def _input_value_changed(self, value, attr_id): instance_ids = self._attr_def_id_to_instances.get(attr_id) @@ -161,9 +175,9 @@ def _input_value_changed(self, value, attr_id): class PublishPluginAttrsWidget(QtWidgets.QWidget): - """Widget showing publsish plugin attributes for selected instances. + """Widget showing publish plugin attributes for selected instances. - Attributes are defined on publish plugins. Publihs plugin may define + Attributes are defined on publish plugins. Publish plugin may define attribute definitions but must inherit `AYONPyblishPluginMixin` (~/ayon_core/pipeline/publish). At the moment requires to implement `get_attribute_defs` and `convert_attribute_values` class methods. @@ -192,6 +206,18 @@ def __init__( main_layout.setSpacing(0) main_layout.addWidget(scroll_area, 1) + controller.register_event_callback( + "create.context.publish.attrs.changed", + self._on_instance_attr_defs_change + ) + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) + + self._instance_ids = set() + self._context_selected = False + self._main_layout = main_layout self._controller: AbstractPublisherFrontend = controller @@ -215,6 +241,11 @@ def set_instances_valid(self, valid): def set_current_instances(self, instance_ids, context_selected): """Set current instances for which are attribute definitions shown.""" + self._instance_ids = set(instance_ids) + self._context_selected = context_selected + self._refresh_content() + + def _refresh_content(self): prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() @@ -228,7 +259,7 @@ def set_current_instances(self, instance_ids, context_selected): self._attr_def_id_to_plugin_name = {} result = self._controller.get_publish_attribute_definitions( - instance_ids, context_selected + self._instance_ids, self._context_selected ) content_widget = QtWidgets.QWidget(self._scroll_area) @@ -253,8 +284,8 @@ def set_current_instances(self, instance_ids, context_selected): ) hidden_widget = attr_def.hidden # Hide unknown values of publish plugins - # - The keys in most of cases does not represent what would - # label represent + # - 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 @@ -323,3 +354,23 @@ def _input_value_changed(self, value, attr_id): self._controller.set_instances_publish_attr_values( instance_ids, plugin_name, attr_def.key, value ) + + def _on_instance_attr_defs_change(self, event): + for instance_id in event.data: + if ( + instance_id is None and self._context_selected + or instance_id in self._instance_ids + ): + self._refresh_content() + break + + def _on_instance_value_change(self, event): + # TODO try to find more optimized way to update values instead of + # force refresh of all of them. + for instance_id, changes in event["instance_changes"].items(): + if ( + instance_id in self._current_instance_ids + and "publish_attributes" not in changes + ): + self._refresh_content() + break From c4f5475014aee6b905c90e5e07026616cc98e019 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 16:51:52 +0200 Subject: [PATCH 176/266] added missing import --- client/ayon_core/tools/publisher/models/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/__init__.py b/client/ayon_core/tools/publisher/models/__init__.py index 07f061deaa..26eeb3cdbb 100644 --- a/client/ayon_core/tools/publisher/models/__init__.py +++ b/client/ayon_core/tools/publisher/models/__init__.py @@ -1,10 +1,11 @@ -from .create import CreateModel, CreatorItem +from .create import CreateModel, CreatorItem, InstanceItem from .publish import PublishModel, PublishErrorInfo __all__ = ( "CreateModel", "CreatorItem", + "InstanceItem", "PublishModel", "PublishErrorInfo", From 279b2f886b23841247836287740ddf6d71daa62e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:22:36 +0200 Subject: [PATCH 177/266] there is option to update precreate attributes --- client/ayon_core/pipeline/create/context.py | 452 +++++++++++------- .../pipeline/create/creator_plugins.py | 10 + 2 files changed, 278 insertions(+), 184 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index d86dbea9fa..9cde82db10 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -72,6 +72,7 @@ INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" +PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed" CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed" PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed" @@ -233,11 +234,14 @@ def __init__( # using context manager which will trigger validation # after leaving of last context manager scope self._bulk_info = { - # Collect instances + # Added instances "add": BulkInfo(), + # Removed instances "remove": BulkInfo(), # Change values of instances or create context "change": BulkInfo(), + # Pre create attribute definitions changed + "pre_create_attrs_change": BulkInfo(), # Create attribute definitions changed "create_attrs_change": BulkInfo(), # Publish attribute definitions changed @@ -538,7 +542,7 @@ def refresh_thumbnails(self): """Cleanup thumbnail paths. Remove all thumbnail filepaths that are empty or lead to files which - does not exists or of instances that are not available anymore. + does not exist or of instances that are not available anymore. """ invalid = set() @@ -1088,6 +1092,11 @@ def bulk_value_changes(self, sender=None): with self._bulk_context("change", sender) as bulk_info: yield bulk_info + @contextmanager + def bulk_pre_create_attr_defs_change(self, sender=None): + with self._bulk_context("pre_create_attrs_change", sender) as bulk_info: + yield bulk_info + @contextmanager def bulk_create_attr_defs_change(self, sender=None): with self._bulk_context("create_attrs_change", sender) as bulk_info: @@ -1099,41 +1108,78 @@ def bulk_publish_attr_defs_change(self, sender=None): yield bulk_info # --- instance change callbacks --- - def _is_instance_events_ready(self, instance_id): - # Context is ready - if instance_id is None: - return True - # Instance is not in yet in context - if instance_id not in self._instances_by_id: - return False + def create_plugin_pre_create_attr_defs_changed(self, identifier: str): + """Create plugin pre-create attributes changed. - # Instance in 'collect' bulk will be ignored - for instance in self._bulk_info["add"].get_data(): - if instance.id == instance_id: - return False - return True + Triggered by 'Creator'. + + Args: + identifier (str): Create plugin identifier. + + """ + with self.bulk_pre_create_attr_defs_change() as bulk_item: + bulk_item.append(identifier) + + def instance_create_attr_defs_changed(self, instance_id: str): + """Instance attribute definitions changed. - def instance_create_attr_defs_changed(self, instance_id): + Triggered by instance 'CreatorAttributeValues' on instance. + + Args: + instance_id (str): Instance id. + + """ if self._is_instance_events_ready(instance_id): with self.bulk_create_attr_defs_change() as bulk_item: bulk_item.append(instance_id) def instance_publish_attr_defs_changed( - self, instance_id, plugin_name + self, instance_id: Optional[str], plugin_name: str ): + """Instance attribute definitions changed. + + Triggered by instance 'PublishAttributeValues' on instance. + + Args: + instance_id (Optional[str]): Instance id or None for context. + plugin_name (str): Plugin name which attribute definitions were + changed. + + """ if self._is_instance_events_ready(instance_id): with self.bulk_publish_attr_defs_change() as bulk_item: bulk_item.append((instance_id, plugin_name)) def instance_values_changed( - self, instance_id, new_values + self, instance_id: Optional[str], new_values: Dict[str, Any] ): + """Instance value changed. + + Triggered by `CreatedInstance, 'CreatorAttributeValues' + or 'PublishAttributeValues' on instance. + + Args: + instance_id (Optional[str]): Instance id or None for context. + new_values (Dict[str, Any]): Changed values. + + """ if self._is_instance_events_ready(instance_id): with self.bulk_value_changes() as bulk_item: bulk_item.append((instance_id, new_values)) # --- context change callbacks --- - def publish_attribute_value_changed(self, plugin_name, value): + def publish_attribute_value_changed( + self, plugin_name: str, value: Dict[str, Any] + ): + """Context publish attribute values changed. + + Triggered by instance 'PublishAttributeValues' on context. + + Args: + plugin_name (str): Plugin name which changed value. + value (Dict[str, Any]): Changed values. + + """ self.instance_values_changed( None, { @@ -1143,172 +1189,6 @@ def publish_attribute_value_changed(self, plugin_name, value): }, ) - @contextmanager - def _bulk_context(self, key, sender): - bulk_info = self._bulk_info[key] - bulk_info.set_sender(sender) - - bulk_info.increase() - if key not in self._bulk_order: - self._bulk_order.append(key) - try: - yield bulk_info - finally: - bulk_info.decrease() - if bulk_info: - self._bulk_finished(key) - - def _bulk_finished(self, key): - if self._bulk_order[0] != key: - return - - self._bulk_order.pop(0) - self._bulk_finish(key) - - while self._bulk_order: - key = self._bulk_order[0] - if not self._bulk_info[key]: - break - self._bulk_order.pop(0) - self._bulk_finish(key) - - def _bulk_finish(self, key): - bulk_info = self._bulk_info[key] - sender = bulk_info.get_sender() - data = bulk_info.pop_data() - if key == "add": - self._bulk_add_instances_finished(data, sender) - elif key == "remove": - self._bulk_remove_instances_finished(data, sender) - elif key == "change": - self._bulk_values_change_finished(data, sender) - elif key == "create_attrs_change": - self._bulk_create_attrs_change_finished(data, sender) - elif key == "publish_attrs_change": - self._bulk_publish_attrs_change_finished(data, sender) - - def _bulk_add_instances_finished(self, instances_to_validate, sender): - if not instances_to_validate: - return - - # Cache folder and task entities for all instances at once - self.get_instances_context_info(instances_to_validate) - - self._emit_event( - INSTANCE_ADDED_TOPIC, - { - "instances": instances_to_validate, - }, - sender, - ) - - def _bulk_remove_instances_finished(self, instances_to_remove, sender): - if not instances_to_remove: - return - - self._emit_event( - INSTANCE_REMOVED_TOPIC, - { - "instances": instances_to_remove, - }, - sender, - ) - - def _bulk_values_change_finished( - self, - changes: Tuple[Union[str, None], Dict[str, Any]], - sender: Optional[str], - ): - if not changes: - return - item_data_by_id = {} - for item_id, item_changes in changes: - item_values = item_data_by_id.setdefault(item_id, {}) - if "creator_attributes" in item_changes: - current_value = item_values.setdefault( - "creator_attributes", {} - ) - current_value.update( - item_changes.pop("creator_attributes") - ) - - if "publish_attributes" in item_changes: - current_publish = item_values.setdefault( - "publish_attributes", {} - ) - for plugin_name, plugin_value in item_changes.pop( - "publish_attributes" - ).items(): - plugin_changes = current_publish.setdefault( - plugin_name, {} - ) - plugin_changes.update(plugin_value) - - item_values.update(item_changes) - - event_changes = [] - for item_id, item_changes in item_data_by_id.items(): - instance = self.get_instance_by_id(item_id) - event_changes.append({ - "instance": instance, - "changes": item_changes, - }) - - event_data = { - "changes": event_changes, - } - - self._emit_event( - VALUE_CHANGED_TOPIC, - event_data, - sender - ) - - def _bulk_create_attrs_change_finished( - self, instance_ids: List[str], sender: Optional[str] - ): - if not instance_ids: - return - - instances = [ - self.get_instance_by_id(instance_id) - for instance_id in set(instance_ids) - ] - self._emit_event( - CREATE_ATTR_DEFS_CHANGED_TOPIC, - { - "instances": instances, - }, - sender, - ) - - def _bulk_publish_attrs_change_finished( - self, - attr_info: Tuple[str, Union[str, None]], - sender: Optional[str], - ): - if not attr_info: - return - - instance_changes = {} - for instance_id, plugin_name in attr_info: - instance_data = instance_changes.setdefault( - instance_id, - { - "instance": None, - "plugin_names": set(), - } - ) - instance = self.get_instance_by_id(instance_id) - instance_data["instance"] = instance - instance_data["plugin_names"].add(plugin_name) - - self._emit_event( - PUBLISH_ATTR_DEFS_CHANGED_TOPIC, - {"instance_changes": instance_changes}, - sender, - ) - def reset_instances(self): """Reload instances""" self._instances_by_id = collections.OrderedDict() @@ -1809,3 +1689,207 @@ def _create_with_unified_error( identifier, label, exc_info, add_traceback ) return result, fail_info + + def _is_instance_events_ready(self, instance_id: Optional[str]) -> bool: + # Context is ready + if instance_id is None: + return True + # Instance is not in yet in context + if instance_id not in self._instances_by_id: + return False + + # Instance in 'collect' bulk will be ignored + for instance in self._bulk_info["add"].get_data(): + if instance.id == instance_id: + return False + return True + + @contextmanager + def _bulk_context(self, key: str, sender: Optional[str]): + bulk_info = self._bulk_info[key] + bulk_info.set_sender(sender) + + bulk_info.increase() + if key not in self._bulk_order: + self._bulk_order.append(key) + try: + yield bulk_info + finally: + bulk_info.decrease() + if bulk_info: + self._bulk_finished(key) + + def _bulk_finished(self, key: str): + if self._bulk_order[0] != key: + return + + self._bulk_order.pop(0) + self._bulk_finish(key) + + while self._bulk_order: + key = self._bulk_order[0] + if not self._bulk_info[key]: + break + self._bulk_order.pop(0) + self._bulk_finish(key) + + def _bulk_finish(self, key: str): + bulk_info = self._bulk_info[key] + sender = bulk_info.get_sender() + data = bulk_info.pop_data() + if key == "add": + self._bulk_add_instances_finished(data, sender) + elif key == "remove": + self._bulk_remove_instances_finished(data, sender) + elif key == "change": + self._bulk_values_change_finished(data, sender) + elif key == "pre_create_attrs_change": + self._bulk_pre_create_attrs_change_finished(data, sender) + elif key == "create_attrs_change": + self._bulk_create_attrs_change_finished(data, sender) + elif key == "publish_attrs_change": + self._bulk_publish_attrs_change_finished(data, sender) + + def _bulk_add_instances_finished( + self, + instances_to_validate: List["CreatedInstance"], + sender: Optional[str] + ): + if not instances_to_validate: + return + + # Cache folder and task entities for all instances at once + self.get_instances_context_info(instances_to_validate) + + self._emit_event( + INSTANCE_ADDED_TOPIC, + { + "instances": instances_to_validate, + }, + sender, + ) + + def _bulk_remove_instances_finished( + self, + instances_to_remove: List["CreatedInstance"], + sender: Optional[str] + ): + if not instances_to_remove: + return + + self._emit_event( + INSTANCE_REMOVED_TOPIC, + { + "instances": instances_to_remove, + }, + sender, + ) + + def _bulk_values_change_finished( + self, + changes: Tuple[Union[str, None], Dict[str, Any]], + sender: Optional[str], + ): + if not changes: + return + item_data_by_id = {} + for item_id, item_changes in changes: + item_values = item_data_by_id.setdefault(item_id, {}) + if "creator_attributes" in item_changes: + current_value = item_values.setdefault( + "creator_attributes", {} + ) + current_value.update( + item_changes.pop("creator_attributes") + ) + + if "publish_attributes" in item_changes: + current_publish = item_values.setdefault( + "publish_attributes", {} + ) + for plugin_name, plugin_value in item_changes.pop( + "publish_attributes" + ).items(): + plugin_changes = current_publish.setdefault( + plugin_name, {} + ) + plugin_changes.update(plugin_value) + + item_values.update(item_changes) + + event_changes = [] + for item_id, item_changes in item_data_by_id.items(): + instance = self.get_instance_by_id(item_id) + event_changes.append({ + "instance": instance, + "changes": item_changes, + }) + + event_data = { + "changes": event_changes, + } + + self._emit_event( + VALUE_CHANGED_TOPIC, + event_data, + sender + ) + + def _bulk_pre_create_attrs_change_finished( + self, identifiers: List[str], sender: Optional[str] + ): + if not identifiers: + return + identifiers = list(set(identifiers)) + self._emit_event( + PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, + { + "identifiers": identifiers, + }, + sender, + ) + + def _bulk_create_attrs_change_finished( + self, instance_ids: List[str], sender: Optional[str] + ): + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + self._emit_event( + CREATE_ATTR_DEFS_CHANGED_TOPIC, + { + "instances": instances, + }, + sender, + ) + + def _bulk_publish_attrs_change_finished( + self, + attr_info: Tuple[str, Union[str, None]], + sender: Optional[str], + ): + if not attr_info: + return + + instance_changes = {} + for instance_id, plugin_name in attr_info: + instance_data = instance_changes.setdefault( + instance_id, + { + "instance": None, + "plugin_names": set(), + } + ) + instance = self.get_instance_by_id(instance_id) + instance_data["instance"] = instance + instance_data["plugin_names"].add(plugin_name) + + self._emit_event( + PUBLISH_ATTR_DEFS_CHANGED_TOPIC, + {"instance_changes": instance_changes}, + sender, + ) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 8d7ede1fa6..fe41d2fe65 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -831,6 +831,16 @@ def get_pre_create_attr_defs(self): """ return self.pre_create_attr_defs + def _pre_create_attr_defs_changed(self): + """Called when pre-create attribute definitions change. + + Create plugin can call this method when knows that + 'get_pre_create_attr_defs' should be called again. + """ + self.create_context.create_plugin_pre_create_attr_defs_changed( + self.identifier + ) + class HiddenCreator(BaseCreator): @abstractmethod From cef35485eec06746ba4bbee520ea1b55a10c1974 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:23:05 +0200 Subject: [PATCH 178/266] fix variable name --- .../tools/publisher/widgets/product_attributes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py index 6478748f7a..7372e66efe 100644 --- a/client/ayon_core/tools/publisher/widgets/product_attributes.py +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -215,7 +215,7 @@ def __init__( self._on_instance_value_change ) - self._instance_ids = set() + self._current_instance_ids = set() self._context_selected = False self._main_layout = main_layout @@ -241,7 +241,7 @@ def set_instances_valid(self, valid): def set_current_instances(self, instance_ids, context_selected): """Set current instances for which are attribute definitions shown.""" - self._instance_ids = set(instance_ids) + self._current_instance_ids = set(instance_ids) self._context_selected = context_selected self._refresh_content() @@ -259,7 +259,7 @@ def _refresh_content(self): self._attr_def_id_to_plugin_name = {} result = self._controller.get_publish_attribute_definitions( - self._instance_ids, self._context_selected + self._current_instance_ids, self._context_selected ) content_widget = QtWidgets.QWidget(self._scroll_area) @@ -359,7 +359,7 @@ def _on_instance_attr_defs_change(self, event): for instance_id in event.data: if ( instance_id is None and self._context_selected - or instance_id in self._instance_ids + or instance_id in self._current_instance_ids ): self._refresh_content() break From 7537cf8ebc2f5733c6bc69f155ca267c668882f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 17:45:48 +0200 Subject: [PATCH 179/266] update pre-create attributes on change --- client/ayon_core/pipeline/create/context.py | 5 ++++ client/ayon_core/tools/publisher/abstract.py | 20 +++++++++++++ .../tools/publisher/models/create.py | 28 ++++++++++++++++- .../tools/publisher/widgets/create_widget.py | 30 ++++++++++++++----- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 9cde82db10..3245b68699 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -801,6 +801,11 @@ def listen_to_removed_instances(self, callback): def listen_to_value_changes(self, callback): self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) + def listen_to_pre_create_attr_defs_change(self, callback): + self._event_hub.add_callback( + PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback + ) + def listen_to_create_attr_defs_change(self, callback): self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 9a81fa0ac0..bc72947551 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -265,6 +265,11 @@ def are_folder_paths_valid(self, folder_paths: Iterable[str]) -> bool: """ pass + @abstractmethod + def get_folder_id_from_path(self, folder_path: str) -> Optional[str]: + """Get folder id from folder path.""" + pass + # --- Create --- @abstractmethod def get_creator_items(self) -> Dict[str, "CreatorItem"]: @@ -276,6 +281,21 @@ def get_creator_items(self) -> Dict[str, "CreatorItem"]: """ pass + @abstractmethod + def get_creator_item_by_id( + self, identifier: str + ) -> Optional["CreatorItem"]: + """Get creator item by identifier. + + Args: + identifier (str): Create plugin identifier. + + Returns: + Optional[CreatorItem]: Creator item or None. + + """ + pass + @abstractmethod def get_creator_icon( self, identifier: str diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index a2530cacc6..f6f9789514 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -374,6 +374,9 @@ def reset(self): self._create_context.listen_to_value_changes( self._cc_value_changed ) + self._create_context.listen_to_pre_create_attr_defs_change( + self._cc_pre_create_attr_changed + ) self._create_context.listen_to_create_attr_defs_change( self._cc_create_attr_changed ) @@ -386,7 +389,7 @@ def reset(self): def get_creator_items(self) -> Dict[str, CreatorItem]: """Creators that can be shown in create dialog.""" if self._creator_items is None: - self._creator_items = self._collect_creator_items() + self._refresh_creator_items() return self._creator_items def get_creator_item_by_id( @@ -883,6 +886,21 @@ def _collect_creator_items(self) -> Dict[str, CreatorItem]: return output + def _refresh_creator_items(self, identifiers=None): + if identifiers is None: + self._creator_items = self._collect_creator_items() + return + + for identifier in identifiers: + if identifier not in self._creator_items: + continue + creator = self._create_context.creators.get(identifier) + if creator is None: + continue + self._creator_items[identifier] = ( + CreatorItem.from_creator(creator) + ) + def _cc_added_instance(self, event): instance_ids = { instance.id @@ -919,6 +937,14 @@ def _cc_value_changed(self, event): {"instance_changes": instance_changes}, ) + def _cc_pre_create_attr_changed(self, event): + identifiers = event["identifiers"] + self._refresh_creator_items(identifiers) + self._emit_event( + "create.context.pre.create.attrs.changed", + {"identifiers": identifiers}, + ) + def _cc_create_attr_changed(self, event): instance_ids = { instance.id diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index 4c94c5c9b9..aecea2ec44 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -111,7 +111,7 @@ def __init__(self, controller, parent=None): self._folder_path = None self._product_names = None - self._selected_creator = None + self._selected_creator_identifier = None self._prereq_available = False @@ -262,6 +262,10 @@ def __init__(self, controller, parent=None): controller.register_event_callback( "controller.reset.finished", self._on_controler_reset ) + controller.register_event_callback( + "create.context.pre.create.attrs.changed", + self._pre_create_attr_changed + ) self._main_splitter_widget = main_splitter_widget @@ -512,6 +516,15 @@ def _on_controler_reset(self): # Trigger refresh only if is visible self.refresh() + def _pre_create_attr_changed(self, event): + if ( + self._selected_creator_identifier is None + or self._selected_creator_identifier not in event["identifiers"] + ): + return + + self._set_creator_by_identifier(self._selected_creator_identifier) + def _on_folder_change(self): self._refresh_product_name() if self._context_change_is_enabled(): @@ -563,12 +576,13 @@ def _set_creator(self, creator_item): self._set_creator_detailed_text(creator_item) self._pre_create_widget.set_creator_item(creator_item) - self._selected_creator = creator_item - if not creator_item: + self._selected_creator_identifier = None self._set_context_enabled(False) return + self._selected_creator_identifier = creator_item.identifier + if ( creator_item.create_allow_context_change != self._context_change_is_enabled() @@ -603,7 +617,7 @@ def _on_variant_change(self, variant_value=None): return # This should probably never happen? - if not self._selected_creator: + if not self._selected_creator_identifier: if self.product_name_input.text(): self.product_name_input.setText("") return @@ -625,11 +639,13 @@ def _on_variant_change(self, variant_value=None): folder_path = self._get_folder_path() task_name = self._get_task_name() - creator_idenfier = self._selected_creator.identifier # Calculate product name with Creator plugin try: product_name = self._controller.get_product_name( - creator_idenfier, variant_value, task_name, folder_path + self._selected_creator_identifier, + variant_value, + task_name, + folder_path ) except TaskNotSetError: self._create_btn.setEnabled(False) @@ -755,7 +771,7 @@ def _on_create(self): ) if success: - self._set_creator(self._selected_creator) + self._set_creator_by_identifier(self._selected_creator_identifier) self._variant_widget.setText(variant) self._controller.emit_card_message("Creation finished...") self._last_thumbnail_path = None From b35055c3c7e6c0e77c32624d94b3004bad22d2ef Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 19:01:16 +0200 Subject: [PATCH 180/266] added method to update context --- client/ayon_core/tools/publisher/abstract.py | 6 ++++++ client/ayon_core/tools/publisher/control.py | 5 +++++ client/ayon_core/tools/publisher/models/create.py | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index bc72947551..3a968eee28 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -347,6 +347,12 @@ def get_instances_context_info( ): pass + @abstractmethod + def set_instances_context_info( + self, changes_by_instance_id: Dict[str, Dict[str, Any]] + ): + 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 8664cfe605..43b491a20f 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -195,6 +195,11 @@ def get_instance_items_by_id(self, instance_ids=None): def get_instances_context_info(self, instance_ids=None): return self._create_model.get_instances_context_info(instance_ids) + def set_instances_context_info(self, changes_by_instance_id): + return self._create_model.set_instances_context_info( + changes_by_instance_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 f6f9789514..efc761a407 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -447,6 +447,13 @@ def get_instances_context_info( instances ) + def set_instances_context_info(self, changes_by_instance_id): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id, changes in changes_by_instance_id.items(): + instance = self._get_instance_by_id(instance_id) + for key, value in changes.items(): + instance[key] = value + def get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id From f7dc16d9bfe9be0ce38a0d915d3c7170244aeaf8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 1 Oct 2024 19:01:34 +0200 Subject: [PATCH 181/266] update context on instance on context change --- .../publisher/widgets/product_context.py | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index b66d67717c..977f5eccb3 100644 --- a/client/ayon_core/tools/publisher/widgets/product_context.py +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -632,7 +632,7 @@ def __init__( super().__init__(parent) self._controller: AbstractPublisherFrontend = controller - self._current_instances = [] + self._current_instances_by_id = {} variant_input = VariantInputWidget(self) folder_value_widget = FoldersFields(controller, self) @@ -678,6 +678,11 @@ def __init__( submit_btn.clicked.connect(self._on_submit) cancel_btn.clicked.connect(self._on_cancel) + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) + self.variant_input = variant_input self.folder_value_widget = folder_value_widget self.task_value_widget = task_value_widget @@ -704,21 +709,26 @@ def _on_submit(self): product_names = set() invalid_tasks = False folder_paths = [] - for instance in self._current_instances: + changes_by_id = {} + for instance in self._current_instances_by_id.values(): # Ignore instances that have promised context if instance.has_promised_context: continue + instance_changes = {} new_variant_value = instance.variant new_folder_path = instance.folder_path new_task_name = instance.task_name if variant_value is not None: + instance_changes["variant"] = variant_value new_variant_value = variant_value if folder_path is not None: + instance_changes["folderPath"] = folder_path new_folder_path = folder_path if task_name is not None: + instance_changes["task"] = task_name new_task_name = task_name folder_paths.append(new_folder_path) @@ -747,6 +757,9 @@ def _on_submit(self): instance.task_name = task_name or None instance.product_name = new_product_name + if instance.product_name != new_product_name: + instance_changes["productName"] = new_product_name + changes_by_id[instance.id] = instance_changes if invalid_tasks: self.task_value_widget.set_invalid_empty_task() @@ -765,6 +778,7 @@ def _on_submit(self): if task_name is not None: self.task_value_widget.confirm_value(folder_paths) + self._controller.set_instances_context_info(changes_by_id) self.instance_context_changed.emit() def _on_cancel(self): @@ -818,20 +832,25 @@ def set_current_instances(self, instances): """ self._set_btns_visible(False) - self._current_instances = instances + self._current_instances_by_id = { + instance.id: instance + for instance in instances + } + self._refresh_content() + def _refresh_content(self): folder_paths = set() variants = set() product_types = set() product_names = set() editable = True - if len(instances) == 0: + if len(self._current_instances_by_id) == 0: editable = False folder_task_combinations = [] context_editable = None - for instance in instances: + for instance in self._current_instances_by_id.values(): if not instance.has_promised_context: context_editable = True elif context_editable is None: @@ -879,3 +898,31 @@ def set_current_instances(self, instances): self.folder_value_widget.setToolTip(folder_tooltip) self.task_value_widget.setToolTip(task_tooltip) + + def _on_instance_value_change(self, event): + if not self._current_instances_by_id: + return + + changed = False + for instance_id, changes in event["instance_changes"].items(): + instance = self._current_instances_by_id.get(instance_id) + if instance is None: + continue + + for key, attr_name in ( + ("folderPath", "folder_path"), + ("task", "task_name"), + ("variant", "variant"), + ("productType", "product_type"), + ("productName", "product_name"), + ): + if key in changes: + setattr(instance, attr_name, changes[key]) + changed = True + break + if changed: + break + + if changed: + self._refresh_content() + self.instance_context_changed.emit() From d69edc69d5ce5201f9185402395374f8e58416ea Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 1 Oct 2024 17:10:41 -0400 Subject: [PATCH 182/266] Add backward-compatibility for relative source ranges. --- client/ayon_core/pipeline/editorial.py | 33 ++- .../resources/legacy_img_sequence.json | 216 ++++++++++++++++++ .../test_media_range_with_retimes.py | 20 ++ 3 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/legacy_img_sequence.json diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 2dc15bd645..8d81737533 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -178,6 +178,30 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): available_range = otio_clip.available_range() available_range_rate = available_range.start_time.rate + # If media source is an image sequence, returned + # mediaIn/mediaOut have to correspond + # to frame numbers from source sequence. + media_ref = otio_clip.media_reference + is_input_sequence = ( + hasattr(otio.schema, "ImageSequenceReference") + and isinstance(media_ref, otio.schema.ImageSequenceReference) + ) + + # Temporary. + # Some AYON custom OTIO exporter were implemented with relative + # source range for image sequence. Following code maintain + # backward-compatibility by adjusting available range + # while we are updating those. + if ( + is_input_sequence + and available_range.start_time.to_frames() == media_ref.start_frame + and source_range.start_time.to_frames() < media_ref.start_frame + ): + available_range = _ot.TimeRange( + _ot.RationalTime(0, rate=available_range_rate), + available_range.duration, + ) + # Conform source range bounds to available range rate # .e.g. embedded TC of (3600 sec/ 1h), duration 100 frames # @@ -272,15 +296,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): media_in = available_range.start_time.value media_out = available_range.end_time_inclusive().value - # If media source is an image sequence, returned - # mediaIn/mediaOut have to correspond - # to frame numbers from source sequence. - media_ref = otio_clip.media_reference - is_input_sequence = ( - hasattr(otio.schema, "ImageSequenceReference") - and isinstance(media_ref, otio.schema.ImageSequenceReference) - ) - if is_input_sequence: # preserve discrete frame numbers media_in_trimmed = otio.opentime.RationalTime.from_frames( diff --git a/tests/client/ayon_core/pipeline/editorial/resources/legacy_img_sequence.json b/tests/client/ayon_core/pipeline/editorial/resources/legacy_img_sequence.json new file mode 100644 index 0000000000..a50ca102fe --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/legacy_img_sequence.json @@ -0,0 +1,216 @@ +{ + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output.[1000-1100].exr", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 104.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [ + { + "OTIO_SCHEMA": "Marker.2", + "metadata": { + "clip_index": "18b19490-21ea-4533-9e0c-03f434c66f14", + "publish": true, + "resolve_sub_products": { + "io.ayon.creators.resolve.plate": { + "active": true, + "clip_index": "18b19490-21ea-4533-9e0c-03f434c66f14", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "parentInstance": "/shots/sq_old_otio/sh010 shot", + "vSyncOn": true, + "vSyncTrack": "Video 1" + }, + "creator_identifier": "io.ayon.creators.resolve.plate", + "episode": "ep01", + "folder": "/shots/sq_old_otio/sh010", + "folderPath": "/shots/sq_old_otio/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq_old_otio", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq_old_otio", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "d27c5f77-7218-44ca-8061-5b6d33f96116", + "label": "/shots/sq_old_otio/sh010 plate", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parent_instance_id": "24792946-9ac4-4c8d-922f-80a83dea4be1", + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq_old_otio", + "folder_type": "sequence" + } + ], + "productName": "plateVideo_1", + "productType": "plate", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video 1", + "sequence": "sq_old_otio", + "shot": "sh###", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "uuid": "dec1a40b-7ce8-43cd-94b8-08a53056a171", + "variant": "Video_1", + "workfileFrameStart": 1001 + }, + "io.ayon.creators.resolve.shot": { + "active": true, + "clip_index": "18b19490-21ea-4533-9e0c-03f434c66f14", + "clip_source_resolution": { + "height": "720", + "pixelAspect": 1.0, + "width": "956" + }, + "clip_variant": "", + "creator_attributes": { + "clipDuration": 104, + "clipIn": 90000, + "clipOut": 90104, + "fps": "from_selection", + "frameEnd": 1105, + "frameStart": 1001, + "handleEnd": 10, + "handleStart": 10, + "sourceIn": 0, + "sourceOut": 104, + "workfileFrameStart": 1001 + }, + "creator_identifier": "io.ayon.creators.resolve.shot", + "episode": "ep01", + "folder": "/shots/sq_old_otio/sh010", + "folderPath": "/shots/sq_old_otio/sh010", + "handleEnd": 10, + "handleStart": 10, + "has_promised_context": true, + "heroTrack": true, + "hierarchy": "shots/sq_old_otio", + "hierarchyData": { + "episode": "ep01", + "folder": "shots", + "sequence": "sq_old_otio", + "shot": "sh010", + "track": "Video_1" + }, + "id": "pyblish.avalon.instance", + "instance_id": "24792946-9ac4-4c8d-922f-80a83dea4be1", + "label": "/shots/sq_old_otio/sh010 shot", + "newAssetPublishing": true, + "newHierarchyIntegration": true, + "parents": [ + { + "entity_name": "shots", + "folder_type": "folder" + }, + { + "entity_name": "sq_old_otio", + "folder_type": "sequence" + } + ], + "productName": "shotMain", + "productType": "shot", + "publish_attributes": { + "CollectSlackFamilies": { + "additional_message": "" + } + }, + "reviewTrack": "Video 1", + "sequence": "sq_old_otio", + "shot": "sh###", + "sourceResolution": false, + "task": null, + "track": "{_track_}", + "uuid": "dec1a40b-7ce8-43cd-94b8-08a53056a171", + "variant": "Main", + "workfileFrameStart": 1001 + } + } + }, + "name": "AYONData", + "color": "MINT", + "marked_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 52.0 + } + }, + "comment": "" + } + ], + "enabled": true, + "media_references": { + "DEFAULT_MEDIA": { + "OTIO_SCHEMA": "ImageSequenceReference.1", + "metadata": { + "height": 720, + "isSequence": true, + "padding": 4, + "pixelAspect": 1.0, + "width": 956 + }, + "name": "", + "available_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\legacy\\", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "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 270b01a799..e5f0d335b5 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 @@ -146,3 +146,23 @@ def test_img_sequence_with_embedded_tc_and_handles(): "img_seq_embedded_tc.json", expected_data ) + + +def test_img_sequence_relative_source_range(): + """ + Img sequence clip (embedded timecode 1h) + available files = 1000-1100 + source_range = fps + """ + expected_data = { + 'mediaIn': 1000, + 'mediaOut': 1098, + 'handleStart': 0, + 'handleEnd': 2, + 'speed': 1.0 + } + + _check_expected_retimed_values( + "legacy_img_sequence.json", + expected_data + ) From 8182914f112a2790437c986a09d54b08102ce6fb Mon Sep 17 00:00:00 2001 From: "robin@ynput.io" Date: Tue, 1 Oct 2024 17:48:44 -0400 Subject: [PATCH 183/266] Fix linting. --- client/ayon_core/pipeline/editorial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 8d81737533..f42c0a2fe5 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -190,7 +190,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # Temporary. # Some AYON custom OTIO exporter were implemented with relative # source range for image sequence. Following code maintain - # backward-compatibility by adjusting available range + # backward-compatibility by adjusting available range # while we are updating those. if ( is_input_sequence From 1a64490256063dc33fd9e9990329e5182b339838 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Oct 2024 00:26:39 +0200 Subject: [PATCH 184/266] Allow representation switch on update if representation does not exist (e.g. `ma` -> `mb` representation) --- client/ayon_core/pipeline/load/plugins.py | 20 +++++++++++ client/ayon_core/pipeline/load/utils.py | 43 ++++++++++++++++------- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 2475800cbb..28a7e775d6 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -242,6 +242,26 @@ def fname(self): if hasattr(self, "_fname"): return self._fname + def update_allowed_representation_switches(self): + """Return a mapping from source representation names to ordered + destination representation names to which switching is allowed if + the source representation name does not exist for the new version. + + For example, to allow an automated switch on update from representation + `ma` to `mb` or `abc` if the new version does not have a `ma` + representation you can return: + {"ma": ["mb", "abc"]} + + The order of the names in the returned values is important, because + if `ma` is missing and both of the replacement representations are + present than the first one will be chosen. + + Returns: + Dict[str, List[str]]: Mapping from representation names to allowed + alias representation names switching to is allowed on update. + """ + 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..bb74194ea1 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -505,28 +505,47 @@ def update_container(container, version=-1): project_name, product_entity["folderId"] ) + # Run update on the Loader for this container + Loader = _get_container_loader(container) + if not Loader: + raise LoaderNotFoundError( + "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: - raise ValueError( - "Representation '{}' wasn't found on requested version".format( - repre_name + # The representation name is not found in the new version. + # Allow updating to a 'matching' representation if the loader + # has defined compatible update conversions + mapping = Loader.update_allowed_representation_switches() + switch_repre_names = mapping.get(repre_name) + if switch_repre_names: + representations = ayon_api.get_representations( + project_name, + representation_names=switch_repre_names, + version_ids=[new_version["id"]]) + representations_by_name = { + repre["name"]: repre for repre in representations + } + for name in switch_repre_names: + 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)) - - # Run update on the Loader for this container - Loader = _get_container_loader(container) - if not Loader: - raise LoaderNotFoundError( - "Can't update container because loader '{}' was not found." - .format(container.get("loader")) - ) project_entity = ayon_api.get_project(project_name) context = { "project": project_entity, From 1dbaa57e0926fabc6afa9b85a5ae3e7856dccb07 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Oct 2024 00:39:04 +0200 Subject: [PATCH 185/266] Fix call to method --- client/ayon_core/pipeline/load/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index bb74194ea1..b258d20a3d 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -521,7 +521,7 @@ def update_container(container, version=-1): # The representation name is not found in the new version. # Allow updating to a 'matching' representation if the loader # has defined compatible update conversions - mapping = Loader.update_allowed_representation_switches() + mapping = Loader().update_allowed_representation_switches() switch_repre_names = mapping.get(repre_name) if switch_repre_names: representations = ayon_api.get_representations( From 09b0eb9ed2e0150d9121b1e268254e8c185404c2 Mon Sep 17 00:00:00 2001 From: ReeceMulley <153881471+ReeceMulley@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:13:57 +1000 Subject: [PATCH 186/266] taskType bugfix --- .../ayon_core/tools/push_to_project/models/integrate.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/push_to_project/models/integrate.py b/client/ayon_core/tools/push_to_project/models/integrate.py index 5937ffa4da..ba603699bc 100644 --- a/client/ayon_core/tools/push_to_project/models/integrate.py +++ b/client/ayon_core/tools/push_to_project/models/integrate.py @@ -777,7 +777,7 @@ def _fill_or_create_destination_folder(self): task_info = copy.deepcopy(task_info) task_info["name"] = dst_task_name # Fill rest of task information based on task type - task_type_name = task_info["type"] + task_type_name = task_info["taskType"] task_types_by_name = { task_type["name"]: task_type for task_type in self._project_entity["taskTypes"] @@ -821,7 +821,7 @@ def _determine_product_name(self): task_name = task_type = None if task_info: task_name = task_info["name"] - task_type = task_info["type"] + task_type = task_info["taskType"] product_name = get_product_name( self._item.dst_project_name, @@ -905,7 +905,7 @@ def _make_sure_version_exists(self): project_name, self.host_name, task_name=self._task_info["name"], - task_type=self._task_info["type"], + task_type=self._task_info["taskType"], product_type=product_type, product_name=product_entity["name"] ) @@ -959,7 +959,7 @@ def _real_integrate_representations(self): formatting_data = get_template_data( self._project_entity, self._folder_entity, - self._task_info.get("name"), + self._task_info, self.host_name ) formatting_data.update({ From e299b405fc01fc6d5daac5d40a6da1b3955853c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 2 Oct 2024 10:01:33 +0200 Subject: [PATCH 187/266] Update server/settings/conversion.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/conversion.py | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index b933d5856f..aabf41f8d3 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -14,30 +14,19 @@ def _convert_imageio_configs_0_4_5(overrides): ocio_config_profiles = imageio_overrides["ocio_config_profiles"] - for inx, profile in enumerate(ocio_config_profiles): - if profile["type"] != "product_name": + for profile in ocio_config_profiles: + if profile.get("type") != "product_name": continue - - # create new profile - new_profile = { - "type": "published_product", - "published_product": { - "product_name": profile["product_name"], - "fallback": { - "type": "builtin_path", - "builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", - }, + + profile["type"] = "published_product" + profile["published_product"] = { + "product_name": profile.pop("product_name"), + "fallback": { + "type": "builtin_path", + "builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", }, - "host_names": profile["host_names"], - "task_names": profile["task_names"], - "task_types": profile["task_types"], - "custom_path": profile["custom_path"], - "builtin_path": profile["builtin_path"], } - # replace old profile with new profile - ocio_config_profiles[inx] = new_profile - def _convert_imageio_configs_0_3_1(overrides): """Imageio config settings did change to profiles since 0.3.1. .""" From 8b14e79629058d4c44f2ecc89f1262bb0a157825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 2 Oct 2024 10:03:28 +0200 Subject: [PATCH 188/266] Update server/settings/conversion.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- server/settings/conversion.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index aabf41f8d3..315f5a2027 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -9,11 +9,10 @@ def _convert_imageio_configs_0_4_5(overrides): imageio_overrides = overrides.get("imageio") or {} # make sure settings are already converted to profiles - if "ocio_config_profiles" not in imageio_overrides: + ocio_config_profiles = imageio_overrides.get("ocio_config_profiles") + if not ocio_config_profiles: return - ocio_config_profiles = imageio_overrides["ocio_config_profiles"] - for profile in ocio_config_profiles: if profile.get("type") != "product_name": continue From 589a642d69e85d4a9b9b6364b57cfa3a4d20624b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Oct 2024 10:10:57 +0200 Subject: [PATCH 189/266] Merge branch 'develop' into enhancement/AY-6198_OCIO-fallback-for-profiles-and-templated-values --- .github/workflows/release_trigger.yml | 12 +++ client/ayon_core/hooks/pre_ocio_hook.py | 3 +- client/ayon_core/lib/path_tools.py | 5 +- client/ayon_core/pipeline/__init__.py | 2 - client/ayon_core/pipeline/constants.py | 17 ---- client/ayon_core/plugins/load/delivery.py | 18 +++- .../publish/extract_color_transcode.py | 19 +++- client/ayon_core/plugins/publish/integrate.py | 7 +- .../plugins/publish/validate_file_saved.py | 3 +- client/ayon_core/tools/publisher/window.py | 5 +- server/settings/conversion.py | 35 ++++++- server/settings/publish_plugins.py | 92 ++++++++++++++++--- 12 files changed, 172 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/release_trigger.yml diff --git a/.github/workflows/release_trigger.yml b/.github/workflows/release_trigger.yml new file mode 100644 index 0000000000..01a3b3a682 --- /dev/null +++ b/.github/workflows/release_trigger.yml @@ -0,0 +1,12 @@ +name: 🚀 Release Trigger + +on: + workflow_dispatch: + +jobs: + call-release-trigger: + uses: ynput/ops-repo-automation/.github/workflows/release_trigger.yml@main + secrets: + token: ${{ secrets.YNPUT_BOT_TOKEN }} + email: ${{ secrets.CI_EMAIL }} + user: ${{ secrets.CI_USER }} diff --git a/client/ayon_core/hooks/pre_ocio_hook.py b/client/ayon_core/hooks/pre_ocio_hook.py index 6c30b267bc..7406aa42cf 100644 --- a/client/ayon_core/hooks/pre_ocio_hook.py +++ b/client/ayon_core/hooks/pre_ocio_hook.py @@ -19,7 +19,8 @@ class OCIOEnvHook(PreLaunchHook): "nuke", "hiero", "resolve", - "openrv" + "openrv", + "cinema4d" } launch_types = set() diff --git a/client/ayon_core/lib/path_tools.py b/client/ayon_core/lib/path_tools.py index a65f0f8e13..5c81fbfebf 100644 --- a/client/ayon_core/lib/path_tools.py +++ b/client/ayon_core/lib/path_tools.py @@ -81,7 +81,10 @@ def collect_frames(files): dict: {'/folder/product_v001.0001.png': '0001', ....} """ - patterns = [clique.PATTERNS["frames"]] + # clique.PATTERNS["frames"] supports only `.1001.exr` not `_1001.exr` so + # we use a customized pattern. + pattern = "[_.](?P(?P0*)\\d+)\\.\\D+\\d?$" + patterns = [pattern] collections, remainder = clique.assemble( files, minimum_items=1, patterns=patterns) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 4fcea60d5e..8e89029e7b 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -3,7 +3,6 @@ AVALON_INSTANCE_ID, AYON_CONTAINER_ID, AYON_INSTANCE_ID, - HOST_WORKFILE_EXTENSIONS, ) from .anatomy import Anatomy @@ -114,7 +113,6 @@ "AVALON_INSTANCE_ID", "AYON_CONTAINER_ID", "AYON_INSTANCE_ID", - "HOST_WORKFILE_EXTENSIONS", # --- Anatomy --- "Anatomy", diff --git a/client/ayon_core/pipeline/constants.py b/client/ayon_core/pipeline/constants.py index 7a08cbb3aa..e6156b3138 100644 --- a/client/ayon_core/pipeline/constants.py +++ b/client/ayon_core/pipeline/constants.py @@ -4,20 +4,3 @@ # Backwards compatibility AVALON_CONTAINER_ID = "pyblish.avalon.container" AVALON_INSTANCE_ID = "pyblish.avalon.instance" - -# TODO get extensions from host implementations -HOST_WORKFILE_EXTENSIONS = { - "blender": [".blend"], - "celaction": [".scn"], - "tvpaint": [".tvpp"], - "fusion": [".comp"], - "harmony": [".zip"], - "houdini": [".hip", ".hiplc", ".hipnc"], - "maya": [".ma", ".mb"], - "nuke": [".nk"], - "hiero": [".hrox"], - "photoshop": [".psd", ".psb"], - "premiere": [".prproj"], - "resolve": [".drp"], - "aftereffects": [".aep"] -} diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 6a0947cc42..5c53d170eb 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -230,6 +230,11 @@ def deliver(self): self.log ] + # TODO: This will currently incorrectly detect 'resources' + # that are published along with the publish, because those should + # not adhere to the template directly but are ingested in a + # customized way. For example, maya look textures or any publish + # that directly adds files into `instance.data["transfers"]` src_paths = [] for repre_file in repre["files"]: src_path = self.anatomy.fill_root(repre_file["path"]) @@ -261,7 +266,18 @@ def deliver(self): frame = dst_frame if frame is not None: - anatomy_data["frame"] = frame + if repre["context"].get("frame"): + anatomy_data["frame"] = frame + elif repre["context"].get("udim"): + anatomy_data["udim"] = frame + else: + # Fallback + self.log.warning( + "Representation context has no frame or udim" + " data. Supplying sequence frame to '{frame}'" + " formatting data." + ) + anatomy_data["frame"] = frame new_report_items, uploaded = deliver_single_file(*args) report_items.update(new_report_items) self._update_progress(uploaded) diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index a28a761e7e..3e54d324e3 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -122,13 +122,22 @@ def process(self, instance): transcoding_type = output_def["transcoding_type"] target_colorspace = view = display = None + # NOTE: we use colorspace_data as the fallback values for + # the target colorspace. if transcoding_type == "colorspace": + # TODO: Should we fallback to the colorspace + # (which used as source above) ? + # or should we compute the target colorspace from + # current view and display ? target_colorspace = (output_def["colorspace"] or colorspace_data.get("colorspace")) - else: - view = output_def["view"] or colorspace_data.get("view") - display = (output_def["display"] or - colorspace_data.get("display")) + elif transcoding_type == "display_view": + display_view = output_def["display_view"] + view = display_view["view"] or colorspace_data.get("view") + display = ( + display_view["display"] + or colorspace_data.get("display") + ) # both could be already collected by DCC, # but could be overwritten when transcoding @@ -192,7 +201,7 @@ def process(self, instance): new_repre["files"] = new_repre["files"][0] # If the source representation has "review" tag, but its not - # part of the output defintion tags, then both the + # part of the output definition tags, then both the # representations will be transcoded in ExtractReview and # their outputs will clash in integration. if "review" in repre.get("tags", []): diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index d3f6c04333..e8fe09bab7 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -509,8 +509,11 @@ def _validate_repre_files(self, files, is_sequence_representation): if not is_sequence_representation: files = [files] - if any(os.path.isabs(fname) for fname in files): - raise KnownPublishError("Given file names contain full paths") + for fname in files: + if os.path.isabs(fname): + raise KnownPublishError( + f"Representation file names contains full paths: {fname}" + ) if not is_sequence_representation: return diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index d132ba8d3a..f52998cef3 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -36,7 +36,8 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 - hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter"] + hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter", + "cinema4d"] actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index a8ca605ecb..434c2ca602 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -439,10 +439,13 @@ def set_comment(self, comment): def make_sure_is_visible(self): if self._window_is_visible: self.setWindowState(QtCore.Qt.WindowActive) - else: self.show() + self.raise_() + self.activateWindow() + self.showNormal() + def showEvent(self, event): self._window_is_visible = True super().showEvent(event) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 315f5a2027..34820b5b32 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -16,7 +16,7 @@ def _convert_imageio_configs_0_4_5(overrides): for profile in ocio_config_profiles: if profile.get("type") != "product_name": continue - + profile["type"] = "published_product" profile["published_product"] = { "product_name": profile.pop("product_name"), @@ -94,10 +94,43 @@ def _convert_validate_version_0_3_3(publish_overrides): validate_version["plugin_state_profiles"] = [profile] +def _convert_oiio_transcode_0_4_5(publish_overrides): + """ExtractOIIOTranscode plugin changed in 0.4.5.""" + if "ExtractOIIOTranscode" not in publish_overrides: + return + + transcode_profiles = publish_overrides["ExtractOIIOTranscode"].get( + "profiles") + if not transcode_profiles: + return + + for profile in transcode_profiles: + outputs = profile.get("outputs") + if outputs is None: + return + + for output in outputs: + # Already new settings + if "display_view" in output: + break + + # Fix 'display' -> 'display_view' in 'transcoding_type' + transcode_type = output.get("transcoding_type") + if transcode_type == "display": + output["transcoding_type"] = "display_view" + + # Convert 'display' and 'view' to new values + output["display_view"] = { + "display": output.pop("display", ""), + "view": output.pop("view", ""), + } + + def _convert_publish_plugins(overrides): if "publish" not in overrides: return _convert_validate_version_0_3_3(overrides["publish"]) + _convert_oiio_transcode_0_4_5(overrides["publish"]) def convert_settings_overrides( diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 61972e64c4..cdcd28a9ce 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -268,13 +268,36 @@ class ExtractThumbnailModel(BaseSettingsModel): def _extract_oiio_transcoding_type(): return [ {"value": "colorspace", "label": "Use Colorspace"}, - {"value": "display", "label": "Use Display&View"} + {"value": "display_view", "label": "Use Display&View"} ] class OIIOToolArgumentsModel(BaseSettingsModel): additional_command_args: list[str] = SettingsField( - default_factory=list, title="Arguments") + default_factory=list, + title="Arguments", + description="Additional command line arguments for *oiiotool*." + ) + + +class UseDisplayViewModel(BaseSettingsModel): + _layout = "expanded" + display: str = SettingsField( + "", + title="Target Display", + description=( + "Display of the target transform. If left empty, the" + " source Display value will be used." + ) + ) + view: str = SettingsField( + "", + title="Target View", + description=( + "View of the target transform. If left empty, the" + " source View value will be used." + ) + ) class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): @@ -285,22 +308,57 @@ class ExtractOIIOTranscodeOutputModel(BaseSettingsModel): description="Output name (no space)", regex=r"[a-zA-Z0-9_]([a-zA-Z0-9_\.\-]*[a-zA-Z0-9_])?$", ) - extension: str = SettingsField("", title="Extension") + extension: str = SettingsField( + "", + title="Extension", + description=( + "Target extension. If left empty, original" + " extension is used." + ), + ) transcoding_type: str = SettingsField( "colorspace", title="Transcoding type", - enum_resolver=_extract_oiio_transcoding_type + enum_resolver=_extract_oiio_transcoding_type, + conditionalEnum=True, + description=( + "Select the transcoding type for your output, choosing either " + "*Colorspace* or *Display&View* transform." + " Only one option can be applied per output definition." + ), + ) + colorspace: str = SettingsField( + "", + title="Target Colorspace", + description=( + "Choose the desired target colorspace, confirming its availability" + " in the active OCIO config. If left empty, the" + " source colorspace value will be used, resulting in no" + " colorspace conversion." + ) + ) + display_view: UseDisplayViewModel = SettingsField( + title="Use Display&View", + default_factory=UseDisplayViewModel ) - colorspace: str = SettingsField("", title="Colorspace") - display: str = SettingsField("", title="Display") - view: str = SettingsField("", title="View") + oiiotool_args: OIIOToolArgumentsModel = SettingsField( default_factory=OIIOToolArgumentsModel, title="OIIOtool arguments") - tags: list[str] = SettingsField(default_factory=list, title="Tags") + tags: list[str] = SettingsField( + default_factory=list, + title="Tags", + description=( + "Additional tags that will be added to the created representation." + "\nAdd *review* tag to create review from the transcoded" + " representation instead of the original." + ) + ) custom_tags: list[str] = SettingsField( - default_factory=list, title="Custom Tags" + default_factory=list, + title="Custom Tags", + description="Additional custom tags that will be added to the created representation." ) @@ -328,7 +386,13 @@ class ExtractOIIOTranscodeProfileModel(BaseSettingsModel): ) delete_original: bool = SettingsField( True, - title="Delete Original Representation" + title="Delete Original Representation", + description=( + "Choose to preserve or remove the original representation.\n" + "Keep in mind that if the transcoded representation includes" + " a `review` tag, it will take precedence over" + " the original for creating reviews." + ), ) outputs: list[ExtractOIIOTranscodeOutputModel] = SettingsField( default_factory=list, @@ -371,7 +435,7 @@ class ExtractReviewFFmpegModel(BaseSettingsModel): def extract_review_filter_enum(): return [ { - "value": "everytime", + "value": "everytime", # codespell:ignore everytime "label": "Always" }, { @@ -393,7 +457,7 @@ class ExtractReviewFilterModel(BaseSettingsModel): default_factory=list, title="Custom Tags" ) single_frame_filter: str = SettingsField( - "everytime", + "everytime", # codespell:ignore everytime description=( "Use output always / only if input is 1 frame" " image / only if has 2+ frames or is video" @@ -791,7 +855,7 @@ class IntegrateHeroVersionModel(BaseSettingsModel): class CleanUpModel(BaseSettingsModel): _isGroup = True - paterns: list[str] = SettingsField( + paterns: list[str] = SettingsField( # codespell:ignore paterns default_factory=list, title="Patterns (regex)" ) @@ -1225,7 +1289,7 @@ class PublishPuginsModel(BaseSettingsModel): "use_hardlinks": False }, "CleanUp": { - "paterns": [], + "paterns": [], # codespell:ignore paterns "remove_temp_renders": False }, "CleanUpFarm": { From 0b9cea926cf8d061128321708d10f395453cf18c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Oct 2024 10:12:51 +0200 Subject: [PATCH 190/266] Refactor code for better readability and consistency. - Refactored code to improve readability by adjusting indentation and line breaks. - Made changes to ensure consistent formatting of the code. --- server/settings/conversion.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/settings/conversion.py b/server/settings/conversion.py index 04ab0f8d81..34820b5b32 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -99,7 +99,8 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): if "ExtractOIIOTranscode" not in publish_overrides: return - transcode_profiles = publish_overrides["ExtractOIIOTranscode"].get("profiles") + transcode_profiles = publish_overrides["ExtractOIIOTranscode"].get( + "profiles") if not transcode_profiles: return @@ -108,7 +109,7 @@ def _convert_oiio_transcode_0_4_5(publish_overrides): if outputs is None: return - for output in outputs : + for output in outputs: # Already new settings if "display_view" in output: break From 7298ddb745e003ca8e317eae3baccab246a2ad37 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Wed, 2 Oct 2024 12:27:32 +0300 Subject: [PATCH 191/266] add note about using more accurate variable names --- .../ayon_core/pipeline/workfile/workfile_template_builder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 17debdb2e8..4412e4489b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -521,6 +521,9 @@ def build_template( scene state. """ + # More accurate variable name + # - logic related to workfile creation should be moved out in future + explicit_build_requested = not workfile_creation_enabled # Get default values if not provided if ( @@ -538,7 +541,6 @@ def build_template( # Build the template if we are explicitly requesting it or if it's # an unsaved "new file". is_new_file = not self.host.get_current_workfile() - explicit_build_requested = not workfile_creation_enabled if is_new_file or explicit_build_requested: self.log.info(f"Building the workfile template: {template_path}") self.import_template(template_path) From 05291b2fe9b46e513c0d8bdae6640e4a36ddb8a8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Oct 2024 11:51:21 +0200 Subject: [PATCH 192/266] ruff suggestions --- .../plugins/publish/extract_otio_review.py | 61 ++++++++++--------- .../editorial/test_extract_otio_review.py | 28 +++++---- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 2a0b51b123..55eff782d9 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -57,13 +57,12 @@ def process(self, instance): # Not all hosts can import these modules. import opentimelineio as otio from ayon_core.pipeline.editorial import ( - otio_range_to_frame_range, make_sequence_collection, remap_range_on_file_sequence, is_clip_from_media_sequence ) - # TODO refactore from using instance variable + # TODO refactor from using instance variable self.temp_file_head = self._get_folder_name_based_prefix(instance) # TODO: convert resulting image sequence to mp4 @@ -75,8 +74,8 @@ def process(self, instance): otio_review_clips = instance.data["otioReviewClips"] # add plugin wide attributes - self.representation_files = list() - self.used_frames = list() + self.representation_files = [] + self.used_frames = [] self.workfile_start = int(instance.data.get( "workfileFrameStart", 1001)) - handle_start self.padding = len(str(self.workfile_start)) @@ -101,9 +100,7 @@ def process(self, instance): for index, r_otio_cl in enumerate(otio_review_clips): # QUESTION: what if transition on clip? - # check if resolution is the same - width = self.to_width - height = self.to_height + # check if resolution is the same as source otio_media = r_otio_cl.media_reference media_metadata = otio_media.metadata @@ -151,7 +148,7 @@ def process(self, instance): # Gap: no media, generate range based on source range else: - available_range = processing_range = None + available_range = processing_range = None self.actual_fps = src_range.duration.rate start = src_range.start_time duration = src_range.duration @@ -216,7 +213,7 @@ def process(self, instance): collection.indexes.update( [i for i in range(first, (last + 1))]) # render segment - self._render_seqment( + self._render_segment( sequence=[dirname, collection, input_fps]) # generate used frames self._generate_used_frames( @@ -230,7 +227,7 @@ def process(self, instance): dir_path, collection = collection_data # render segment - self._render_seqment( + self._render_segment( sequence=[dir_path, collection, input_fps]) # generate used frames self._generate_used_frames( @@ -252,7 +249,7 @@ def process(self, instance): duration=processing_range.duration, ) # render video file to sequence - self._render_seqment( + self._render_segment( video=[path, extract_range]) # generate used frames self._generate_used_frames( @@ -261,7 +258,7 @@ def process(self, instance): # QUESTION: what if nested track composition is in place? else: # at last process a Gap - self._render_seqment(gap=duration) + self._render_segment(gap=duration) # generate used frames self._generate_used_frames(duration) @@ -334,7 +331,6 @@ def _trim_available_range(self, avl_range, start, duration): ) avl_start = avl_range.start_time - avl_duration = avl_range.duration # An additional gap is required before the available # range to conform source start point and head handles. @@ -344,7 +340,7 @@ def _trim_available_range(self, avl_range, start, duration): duration -= gap_duration # create gap data to disk - self._render_seqment(gap=gap_duration.round().to_frames()) + self._render_segment(gap=gap_duration.round().to_frames()) # generate used frames self._generate_used_frames(gap_duration.round().to_frames()) @@ -358,7 +354,7 @@ def _trim_available_range(self, avl_range, start, duration): duration -= gap_duration # create gap data to disk - self._render_seqment( + self._render_segment( gap=gap_duration.round().to_frames(), end_offset=duration.to_frames() ) @@ -377,10 +373,10 @@ def _trim_available_range(self, avl_range, start, duration): ) ) - def _render_seqment(self, sequence=None, + def _render_segment(self, sequence=None, video=None, gap=None, end_offset=None): """ - Render seqment into image sequence frames. + Render segment into image sequence frames. Using ffmpeg to convert compatible video and image source to defined image sequence format. @@ -416,18 +412,24 @@ def _render_seqment(self, sequence=None, input_path = os.path.join(input_dir, input_file) input_extension = os.path.splitext(input_path)[-1] - # form command for rendering sequence files - # (need to explicit set the input frame range - # if case input sequence has framerate metadata - # to preserve frame range and avoid silent dropped - # frames caused by input mismatch with FFmpeg default - # rate 25.0 fps) more info refer to FFmpeg image2 demuxer - # - # Implicit - # [Input 100 frames (24fps from metadata)] -> [Demuxer video 25fps] -> [Output 98 frames, dropped 2] - # - # Explicit with "-framerate" - # [Input 100 frames (24fps from metadata)] -> [Demuxer video 24fps] -> [Output 100 frames] + """ + Form Command for Rendering Sequence Files + + To explicitly set the input frame range and preserve the frame + range, avoid silent dropped frames caused by input mismatch + with FFmpeg's default rate of 25.0 fps. For more info, + refer to the FFmpeg image2 demuxer. + + Implicit: + - Input: 100 frames (24fps from metadata) + - Demuxer: video 25fps + - Output: 98 frames, dropped 2 + + Explicit with "-framerate": + - Input: 100 frames (24fps from metadata) + - Demuxer: video 24fps + - Output: 100 frames, no dropped frames + """ command.extend([ "-start_number", str(in_frame_start), @@ -566,4 +568,3 @@ def _get_folder_name_based_prefix(self, instance): self.log.debug(f"file_prefix::{file_prefix}") return file_prefix - diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index f266a40f50..0a38301755 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -1,11 +1,10 @@ import mock import os -import pytest +import pytest # noqa from typing import NamedTuple import opentimelineio as otio -import ayon_core.lib from ayon_core.plugins.publish import extract_otio_review @@ -19,7 +18,7 @@ class MockInstance(): """ Mock pyblish instance for testing purpose. """ def __init__(self, data: dict): - self.data = data + self.data = data self.context = self @@ -34,7 +33,7 @@ def append_call(self, *args, **kwargs): self.calls.append(" ".join(ffmpeg_args_list)) return True - def get_fmpeg_executable(self, _): + def get_ffmpeg_executable(self, _): return ["/path/to/ffmpeg"] @@ -48,20 +47,23 @@ def run_process(file_name: str): # Prepare dummy instance and capture call object capture_call = CaptureFFmpegCalls() processor = extract_otio_review.ExtractOTIOReview() - instance = MockInstance({ - "otioReviewClips": [clip], - "handleStart": 10, - "handleEnd": 10, - "workfileFrameStart": 1001, - "folderPath": "/dummy/path", - "anatomy": NamedTuple("Anatomy", [('project_name', "test_project")]) - }) + Anatomy = NamedTuple("Anatomy", [("project_name")]) + instance = MockInstance( + { + "otioReviewClips": [clip], + "handleStart": 10, + "handleEnd": 10, + "workfileFrameStart": 1001, + "folderPath": "/dummy/path", + "anatomy": Anatomy("test_project"), + } + ) # Mock calls to extern and run plugins. with mock.patch.object( extract_otio_review, "get_ffmpeg_tool_args", - side_effect=capture_call.get_fmpeg_executable, + side_effect=capture_call.get_ffmpeg_executable, ): with mock.patch.object( extract_otio_review, From 7bd382a1874eb22b822790a6461e39d1399956d3 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 2 Oct 2024 11:57:38 +0200 Subject: [PATCH 193/266] Refactor Anatomy NamedTuple for project_name\nUpdate Anatomy NamedTuple to specify project_name as string type. This change ensures consistency and clarity in the codebase. --- .../ayon_core/pipeline/editorial/test_extract_otio_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 0a38301755..7bc1a750d7 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -47,7 +47,7 @@ def run_process(file_name: str): # Prepare dummy instance and capture call object capture_call = CaptureFFmpegCalls() processor = extract_otio_review.ExtractOTIOReview() - Anatomy = NamedTuple("Anatomy", [("project_name")]) + Anatomy = NamedTuple("Anatomy", project_name=str) instance = MockInstance( { "otioReviewClips": [clip], From db41e53511870112df72631ad5123566ca04e943 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:40:01 +0200 Subject: [PATCH 194/266] updated create package script --- create_package.py | 490 +++++++++++++++++++++++++++++----------------- 1 file changed, 314 insertions(+), 176 deletions(-) diff --git a/create_package.py b/create_package.py index 48952c43c5..843e993de1 100644 --- a/create_package.py +++ b/create_package.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + """Prepares server package from addon repo to upload to server. Requires Python 3.9. (Or at least 3.8+). @@ -22,32 +24,39 @@ import os import sys import re +import io import shutil -import argparse import platform +import argparse import logging import collections import zipfile -import hashlib - -from typing import Optional - -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) -PACKAGE_PATH = os.path.join(CURRENT_DIR, "package.py") -package_content = {} -with open(PACKAGE_PATH, "r") as stream: - exec(stream.read(), package_content) - -ADDON_VERSION = package_content["version"] -ADDON_NAME = package_content["name"] -ADDON_CLIENT_DIR = package_content["client_dir"] -CLIENT_VERSION_CONTENT = '''# -*- coding: utf-8 -*- -"""Package declaring AYON core addon version.""" -__version__ = "{}" +import subprocess +from typing import Optional, Iterable, Pattern, Union, List, Tuple + +import package + +FileMapping = Tuple[Union[str, io.BytesIO], str] +ADDON_NAME: str = package.name +ADDON_VERSION: str = package.version +ADDON_CLIENT_DIR: Union[str, None] = getattr(package, "client_dir", None) + +CURRENT_ROOT: str = os.path.dirname(os.path.abspath(__file__)) +SERVER_ROOT: str = os.path.join(CURRENT_ROOT, "server") +FRONTEND_ROOT: str = os.path.join(CURRENT_ROOT, "frontend") +FRONTEND_DIST_ROOT: str = os.path.join(FRONTEND_ROOT, "dist") +DST_DIST_DIR: str = os.path.join("frontend", "dist") +PRIVATE_ROOT: str = os.path.join(CURRENT_ROOT, "private") +PUBLIC_ROOT: str = os.path.join(CURRENT_ROOT, "public") +CLIENT_ROOT: str = os.path.join(CURRENT_ROOT, "client") + +VERSION_PY_CONTENT = f'''# -*- coding: utf-8 -*- +"""Package declaring AYON addon '{ADDON_NAME}' version.""" +__version__ = "{ADDON_VERSION}" ''' # Patterns of directories to be skipped for server part of addon -IGNORE_DIR_PATTERNS = [ +IGNORE_DIR_PATTERNS: List[Pattern] = [ re.compile(pattern) for pattern in { # Skip directories starting with '.' @@ -58,7 +67,7 @@ ] # Patterns of files to be skipped for server part of addon -IGNORE_FILE_PATTERNS = [ +IGNORE_FILE_PATTERNS: List[Pattern] = [ re.compile(pattern) for pattern in { # Skip files starting with '.' @@ -70,15 +79,6 @@ ] -def calculate_file_checksum(filepath, hash_algorithm, chunk_size=10000): - func = getattr(hashlib, hash_algorithm) - hash_obj = func() - with open(filepath, "rb") as f: - for chunk in iter(lambda: f.read(chunk_size), b""): - hash_obj.update(chunk) - return hash_obj.hexdigest() - - class ZipFileLongPaths(zipfile.ZipFile): """Allows longer paths in zip files. @@ -97,12 +97,28 @@ def _extract_member(self, member, tpath, pwd): else: tpath = "\\\\?\\" + tpath - return super(ZipFileLongPaths, self)._extract_member( - member, tpath, pwd - ) + return super()._extract_member(member, tpath, pwd) -def safe_copy_file(src_path, dst_path): +def _get_yarn_executable() -> Union[str, None]: + cmd = "which" + if platform.system().lower() == "windows": + cmd = "where" + + for line in subprocess.check_output( + [cmd, "yarn"], encoding="utf-8" + ).splitlines(): + if not line or not os.path.exists(line): + continue + try: + subprocess.call([line, "--version"]) + return line + except OSError: + continue + return None + + +def safe_copy_file(src_path: str, dst_path: str): """Copy file and make sure destination directory exists. Ignore if destination already contains directories from source. @@ -115,210 +131,335 @@ def safe_copy_file(src_path, dst_path): if src_path == dst_path: return - dst_dir = os.path.dirname(dst_path) - try: - os.makedirs(dst_dir) - except Exception: - pass + dst_dir: str = os.path.dirname(dst_path) + os.makedirs(dst_dir, exist_ok=True) shutil.copy2(src_path, dst_path) -def _value_match_regexes(value, regexes): - for regex in regexes: - if regex.search(value): - return True - return False +def _value_match_regexes(value: str, regexes: Iterable[Pattern]) -> bool: + return any( + regex.search(value) + for regex in regexes + ) def find_files_in_subdir( - src_path, - ignore_file_patterns=None, - ignore_dir_patterns=None -): + src_path: str, + ignore_file_patterns: Optional[List[Pattern]] = None, + ignore_dir_patterns: Optional[List[Pattern]] = None +) -> List[Tuple[str, str]]: + """Find all files to copy in subdirectories of given path. + + All files that match any of the patterns in 'ignore_file_patterns' will + be skipped and any directories that match any of the patterns in + 'ignore_dir_patterns' will be skipped with all subfiles. + + Args: + src_path (str): Path to directory to search in. + ignore_file_patterns (Optional[list[Pattern]]): List of regexes + to match files to ignore. + ignore_dir_patterns (Optional[list[Pattern]]): List of regexes + to match directories to ignore. + + Returns: + list[tuple[str, str]]: List of tuples with path to file and parent + directories relative to 'src_path'. + """ + if ignore_file_patterns is None: ignore_file_patterns = IGNORE_FILE_PATTERNS if ignore_dir_patterns is None: ignore_dir_patterns = IGNORE_DIR_PATTERNS - output = [] + output: List[Tuple[str, str]] = [] + if not os.path.exists(src_path): + return output - hierarchy_queue = collections.deque() + hierarchy_queue: collections.deque = collections.deque() hierarchy_queue.append((src_path, [])) while hierarchy_queue: - item = hierarchy_queue.popleft() + item: Tuple[str, str] = hierarchy_queue.popleft() dirpath, parents = item for name in os.listdir(dirpath): - path = os.path.join(dirpath, name) + path: str = os.path.join(dirpath, name) if os.path.isfile(path): if not _value_match_regexes(name, ignore_file_patterns): - items = list(parents) + items: List[str] = list(parents) items.append(name) output.append((path, os.path.sep.join(items))) continue if not _value_match_regexes(name, ignore_dir_patterns): - items = list(parents) + items: List[str] = list(parents) items.append(name) hierarchy_queue.append((path, items)) return output -def copy_server_content(addon_output_dir, current_dir, log): - """Copies server side folders to 'addon_package_dir' +def update_client_version(logger): + """Update version in client code if version.py is present.""" + if not ADDON_CLIENT_DIR: + return - Args: - addon_output_dir (str): package dir in addon repo dir - current_dir (str): addon repo dir - log (logging.Logger) - """ + version_path: str = os.path.join( + CLIENT_ROOT, ADDON_CLIENT_DIR, "version.py" + ) + if not os.path.exists(version_path): + logger.debug("Did not find version.py in client directory") + return - log.info("Copying server content") + logger.info("Updating client version") + with open(version_path, "w") as stream: + stream.write(VERSION_PY_CONTENT) - filepaths_to_copy = [] - server_dirpath = os.path.join(current_dir, "server") - for item in find_files_in_subdir(server_dirpath): - src_path, dst_subpath = item - dst_path = os.path.join(addon_output_dir, "server", dst_subpath) - filepaths_to_copy.append((src_path, dst_path)) +def update_pyproject_toml(logger): + filepath = os.path.join(CURRENT_ROOT, "pyproject.toml") + new_lines = [] + with open(filepath, "r") as stream: + version_found = False + for line in stream.readlines(): + if not version_found and line.startswith("version ="): + line = f'version = "{ADDON_VERSION}"\n' + version_found = True - # Copy files - for src_path, dst_path in filepaths_to_copy: - safe_copy_file(src_path, dst_path) + new_lines.append(line) + with open(filepath, "w") as stream: + stream.write("".join(new_lines)) -def _update_client_version(client_addon_dir): - """Write version.py file to 'client' directory. - Make sure the version in client dir is the same as in package.py. +def build_frontend(): + yarn_executable = _get_yarn_executable() + if yarn_executable is None: + raise RuntimeError("Yarn executable was not found.") - Args: - client_addon_dir (str): Directory path of client addon. - """ + subprocess.run([yarn_executable, "install"], cwd=FRONTEND_ROOT) + subprocess.run([yarn_executable, "build"], cwd=FRONTEND_ROOT) + if not os.path.exists(FRONTEND_DIST_ROOT): + raise RuntimeError( + "Frontend build failed. Did not find 'dist' folder." + ) - dst_version_path = os.path.join(client_addon_dir, "version.py") - with open(dst_version_path, "w") as stream: - stream.write(CLIENT_VERSION_CONTENT.format(ADDON_VERSION)) +def get_client_files_mapping() -> List[Tuple[str, str]]: + """Mapping of source client code files to destination paths. -def zip_client_side(addon_package_dir, current_dir, log): - """Copy and zip `client` content into 'addon_package_dir'. + Example output: + [ + ( + "C:/addons/MyAddon/version.py", + "my_addon/version.py" + ), + ( + "C:/addons/MyAddon/client/my_addon/__init__.py", + "my_addon/__init__.py" + ) + ] + + Returns: + list[tuple[str, str]]: List of path mappings to copy. The destination + path is relative to expected output directory. - Args: - addon_package_dir (str): Output package directory path. - current_dir (str): Directory path of addon source. - log (logging.Logger): Logger object. """ + # Add client code content to zip + client_code_dir: str = os.path.join(CLIENT_ROOT, ADDON_CLIENT_DIR) + mapping = [ + (path, os.path.join(ADDON_CLIENT_DIR, sub_path)) + for path, sub_path in find_files_in_subdir(client_code_dir) + ] - client_dir = os.path.join(current_dir, "client") - client_addon_dir = os.path.join(client_dir, ADDON_CLIENT_DIR) - if not os.path.isdir(client_addon_dir): - raise ValueError( - f"Failed to find client directory '{client_addon_dir}'" - ) + license_path = os.path.join(CURRENT_ROOT, "LICENSE") + if os.path.exists(license_path): + mapping.append((license_path, f"{ADDON_CLIENT_DIR}/LICENSE")) + return mapping + +def get_client_zip_content(log) -> io.BytesIO: log.info("Preparing client code zip") - private_dir = os.path.join(addon_package_dir, "private") + files_mapping: List[Tuple[str, str]] = get_client_files_mapping() + stream = io.BytesIO() + with ZipFileLongPaths(stream, "w", zipfile.ZIP_DEFLATED) as zipf: + for src_path, subpath in files_mapping: + zipf.write(src_path, subpath) + stream.seek(0) + return stream + + +def get_base_files_mapping() -> List[FileMapping]: + filepaths_to_copy: List[FileMapping] = [ + ( + os.path.join(CURRENT_ROOT, "package.py"), + "package.py" + ) + ] + # Add license file to package if exists + license_path = os.path.join(CURRENT_ROOT, "LICENSE") + if os.path.exists(license_path): + filepaths_to_copy.append((license_path, "LICENSE")) + + # Go through server, private and public directories and find all files + for dirpath in (SERVER_ROOT, PRIVATE_ROOT, PUBLIC_ROOT): + if not os.path.exists(dirpath): + continue + + dirname = os.path.basename(dirpath) + for src_file, subpath in find_files_in_subdir(dirpath): + dst_subpath = os.path.join(dirname, subpath) + filepaths_to_copy.append((src_file, dst_subpath)) + + if os.path.exists(FRONTEND_DIST_ROOT): + for src_file, subpath in find_files_in_subdir(FRONTEND_DIST_ROOT): + dst_subpath = os.path.join(DST_DIST_DIR, subpath) + filepaths_to_copy.append((src_file, dst_subpath)) + + pyproject_toml = os.path.join(CLIENT_ROOT, "pyproject.toml") + if os.path.exists(pyproject_toml): + filepaths_to_copy.append( + (pyproject_toml, "private/pyproject.toml") + ) + + return filepaths_to_copy + + +def copy_client_code(output_dir: str, log: logging.Logger): + """Copies server side folders to 'addon_package_dir' - if not os.path.exists(private_dir): - os.makedirs(private_dir) + Args: + output_dir (str): Output directory path. + log (logging.Logger) - _update_client_version(client_addon_dir) + """ + log.info(f"Copying client for {ADDON_NAME}-{ADDON_VERSION}") - zip_filepath = os.path.join(os.path.join(private_dir, "client.zip")) - with ZipFileLongPaths(zip_filepath, "w", zipfile.ZIP_DEFLATED) as zipf: - # Add client code content to zip - for path, sub_path in find_files_in_subdir(client_addon_dir): - sub_path = os.path.join(ADDON_CLIENT_DIR, sub_path) - zipf.write(path, sub_path) + full_output_path = os.path.join( + output_dir, f"{ADDON_NAME}_{ADDON_VERSION}" + ) + if os.path.exists(full_output_path): + shutil.rmtree(full_output_path) + os.makedirs(full_output_path, exist_ok=True) - shutil.copy(os.path.join(client_dir, "pyproject.toml"), private_dir) + for src_path, dst_subpath in get_client_files_mapping(): + dst_path = os.path.join(full_output_path, dst_subpath) + safe_copy_file(src_path, dst_path) + log.info("Client copy finished") -def create_server_package( + +def copy_addon_package( output_dir: str, - addon_output_dir: str, + files_mapping: List[FileMapping], log: logging.Logger ): - """Create server package zip file. - - The zip file can be installed to a server using UI or rest api endpoints. + """Copy client code to output directory. Args: - output_dir (str): Directory path to output zip file. - addon_output_dir (str): Directory path to addon output directory. + output_dir (str): Directory path to output client code. + files_mapping (List[FileMapping]): List of tuples with source file + and destination subpath. log (logging.Logger): Logger object. + """ + log.info(f"Copying package for {ADDON_NAME}-{ADDON_VERSION}") + + # Add addon name and version to output directory + addon_output_dir: str = os.path.join( + output_dir, ADDON_NAME, ADDON_VERSION + ) + if os.path.isdir(addon_output_dir): + log.info(f"Purging {addon_output_dir}") + shutil.rmtree(addon_output_dir) - log.info("Creating server package") + os.makedirs(addon_output_dir, exist_ok=True) + + # Copy server content + for src_file, dst_subpath in files_mapping: + dst_path: str = os.path.join(addon_output_dir, dst_subpath) + dst_dir: str = os.path.dirname(dst_path) + os.makedirs(dst_dir, exist_ok=True) + if isinstance(src_file, io.BytesIO): + with open(dst_path, "wb") as stream: + stream.write(src_file.getvalue()) + else: + safe_copy_file(src_file, dst_path) + + log.info("Package copy finished") + + +def create_addon_package( + output_dir: str, + files_mapping: List[FileMapping], + log: logging.Logger +): + log.info(f"Creating package for {ADDON_NAME}-{ADDON_VERSION}") + + os.makedirs(output_dir, exist_ok=True) output_path = os.path.join( output_dir, f"{ADDON_NAME}-{ADDON_VERSION}.zip" ) - with ZipFileLongPaths(output_path, "w", zipfile.ZIP_DEFLATED) as zipf: - # Move addon content to zip into 'addon' directory - addon_output_dir_offset = len(addon_output_dir) + 1 - for root, _, filenames in os.walk(addon_output_dir): - if not filenames: - continue - dst_root = None - if root != addon_output_dir: - dst_root = root[addon_output_dir_offset:] - for filename in filenames: - src_path = os.path.join(root, filename) - dst_path = filename - if dst_root: - dst_path = os.path.join(dst_root, dst_path) - zipf.write(src_path, dst_path) + with ZipFileLongPaths(output_path, "w", zipfile.ZIP_DEFLATED) as zipf: + # Copy server content + for src_file, dst_subpath in files_mapping: + if isinstance(src_file, io.BytesIO): + zipf.writestr(dst_subpath, src_file.getvalue()) + else: + zipf.write(src_file, dst_subpath) - log.info(f"Output package can be found: {output_path}") + log.info("Package created") def main( - output_dir: Optional[str]=None, - skip_zip: bool=False, - keep_sources: bool=False, - clear_output_dir: bool=False + output_dir: Optional[str] = None, + skip_zip: Optional[bool] = False, + only_client: Optional[bool] = False ): - log = logging.getLogger("create_package") - log.info("Start creating package") + log: logging.Logger = logging.getLogger("create_package") + log.info("Package creation started") - current_dir = os.path.dirname(os.path.abspath(__file__)) if not output_dir: - output_dir = os.path.join(current_dir, "package") + output_dir = os.path.join(CURRENT_ROOT, "package") + has_client_code = bool(ADDON_CLIENT_DIR) + if has_client_code: + client_dir: str = os.path.join(CLIENT_ROOT, ADDON_CLIENT_DIR) + if not os.path.exists(client_dir): + raise RuntimeError( + f"Client directory was not found '{client_dir}'." + " Please check 'client_dir' in 'package.py'." + ) + update_client_version(log) - new_created_version_dir = os.path.join( - output_dir, ADDON_NAME, ADDON_VERSION - ) + update_pyproject_toml(log) + + if only_client: + if not has_client_code: + raise RuntimeError("Client code is not available. Skipping") - if os.path.isdir(new_created_version_dir) and clear_output_dir: - log.info(f"Purging {new_created_version_dir}") - shutil.rmtree(output_dir) + copy_client_code(output_dir, log) + return log.info(f"Preparing package for {ADDON_NAME}-{ADDON_VERSION}") - addon_output_root = os.path.join(output_dir, ADDON_NAME) - addon_output_dir = os.path.join(addon_output_root, ADDON_VERSION) - if not os.path.exists(addon_output_dir): - os.makedirs(addon_output_dir) + if os.path.exists(FRONTEND_ROOT): + build_frontend() - copy_server_content(addon_output_dir, current_dir, log) - safe_copy_file( - PACKAGE_PATH, - os.path.join(addon_output_dir, os.path.basename(PACKAGE_PATH)) - ) - zip_client_side(addon_output_dir, current_dir, log) + files_mapping: List[FileMapping] = [] + files_mapping.extend(get_base_files_mapping()) + + if has_client_code: + files_mapping.append( + (get_client_zip_content(log), "private/client.zip") + ) # Skip server zipping - if not skip_zip: - create_server_package(output_dir, addon_output_dir, log) - # Remove sources only if zip file is created - if not keep_sources: - log.info("Removing source files for server package") - shutil.rmtree(addon_output_root) + if skip_zip: + copy_addon_package(output_dir, files_mapping, log) + else: + create_addon_package(output_dir, files_mapping, log) + log.info("Package creation finished") @@ -334,36 +475,33 @@ def main( ) ) parser.add_argument( - "--keep-sources", - dest="keep_sources", - action="store_true", + "-o", "--output", + dest="output_dir", + default=None, help=( - "Keep folder structure when server package is created." + "Directory path where package will be created" + " (Will be purged if already exists!)" ) ) parser.add_argument( - "-c", "--clear-output-dir", - dest="clear_output_dir", + "--only-client", + dest="only_client", action="store_true", help=( - "Clear output directory before package creation." + "Extract only client code. This is useful for development." + " Requires '-o', '--output' argument to be filled." ) ) - parser.add_argument( - "-o", "--output", - dest="output_dir", - default=None, - help=( - "Directory path where package will be created" - " (Will be purged if already exists!)" - ) + "--debug", + dest="debug", + action="store_true", + help="Debug log messages." ) args = parser.parse_args(sys.argv[1:]) - main( - args.output_dir, - args.skip_zip, - args.keep_sources, - args.clear_output_dir - ) + level = logging.INFO + if args.debug: + level = logging.DEBUG + logging.basicConfig(level=level) + main(args.output_dir, args.skip_zip, args.only_client) From ba002d56377a5c221c80bb91fd515bade2c87dbf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:40:13 +0200 Subject: [PATCH 195/266] bump version to '1.0.0' --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 3ee3c976b9..0b6322645f 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON core addon version.""" -__version__ = "0.4.5-dev.1" +__version__ = "1.0.0" diff --git a/package.py b/package.py index 26c004ae84..b06959d5cf 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.5-dev.1" +version = "1.0.0" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index db98ee4eba..091cdc273d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "0.4.3-dev.1" +version = "1.0.0" description = "" authors = ["Ynput Team "] readme = "README.md" From 10ffb37518349101598a23399878c8a86957fd5e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:41:27 +0200 Subject: [PATCH 196/266] bump version to '1.0.0+dev' --- client/ayon_core/version.py | 4 ++-- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 0b6322645f..75116c703e 100644 --- a/client/ayon_core/version.py +++ b/client/ayon_core/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -"""Package declaring AYON core addon version.""" -__version__ = "1.0.0" +"""Package declaring AYON addon 'core' version.""" +__version__ = "1.0.0+dev" diff --git a/package.py b/package.py index b06959d5cf..1466031daa 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.0" +version = "1.0.0+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 091cdc273d..4a63529c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.0" +version = "1.0.0+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From a834e2c6639c22e617caa6cab0cb4dd3ee9f74f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Oct 2024 12:43:29 +0000 Subject: [PATCH 197/266] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e6badf936a..54f5d68b98 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report description: File a bug report -title: 'Your issue title here' +title: Your issue title here labels: - 'type: bug' body: @@ -36,6 +36,16 @@ body: description: What version are you running? Look to AYON Tray options: - 1.0.0 + - 0.4.4 + - 0.4.3 + - 0.4.2 + - 0.4.1 + - 0.4.0 + - 0.3.2 + - 0.3.1 + - 0.3.0 + - 0.2.1 + - 0.2.0 validations: required: true - type: dropdown From 41302936c26156b36627a43a413bd1b95cecd511 Mon Sep 17 00:00:00 2001 From: robin Date: Wed, 2 Oct 2024 11:06:29 -0400 Subject: [PATCH 198/266] Fix multiple review clips in OTIO review plugins with tests. --- .../plugins/publish/extract_otio_review.py | 61 +- .../resources/multiple_review_clips.json | 1511 +++++++++++++++++ .../resources/multiple_review_clips_gap.json | 289 ++++ .../editorial/test_extract_otio_review.py | 153 +- 4 files changed, 1969 insertions(+), 45 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips.json create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips_gap.json diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 55eff782d9..00a90df695 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -100,30 +100,30 @@ def process(self, instance): for index, r_otio_cl in enumerate(otio_review_clips): # QUESTION: what if transition on clip? - # check if resolution is the same as source - otio_media = r_otio_cl.media_reference - media_metadata = otio_media.metadata - - # get from media reference metadata source - # TODO 'openpype' prefix should be removed (added 24/09/03) - # NOTE it looks like it is set only in hiero integration - res_data = {"width": self.to_width, "height": self.to_height} - for key in res_data: - for meta_prefix in ("ayon.source.", "openpype.source."): - meta_key = f"{meta_prefix}.{key}" - value = media_metadata.get(meta_key) - if value is not None: - res_data[key] = value - break - - self.to_width, self.to_height = res_data["width"], res_data["height"] - self.log.debug("> self.to_width x self.to_height: {} x {}".format( - self.to_width, self.to_height - )) - # Clip: compute process range from available media range. src_range = r_otio_cl.source_range if isinstance(r_otio_cl, otio.schema.Clip): + # check if resolution is the same as source + media_ref = r_otio_cl.media_reference + media_metadata = media_ref.metadata + + # get from media reference metadata source + # TODO 'openpype' prefix should be removed (added 24/09/03) + # NOTE it looks like it is set only in hiero integration + res_data = {"width": self.to_width, "height": self.to_height} + for key in res_data: + for meta_prefix in ("ayon.source.", "openpype.source."): + meta_key = f"{meta_prefix}.{key}" + value = media_metadata.get(meta_key) + if value is not None: + res_data[key] = value + break + + self.to_width, self.to_height = res_data["width"], res_data["height"] + self.log.debug("> self.to_width x self.to_height: {} x {}".format( + self.to_width, self.to_height + )) + available_range = r_otio_cl.available_range() processing_range = None self.actual_fps = available_range.duration.rate @@ -135,7 +135,6 @@ def process(self, instance): # source range for image sequence. Following code maintain # backward-compatibility by adjusting available range # while we are updating those. - media_ref = r_otio_cl.media_reference if ( is_clip_from_media_sequence(r_otio_cl) and available_range.start_time.to_frames() == media_ref.start_frame @@ -154,11 +153,11 @@ def process(self, instance): duration = src_range.duration # Create handle offsets. - handle_start = otio.opentime.RationalTime( + clip_handle_start = otio.opentime.RationalTime( handle_start, rate=self.actual_fps, ) - handle_end = otio.opentime.RationalTime( + clip_handle_end = otio.opentime.RationalTime( handle_end, rate=self.actual_fps, ) @@ -166,16 +165,16 @@ def process(self, instance): # reframing handles conditions if (len(otio_review_clips) > 1) and (index == 0): # more clips | first clip reframing with handle - start -= handle_start - duration += handle_start + start -= clip_handle_start + duration += clip_handle_start elif len(otio_review_clips) > 1 \ and (index == len(otio_review_clips) - 1): # more clips | last clip reframing with handle - duration += handle_end + duration += clip_handle_end elif len(otio_review_clips) == 1: # one clip | add both handles - start -= handle_start - duration += (handle_start + handle_end) + start -= clip_handle_start + duration += (clip_handle_start + clip_handle_end) if available_range: processing_range = self._trim_available_range( @@ -258,9 +257,9 @@ def process(self, instance): # QUESTION: what if nested track composition is in place? else: # at last process a Gap - self._render_segment(gap=duration) + self._render_segment(gap=duration.to_frames()) # generate used frames - self._generate_used_frames(duration) + self._generate_used_frames(duration.to_frames()) # creating and registering representation representation = self._create_representation(start, duration) diff --git a/tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips.json b/tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips.json new file mode 100644 index 0000000000..dcf60abb7d --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips.json @@ -0,0 +1,1511 @@ +{ + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "review", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "scene_linear", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.exr 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 7", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.exr 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "87399", + "foundry.source.umid": "bbfe0c90-5b76-424a-6351-4dac36a8dde7", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "scene_linear", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "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": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1918,1078", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.lineOrder": "0", + "media.exr.nuke.input.frame_rate": "24", + "media.exr.nuke.input.timecode": "01:00:41:15", + "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": "2024-09-23 08:37:23", + "media.input.filereader": "exr", + "media.input.filesize": "1095868", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:37:23", + "media.input.timecode": "01:00:41:15", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "9b", + "media.nuke.version": "15.1v2", + "openpype.source.colourtransform": "scene_linear", + "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": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\with_tc", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "scene_linear", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.exr 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 7", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.exr 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "87399", + "foundry.source.umid": "bbfe0c90-5b76-424a-6351-4dac36a8dde7", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "scene_linear", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "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": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1918,1078", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.lineOrder": "0", + "media.exr.nuke.input.frame_rate": "24", + "media.exr.nuke.input.timecode": "01:00:41:15", + "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": "2024-09-23 08:37:23", + "media.input.filereader": "exr", + "media.input.filesize": "1095868", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:37:23", + "media.input.timecode": "01:00:41:15", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "9b", + "media.nuke.version": "15.1v2", + "openpype.source.colourtransform": "scene_linear", + "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": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\with_tc", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 31.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "matte_paint", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.tif 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "25", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Int8) Open Color IO space: 5", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.tif 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "1000", + "foundry.source.umid": "918126ef-7109-4835-492e-67d31cd7f798", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "matte_paint", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "25", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAABAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAA", + "foundry.timeline.samplerate": "Invalid", + "isSequence": true, + "media.input.bitsperchannel": "8-bit fixed", + "media.input.ctime": "2024-09-23 08:36:04", + "media.input.filereader": "tiff", + "media.input.filesize": "784446", + "media.input.frame": "1", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:36:04", + "media.input.width": "1920", + "media.tiff.resolution_unit": "1", + "media.tiff.xresolution": "72", + "media.tiff.yresolution": "72", + "openpype.source.colourtransform": "matte_paint", + "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": 25.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 25.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\no_tc", + "name_prefix": "output.", + "name_suffix": ".tif", + "start_frame": 1000, + "frame_step": 1, + "rate": 25.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips_gap.json b/tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips_gap.json new file mode 100644 index 0000000000..85667a00dc --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/resources/multiple_review_clips_gap.json @@ -0,0 +1,289 @@ +{ + "OTIO_SCHEMA": "Track.1", + "metadata": {}, + "name": "Video 2", + "source_range": null, + "effects": [], + "markers": [], + "enabled": true, + "children": [ + { + "OTIO_SCHEMA": "Gap.1", + "metadata": {}, + "name": "", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 2.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "enabled": true + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 88.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "scene_linear", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.exr 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 7", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.exr 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "87399", + "foundry.source.umid": "bbfe0c90-5b76-424a-6351-4dac36a8dde7", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "scene_linear", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "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": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1918,1078", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.lineOrder": "0", + "media.exr.nuke.input.frame_rate": "24", + "media.exr.nuke.input.timecode": "01:00:41:15", + "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": "2024-09-23 08:37:23", + "media.input.filereader": "exr", + "media.input.filesize": "1095868", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:37:23", + "media.input.timecode": "01:00:41:15", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "9b", + "media.nuke.version": "15.1v2", + "openpype.source.colourtransform": "scene_linear", + "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": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\with_tc", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + }, + { + "OTIO_SCHEMA": "Clip.2", + "metadata": {}, + "name": "output", + "source_range": { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 11.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 0.0 + } + }, + "effects": [], + "markers": [], + "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": "scene_linear", + "foundry.source.duration": "101", + "foundry.source.filename": "output.%04d.exr 1000-1100", + "foundry.source.filesize": "", + "foundry.source.fragments": "101", + "foundry.source.framerate": "24", + "foundry.source.fullpath": "", + "foundry.source.height": "1080", + "foundry.source.layers": "colour", + "foundry.source.pixelAspect": "1", + "foundry.source.pixelAspectRatio": "", + "foundry.source.pixelformat": "RGBA (Float16) Open Color IO space: 7", + "foundry.source.reelID": "", + "foundry.source.resolution": "", + "foundry.source.samplerate": "Invalid", + "foundry.source.shortfilename": "output.%04d.exr 1000-1100", + "foundry.source.shot": "", + "foundry.source.shotDate": "", + "foundry.source.startTC": "", + "foundry.source.starttime": "1000", + "foundry.source.timecode": "87399", + "foundry.source.umid": "bbfe0c90-5b76-424a-6351-4dac36a8dde7", + "foundry.source.umidOriginator": "foundry.source.umid", + "foundry.source.width": "1920", + "foundry.timeline.autodiskcachemode": "Manual", + "foundry.timeline.colorSpace": "scene_linear", + "foundry.timeline.duration": "101", + "foundry.timeline.framerate": "24", + "foundry.timeline.outputformat": "", + "foundry.timeline.poster": "0", + "foundry.timeline.posterLayer": "colour", + "foundry.timeline.readParams": "AAAAAQAAAAAAAAAFAAAACmNvbG9yc3BhY2UAAAAFaW50MzIAAAAAAAAAC2VkZ2VfcGl4ZWxzAAAABWludDMyAAAAAAAAABFpZ25vcmVfcGFydF9uYW1lcwAAAARib29sAAAAAAhub3ByZWZpeAAAAARib29sAAAAAB5vZmZzZXRfbmVnYXRpdmVfZGlzcGxheV93aW5kb3cAAAAEYm9vbAE=", + "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": "2", + "media.exr.compressionName": "Zip (1 scanline)", + "media.exr.dataWindow": "1,1,1918,1078", + "media.exr.displayWindow": "0,0,1919,1079", + "media.exr.lineOrder": "0", + "media.exr.nuke.input.frame_rate": "24", + "media.exr.nuke.input.timecode": "01:00:41:15", + "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": "2024-09-23 08:37:23", + "media.input.filereader": "exr", + "media.input.filesize": "1095868", + "media.input.frame": "1", + "media.input.frame_rate": "24", + "media.input.height": "1080", + "media.input.mtime": "2024-09-23 08:37:23", + "media.input.timecode": "01:00:41:15", + "media.input.width": "1920", + "media.nuke.full_layer_names": "0", + "media.nuke.node_hash": "9b", + "media.nuke.version": "15.1v2", + "openpype.source.colourtransform": "scene_linear", + "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": 24.0, + "value": 101.0 + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": 24.0, + "value": 1000.0 + } + }, + "available_image_bounds": null, + "target_url_base": "C:\\with_tc", + "name_prefix": "output.", + "name_suffix": ".exr", + "start_frame": 1000, + "frame_step": 1, + "rate": 24.0, + "frame_zero_padding": 4, + "missing_frame_policy": "error" + } + }, + "active_media_reference_key": "DEFAULT_MEDIA" + } + ], + "kind": "Video" +} \ No newline at end of file diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 7bc1a750d7..25d689e7c1 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -37,27 +37,31 @@ def get_ffmpeg_executable(self, _): return ["/path/to/ffmpeg"] -def run_process(file_name: str): +def run_process(file_name: str, instance_data: dict = None): """ """ - # Get OTIO review data from serialized file_name - file_path = os.path.join(_RESOURCE_DIR, file_name) - clip = otio.schema.Clip.from_json_file(file_path) - # Prepare dummy instance and capture call object capture_call = CaptureFFmpegCalls() processor = extract_otio_review.ExtractOTIOReview() Anatomy = NamedTuple("Anatomy", project_name=str) - instance = MockInstance( - { + + if not instance_data: + # Get OTIO review data from serialized file_name + file_path = os.path.join(_RESOURCE_DIR, file_name) + clip = otio.schema.Clip.from_json_file(file_path) + + instance_data = { "otioReviewClips": [clip], "handleStart": 10, "handleEnd": 10, "workfileFrameStart": 1001, - "folderPath": "/dummy/path", - "anatomy": Anatomy("test_project"), } - ) + + instance_data.update({ + "folderPath": "/dummy/path", + "anatomy": Anatomy("test_project"), + }) + instance = MockInstance(instance_data) # Mock calls to extern and run plugins. with mock.patch.object( @@ -73,9 +77,14 @@ def run_process(file_name: str): with mock.patch.object( processor, "_get_folder_name_based_prefix", - return_value="C:/result/output." + return_value="output." ): - processor.process(instance) + with mock.patch.object( + processor, + "staging_dir", + return_value="C:/result/" + ): + processor.process(instance) # return all calls made to ffmpeg subprocess return capture_call.calls @@ -103,7 +112,7 @@ def test_image_sequence_with_embedded_tc_and_handles_out_of_range(): # Report from source exr (1001-1101) with enforce framerate "/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i " - "C:\\exr_embedded_tc\\output.%04d.exr -start_number 1001 " + f"C:\\exr_embedded_tc{os.sep}output.%04d.exr -start_number 1001 " "C:/result/output.%03d.jpg" ] @@ -133,7 +142,7 @@ def test_image_sequence_and_handles_out_of_range(): # 1001-1095 = source range conformed to 25fps # 1096-1096 = additional 1 tail frames "/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i " - "C:\\tif_seq\\output.%04d.tif -start_number 996 C:/result/output.%03d.jpg" + f"C:\\tif_seq{os.sep}output.%04d.tif -start_number 996 C:/result/output.%03d.jpg" ] assert calls == expected @@ -203,3 +212,119 @@ def test_short_movie_tail_gap_handles(): ] assert calls == expected + +def test_multiple_review_clips_no_gap(): + """ + Use multiple review clips (image sequence). + Timeline 25fps + """ + file_path = os.path.join(_RESOURCE_DIR, "multiple_review_clips.json") + clips = otio.schema.Track.from_json_file(file_path) + instance_data = { + "otioReviewClips": clips, + "handleStart": 10, + "handleEnd": 10, + "workfileFrameStart": 1001, + } + + calls = run_process( + None, + instance_data=instance_data + ) + + expected = [ + # 10 head black frames generated from gap (991-1000) + '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune ' + 'stillimage -start_number 991 C:/result/output.%03d.jpg', + + # Alternance 25fps tiff sequence and 24fps exr sequence for 100 frames each + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1001 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' + f'C:\\with_tc{os.sep}output.%04d.exr ' + '-start_number 1102 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1199 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' + f'C:\\with_tc{os.sep}output.%04d.exr ' + '-start_number 1300 C:/result/output.%03d.jpg', + + # Repeated 25fps tiff sequence multiple times till the end + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1397 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1498 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1599 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1700 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1801 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 1902 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 2003 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 2104 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' + f'C:\\no_tc{os.sep}output.%04d.tif ' + '-start_number 2205 C:/result/output.%03d.jpg' + ] + + assert calls == expected + +def test_multiple_review_clips_with_gap(): + """ + Use multiple review clips (image sequence) with gap. + Timeline 24fps + """ + file_path = os.path.join(_RESOURCE_DIR, "multiple_review_clips_gap.json") + clips = otio.schema.Track.from_json_file(file_path) + instance_data = { + "otioReviewClips": clips, + "handleStart": 10, + "handleEnd": 10, + "workfileFrameStart": 1001, + } + + calls = run_process( + None, + instance_data=instance_data + ) + + expected = [ + # Gap on review track (12 frames) + '/path/to/ffmpeg -t 0.5 -r 24.0 -f lavfi -i color=c=black:s=1280x720 -tune ' + 'stillimage -start_number 991 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' + f'C:\\with_tc{os.sep}output.%04d.exr ' + '-start_number 1003 C:/result/output.%03d.jpg', + + '/path/to/ffmpeg -start_number 1000 -framerate 24.0 -i ' + f'C:\\with_tc{os.sep}output.%04d.exr ' + '-start_number 1091 C:/result/output.%03d.jpg' + ] + + assert calls == expected From 1fce6108c69b15a8aeb3660182d4b6c17530bf84 Mon Sep 17 00:00:00 2001 From: robin Date: Wed, 2 Oct 2024 11:09:01 -0400 Subject: [PATCH 199/266] Fix linting --- .../pipeline/editorial/test_extract_otio_review.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py index 25d689e7c1..ea31e1a260 100644 --- a/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -83,7 +83,7 @@ def run_process(file_name: str, instance_data: dict = None): processor, "staging_dir", return_value="C:/result/" - ): + ): processor.process(instance) # return all calls made to ffmpeg subprocess @@ -233,11 +233,11 @@ def test_multiple_review_clips_no_gap(): ) expected = [ - # 10 head black frames generated from gap (991-1000) + # 10 head black frames generated from gap (991-1000) '/path/to/ffmpeg -t 0.4 -r 25.0 -f lavfi -i color=c=black:s=1280x720 -tune ' 'stillimage -start_number 991 C:/result/output.%03d.jpg', - # Alternance 25fps tiff sequence and 24fps exr sequence for 100 frames each + # Alternance 25fps tiff sequence and 24fps exr sequence for 100 frames each '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' '-start_number 1001 C:/result/output.%03d.jpg', @@ -254,7 +254,7 @@ def test_multiple_review_clips_no_gap(): f'C:\\with_tc{os.sep}output.%04d.exr ' '-start_number 1300 C:/result/output.%03d.jpg', - # Repeated 25fps tiff sequence multiple times till the end + # Repeated 25fps tiff sequence multiple times till the end '/path/to/ffmpeg -start_number 1000 -framerate 25.0 -i ' f'C:\\no_tc{os.sep}output.%04d.tif ' '-start_number 1397 C:/result/output.%03d.jpg', @@ -292,7 +292,7 @@ def test_multiple_review_clips_no_gap(): '-start_number 2205 C:/result/output.%03d.jpg' ] - assert calls == expected + assert calls == expected def test_multiple_review_clips_with_gap(): """ From e20671ddf7bacaa14bd4d9903af1039244fe3dbd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:48:53 +0200 Subject: [PATCH 200/266] simplified product context widget --- .../publisher/widgets/product_context.py | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index 977f5eccb3..9fdf55c3f9 100644 --- a/client/ayon_core/tools/publisher/widgets/product_context.py +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -710,15 +710,15 @@ def _on_submit(self): invalid_tasks = False folder_paths = [] changes_by_id = {} - for instance in self._current_instances_by_id.values(): + for item in self._current_instances_by_id.values(): # Ignore instances that have promised context - if instance.has_promised_context: + if item.has_promised_context: continue instance_changes = {} - new_variant_value = instance.variant - new_folder_path = instance.folder_path - new_task_name = instance.task_name + new_variant_value = item.variant + new_folder_path = item.folder_path + new_task_name = item.task_name if variant_value is not None: instance_changes["variant"] = variant_value new_variant_value = variant_value @@ -734,32 +734,22 @@ def _on_submit(self): folder_paths.append(new_folder_path) try: new_product_name = self._controller.get_product_name( - instance.creator_identifier, + item.creator_identifier, new_variant_value, new_task_name, new_folder_path, - instance.id, + item.id, ) except TaskNotSetError: invalid_tasks = True - product_names.add(instance.product_name) + product_names.add(item.product_name) continue product_names.add(new_product_name) - if variant_value is not None: - instance.variant = variant_value - - if folder_path is not None: - instance.folder_path = folder_path - - if task_name is not None: - instance.task_name = task_name or None - - instance.product_name = new_product_name - if instance.product_name != new_product_name: + if item.product_name != new_product_name: instance_changes["productName"] = new_product_name - changes_by_id[instance.id] = instance_changes + changes_by_id[item.id] = instance_changes if invalid_tasks: self.task_value_widget.set_invalid_empty_task() @@ -838,6 +828,12 @@ def set_current_instances(self, instances): } self._refresh_content() + def _refresh_items(self): + instance_ids = set(self._current_instances_by_id.keys()) + self._current_instances_by_id = ( + self._controller.get_instance_items_by_id(instance_ids) + ) + def _refresh_content(self): folder_paths = set() variants = set() @@ -850,23 +846,23 @@ def _refresh_content(self): folder_task_combinations = [] context_editable = None - for instance in self._current_instances_by_id.values(): - if not instance.has_promised_context: + for item in self._current_instances_by_id.values(): + if not item.has_promised_context: context_editable = True elif context_editable is None: context_editable = False # NOTE I'm not sure how this can even happen? - if instance.creator_identifier is None: + if item.creator_identifier is None: editable = False - variants.add(instance.variant or self.unknown_value) - product_types.add(instance.product_type or self.unknown_value) - folder_path = instance.folder_path or self.unknown_value - task_name = instance.task_name or "" + variants.add(item.variant or self.unknown_value) + product_types.add(item.product_type or self.unknown_value) + folder_path = item.folder_path or self.unknown_value + task_name = item.task_name or "" folder_paths.add(folder_path) folder_task_combinations.append((folder_path, task_name)) - product_names.add(instance.product_name or self.unknown_value) + product_names.add(item.product_name or self.unknown_value) if not editable: context_editable = False @@ -905,8 +901,7 @@ def _on_instance_value_change(self, event): changed = False for instance_id, changes in event["instance_changes"].items(): - instance = self._current_instances_by_id.get(instance_id) - if instance is None: + if instance_id not in self._current_instances_by_id: continue for key, attr_name in ( @@ -917,12 +912,12 @@ def _on_instance_value_change(self, event): ("productName", "product_name"), ): if key in changes: - setattr(instance, attr_name, changes[key]) changed = True break if changed: break if changed: + self._refresh_items() self._refresh_content() self.instance_context_changed.emit() From 73eaa0711645051344d05180f6024ac978660e8e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 2 Oct 2024 19:18:43 +0200 Subject: [PATCH 201/266] fix context change --- .../publisher/widgets/product_context.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index 9fdf55c3f9..f11dc90a5d 100644 --- a/client/ayon_core/tools/publisher/widgets/product_context.py +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -633,6 +633,7 @@ def __init__( self._controller: AbstractPublisherFrontend = controller self._current_instances_by_id = {} + self._invalid_task_item_ids = set() variant_input = VariantInputWidget(self) folder_value_widget = FoldersFields(controller, self) @@ -728,8 +729,8 @@ def _on_submit(self): new_folder_path = folder_path if task_name is not None: - instance_changes["task"] = task_name - new_task_name = task_name + instance_changes["task"] = task_name or None + new_task_name = task_name or None folder_paths.append(new_folder_path) try: @@ -740,8 +741,10 @@ def _on_submit(self): new_folder_path, item.id, ) + self._invalid_task_item_ids.discard(item.id) except TaskNotSetError: + self._invalid_task_item_ids.add(item.id) invalid_tasks = True product_names.add(item.product_name) continue @@ -749,7 +752,9 @@ def _on_submit(self): product_names.add(new_product_name) if item.product_name != new_product_name: instance_changes["productName"] = new_product_name - changes_by_id[item.id] = instance_changes + + if instance_changes: + changes_by_id[item.id] = instance_changes if invalid_tasks: self.task_value_widget.set_invalid_empty_task() @@ -769,6 +774,7 @@ def _on_submit(self): self.task_value_widget.confirm_value(folder_paths) self._controller.set_instances_context_info(changes_by_id) + self._refresh_items() self.instance_context_changed.emit() def _on_cancel(self): @@ -826,6 +832,7 @@ def set_current_instances(self, instances): instance.id: instance for instance in instances } + self._invalid_task_item_ids = set() self._refresh_content() def _refresh_items(self): @@ -846,11 +853,14 @@ def _refresh_content(self): folder_task_combinations = [] context_editable = None + invalid_tasks = False for item in self._current_instances_by_id.values(): if not item.has_promised_context: context_editable = True elif context_editable is None: context_editable = False + if item.id in self._invalid_task_item_ids: + invalid_tasks = True # NOTE I'm not sure how this can even happen? if item.creator_identifier is None: @@ -882,6 +892,9 @@ def _refresh_content(self): self.folder_value_widget.setEnabled(context_editable) self.task_value_widget.setEnabled(context_editable) + if invalid_tasks: + self.task_value_widget.set_invalid_empty_task() + if not editable: folder_tooltip = "Select instances to change folder path." task_tooltip = "Select instances to change task name." @@ -905,11 +918,11 @@ def _on_instance_value_change(self, event): continue for key, attr_name in ( - ("folderPath", "folder_path"), - ("task", "task_name"), - ("variant", "variant"), - ("productType", "product_type"), - ("productName", "product_name"), + "folderPath", + "task", + "variant", + "productType", + "productName", ): if key in changes: changed = True From 266140ad40f7a651244220533257eda57aa305ba Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Oct 2024 19:23:37 +0200 Subject: [PATCH 202/266] Refactor function `update_allowed_representation_switches` -> `get_representation_name_aliases` --- client/ayon_core/pipeline/load/plugins.py | 26 +++++++++++------------ client/ayon_core/pipeline/load/utils.py | 9 ++++---- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 28a7e775d6..1fb906fd65 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -242,25 +242,25 @@ def fname(self): if hasattr(self, "_fname"): return self._fname - def update_allowed_representation_switches(self): - """Return a mapping from source representation names to ordered - destination representation names to which switching is allowed if - the source representation name does not exist for the new version. + @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` if the new version does not have a `ma` - representation you can return: - {"ma": ["mb", "abc"]} + `ma` to `mb` or `abc`, then when `representation_name` is `ma` return: + ["mb", "abc"] - The order of the names in the returned values is important, because - if `ma` is missing and both of the replacement representations are - present than the first one will be chosen. + 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: - Dict[str, List[str]]: Mapping from representation names to allowed - alias representation names switching to is allowed on update. + List[str]: Representation names switching to is allowed on update + if the input representation name is not found on the new version. """ - return {} + return [] class ProductLoaderPlugin(LoaderPlugin): diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index b258d20a3d..ee2c1af07f 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -521,17 +521,16 @@ def update_container(container, version=-1): # The representation name is not found in the new version. # Allow updating to a 'matching' representation if the loader # has defined compatible update conversions - mapping = Loader().update_allowed_representation_switches() - switch_repre_names = mapping.get(repre_name) - if switch_repre_names: + repre_name_aliases = Loader.get_representation_name_aliases(repre_name) + if repre_name_aliases: representations = ayon_api.get_representations( project_name, - representation_names=switch_repre_names, + representation_names=repre_name_aliases, version_ids=[new_version["id"]]) representations_by_name = { repre["name"]: repre for repre in representations } - for name in switch_repre_names: + for name in repre_name_aliases: if name in representations_by_name: new_representation = representations_by_name[name] break From 0ab128b3bfac5aae72f10df11887e1ecf8b26968 Mon Sep 17 00:00:00 2001 From: robin Date: Wed, 2 Oct 2024 16:10:27 -0400 Subject: [PATCH 203/266] Add rounding support for OTIO <= 0.16.0 --- .../plugins/publish/extract_otio_review.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index 00a90df695..faba9fd36d 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -329,6 +329,20 @@ def _trim_available_range(self, avl_range, start, duration): trim_media_range, ) + def _round_to_frame(rational_time): + """ Handle rounding duration to frame. + """ + # OpentimelineIO >= 0.16.0 + try: + return rational_time.round().to_frames() + + # OpentimelineIO < 0.16.0 + except AttributeError: + return otio.opentime.RationalTime( + round(rational_time.value), + rate=rational_time.rate, + ).to_frames() + avl_start = avl_range.start_time # An additional gap is required before the available @@ -337,11 +351,12 @@ def _trim_available_range(self, avl_range, start, duration): gap_duration = avl_start - start start = avl_start duration -= gap_duration + gap_duration = _round_to_frame(gap_duration) # create gap data to disk - self._render_segment(gap=gap_duration.round().to_frames()) + self._render_segment(gap=gap_duration) # generate used frames - self._generate_used_frames(gap_duration.round().to_frames()) + self._generate_used_frames(gap_duration) # An additional gap is required after the available # range to conform to source end point + tail handles @@ -351,15 +366,16 @@ def _trim_available_range(self, avl_range, start, duration): if end_point > avl_end_point: gap_duration = end_point - avl_end_point duration -= gap_duration + gap_duration = _round_to_frame(gap_duration) # create gap data to disk self._render_segment( - gap=gap_duration.round().to_frames(), + gap=gap_duration, end_offset=duration.to_frames() ) # generate used frames self._generate_used_frames( - gap_duration.round().to_frames(), + gap_duration, end_offset=duration.to_frames() ) From 578618c1e317295a411fbf9f79a45bf456980319 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 11:28:28 +0200 Subject: [PATCH 204/266] added method allowing to register to callbacks --- client/ayon_core/pipeline/create/context.py | 3 +++ .../pipeline/publish/publish_plugins.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 3245b68699..a09bf52d79 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -667,6 +667,9 @@ def _reset_publish_plugins(self, discover_publish_plugins): if plugin not in plugins_by_targets ] + for plugin in plugins_with_defs: + plugin.register_create_context_callbacks(self) + self.publish_plugins_mismatch_targets = plugins_mismatch_targets self.publish_discover_result = discover_result self.publish_plugins = plugins_by_targets diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 59547ce651..c9732e4928 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -126,6 +126,27 @@ class AYONPyblishPluginMixin: # for callback in self._state_change_callbacks: # callback(self) + @classmethod + def register_create_context_callbacks(cls, create_context): + """Register callbacks for create context. + + It is possible to register callbacks listening to changes happened + in create context. + + Methods available on create context: + - listen_to_added_instances + - listen_to_removed_instances + - listen_to_value_changes + - listen_to_pre_create_attr_defs_change + - listen_to_create_attr_defs_change + - listen_to_publish_attr_defs_change + + Args: + create_context (CreateContext): Create context. + + """ + pass + @classmethod def get_attribute_defs(cls): """Publish attribute definitions. From 941836cc0daff058afcaf756e97a219a5171736a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:26:37 +0200 Subject: [PATCH 205/266] removed unused import --- client/ayon_core/pipeline/create/structures.py | 1 - 1 file changed, 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 2bbd6dabc5..1142ba84eb 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -6,7 +6,6 @@ from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, UnknownDef, - UIDef, serialize_attr_defs, deserialize_attr_defs, ) From 7f73d9c9e4857aefe6ca05772229db106c64bc98 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 14:26:53 +0200 Subject: [PATCH 206/266] safe 'register_create_context_callbacks' --- client/ayon_core/pipeline/create/context.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a09bf52d79..bc22604bca 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -668,7 +668,21 @@ def _reset_publish_plugins(self, discover_publish_plugins): ] for plugin in plugins_with_defs: - plugin.register_create_context_callbacks(self) + if not inspect.ismethod(plugin.register_create_context_callbacks): + self.log.warning( + f"Plugin {plugin.__name__} does not have" + f" 'register_create_context_callbacks'" + f" defined as class method." + ) + continue + try: + plugin.register_create_context_callbacks(self) + except Exception: + self.log.error( + f"Failed to register callbacks for plugin" + f" {plugin.__name__}.", + exc_info=True + ) self.publish_plugins_mismatch_targets = plugins_mismatch_targets self.publish_discover_result = discover_result From 84d6daf60c357735588f669e4c35a4d5f52febdc Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 3 Oct 2024 08:36:34 -0400 Subject: [PATCH 207/266] Fix NTSC framerates floating issue comparison. --- client/ayon_core/pipeline/editorial.py | 24 +- .../resources/img_seq_23.976_metadata.json | 255 ++++++++++++++++++ .../test_media_range_with_retimes.py | 21 ++ 3 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 tests/client/ayon_core/pipeline/editorial/resources/img_seq_23.976_metadata.json diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index f382f91fec..af2a6ef88c 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -292,13 +292,23 @@ 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. + # NSTC compatibility might introduce floating rates, when these are + # not exactly the same (23.976 vs 23.976024627685547) + # this will cause precision issue in computation. + # Round to 2 decimals for comparison. + 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/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, + ) From a3c9106f35dd3c5876cb216ff4a9ad4f1ba9cac4 Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 3 Oct 2024 09:07:26 -0400 Subject: [PATCH 208/266] Fix typo --- client/ayon_core/pipeline/editorial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index af2a6ef88c..94b101d3d3 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -293,7 +293,7 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # to preserve media range # Compute new source range based on available rate. - # NSTC compatibility might introduce floating rates, when these are + # 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. # Round to 2 decimals for comparison. From b535dcb81f912a7c7ba29299417afa03da2c6c66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:14:41 +0200 Subject: [PATCH 209/266] added docstrings to listen methods --- client/ayon_core/pipeline/create/context.py | 151 +++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index bc22604bca..a08cd3624d 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -667,6 +667,7 @@ def _reset_publish_plugins(self, discover_publish_plugins): if plugin not in plugins_by_targets ] + # Register create context callbacks for plugin in plugins_with_defs: if not inspect.ismethod(plugin.register_create_context_callbacks): self.log.warning( @@ -810,23 +811,171 @@ def reset_context_data(self): ) def listen_to_added_instances(self, callback): - self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) + """Register callback for added instances. + + Event is triggered when instances are already available in context + and have set create/publish attribute definitions. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...] + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instances are added to context. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) def listen_to_removed_instances(self, callback): + """Register callback for removed instances. + + Event is triggered when instances are already removed from context. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...] + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instances are removed from context. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) def listen_to_value_changes(self, callback): + """Register callback to listen value changes. + + Event is triggered when any value changes on any instance or + context data. + + Data structure of event:: + + ```python + { + "changes": [ + { + "instance": CreatedInstance, + "changes": { + "folderPath": "/new/folder/path", + "creator_attributes": { + "attr_1": "value_1" + } + } + } + ] + } + ``` + + Args: + callback (Callable): Callback function that will be called when + value changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) def listen_to_pre_create_attr_defs_change(self, callback): + """Register callback to listen pre-create attribute changes. + + Create plugin can trigger refresh of pre-create attributes. Usage of + this event is mainly for publisher UI. + + Data structure of event:: + + ```python + { + "identifiers": ["create_plugin_identifier"] + } + ``` + + Args: + callback (Callable): Callback function that will be called when + pre-create attributes should be refreshed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ self._event_hub.add_callback( PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback ) def listen_to_create_attr_defs_change(self, callback): + """Register callback to listen create attribute changes. + + Create plugin changed attribute definitions of instance. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...] + } + ``` + + Args: + callback (Callable): Callback function that will be called when + create attributes changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) def listen_to_publish_attr_defs_change(self, callback): + """Register callback to listen publish attribute changes. + + Publish plugin changed attribute definitions of instance of context. + + Data structure of event:: + + ```python + { + "instance_changes": { + None: { + "instance": None, + "plugin_names": {"PluginA"}, + } + "": { + "instance": CreatedInstance, + "plugin_names": {"PluginB", "PluginC"}, + } + } + } + ``` + + Args: + callback (Callable): Callback function that will be called when + publish attributes changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ self._event_hub.add_callback( PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) From 5d1e4863e0005de4edbe0cfadfaac50ee247c0cb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:21:02 +0200 Subject: [PATCH 210/266] added some docstrings --- .../ayon_core/pipeline/create/structures.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 1142ba84eb..68b8d7ecf1 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -734,6 +734,17 @@ def data_to_store(self): return output def update_create_attr_defs(self, attr_defs, value=None): + """Create plugin updates create attribute definitions. + + Method called by create plugin when attribute definitions should + be changed. + + Args: + attr_defs (List[AbstractAttrDef]): Attribute definitions. + value (Optional[Dict[str, Any]]): Values of attribute definitions. + Current values are used if not passed in. + + """ if value is None: value = self._data["creator_attributes"] @@ -780,6 +791,13 @@ def from_existing(cls, instance_data, creator): ) def attribute_value_changed(self, key, changes): + """A value changed. + + Args: + key (str): Key of attribute values. + changes (Dict[str, Any]): Changes in values. + + """ self._create_context.instance_values_changed(self.id, {key: changes}) def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): @@ -798,6 +816,13 @@ def set_publish_plugin_attr_defs(self, plugin_name, attr_defs): ) def publish_attribute_value_changed(self, plugin_name, value): + """Method called from PublishAttributes. + + Args: + plugin_name (str): Plugin name. + value (Dict[str, Any]): Changes in values for the plugin. + + """ self._create_context.instance_values_changed( self.id, { @@ -816,4 +841,10 @@ def add_members(self, members): @property def _create_context(self): + """Get create context. + + Returns: + CreateContext: Context object which wraps object. + + """ return self._creator.create_context From c315c96755c450849dc5af6ce25a36e7664822b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 15:51:46 +0200 Subject: [PATCH 211/266] print logs based on env variable --- .../tools/publisher/models/publish.py | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index ff20d8ec3e..6dfda38885 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -4,12 +4,14 @@ import logging import traceback import collections +from contextlib import contextmanager from functools import partial from typing import Optional, Dict, List, Union, Any, Iterable import arrow import pyblish.plugin +from ayon_core.lib import env_value_to_bool from ayon_core.pipeline import ( PublishValidationError, KnownPublishError, @@ -867,6 +869,10 @@ class PublishModel: def __init__(self, controller: AbstractPublisherBackend): self._controller = controller + self._log_to_console: bool = env_value_to_bool( + "AYON_PUBLISHER_PRINT_LOGS", default=False + ) + # Publishing should stop at validation stage self._publish_up_validation: bool = False self._publish_comment_is_set: bool = False @@ -917,7 +923,13 @@ def __init__(self, controller: AbstractPublisherBackend): self._log_handler: MessageHandler = MessageHandler() def reset(self): + # Allow to change behavior during process lifetime + self._log_to_console = env_value_to_bool( + "AYON_PUBLISHER_PRINT_LOGS", default=False + ) + create_context = self._controller.get_create_context() + self._publish_up_validation = False self._publish_comment_is_set = False self._publish_has_started = False @@ -1285,25 +1297,38 @@ def _publish_iterator(self) -> Iterable[partial]: self._set_progress(self._publish_max_progress) yield partial(self.stop_publish) + @contextmanager + def _log_manager(self, plugin: pyblish.api.Plugin): + root = logging.getLogger() + if not self._log_to_console: + plugin.log.propagate = False + plugin.log.addHandler(self._log_handler) + root.addHandler(self._log_handler) + + try: + if self._log_to_console: + yield None + else: + yield self._log_handler + + finally: + if not self._log_to_console: + plugin.log.propagate = True + plugin.log.removeHandler(self._log_handler) + root.removeHandler(self._log_handler) + self._log_handler.clear_records() + def _process_and_continue( self, plugin: pyblish.api.Plugin, instance: pyblish.api.Instance ): - root = logging.getLogger() - self._log_handler.clear_records() - plugin.log.propagate = False - plugin.log.addHandler(self._log_handler) - root.addHandler(self._log_handler) - try: + with self._log_manager(plugin) as log_handler: result = pyblish.plugin.process( plugin, self._publish_context, instance ) - result["records"] = self._log_handler.records - finally: - plugin.log.propagate = True - plugin.log.removeHandler(self._log_handler) - root.removeHandler(self._log_handler) + if log_handler is not None: + result["records"] = log_handler.records exception = result.get("error") if exception: From 412b4b8d3a558449d2d29ef3353cef22dcddfc71 Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 3 Oct 2024 10:18:12 -0400 Subject: [PATCH 212/266] Address feedback from PR. --- client/ayon_core/pipeline/editorial.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 94b101d3d3..a49a981d2a 100644 --- a/client/ayon_core/pipeline/editorial.py +++ b/client/ayon_core/pipeline/editorial.py @@ -293,10 +293,13 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): # to preserve media range # 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. - # Round to 2 decimals for comparison. + # 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: From 162a47db60d61a80774b86b00daddcae7d34298c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 3 Oct 2024 14:27:19 +0000 Subject: [PATCH 213/266] [Automated] Add generated package files from main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 75116c703e..e6d6c6f373 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.1" diff --git a/package.py b/package.py index 1466031daa..b7f74e5126 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.0+dev" +version = "1.0.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 4a63529c67..afb48efec3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.0+dev" +version = "1.0.1" description = "" authors = ["Ynput Team "] readme = "README.md" From d2cbdc1d2147a3fa134e651655b61ca3dc5131b4 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Thu, 3 Oct 2024 14:27:53 +0000 Subject: [PATCH 214/266] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e6d6c6f373..458129f367 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.1" +__version__ = "1.0.1+dev" diff --git a/package.py b/package.py index b7f74e5126..c059eed423 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.1" +version = "1.0.1+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index afb48efec3..0a7d0d76c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.1" +version = "1.0.1+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From 820fd54a567881bc386a05e69986c7b33a6c9b12 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 3 Oct 2024 17:39:10 +0200 Subject: [PATCH 215/266] remove pyblish exception logfrom records --- .../tools/publisher/models/publish.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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 @@ 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 @@ def _process_and_continue( 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: From ceafd5b6d9e647246b71650d815f89f60828b4a4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 4 Oct 2024 02:11:42 +0200 Subject: [PATCH 216/266] Cinema4D: Open last workfile on launch --- client/ayon_core/hooks/pre_add_last_workfile_arg.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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} From 58cc97ebfd0d576540f2c7a814eeb791225c6339 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:37:52 +0200 Subject: [PATCH 217/266] added information about 'create_context' in event callback --- client/ayon_core/pipeline/create/context.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index a08cd3624d..0ea69d3173 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -820,7 +820,8 @@ def listen_to_added_instances(self, callback): ```python { - "instances": [CreatedInstance, ...] + "instances": [CreatedInstance, ...], + "create_context": CreateContext } ``` @@ -844,7 +845,8 @@ def listen_to_removed_instances(self, callback): ```python { - "instances": [CreatedInstance, ...] + "instances": [CreatedInstance, ...], + "create_context": CreateContext } ``` @@ -879,7 +881,8 @@ def listen_to_value_changes(self, callback): } } } - ] + ], + "create_context": CreateContext } ``` @@ -904,7 +907,8 @@ def listen_to_pre_create_attr_defs_change(self, callback): ```python { - "identifiers": ["create_plugin_identifier"] + "identifiers": ["create_plugin_identifier"], + "create_context": CreateContext } ``` @@ -930,7 +934,8 @@ def listen_to_create_attr_defs_change(self, callback): ```python { - "instances": [CreatedInstance, ...] + "instances": [CreatedInstance, ...], + "create_context": CreateContext } ``` @@ -963,7 +968,8 @@ def listen_to_publish_attr_defs_change(self, callback): "instance": CreatedInstance, "plugin_names": {"PluginB", "PluginC"}, } - } + }, + "create_context": CreateContext } ``` From 652d17113bad063cca059c716267bd250f4df812 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 11:38:02 +0200 Subject: [PATCH 218/266] added method to update context publish attributes --- client/ayon_core/pipeline/create/context.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0ea69d3173..657f71aa97 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1002,6 +1002,21 @@ def context_data_changes(self): self._original_context_data, self.context_data_to_store() ) + def set_context_publish_plugin_attr_defs(self, plugin_name, attr_defs): + """Set attribute definitions for CreateContext publish plugin. + + Args: + plugin_name(str): Name of publish plugin. + attr_defs(List[AbstractAttrDef]): Attribute definitions. + + """ + self.publish_attributes.set_publish_plugin_attr_defs( + plugin_name, attr_defs + ) + self.instance_publish_attr_defs_changed( + None, plugin_name + ) + def creator_adds_instance(self, instance: "CreatedInstance"): """Creator adds new instance to context. From 36b54c0b4b9d04eb9ed47b8beab12e2b07a4164a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:20:15 +0200 Subject: [PATCH 219/266] added helper method 'instance_matches_plugin_families' --- .../pipeline/publish/publish_plugins.py | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index c9732e4928..e755236cf8 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -176,6 +176,26 @@ def get_attribute_defs_for_context(cls, create_context): return [] return cls.get_attribute_defs() + @classmethod + def instance_matches_plugin_families(cls, instance): + """Check if instance matches families. + + Args: + instance (CreatedInstance): Instance to check. + + Returns: + bool: True if instance matches plugin families. + + """ + if not cls.__instanceEnabled__: + return False + + for _ in pyblish.logic.plugins_by_families( + [cls], [instance.product_type] + ): + return True + return False + @classmethod def get_attribute_defs_for_instance(cls, create_context, instance): """Publish attribute definitions for an instance. @@ -191,14 +211,9 @@ def get_attribute_defs_for_instance(cls, create_context, instance): list[AbstractAttrDef]: Attribute definitions for plugin. """ - if not cls.__instanceEnabled__: + if not cls.instance_matches_plugin_families(instance): return [] - - for _ in pyblish.logic.plugins_by_families( - [cls], [instance.product_type] - ): - return cls.get_attribute_defs() - return [] + return cls.get_attribute_defs() @classmethod def convert_attribute_values(cls, create_context, instance): From 5cb71523857c073d4b4f444e4f972f7d4e9edd42 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:40:18 +0200 Subject: [PATCH 220/266] allow to use the method for context plugins --- client/ayon_core/pipeline/publish/publish_plugins.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index e755236cf8..cef3bb8bea 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -181,14 +181,15 @@ def instance_matches_plugin_families(cls, instance): """Check if instance matches families. Args: - instance (CreatedInstance): Instance to check. + instance (Optional[CreatedInstance]): Instance to check. Or None + for context. Returns: bool: True if instance matches plugin families. """ if not cls.__instanceEnabled__: - return False + return instance is None for _ in pyblish.logic.plugins_by_families( [cls], [instance.product_type] From d962e01c53e299faaa8c7b168e705d70003f7563 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:55:54 +0200 Subject: [PATCH 221/266] fix attribute error --- client/ayon_core/pipeline/publish/publish_plugins.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index cef3bb8bea..4db289e9ba 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -188,8 +188,11 @@ def instance_matches_plugin_families(cls, instance): bool: True if instance matches plugin families. """ + if instance is None: + return not cls.__instanceEnabled__ + if not cls.__instanceEnabled__: - return instance is None + return False for _ in pyblish.logic.plugins_by_families( [cls], [instance.product_type] From 0348e9ea2224e32bb860048d1787b49ded6872c6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 16:50:00 +0200 Subject: [PATCH 222/266] implemented 'get' in 'PublishAttributes' --- client/ayon_core/pipeline/create/structures.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 68b8d7ecf1..32aac6562e 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -271,6 +271,9 @@ def values(self): def items(self): return self._data.items() + def get(self, key, default=None): + return self._data.get(key, default) + def pop(self, key, default=None): """Remove or reset value for plugin. From 03560f8ff6b772466f8ab628a1a91d4cc6f41880 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:35:15 +0200 Subject: [PATCH 223/266] unify method name --- client/ayon_core/pipeline/create/context.py | 2 +- client/ayon_core/pipeline/publish/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 657f71aa97..e4f462a9c5 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -1251,7 +1251,7 @@ def bulk_add_instances(self, sender=None): for plugin in self.plugins_with_defs: attr_defs = None try: - attr_defs = plugin.get_attribute_defs_for_instance( + attr_defs = plugin.get_attr_defs_for_instance( self, instance ) except Exception: diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 4db289e9ba..139a585287 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -201,7 +201,7 @@ def instance_matches_plugin_families(cls, instance): return False @classmethod - def get_attribute_defs_for_instance(cls, create_context, instance): + def get_attr_defs_for_instance(cls, create_context, instance): """Publish attribute definitions for an instance. Attributes available for all families in plugin's `families` attribute. From d13990a604ce18cb0ed825956a35d65c06867a78 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:37:16 +0200 Subject: [PATCH 224/266] unify context method too --- client/ayon_core/pipeline/create/context.py | 2 +- client/ayon_core/pipeline/publish/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index e4f462a9c5..0d98fe28e2 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -803,7 +803,7 @@ def reset_context_data(self): publish_attributes.update(output) for plugin in self.plugins_with_defs: - attr_defs = plugin.get_attribute_defs_for_context(self) + attr_defs = plugin.get_attr_defs_for_context (self) if not attr_defs: continue self._publish_attributes.set_publish_plugin_attr_defs( diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 139a585287..c65eeb73b4 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -160,7 +160,7 @@ def get_attribute_defs(cls): return [] @classmethod - def get_attribute_defs_for_context(cls, create_context): + def get_attr_defs_for_context (cls, create_context): """Publish attribute definitions for context. Attributes available for all families in plugin's `families` attribute. From 344ed86dc0350728bb967a8d83566d0f2fedfbd7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:41:34 +0200 Subject: [PATCH 225/266] better name to change create attr defs --- client/ayon_core/pipeline/create/structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 32aac6562e..9d7bd5387e 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -736,7 +736,7 @@ def data_to_store(self): return output - def update_create_attr_defs(self, attr_defs, value=None): + def set_create_attr_defs(self, attr_defs, value=None): """Create plugin updates create attribute definitions. Method called by create plugin when attribute definitions should From cd8a16cf88b000c29f2c1979329912a90cbb3e1c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 17:46:19 +0200 Subject: [PATCH 226/266] fix used method name --- client/ayon_core/pipeline/create/structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 9d7bd5387e..8594d82848 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -512,7 +512,7 @@ def __init__( self._data["instance_id"] = str(uuid4()) creator_attr_defs = creator.get_attr_defs_for_instance(self) - self.update_create_attr_defs( + self.set_create_attr_defs( creator_attr_defs, creator_values ) From 8af66a0f783a959ca477418618775fe9081f6574 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 18:05:12 +0200 Subject: [PATCH 227/266] replaced 'liste_to' prefix with 'add' prefix --- client/ayon_core/pipeline/create/context.py | 12 ++++++------ client/ayon_core/pipeline/publish/publish_plugins.py | 12 ++++++------ client/ayon_core/tools/publisher/models/create.py | 12 ++++++------ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0d98fe28e2..adbb03a820 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -810,7 +810,7 @@ def reset_context_data(self): plugin.__name__, attr_defs ) - def listen_to_added_instances(self, callback): + def add_instances_added_callback(self, callback): """Register callback for added instances. Event is triggered when instances are already available in context @@ -836,7 +836,7 @@ def listen_to_added_instances(self, callback): """ return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) - def listen_to_removed_instances(self, callback): + def add_instances_removed_callback (self, callback): """Register callback for removed instances. Event is triggered when instances are already removed from context. @@ -861,7 +861,7 @@ def listen_to_removed_instances(self, callback): """ self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) - def listen_to_value_changes(self, callback): + def add_value_changed_callback(self, callback): """Register callback to listen value changes. Event is triggered when any value changes on any instance or @@ -897,7 +897,7 @@ def listen_to_value_changes(self, callback): """ self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) - def listen_to_pre_create_attr_defs_change(self, callback): + def add_pre_create_attr_defs_change_callback (self, callback): """Register callback to listen pre-create attribute changes. Create plugin can trigger refresh of pre-create attributes. Usage of @@ -925,7 +925,7 @@ def listen_to_pre_create_attr_defs_change(self, callback): PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback ) - def listen_to_create_attr_defs_change(self, callback): + def add_create_attr_defs_change_callback (self, callback): """Register callback to listen create attribute changes. Create plugin changed attribute definitions of instance. @@ -950,7 +950,7 @@ def listen_to_create_attr_defs_change(self, callback): """ self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) - def listen_to_publish_attr_defs_change(self, callback): + def add_publish_attr_defs_change_callback (self, callback): """Register callback to listen publish attribute changes. Publish plugin changed attribute definitions of instance of context. diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index c65eeb73b4..3c2bafdba3 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -134,12 +134,12 @@ def register_create_context_callbacks(cls, create_context): in create context. Methods available on create context: - - listen_to_added_instances - - listen_to_removed_instances - - listen_to_value_changes - - listen_to_pre_create_attr_defs_change - - listen_to_create_attr_defs_change - - listen_to_publish_attr_defs_change + - add_instances_added_callback + - add_instances_removed_callback + - add_value_changed_callback + - add_pre_create_attr_defs_change_callback + - add_create_attr_defs_change_callback + - add_publish_attr_defs_change_callback Args: create_context (CreateContext): Create context. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index efc761a407..4b27081db2 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -365,22 +365,22 @@ def reset(self): self._emit_event("create.model.reset") - self._create_context.listen_to_added_instances( + self._create_context.add_instances_added_callback( self._cc_added_instance ) - self._create_context.listen_to_removed_instances( + self._create_context.add_instances_removed_callback ( self._cc_removed_instance ) - self._create_context.listen_to_value_changes( + self._create_context.add_value_changed_callback( self._cc_value_changed ) - self._create_context.listen_to_pre_create_attr_defs_change( + self._create_context.add_pre_create_attr_defs_change_callback ( self._cc_pre_create_attr_changed ) - self._create_context.listen_to_create_attr_defs_change( + self._create_context.add_create_attr_defs_change_callback ( self._cc_create_attr_changed ) - self._create_context.listen_to_publish_attr_defs_change( + self._create_context.add_publish_attr_defs_change_callback ( self._cc_publish_attr_changed ) From a40d8cc8172b23b641ef8e5d88a86576d3f646d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 18:14:03 +0200 Subject: [PATCH 228/266] added typehint imports --- client/ayon_core/pipeline/publish/publish_plugins.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 3c2bafdba3..118596bffb 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -1,5 +1,7 @@ import inspect from abc import ABCMeta +import typing + import pyblish.api import pyblish.logic from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin @@ -16,6 +18,8 @@ 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): From 8a3a1e8042ce047fe908d2a56d109e17401be7b3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 4 Oct 2024 18:14:12 +0200 Subject: [PATCH 229/266] moved imports a little --- client/ayon_core/pipeline/publish/publish_plugins.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 118596bffb..9124e8b763 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -5,8 +5,14 @@ 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, @@ -14,10 +20,6 @@ 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 From fef043d3e293611988ebab9580c3f09a02c884bf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 8 Oct 2024 13:01:11 +0200 Subject: [PATCH 230/266] use imported classes for typehints --- .../pipeline/publish/publish_plugins.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 9124e8b763..d2c70894cc 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -1,6 +1,7 @@ import inspect from abc import ABCMeta import typing +from typing import Optional import pyblish.api import pyblish.logic @@ -133,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 @@ -166,7 +169,7 @@ def get_attribute_defs(cls): 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. @@ -183,7 +186,9 @@ def get_attr_defs_for_context (cls, create_context): 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: @@ -207,7 +212,9 @@ def instance_matches_plugin_families(cls, instance): 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. @@ -226,7 +233,9 @@ def get_attr_defs_for_instance(cls, create_context, instance): 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: From d9e012c4af33fce61c41a0e67fb3d4439893887a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:12:43 +0200 Subject: [PATCH 231/266] fix key iteration Co-authored-by: Roy Nieterau --- client/ayon_core/tools/publisher/widgets/product_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index f11dc90a5d..c2f1f24d2f 100644 --- a/client/ayon_core/tools/publisher/widgets/product_context.py +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -917,7 +917,7 @@ def _on_instance_value_change(self, event): if instance_id not in self._current_instances_by_id: continue - for key, attr_name in ( + for key in ( "folderPath", "task", "variant", From b2b2ae6cfe369f15289ea09f4512531cf4a52fde Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:19:42 +0200 Subject: [PATCH 232/266] added some type hints --- client/ayon_core/lib/attribute_definitions.py | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 639778b16d..c565a00501 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -6,6 +6,7 @@ import copy import warnings from abc import ABCMeta, abstractmethod +from typing import Any, Optional import clique @@ -147,15 +148,15 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): def __init__( self, - key, - default, - label=None, - tooltip=None, - is_label_horizontal=None, - visible=None, - enabled=None, - hidden=None, - disabled=None, + 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 @@ -167,35 +168,35 @@ def __init__( 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.visible = visible - self.enabled = enabled - 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 @property - def hidden(self): + def hidden(self) -> bool: return not self.visible @hidden.setter - def hidden(self, value): + def hidden(self, value: bool): self.visible = not value @property - def disabled(self): + def disabled(self) -> bool: return not self.enabled @disabled.setter - def disabled(self, value): + def disabled(self, value: bool): self.enabled = not value def __eq__(self, other): @@ -213,7 +214,7 @@ def __ne__(self, other): @property @abstractmethod - def type(self): + def type(self) -> str: """Attribute definition type also used as identifier of class. Returns: @@ -286,7 +287,7 @@ 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): @@ -309,7 +310,7 @@ class UnknownDef(AbstractAttrDef): def __init__(self, key, default=None, **kwargs): kwargs["default"] = default - super(UnknownDef, self).__init__(key, **kwargs) + super().__init__(key, **kwargs) def convert_value(self, value): return value @@ -539,7 +540,7 @@ def convert_value(self, value): return list(self._item_values.intersection(value)) def serialize(self): - data = super(EnumDef, self).serialize() + data = super().serialize() data["items"] = copy.deepcopy(self.items) data["multiselection"] = self.multiselection return data @@ -619,7 +620,7 @@ 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 convert_value(self, value): if isinstance(value, bool): From b8bc6ec2e3c33ec60246dbca7daa8218be9b8ab2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:19:56 +0200 Subject: [PATCH 233/266] simplified comparison --- client/ayon_core/lib/attribute_definitions.py | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index c565a00501..d5b9239809 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -199,18 +199,31 @@ def disabled(self) -> bool: def disabled(self, value: bool): self.enabled = not value - def __eq__(self, other): - if not isinstance(other, self.__class__): + 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, + ) -> bool: + if not isinstance(other, self.__class__) or self.key != other.key: + return False + if not self._custom_def_compare(other): return False return ( - self.key == other.key - and self.default == other.default - and self.visible == other.visible - and self.enabled == other.enabled + (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) + def _custom_def_compare(self, other: "AbstractAttrDef") -> bool: + return True @property @abstractmethod @@ -289,9 +302,7 @@ class UILabelDef(UIDef): def __init__(self, label, key=None): super().__init__(label=label, key=key) - def __eq__(self, other): - if not super(UILabelDef, self).__eq__(other): - return False + def _custom_def_compare(self, other: "UILabelDef") -> bool: return self.label == other.label @@ -387,10 +398,7 @@ def __init__( self.maximum = maximum self.decimals = 0 if decimals is None else decimals - def __eq__(self, other): - if not super(NumberDef, self).__eq__(other): - return False - + def _custom_def_compare(self, other: "NumberDef") -> bool: return ( self.decimals == other.decimals and self.maximum == other.maximum @@ -457,10 +465,8 @@ def __init__( self.placeholder = placeholder self.regex = regex - def __eq__(self, other): - if not super(TextDef, self).__eq__(other): - return False + def _custom_def_compare(self, other: "TextDef") -> bool: return ( self.multiline == other.multiline and self.regex == other.regex @@ -514,16 +520,13 @@ def __init__( 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 - + def _custom_def_compare(self, other: "EnumDef") -> bool: return ( self.items == other.items and self.multiselection == other.multiselection From 116061aefbfa7e78c5128998ec98242f8d933038 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:25:19 +0200 Subject: [PATCH 234/266] added option to skip def specific comparison --- client/ayon_core/lib/attribute_definitions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index d5b9239809..99f9e9988f 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -211,10 +211,11 @@ def compare_to_def( 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 self._custom_def_compare(other): + if not ignore_def_type_compare and not self._def_type_compare(other): return False return ( (ignore_default or self.default == other.default) @@ -222,7 +223,7 @@ def compare_to_def( and (ignore_enabled or self.enabled == other.enabled) ) - def _custom_def_compare(self, other: "AbstractAttrDef") -> bool: + def _def_type_compare(self, other: "AbstractAttrDef") -> bool: return True @property @@ -398,7 +399,7 @@ def __init__( self.maximum = maximum self.decimals = 0 if decimals is None else decimals - def _custom_def_compare(self, other: "NumberDef") -> bool: + def _def_type_compare(self, other: "NumberDef") -> bool: return ( self.decimals == other.decimals and self.maximum == other.maximum @@ -465,8 +466,7 @@ def __init__( self.placeholder = placeholder self.regex = regex - - def _custom_def_compare(self, other: "TextDef") -> bool: + def _def_type_compare(self, other: "TextDef") -> bool: return ( self.multiline == other.multiline and self.regex == other.regex @@ -526,7 +526,7 @@ def __init__( self._item_values = item_values_set self.multiselection = multiselection - def _custom_def_compare(self, other: "EnumDef") -> bool: + def _def_type_compare(self, other: "EnumDef") -> bool: return ( self.items == other.items and self.multiselection == other.multiselection From 0c801ba892a011cc2a2d0278886a2d1f0d609cf1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:25:32 +0200 Subject: [PATCH 235/266] fix ui label --- client/ayon_core/lib/attribute_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 99f9e9988f..01f5606f17 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -303,7 +303,7 @@ class UILabelDef(UIDef): def __init__(self, label, key=None): super().__init__(label=label, key=key) - def _custom_def_compare(self, other: "UILabelDef") -> bool: + def _def_type_compare(self, other: "UILabelDef") -> bool: return self.label == other.label From 88e4a159ca79424c156afd832d2f924985b2e46d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:25:50 +0200 Subject: [PATCH 236/266] fix clone --- client/ayon_core/lib/attribute_definitions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 01f5606f17..dd467797f1 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -183,6 +183,11 @@ def __init__( def id(self) -> str: return self._id + def clone(self): + data = self.serialize() + data.pop("type") + return self.deserialize(data) + @property def hidden(self) -> bool: return not self.visible @@ -275,6 +280,9 @@ def deserialize(cls, data): Data can be received using 'serialize' method. """ + if "type" in data: + data = dict(data) + data.pop("type") return cls(**data) From 5ca53978fcd8165fc24832e8da5b5edd70b3b5bd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:26:07 +0200 Subject: [PATCH 237/266] added missing data to serialization o textdef --- client/ayon_core/lib/attribute_definitions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index dd467797f1..8ce270c218 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -488,6 +488,8 @@ def convert_value(self, value): def serialize(self): data = super(TextDef, self).serialize() data["regex"] = self.regex.pattern + data["multiline"] = self.multiline + data["placeholder"] = self.placeholder return data From 87a909ae40b497b03db01921aae2f695c14def86 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:26:17 +0200 Subject: [PATCH 238/266] use py3 super --- client/ayon_core/lib/attribute_definitions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 8ce270c218..6a0a10c349 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -295,7 +295,7 @@ 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 convert_value(self, value): return value @@ -401,7 +401,7 @@ def __init__( 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 @@ -457,7 +457,7 @@ def __init__( 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 @@ -486,7 +486,7 @@ def convert_value(self, value): 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 @@ -931,10 +931,10 @@ def __init__( 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 ( From a2170a76fe055e58d906fb75d056dc344eeb1160 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:26:41 +0200 Subject: [PATCH 239/266] initial idea of merging attributes --- .../tools/publisher/models/create.py | 79 ++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 4b27081db2..577340d053 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -6,6 +6,7 @@ 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 @@ -296,6 +297,71 @@ def from_instance(cls, instance: CreatedInstance): ) +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 + } + 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): + is_enum = isinstance(attr_def, EnumDef) + match_idx = None + match_attr = None + for union_idx, union_def in enumerate(defs_union): + 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: def __init__(self, controller: AbstractPublisherBackend): self._log = None @@ -729,9 +795,10 @@ def get_publish_attribute_definitions( 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 +811,10 @@ def get_publish_attribute_definitions( 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 +822,7 @@ def get_publish_attribute_definitions( continue output.append(( plugin_name, - all_defs_by_plugin_name[plugin_name], + attr_defs_by_plugin_name[plugin_name], all_plugin_values )) return output From b0001f9a5886a4e658bc47a7fd828bcd50f0eee1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:32:47 +0200 Subject: [PATCH 240/266] fix values loop --- client/ayon_core/tools/publisher/models/create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 577340d053..536e1475ea 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -312,7 +312,7 @@ def _merge_attr_defs( src_item_values = { item["value"] - for item in attr_def_src + for item in attr_def_src.items } for item in attr_def_new.items: if item["value"] not in src_item_values: From ed36077292d28ea3fee027f1545b4fd3289a2360 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:17:16 +0200 Subject: [PATCH 241/266] added 'is_value_valid' implementation for attribute definitions --- client/ayon_core/lib/attribute_definitions.py | 117 +++++++++++++++--- 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 6a0a10c349..4877a45118 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -228,8 +228,21 @@ def compare_to_def( and (ignore_enabled or self.enabled == other.enabled) ) - def _def_type_compare(self, other: "AbstractAttrDef") -> bool: - return True + @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 @@ -286,6 +299,9 @@ def deserialize(cls, data): return cls(**data) + def _def_type_compare(self, other: "AbstractAttrDef") -> bool: + return True + # ----------------------------------------- # UI attribute definitions won't hold value @@ -297,6 +313,9 @@ class UIDef(AbstractAttrDef): def __init__(self, key=None, default=None, *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 @@ -332,6 +351,9 @@ def __init__(self, key, default=None, **kwargs): kwargs["default"] = default super().__init__(key, **kwargs) + def is_value_valid(self, value: Any) -> bool: + return True + def convert_value(self, value): return value @@ -352,6 +374,9 @@ def __init__(self, key, default=None, **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 @@ -407,12 +432,15 @@ def __init__( self.maximum = maximum self.decimals = 0 if decimals is None else decimals - def _def_type_compare(self, other: "NumberDef") -> bool: - return ( - self.decimals == other.decimals - and self.maximum == other.maximum - and self.maximum == other.maximum - ) + 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 + if self.minimum > value > self.maximum: + return False + return True def convert_value(self, value): if isinstance(value, str): @@ -428,6 +456,13 @@ def convert_value(self, value): 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. @@ -474,11 +509,12 @@ def __init__( self.placeholder = placeholder self.regex = regex - def _def_type_compare(self, other: "TextDef") -> bool: - return ( - self.multiline == other.multiline - and self.regex == other.regex - ) + def is_value_valid(self, value: Any) -> bool: + if not isinstance(value, str): + return False + if self.regex and not self.regex.match(value): + return False + return True def convert_value(self, value): if isinstance(value, str): @@ -492,6 +528,12 @@ def serialize(self): 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. @@ -536,12 +578,6 @@ def __init__( self._item_values = item_values_set self.multiselection = multiselection - def _def_type_compare(self, other: "EnumDef") -> bool: - 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: @@ -552,6 +588,17 @@ def convert_value(self, value): 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().serialize() data["items"] = copy.deepcopy(self.items) @@ -620,6 +667,12 @@ def prepare_enum_items(items): 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. @@ -635,6 +688,9 @@ def __init__(self, key, default=None, **kwargs): default = False 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): return value @@ -944,6 +1000,29 @@ def __eq__(self, other): 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] From a9e58c40477d374d4ba5935f91757f8b82f71b46 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:17:27 +0200 Subject: [PATCH 242/266] added method to get attribute definition --- client/ayon_core/pipeline/create/structures.py | 3 +++ 1 file changed, 3 insertions(+) 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 @@ def items(self): 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(): From 87329306d4a791b90e540ec49054ce187aa6d2df Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:17:45 +0200 Subject: [PATCH 243/266] don't set all values unless they are valid for the instance --- .../tools/publisher/models/create.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 536e1475ea..493fcc3b01 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -333,10 +333,18 @@ def merge_attr_defs(attr_defs: List[List[AbstractAttrDef]]): 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, @@ -759,6 +767,18 @@ def set_instances_publish_attr_values( 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( From c3aff4deb2751796178ee94940abf437658bec13 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 9 Oct 2024 15:21:16 +0200 Subject: [PATCH 244/266] implemented similar logic to create attributes --- client/ayon_core/tools/publisher/models/create.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 493fcc3b01..f2a7901e42 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -717,8 +717,16 @@ def set_instances_create_attr_values(self, instance_ids, key, value): 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] From e2eb8260deff0b5b108f4a613f97d426a422b6e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:41:07 +0200 Subject: [PATCH 245/266] fix validation of context in main window --- client/ayon_core/tools/publisher/control.py | 22 ++++++++++++++++++- .../tools/publisher/models/create.py | 22 ++++++++++++++++++- client/ayon_core/tools/publisher/window.py | 19 ++++++++++++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 43b491a20f..51eaefe0e0 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. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index f2a7901e42..a08a3c6863 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -371,6 +371,8 @@ def merge_attr_defs(attr_defs: List[List[AbstractAttrDef]]): class CreateModel: + _CONTEXT_KEYS = {"folderPath", "task", "variant", "productName"} + def __init__(self, controller: AbstractPublisherBackend): self._log = None self._controller: AbstractPublisherBackend = controller @@ -527,6 +529,12 @@ def set_instances_context_info(self, changes_by_instance_id): 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 get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id @@ -1032,16 +1040,28 @@ def _cc_value_changed(self, event): 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/window.py b/client/ayon_core/tools/publisher/window.py index e4da71b3d6..1d16159ffa 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -281,7 +281,19 @@ def __init__( ) 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 +948,16 @@ def _validate_create_instances(self): 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) From 36541c5aae0023f479f343521fc25ee0e7010134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= <33513211+antirotor@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:45:41 +0200 Subject: [PATCH 246/266] :art: add unreal to hosts unreal can do local rendering/publishing and without it, it is missing thumbnail. --- client/ayon_core/plugins/publish/extract_thumbnail.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 047cc8e0cf90d367f683739642ea25c131af2fe2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:01:20 +0200 Subject: [PATCH 247/266] fix issues in list view widgets --- .../tools/publisher/widgets/list_view_widgets.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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..a6a2f08752 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 @@ def group_item_paint(self, painter, option, index): 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() @@ -245,8 +245,8 @@ def mouseDoubleClickEvent(self, event): 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 +392,7 @@ def event(self, event): 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 +588,7 @@ def refresh(self): # 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 +612,7 @@ def refresh(self): # 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() ) From 5f380d244b9f86c9d99b2af444db8da3f6f2c921 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:29:55 +0200 Subject: [PATCH 248/266] consider active as context change --- client/ayon_core/tools/publisher/models/create.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index a08a3c6863..a68b5e2879 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -371,7 +371,13 @@ def merge_attr_defs(attr_defs: List[List[AbstractAttrDef]]): class CreateModel: - _CONTEXT_KEYS = {"folderPath", "task", "variant", "productName"} + _CONTEXT_KEYS = { + "active", + "folderPath", + "task", + "variant", + "productName", + } def __init__(self, controller: AbstractPublisherBackend): self._log = None From cc3aa6936eccb21d5dc3e5428a54a5bd92853680 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:30:06 +0200 Subject: [PATCH 249/266] remove unnecessary callbacks --- client/ayon_core/tools/publisher/window.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 1d16159ffa..a912495d4e 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -253,12 +253,6 @@ def __init__( 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 ) From a21341ad1588f53ab74789cd84f4833580045238 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:43:38 +0200 Subject: [PATCH 250/266] views are propagating context changes --- .../publisher/widgets/card_view_widgets.py | 36 ++++++++++++------- .../publisher/widgets/list_view_widgets.py | 19 +++++++--- .../publisher/widgets/overview_widget.py | 14 ++++---- .../publisher/widgets/product_context.py | 3 -- .../tools/publisher/widgets/product_info.py | 12 +++---- 5 files changed, 49 insertions(+), 35 deletions(-) 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..67793bb50e 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 @@ import re import collections +from typing import Dict from qtpy import QtWidgets, QtCore @@ -217,11 +218,18 @@ def __init__(self, group_icons, *args, **kwargs): 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. @@ -391,9 +399,6 @@ def __init__(self, item, parent): 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 +466,7 @@ def __init__(self, instance, context_info, group_icon, parent): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self.update_instance_values(context_info) + self._update_instance_context(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -486,7 +491,7 @@ def set_active(self, 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_context(context_info) def _validate_context(self, context_info): valid = context_info.is_valid @@ -522,7 +527,7 @@ def _update_product_name(self): QtCore.Qt.NoTextInteraction ) - def update_instance_values(self, context_info): + def _update_instance_context(self, context_info): """Update instance data""" self._update_product_name() self.set_active(self.instance.is_active) @@ -596,7 +601,7 @@ def __init__(self, controller, parent): 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 = [] @@ -702,7 +707,7 @@ def refresh(self): # 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,11 +822,18 @@ def _update_convertor_items_group(self): 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] 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 a6a2f08752..a8144e71f4 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -191,9 +191,9 @@ def set_active(self, new_value): def update_instance(self, instance, context_info): """Update instance object.""" self.instance = instance - self.update_instance_values(context_info) + self._update_instance_values(context_info) - def update_instance_values(self, context_info): + def _update_instance_values(self, context_info): """Update instance data propagated to widgets.""" # Check product name label = self.instance.label @@ -873,12 +873,21 @@ def _remove_groups_except(self, group_names): 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() diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index beefa1ca98..5e8b803fc3 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -16,7 +16,6 @@ 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() @@ -134,9 +133,6 @@ def __init__( 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 +159,10 @@ def __init__( "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 @@ -362,7 +362,7 @@ def _change_visibility_for_state(self): 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 +372,7 @@ def _on_instance_context_change(self): 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_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py index c2f1f24d2f..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 @@ def _on_submit(self): 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.""" @@ -933,4 +931,3 @@ def _on_instance_value_change(self, event): 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 @@ def __init__( 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 @@ def _refresh_instances(self): 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 @@ def _on_instance_context_changed(self): 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() From 22c3142894ac83af93e72c990f79313c8163060b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:05:59 +0200 Subject: [PATCH 251/266] added helper method to change active state --- client/ayon_core/tools/publisher/abstract.py | 7 ++++ client/ayon_core/tools/publisher/control.py | 3 ++ .../tools/publisher/models/create.py | 35 +++++++++++++++++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 3a968eee28..4787e8a21b 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -3,6 +3,7 @@ Optional, Dict, List, + Set, Tuple, Any, Callable, @@ -353,6 +354,12 @@ def set_instances_context_info( ): 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 51eaefe0e0..347755d557 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -220,6 +220,9 @@ def set_instances_context_info(self, changes_by_instance_id): 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 a68b5e2879..2aa7b169a0 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -1,6 +1,16 @@ import logging import re -from typing import Union, List, Dict, Tuple, Any, Optional, Iterable, Pattern +from typing import ( + Union, + List, + Dict, + Set, + Tuple, + Any, + Optional, + Iterable, + Pattern, +) from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, @@ -542,6 +552,21 @@ def set_instances_context_info(self, changes_by_instance_id): } ) + 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 @@ -896,8 +921,12 @@ def set_thumbnail_paths_for_instances( } ) - 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. From c3641b380dd0dd6bac41b6e983faf0d551f1f41b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:11:04 +0200 Subject: [PATCH 252/266] card view widget is able to change active state --- .../publisher/widgets/card_view_widgets.py | 49 +++++++++---------- .../publisher/widgets/overview_widget.py | 13 ----- .../tools/publisher/widgets/widgets.py | 1 - 3 files changed, 22 insertions(+), 41 deletions(-) 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 67793bb50e..095a4eae7c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -315,8 +315,9 @@ def is_selected(self): 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) @@ -466,7 +467,7 @@ def __init__(self, instance, context_info, group_icon, parent): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self._update_instance_context(context_info) + self._update_instance_values(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -475,23 +476,16 @@ def set_active_toggle_enabled(self, enabled): 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_context(context_info) + self._update_instance_values(context_info) def _validate_context(self, context_info): valid = context_info.is_valid @@ -527,10 +521,10 @@ def _update_product_name(self): QtCore.Qt.NoTextInteraction ) - def _update_instance_context(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): @@ -544,7 +538,6 @@ def _on_active_change(self): 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): @@ -630,24 +623,25 @@ def _toggle_instances(self, value): 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 not active_state_by_id: + return - if changed: - self.active_changed.emit() + self._controller.set_instances_active_state(active_state_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: @@ -838,14 +832,15 @@ def refresh_instance_states(self, instance_ids=None): 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/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 5e8b803fc3..a09ee80ed5 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -15,7 +15,6 @@ class OverviewWidget(QtWidgets.QFrame): - active_changed = QtCore.Signal() create_requested = QtCore.Signal() convert_requested = QtCore.Signal() publish_tab_requested = QtCore.Signal() @@ -125,13 +124,6 @@ def __init__( 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.convert_requested.connect( self._on_convert_requested @@ -312,11 +304,6 @@ def _on_product_change(self, *_args): 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) 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 @@ def __init__(self, parent=None): 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 From 8b5e3e7d77b4933dbe5b00b3101adade2a077f98 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:54:34 +0200 Subject: [PATCH 253/266] implemented active state in list view --- .../publisher/widgets/list_view_widgets.py | 63 +++++++------------ 1 file changed, 24 insertions(+), 39 deletions(-) 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 a8144e71f4..bc3353ba5e 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -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 @@ def _set_valid_property(self, valid): 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) @@ -892,20 +879,21 @@ def refresh_instance_states(self, instance_ids=None): 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) @@ -917,16 +905,11 @@ def _change_active_instances(self, instance_ids, new_value): 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() @@ -965,14 +948,16 @@ def _on_group_toggle_request(self, group_name, state): 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._controller.set_instances_active_state(active_by_id) - self._change_active_instances(instance_ids, active) + 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): From 6a53253ca13b9e087861ae8d68824c56047d59ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 14 Oct 2024 17:58:50 +0200 Subject: [PATCH 254/266] removed unused import --- client/ayon_core/tools/publisher/abstract.py | 1 - client/ayon_core/tools/publisher/models/create.py | 1 - 2 files changed, 2 deletions(-) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 4787e8a21b..a6ae93cecd 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -3,7 +3,6 @@ Optional, Dict, List, - Set, Tuple, Any, Callable, diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 2aa7b169a0..7cb46215df 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -4,7 +4,6 @@ Union, List, Dict, - Set, Tuple, Any, Optional, From f1a1e77134b5101d87715506d0ebc5591058b01f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:23:11 +0200 Subject: [PATCH 255/266] added new function to calculate representation delivery data --- client/ayon_core/pipeline/delivery.py | 80 ++++++++++++++++++++++- client/ayon_core/plugins/load/delivery.py | 33 ++++++---- 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 029775e1db..174e81c194 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -3,11 +3,21 @@ import copy import shutil import glob -import clique import collections +from typing import List, Dict, Any, Iterable + +import clique +import ayon_api from ayon_core.lib import create_hard_link +from .anatomy import Anatomy +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 +337,71 @@ def hash_path_exist(myPath): 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/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 5c53d170eb..3c9f1b9691 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -200,20 +200,29 @@ def deliver(self): 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_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 +233,7 @@ def deliver(self): repre, self.anatomy, template_name, - anatomy_data, + template_data, format_dict, report_items, self.log @@ -267,9 +276,9 @@ def deliver(self): 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 +286,7 @@ def deliver(self): " 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) From ebdd757c669c6d72421abdb3df59742ad3eb4a03 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Oct 2024 13:55:24 +0200 Subject: [PATCH 256/266] fix imports --- client/ayon_core/pipeline/delivery.py | 3 +-- client/ayon_core/plugins/load/delivery.py | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 174e81c194..2a2adf984a 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -4,14 +4,13 @@ import shutil import glob import collections -from typing import List, Dict, Any, Iterable +from typing import Dict, Any, Iterable import clique import ayon_api from ayon_core.lib import create_hard_link -from .anatomy import Anatomy from .template_data import ( get_general_template_data, get_folder_template_data, diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 3c9f1b9691..e1cd136b26 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_template_data, ) From e175121d0a30ccef65fcc1478c05740badc1dd99 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Oct 2024 13:55:38 +0200 Subject: [PATCH 257/266] fix typo --- client/ayon_core/plugins/load/delivery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index e1cd136b26..559950c997 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -350,8 +350,8 @@ def _prepare_label(self): 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 From d15740e33f0d3d652ff4c9bed1b82d852d316acc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Oct 2024 15:30:50 +0200 Subject: [PATCH 258/266] fix import --- client/ayon_core/plugins/load/delivery.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index 559950c997..406040d936 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -16,7 +16,7 @@ get_format_dict, check_destination_path, deliver_single_file, - get_representations_template_data, + get_representations_delivery_template_data, ) @@ -206,8 +206,10 @@ def deliver(self): filtered_repres.append(repre) repre_ids.add(repre["id"]) - template_data_by_repre_id = get_representations_template_data( - self.anatomy.project_name, repre_ids + 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( From ceedd7fbcd4489229aea76f6d64e89f76d233ea3 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 15 Oct 2024 14:22:34 +0000 Subject: [PATCH 259/266] [Automated] Add generated package files to main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 458129f367..e9ce613942 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.1+dev" +__version__ = "1.0.2" diff --git a/package.py b/package.py index c059eed423..a9c66833cc 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.1+dev" +version = "1.0.2" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 0a7d0d76c9..293be52d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.1+dev" +version = "1.0.2" description = "" authors = ["Ynput Team "] readme = "README.md" From 50cad97cad63474ff5a51d2fc2214cfb45d2766c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Tue, 15 Oct 2024 14:23:14 +0000 Subject: [PATCH 260/266] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index e9ce613942..8fa97eb9cb 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.2" +__version__ = "1.0.2+dev" diff --git a/package.py b/package.py index a9c66833cc..b5cd0a1903 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.2" +version = "1.0.2+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 293be52d6e..8b03020f6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.2" +version = "1.0.2+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From b239cdd8916e07c51bbed7ce7d024a911b554a4b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 16 Oct 2024 00:06:40 +0200 Subject: [PATCH 261/266] Fix single frame publishing (from e.g. Maya) Also fixes it for other hosts that use instance.data[`expectedFiles`] with the value being `list[dict[str, list[str]]]` (Basically the files per AOV, where the list of filenames is `list[str]` but the integrator and other areas really want a single `str` insteaf of `list[str]` if it's a single frame) --- client/ayon_core/pipeline/farm/pyblish_functions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From 1eb25ef945ffe0fe279f0ad4e4e54ba818f85e00 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 16 Oct 2024 09:34:12 +0000 Subject: [PATCH 262/266] [Automated] Add generated package files to main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 8fa97eb9cb..3da4af0b4e 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.2+dev" +__version__ = "1.0.3" diff --git a/package.py b/package.py index b5cd0a1903..a5319dd139 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.2+dev" +version = "1.0.3" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 8b03020f6d..9b2b13ffa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.2+dev" +version = "1.0.3" description = "" authors = ["Ynput Team "] readme = "README.md" From 47b5d90495563be2660d8b10b1b0479c3d1330ad Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 16 Oct 2024 09:34:49 +0000 Subject: [PATCH 263/266] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 3da4af0b4e..9a951d7fd4 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.3" +__version__ = "1.0.3+dev" diff --git a/package.py b/package.py index a5319dd139..5d5218748c 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.3" +version = "1.0.3+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 9b2b13ffa8..ebf08be4a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.3" +version = "1.0.3+dev" description = "" authors = ["Ynput Team "] readme = "README.md" From c93f3449b6b419eb429b641a65449e7bc44c68ca Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:15:49 +0200 Subject: [PATCH 264/266] ignore publish attributes without attribute definitions --- client/ayon_core/tools/publisher/models/create.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 7cb46215df..9c13d8ae2f 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -27,6 +27,7 @@ Creator, CreateContext, CreatedInstance, + AttributeValues, ) from ayon_core.pipeline.create import ( CreatorsOperationFailed, @@ -857,7 +858,10 @@ def get_publish_attribute_definitions( 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 From 0e8b129d6af008397e48a850544b18b75065722b Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 23 Oct 2024 14:49:16 +0000 Subject: [PATCH 265/266] [Automated] Add generated package files to main --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 9a951d7fd4..47da5b3a1b 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.3+dev" +__version__ = "1.0.4" diff --git a/package.py b/package.py index 5d5218748c..0ba9303182 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.3+dev" +version = "1.0.4" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index ebf08be4a8..64b389ea3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.3+dev" +version = "1.0.4" description = "" authors = ["Ynput Team "] readme = "README.md" From d2ee4167ae0151908eda349d582663bf193efdd9 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 23 Oct 2024 14:49:58 +0000 Subject: [PATCH 266/266] [Automated] Update version in package.py for develop --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 47da5b3a1b..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.4" +__version__ = "1.0.4+dev" diff --git a/package.py b/package.py index 0ba9303182..7c5bffe81f 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "1.0.4" +version = "1.0.4+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 64b389ea3e..c686d685fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "1.0.4" +version = "1.0.4+dev" description = "" authors = ["Ynput Team "] readme = "README.md"