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 diff --git a/.github/workflows/release_trigger.yml b/.github/workflows/release_trigger.yml new file mode 100644 index 0000000000..4293e4a8e9 --- /dev/null +++ b/.github/workflows/release_trigger.yml @@ -0,0 +1,25 @@ +name: 🚀 Release Trigger + +on: + workflow_dispatch: + inputs: + draft: + type: boolean + description: "Create Release Draft" + required: false + default: false + release_overwrite: + type: string + description: "Set Version Release Tag" + required: false + +jobs: + call-release-trigger: + uses: ynput/ops-repo-automation/.github/workflows/release_trigger.yml@main + with: + draft: ${{ inputs.draft }} + release_overwrite: ${{ inputs.release_overwrite }} + secrets: + token: ${{ secrets.YNPUT_BOT_TOKEN }} + email: ${{ secrets.CI_EMAIL }} + user: ${{ secrets.CI_USER }} diff --git a/.github/workflows/upload_to_ynput_cloud.yml b/.github/workflows/upload_to_ynput_cloud.yml new file mode 100644 index 0000000000..7745a8e016 --- /dev/null +++ b/.github/workflows/upload_to_ynput_cloud.yml @@ -0,0 +1,16 @@ +name: 📤 Upload to Ynput Cloud + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + call-upload-to-ynput-cloud: + uses: ynput/ops-repo-automation/.github/workflows/upload_to_ynput_cloud.yml@main + secrets: + CI_EMAIL: ${{ secrets.CI_EMAIL }} + CI_USER: ${{ secrets.CI_USER }} + YNPUT_BOT_TOKEN: ${{ secrets.YNPUT_BOT_TOKEN }} + YNPUT_CLOUD_URL: ${{ secrets.YNPUT_CLOUD_URL }} + YNPUT_CLOUD_TOKEN: ${{ secrets.YNPUT_CLOUD_TOKEN }} diff --git a/client/ayon_core/__init__.py b/client/ayon_core/__init__.py index ce5a28601c..6cde11c822 100644 --- a/client/ayon_core/__init__.py +++ b/client/ayon_core/__init__.py @@ -9,10 +9,6 @@ AYON_CORE_ROOT = os.path.dirname(os.path.abspath(__file__)) # ------------------------- PACKAGE_DIR = AYON_CORE_ROOT PLUGINS_DIR = os.path.join(AYON_CORE_ROOT, "plugins") -AYON_SERVER_ENABLED = True - -# Indicate if AYON entities should be used instead of OpenPype entities -USE_AYON_ENTITIES = True # ------------------------- @@ -23,6 +19,4 @@ __all__ = ( "AYON_CORE_ROOT", "PACKAGE_DIR", "PLUGINS_DIR", - "AYON_SERVER_ENABLED", - "USE_AYON_ENTITIES", ) diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index c7eccd7b6c..6a7ce8a3cb 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -9,11 +9,18 @@ from .interfaces import ( ) from .base import ( + ProcessPreparationError, + ProcessContext, AYONAddon, AddonsManager, load_addons, ) +from .utils import ( + ensure_addons_are_process_context_ready, + ensure_addons_are_process_ready, +) + __all__ = ( "click_wrap", @@ -24,7 +31,12 @@ __all__ = ( "ITrayService", "IHostAddon", + "ProcessPreparationError", + "ProcessContext", "AYONAddon", "AddonsManager", "load_addons", + + "ensure_addons_are_process_context_ready", + "ensure_addons_are_process_ready", ) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 08dd2d6bbd..982626ad9d 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -10,13 +10,18 @@ import threading import collections from uuid import uuid4 from abc import ABC, abstractmethod +from typing import Optional -import appdirs import ayon_api from semver import VersionInfo from ayon_core import AYON_CORE_ROOT -from ayon_core.lib import Logger, is_dev_mode_enabled +from ayon_core.lib import ( + Logger, + is_dev_mode_enabled, + get_launcher_storage_dir, + is_headless_mode_enabled, +) from ayon_core.settings import get_studio_settings from .interfaces import ( @@ -31,9 +36,6 @@ IGNORED_FILENAMES = { # Files ignored on addons import from "./ayon_core/modules" IGNORED_DEFAULT_FILENAMES = { "__init__.py", - "base.py", - "interfaces.py", - "click_wrap.py", } # When addon was moved from ayon-core codebase @@ -64,77 +66,65 @@ MOVED_ADDON_MILESTONE_VERSIONS = { } -# Inherit from `object` for Python 2 hosts -class _ModuleClass(object): - """Fake module class for storing AYON addons. +class ProcessPreparationError(Exception): + """Exception that can be used when process preparation failed. + + The message is shown to user (either as UI dialog or printed). If + different error is raised a "generic" error message is shown to user + with option to copy error message to clipboard. - Object of this class can be stored to `sys.modules` and used for storing - dynamically imported modules. """ + pass - def __init__(self, name): - # Call setattr on super class - super(_ModuleClass, self).__setattr__("name", name) - super(_ModuleClass, self).__setattr__("__name__", name) - # Where modules and interfaces are stored - super(_ModuleClass, self).__setattr__("__attributes__", dict()) - super(_ModuleClass, self).__setattr__("__defaults__", set()) +class ProcessContext: + """Hold context of process that is going to be started. - super(_ModuleClass, self).__setattr__("_log", None) + Right now the context is simple, having information about addon that wants + to trigger preparation and possibly project name for which it should + happen. - def __getattr__(self, attr_name): - if attr_name not in self.__attributes__: - if attr_name in ("__path__", "__file__"): - return None - raise AttributeError("'{}' has not attribute '{}'".format( - self.name, attr_name - )) - return self.__attributes__[attr_name] + Preparation for process can be required for ayon-core or any other addon. + It can be, change of environment variables, or request login to + a project management. - def __iter__(self): - for module in self.values(): - yield module + At the moment of creation is 'ProcessContext' only data holder, but that + might change in future if there will be need. - def __setattr__(self, attr_name, value): - if attr_name in self.__attributes__: - self.log.warning( - "Duplicated name \"{}\" in {}. Overriding.".format( - attr_name, self.name - ) - ) - self.__attributes__[attr_name] = value + Args: + addon_name (str): Addon name which triggered process. + addon_version (str): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. Value is NOT autofilled. + headless (Optional[bool]): Is process running in headless mode. Value + is filled with value based on state set in AYON launcher. - def __setitem__(self, key, value): - self.__setattr__(key, value) + """ + def __init__( + self, + addon_name: str, + addon_version: str, + project_name: Optional[str] = None, + headless: Optional[bool] = None, + **kwargs, + ): + if headless is None: + headless = is_headless_mode_enabled() + self.addon_name: str = addon_name + self.addon_version: str = addon_version + self.project_name: Optional[str] = project_name + self.headless: bool = headless - def __getitem__(self, key): - return getattr(self, key) - - @property - def log(self): - if self._log is None: - super(_ModuleClass, self).__setattr__( - "_log", Logger.get_logger(self.name) - ) - return self._log - - def get(self, key, default=None): - return self.__attributes__.get(key, default) - - def keys(self): - return self.__attributes__.keys() - - def values(self): - return self.__attributes__.values() - - def items(self): - return self.__attributes__.items() + if kwargs: + unknown_keys = ", ".join([f'"{key}"' for key in kwargs.keys()]) + print(f"Unknown keys in ProcessContext: {unknown_keys}") class _LoadCache: addons_lock = threading.Lock() addons_loaded = False + addon_modules = [] def load_addons(force=False): @@ -248,7 +238,7 @@ def _handle_moved_addons(addon_name, milestone_version, log): return addon_dir -def _load_ayon_addons(openpype_modules, modules_key, log): +def _load_ayon_addons(log): """Load AYON addons based on information from server. This function should not trigger downloading of any addons but only use @@ -256,30 +246,18 @@ def _load_ayon_addons(openpype_modules, modules_key, log): development). Args: - openpype_modules (_ModuleClass): Module object where modules are - stored. - modules_key (str): Key under which will be modules imported in - `sys.modules`. log (logging.Logger): Logger object. - Returns: - List[str]: List of v3 addons to skip to load because v4 alternative is - imported. """ - - addons_to_skip_in_core = [] - + all_addon_modules = [] bundle_info = _get_ayon_bundle_data() addons_info = _get_ayon_addons_information(bundle_info) if not addons_info: - return addons_to_skip_in_core + return all_addon_modules addons_dir = os.environ.get("AYON_ADDONS_DIR") if not addons_dir: - addons_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "addons" - ) + addons_dir = get_launcher_storage_dir("addons") dev_mode_enabled = is_dev_mode_enabled() dev_addons_info = {} @@ -298,7 +276,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addon_version = addon_info["version"] # core addon does not have any addon object - if addon_name in ("openpype", "core"): + if addon_name == "core": continue dev_addon_info = dev_addons_info.get(addon_name, {}) @@ -337,7 +315,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): continue sys.path.insert(0, addon_dir) - imported_modules = [] + addon_modules = [] for name in os.listdir(addon_dir): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon @@ -364,7 +342,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): inspect.isclass(attr) and issubclass(attr, AYONAddon) ): - imported_modules.append(mod) + addon_modules.append(mod) break except BaseException: @@ -373,50 +351,37 @@ def _load_ayon_addons(openpype_modules, modules_key, log): exc_info=True ) - if not imported_modules: + if not addon_modules: log.warning("Addon {} {} has no content to import".format( addon_name, addon_version )) continue - if len(imported_modules) > 1: + if len(addon_modules) > 1: log.warning(( - "Skipping addon '{}'." - " Multiple modules were found ({}) in dir {}." + "Multiple modules ({}) were found in addon '{}' in dir {}." ).format( + ", ".join([m.__name__ for m in addon_modules]), addon_name, - ", ".join([m.__name__ for m in imported_modules]), addon_dir, )) - continue + all_addon_modules.extend(addon_modules) - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - addons_to_skip_in_core.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) - - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) - - return addons_to_skip_in_core + return all_addon_modules -def _load_addons_in_core( - ignore_addon_names, openpype_modules, modules_key, log -): +def _load_addons_in_core(log): # Add current directory at first place # - has small differences in import logic + addon_modules = [] modules_dir = os.path.join(AYON_CORE_ROOT, "modules") if not os.path.exists(modules_dir): log.warning( f"Could not find path when loading AYON addons \"{modules_dir}\"" ) - return + return addon_modules ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES - for filename in os.listdir(modules_dir): # Ignore filenames if filename in ignored_filenames: @@ -425,9 +390,6 @@ def _load_addons_in_core( fullpath = os.path.join(modules_dir, filename) basename, ext = os.path.splitext(filename) - if basename in ignore_addon_names: - continue - # Validations if os.path.isdir(fullpath): # Check existence of init file @@ -446,69 +408,43 @@ def _load_addons_in_core( # - check manifest and content of manifest try: # Don't import dynamically current directory modules - new_import_str = f"{modules_key}.{basename}" - import_str = f"ayon_core.modules.{basename}" default_module = __import__(import_str, fromlist=("", )) - sys.modules[new_import_str] = default_module - setattr(openpype_modules, basename, default_module) + addon_modules.append(default_module) except Exception: log.error( f"Failed to import in-core addon '{basename}'.", exc_info=True ) + return addon_modules def _load_addons(): - # Key under which will be modules imported in `sys.modules` - modules_key = "openpype_modules" - - # Change `sys.modules` - sys.modules[modules_key] = openpype_modules = _ModuleClass(modules_key) - log = Logger.get_logger("AddonsLoader") - ignore_addon_names = _load_ayon_addons( - openpype_modules, modules_key, log - ) - _load_addons_in_core( - ignore_addon_names, openpype_modules, modules_key, log - ) + addon_modules = _load_ayon_addons(log) + # All addon in 'modules' folder are tray actions and should be moved + # to tray tool. + # TODO remove + addon_modules.extend(_load_addons_in_core(log)) - -_MARKING_ATTR = "_marking" -def mark_func(func): - """Mark function to be used in report. - - Args: - func (Callable): Function to mark. - - Returns: - Callable: Marked function. - """ - - setattr(func, _MARKING_ATTR, True) - return func - - -def is_func_marked(func): - return getattr(func, _MARKING_ATTR, False) + # Store modules to local cache + _LoadCache.addon_modules = addon_modules class AYONAddon(ABC): """Base class of AYON addon. Attributes: - id (UUID): Addon object id. enabled (bool): Is addon enabled. name (str): Addon name. Args: manager (AddonsManager): Manager object who discovered addon. settings (dict[str, Any]): AYON settings. - """ + """ enabled = True _id = None @@ -528,8 +464,8 @@ class AYONAddon(ABC): Returns: str: Object id. - """ + """ if self._id is None: self._id = uuid4() return self._id @@ -541,8 +477,8 @@ class AYONAddon(ABC): Returns: str: Addon name. - """ + """ pass @property @@ -573,18 +509,40 @@ class AYONAddon(ABC): Args: settings (dict[str, Any]): Settings. - """ + """ pass - @mark_func def connect_with_addons(self, enabled_addons): """Connect with other enabled addons. Args: enabled_addons (list[AYONAddon]): Addons that are enabled. - """ + """ + pass + + def ensure_is_process_ready( + self, process_context: ProcessContext + ): + """Make sure addon is prepared for a process. + + This method is called when some action makes sure that addon has set + necessary data. For example if user should be logged in + and filled credentials in environment variables this method should + ask user for credentials. + + Implementation of this method is optional. + + Note: + The logic can be similar to logic in tray, but tray does not require + to be logged in. + + Args: + process_context (ProcessContext): Context of child + process. + + """ pass def get_global_environments(self): @@ -594,8 +552,8 @@ class AYONAddon(ABC): Returns: dict[str, str]: Environment variables. - """ + """ return {} def modify_application_launch_arguments(self, application, env): @@ -607,8 +565,8 @@ class AYONAddon(ABC): Args: application (Application): Application that is launched. env (dict[str, str]): Current environment variables. - """ + """ pass def on_host_install(self, host, host_name, project_name): @@ -627,8 +585,8 @@ class AYONAddon(ABC): host_name (str): Name of host. project_name (str): Project name which is main part of host context. - """ + """ pass def cli(self, addon_click_group): @@ -655,31 +613,11 @@ class AYONAddon(ABC): Args: addon_click_group (click.Group): Group to which can be added commands. + """ - pass -class OpenPypeModule(AYONAddon): - """Base class of OpenPype module. - - Deprecated: - Use `AYONAddon` instead. - - Args: - manager (AddonsManager): Manager object who discovered addon. - settings (dict[str, Any]): Module settings (OpenPype settings). - """ - - # Disable by default - enabled = False - - -class OpenPypeAddOn(OpenPypeModule): - # Enable Addon by default - enabled = True - - class _AddonReportInfo: def __init__( self, class_name, name, version, report_value_by_label @@ -711,8 +649,8 @@ class AddonsManager: settings (Optional[dict[str, Any]]): AYON studio settings. initialize (Optional[bool]): Initialize addons on init. True by default. - """ + """ # Helper attributes for report _report_total_key = "Total" _log = None @@ -748,8 +686,8 @@ class AddonsManager: Returns: Union[AYONAddon, Any]: Addon found by name or `default`. - """ + """ return self._addons_by_name.get(addon_name, default) @property @@ -776,8 +714,8 @@ class AddonsManager: Returns: Union[AYONAddon, None]: Enabled addon found by name or None. - """ + """ addon = self.get(addon_name) if addon is not None and addon.enabled: return addon @@ -788,8 +726,8 @@ class AddonsManager: Returns: list[AYONAddon]: Initialized and enabled addons. - """ + """ return [ addon for addon in self._addons @@ -801,8 +739,6 @@ class AddonsManager: # Make sure modules are loaded load_addons() - import openpype_modules - self.log.debug("*** AYON addons initialization.") # Prepare settings for addons @@ -810,14 +746,12 @@ class AddonsManager: if settings is None: settings = get_studio_settings() - modules_settings = {} - report = {} time_start = time.time() prev_start_time = time_start addon_classes = [] - for module in openpype_modules: + for module in _LoadCache.addon_modules: # Go through globals in `ayon_core.modules` for name in dir(module): modules_item = getattr(module, name, None) @@ -826,8 +760,6 @@ class AddonsManager: if ( not inspect.isclass(modules_item) or modules_item is AYONAddon - or modules_item is OpenPypeModule - or modules_item is OpenPypeAddOn or not issubclass(modules_item, AYONAddon) ): continue @@ -853,33 +785,14 @@ class AddonsManager: addon_classes.append(modules_item) - aliased_names = [] for addon_cls in addon_classes: name = addon_cls.__name__ - if issubclass(addon_cls, OpenPypeModule): - # TODO change to warning - self.log.debug(( - "Addon '{}' is inherited from 'OpenPypeModule'." - " Please use 'AYONAddon'." - ).format(name)) - try: - # Try initialize module - if issubclass(addon_cls, OpenPypeModule): - addon = addon_cls(self, modules_settings) - else: - addon = addon_cls(self, settings) + addon = addon_cls(self, settings) # Store initialized object self._addons.append(addon) self._addons_by_id[addon.id] = addon self._addons_by_name[addon.name] = addon - # NOTE This will be removed with release 1.0.0 of ayon-core - # please use carefully. - # Gives option to use alias name for addon for cases when - # name in OpenPype was not the same as in AYON. - name_alias = getattr(addon, "openpype_alias", None) - if name_alias: - aliased_names.append((name_alias, addon)) now = time.time() report[addon.__class__.__name__] = now - prev_start_time @@ -898,17 +811,6 @@ class AddonsManager: f"[{enabled_str}] {addon.name} ({addon.version})" ) - for item in aliased_names: - name_alias, addon = item - if name_alias not in self._addons_by_name: - self._addons_by_name[name_alias] = addon - continue - self.log.warning( - "Alias name '{}' of addon '{}' is already assigned.".format( - name_alias, addon.name - ) - ) - if self._report is not None: report[self._report_total_key] = time.time() - time_start self._report["Initialization"] = report @@ -925,16 +827,7 @@ class AddonsManager: self.log.debug("Has {} enabled addons.".format(len(enabled_addons))) for addon in enabled_addons: try: - if not is_func_marked(addon.connect_with_addons): - addon.connect_with_addons(enabled_addons) - - elif hasattr(addon, "connect_with_modules"): - self.log.warning(( - "DEPRECATION WARNING: Addon '{}' still uses" - " 'connect_with_modules' method. Please switch to use" - " 'connect_with_addons' method." - ).format(addon.name)) - addon.connect_with_modules(enabled_addons) + addon.connect_with_addons(enabled_addons) except Exception: self.log.error( @@ -1283,56 +1176,3 @@ class AddonsManager: # Join rows with newline char and add new line at the end output = "\n".join(formatted_rows) + "\n" print(output) - - # DEPRECATED - Module compatibility - @property - def modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules' please use 'addons' instead." - ) - return self.addons - - @property - def modules_by_id(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules_by_id' please use 'addons_by_id' instead." - ) - return self.addons_by_id - - @property - def modules_by_name(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated property" - " 'modules_by_name' please use 'addons_by_name' instead." - ) - return self.addons_by_name - - def get_enabled_module(self, *args, **kwargs): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_enabled_module' please use 'get_enabled_addon' instead." - ) - return self.get_enabled_addon(*args, **kwargs) - - def initialize_modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'initialize_modules' please use 'initialize_addons' instead." - ) - self.initialize_addons() - - def get_enabled_modules(self): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_enabled_modules' please use 'get_enabled_addons' instead." - ) - return self.get_enabled_addons() - - def get_host_module(self, host_name): - self.log.warning( - "DEPRECATION WARNING: Used deprecated method" - " 'get_host_module' please use 'get_host_addon' instead." - ) - return self.get_host_addon(host_name) diff --git a/client/ayon_core/addon/ui/process_ready_error.py b/client/ayon_core/addon/ui/process_ready_error.py new file mode 100644 index 0000000000..78e2b9b9bb --- /dev/null +++ b/client/ayon_core/addon/ui/process_ready_error.py @@ -0,0 +1,132 @@ +import sys +import json +from typing import Optional + +from qtpy import QtWidgets, QtCore + +from ayon_core.style import load_stylesheet +from ayon_core.tools.utils import get_ayon_qt_app + + +class DetailDialog(QtWidgets.QDialog): + def __init__(self, detail, parent): + super().__init__(parent) + + self.setWindowTitle("Detail") + + detail_input = QtWidgets.QPlainTextEdit(self) + detail_input.setPlainText(detail) + detail_input.setReadOnly(True) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(detail_input, 1) + + def showEvent(self, event): + self.resize(600, 400) + super().showEvent(event) + + +class ErrorDialog(QtWidgets.QDialog): + def __init__( + self, + message: str, + detail: Optional[str], + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + self.setWindowTitle("Preparation failed") + self.setWindowFlags( + self.windowFlags() & ~QtCore.Qt.WindowContextHelpButtonHint + ) + + message_label = QtWidgets.QLabel(self) + + detail_wrapper = QtWidgets.QWidget(self) + + detail_label = QtWidgets.QLabel(detail_wrapper) + + detail_layout = QtWidgets.QVBoxLayout(detail_wrapper) + detail_layout.setContentsMargins(0, 0, 0, 0) + detail_layout.addWidget(detail_label) + + btns_wrapper = QtWidgets.QWidget(self) + + copy_detail_btn = QtWidgets.QPushButton("Copy detail", btns_wrapper) + show_detail_btn = QtWidgets.QPushButton("Show detail", btns_wrapper) + confirm_btn = QtWidgets.QPushButton("Close", btns_wrapper) + + btns_layout = QtWidgets.QHBoxLayout(btns_wrapper) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(copy_detail_btn, 0) + btns_layout.addWidget(show_detail_btn, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(message_label, 0) + layout.addWidget(detail_wrapper, 1) + layout.addWidget(btns_wrapper, 0) + + copy_detail_btn.clicked.connect(self._on_copy_clicked) + show_detail_btn.clicked.connect(self._on_show_detail_clicked) + confirm_btn.clicked.connect(self._on_confirm_clicked) + + self._message_label = message_label + self._detail_wrapper = detail_wrapper + self._detail_label = detail_label + + self._copy_detail_btn = copy_detail_btn + self._show_detail_btn = show_detail_btn + self._confirm_btn = confirm_btn + + self._detail_dialog = None + + self._detail = detail + + self.set_message(message, detail) + + def showEvent(self, event): + self.setStyleSheet(load_stylesheet()) + self.resize(320, 140) + super().showEvent(event) + + def set_message(self, message, detail): + self._message_label.setText(message) + self._detail = detail + + for widget in ( + self._copy_detail_btn, + self._show_detail_btn, + ): + widget.setVisible(bool(detail)) + + def _on_copy_clicked(self): + if self._detail: + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._detail) + + def _on_show_detail_clicked(self): + if self._detail_dialog is None: + self._detail_dialog = DetailDialog(self._detail, self) + self._detail_dialog.show() + + def _on_confirm_clicked(self): + self.accept() + + +def main(): + json_path = sys.argv[-1] + with open(json_path, "r") as stream: + data = json.load(stream) + + message = data["message"] + detail = data["detail"] + app = get_ayon_qt_app() + dialog = ErrorDialog(message, detail) + dialog.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/client/ayon_core/addon/utils.py b/client/ayon_core/addon/utils.py new file mode 100644 index 0000000000..f983e37d3c --- /dev/null +++ b/client/ayon_core/addon/utils.py @@ -0,0 +1,201 @@ +import os +import sys +import contextlib +import tempfile +import json +import traceback +from io import StringIO +from typing import Optional + +from ayon_core.lib import run_ayon_launcher_process + +from .base import AddonsManager, ProcessContext, ProcessPreparationError + + +def _handle_error( + process_context: ProcessContext, + message: str, + detail: Optional[str], +): + """Handle error in process ready preparation. + + Shows UI to inform user about the error, or prints the message + to stdout if running in headless mode. + + Todos: + Make this functionality with the dialog as unified function, so it can + be used elsewhere. + + Args: + process_context (ProcessContext): The context in which the + error occurred. + message (str): The message to show. + detail (Optional[str]): The detail message to show (usually + traceback). + + """ + if process_context.headless: + if detail: + print(detail) + print(f"{10*'*'}\n{message}\n{10*'*'}") + return + + current_dir = os.path.dirname(os.path.abspath(__file__)) + script_path = os.path.join(current_dir, "ui", "process_ready_error.py") + with tempfile.NamedTemporaryFile("w", delete=False) as tmp: + tmp_path = tmp.name + json.dump( + {"message": message, "detail": detail}, + tmp.file + ) + + try: + run_ayon_launcher_process( + "--skip-bootstrap", + script_path, + tmp_path, + add_sys_paths=True, + creationflags=0, + ) + + finally: + os.remove(tmp_path) + + +def _start_tray(): + from ayon_core.tools.tray import make_sure_tray_is_running + + make_sure_tray_is_running() + + +def ensure_addons_are_process_context_ready( + process_context: ProcessContext, + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, +) -> bool: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Todos: + Run all preparations and allow to "ignore" failed preparations. + Right now single addon can block using certain actions. + + Args: + process_context (ProcessContext): The context in which the + addons should be prepared. + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + + Returns: + bool: True if all addons are ready, False otherwise. + + """ + if addons_manager is None: + addons_manager = AddonsManager() + + message = None + failed = False + use_detail = False + # Wrap the output in StringIO to capture it for details on fail + # - but in case stdout was invalid on start of process also store + # the tracebacks + tracebacks = [] + output = StringIO() + with contextlib.redirect_stdout(output): + with contextlib.redirect_stderr(output): + for addon in addons_manager.get_enabled_addons(): + addon_failed = True + try: + addon.ensure_is_process_ready(process_context) + addon_failed = False + except ProcessPreparationError as exc: + message = str(exc) + print(f"Addon preparation failed: '{addon.name}'") + print(message) + + except BaseException: + use_detail = True + message = "An unexpected error occurred." + formatted_traceback = "".join(traceback.format_exception( + *sys.exc_info() + )) + tracebacks.append(formatted_traceback) + print(f"Addon preparation failed: '{addon.name}'") + print(message) + # Print the traceback so it is in the stdout + print(formatted_traceback) + + if addon_failed: + failed = True + break + + output_str = output.getvalue() + # Print stdout/stderr to console as it was redirected + print(output_str) + if not failed: + if not process_context.headless: + _start_tray() + return True + + detail = None + if use_detail: + # In case stdout was not captured, use the tracebacks as detail + if not output_str: + output_str = "\n".join(tracebacks) + detail = output_str + + _handle_error(process_context, message, detail) + if exit_on_failure: + sys.exit(1) + return False + + +def ensure_addons_are_process_ready( + addon_name: str, + addon_version: str, + project_name: Optional[str] = None, + headless: Optional[bool] = None, + *, + addons_manager: Optional[AddonsManager] = None, + exit_on_failure: bool = True, + **kwargs, +) -> bool: + """Ensure all enabled addons are ready to be used in the given context. + + Call this method only in AYON launcher process and as first thing + to avoid possible clashes with preparation. For example 'QApplication' + should not be created. + + Args: + addon_name (str): Addon name which triggered process. + addon_version (str): Addon version which triggered process. + project_name (Optional[str]): Project name. Can be filled in case + process is triggered for specific project. Some addons can have + different behavior based on project. Value is NOT autofilled. + headless (Optional[bool]): Is process running in headless mode. Value + is filled with value based on state set in AYON launcher. + addons_manager (Optional[AddonsManager]): The addons + manager to use. If not provided, a new one will be created. + exit_on_failure (bool, optional): If True, the process will exit + if an error occurs. Defaults to True. + kwargs: The keyword arguments to pass to the ProcessContext. + + Returns: + bool: True if all addons are ready, False otherwise. + + """ + context: ProcessContext = ProcessContext( + addon_name, + addon_version, + project_name, + headless, + **kwargs + ) + return ensure_addons_are_process_context_ready( + context, addons_manager, exit_on_failure + ) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index db6674d88f..b80b243db2 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -21,21 +21,7 @@ from ayon_core.lib import ( -class AliasedGroup(click.Group): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._aliases = {} - - def set_alias(self, src_name, dst_name): - self._aliases[dst_name] = src_name - - def get_command(self, ctx, cmd_name): - if cmd_name in self._aliases: - cmd_name = self._aliases[cmd_name] - return super().get_command(ctx, cmd_name) - - -@click.group(cls=AliasedGroup, invoke_without_command=True) +@click.group(invoke_without_command=True) @click.pass_context @click.option("--use-staging", is_flag=True, expose_value=False, help="use staging variants") @@ -86,10 +72,6 @@ def addon(ctx): pass -# Add 'addon' as alias for module -main_cli.set_alias("addon", "module") - - @main_cli.command() @click.pass_context @click.argument("output_json_path") diff --git a/client/ayon_core/hooks/pre_add_last_workfile_arg.py b/client/ayon_core/hooks/pre_add_last_workfile_arg.py index 74964e0df9..d5914c2352 100644 --- a/client/ayon_core/hooks/pre_add_last_workfile_arg.py +++ b/client/ayon_core/hooks/pre_add_last_workfile_arg.py @@ -28,7 +28,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "substancepainter", "aftereffects", "wrap", - "openrv" + "openrv", + "cinema4d" } launch_types = {LaunchTypes.local} diff --git a/client/ayon_core/hooks/pre_global_host_data.py b/client/ayon_core/hooks/pre_global_host_data.py index e93b512742..12da6f12f8 100644 --- a/client/ayon_core/hooks/pre_global_host_data.py +++ b/client/ayon_core/hooks/pre_global_host_data.py @@ -94,4 +94,4 @@ class GlobalHostDataHook(PreLaunchHook): task_entity = get_task_by_name( project_name, folder_entity["id"], task_name ) - self.data["task_entity"] = task_entity \ No newline at end of file + self.data["task_entity"] = task_entity 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/__init__.py b/client/ayon_core/lib/__init__.py index 12c391d867..03ed574081 100644 --- a/client/ayon_core/lib/__init__.py +++ b/client/ayon_core/lib/__init__.py @@ -7,11 +7,10 @@ from .local_settings import ( JSONSettingRegistry, AYONSecureRegistry, AYONSettingsRegistry, - OpenPypeSecureRegistry, - OpenPypeSettingsRegistry, + get_launcher_local_dir, + get_launcher_storage_dir, get_local_site_id, get_ayon_username, - get_openpype_username, ) from .ayon_connection import initialize_ayon_connection from .cache import ( @@ -57,13 +56,11 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( get_ayon_launcher_args, - get_openpype_execute_args, get_linux_launcher_args, execute, run_subprocess, run_detached_process, run_ayon_launcher_process, - run_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) @@ -130,6 +127,7 @@ from .ayon_info import ( is_in_ayon_launcher_process, is_running_from_build, is_using_ayon_console, + is_headless_mode_enabled, is_staging_enabled, is_dev_mode_enabled, is_in_tests, @@ -142,11 +140,10 @@ __all__ = [ "JSONSettingRegistry", "AYONSecureRegistry", "AYONSettingsRegistry", - "OpenPypeSecureRegistry", - "OpenPypeSettingsRegistry", + "get_launcher_local_dir", + "get_launcher_storage_dir", "get_local_site_id", "get_ayon_username", - "get_openpype_username", "initialize_ayon_connection", @@ -157,13 +154,11 @@ __all__ = [ "register_event_callback", "get_ayon_launcher_args", - "get_openpype_execute_args", "get_linux_launcher_args", "execute", "run_subprocess", "run_detached_process", "run_ayon_launcher_process", - "run_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -241,6 +236,7 @@ __all__ = [ "is_in_ayon_launcher_process", "is_running_from_build", "is_using_ayon_console", + "is_headless_mode_enabled", "is_staging_enabled", "is_dev_mode_enabled", "is_in_tests", diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 360d47ea17..e1381944f6 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -4,84 +4,62 @@ import collections import uuid import json import copy -from abc import ABCMeta, abstractmethod, abstractproperty +import warnings +from abc import ABCMeta, abstractmethod +import typing +from typing import ( + Any, + Optional, + List, + Set, + Dict, + Iterable, + TypeVar, +) import clique +if typing.TYPE_CHECKING: + from typing import Self, Tuple, Union, TypedDict, Pattern + + + class EnumItemDict(TypedDict): + label: str + value: Any + + + EnumItemsInputType = Union[ + Dict[Any, str], + List[Tuple[Any, str]], + List[Any], + List[EnumItemDict] + ] + + + class FileDefItemDict(TypedDict): + directory: str + filenames: List[str] + frames: Optional[List[int]] + template: Optional[str] + is_sequence: Optional[bool] + + # Global variable which store attribute definitions by type # - default types are registered on import _attr_defs_by_type = {} - -def register_attr_def_class(cls): - """Register attribute definition. - - Currently are registered definitions used to deserialize data to objects. - - Attrs: - cls (AbstractAttrDef): Non-abstract class to be registered with unique - 'type' attribute. - - Raises: - KeyError: When type was already registered. - """ - - if cls.type in _attr_defs_by_type: - raise KeyError("Type \"{}\" was already registered".format(cls.type)) - _attr_defs_by_type[cls.type] = cls - - -def get_attributes_keys(attribute_definitions): - """Collect keys from list of attribute definitions. - - Args: - attribute_definitions (List[AbstractAttrDef]): Objects of attribute - definitions. - - Returns: - Set[str]: Keys that will be created using passed attribute definitions. - """ - - keys = set() - if not attribute_definitions: - return keys - - for attribute_def in attribute_definitions: - if not isinstance(attribute_def, UIDef): - keys.add(attribute_def.key) - return keys - - -def get_default_values(attribute_definitions): - """Receive default values for attribute definitions. - - Args: - attribute_definitions (List[AbstractAttrDef]): Attribute definitions - for which default values should be collected. - - Returns: - Dict[str, Any]: Default values for passet attribute definitions. - """ - - output = {} - if not attribute_definitions: - return output - - for attr_def in attribute_definitions: - # Skip UI definitions - if not isinstance(attr_def, UIDef): - output[attr_def.key] = attr_def.default - return output +# Type hint helpers +IntFloatType = "Union[int, float]" class AbstractAttrDefMeta(ABCMeta): - """Metaclass to validate existence of 'key' attribute. + """Metaclass to validate the existence of 'key' attribute. + + Each object of `AbstractAttrDef` must have defined 'key' attribute. - Each object of `AbstractAttrDef` mus have defined 'key' attribute. """ - - def __call__(self, *args, **kwargs): - obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) + def __call__(cls, *args, **kwargs): + obj = super(AbstractAttrDefMeta, cls).__call__(*args, **kwargs) init_class = getattr(obj, "__init__class__", None) if init_class is not AbstractAttrDef: raise TypeError("{} super was not called in __init__.".format( @@ -90,6 +68,34 @@ class AbstractAttrDefMeta(ABCMeta): return obj +def _convert_reversed_attr( + main_value: Any, + depr_value: Any, + main_label: str, + depr_label: str, + default: Any, +) -> Any: + if main_value is not None and depr_value is not None: + if main_value == depr_value: + print( + f"Got invalid '{main_label}' and '{depr_label}' arguments." + f" Using '{main_label}' value." + ) + elif depr_value is not None: + warnings.warn( + ( + "DEPRECATION WARNING: Using deprecated argument" + f" '{depr_label}' please use '{main_label}' instead." + ), + DeprecationWarning, + stacklevel=4, + ) + main_value = not depr_value + elif main_value is None: + main_value = default + return main_value + + class AbstractAttrDef(metaclass=AbstractAttrDefMeta): """Abstraction of attribute definition. @@ -106,90 +112,147 @@ 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 = [] is_value_def = True def __init__( self, - key, - default, - label=None, - tooltip=None, - is_label_horizontal=None, - hidden=False, - disabled=False + key: str, + default: Any, + label: Optional[str] = None, + tooltip: Optional[str] = None, + is_label_horizontal: Optional[bool] = None, + visible: Optional[bool] = None, + enabled: Optional[bool] = None, + hidden: Optional[bool] = None, + disabled: Optional[bool] = None, ): if is_label_horizontal is None: is_label_horizontal = True - if hidden is None: - hidden = False + enabled = _convert_reversed_attr( + enabled, disabled, "enabled", "disabled", True + ) + visible = _convert_reversed_attr( + visible, hidden, "visible", "hidden", True + ) - self.key = key - self.label = label - self.tooltip = tooltip - self.default = default - self.is_label_horizontal = is_label_horizontal - self.hidden = hidden - self.disabled = disabled - self._id = uuid.uuid4().hex + self.key: str = key + self.label: Optional[str] = label + self.tooltip: Optional[str] = tooltip + self.default: Any = default + self.is_label_horizontal: bool = is_label_horizontal + self.visible: bool = visible + self.enabled: bool = enabled + self._id: str = uuid.uuid4().hex self.__init__class__ = AbstractAttrDef @property - def id(self): + def id(self) -> str: return self._id - def __eq__(self, other): - if not isinstance(other, self.__class__): + def clone(self) -> "Self": + data = self.serialize() + data.pop("type") + return self.deserialize(data) + + @property + def hidden(self) -> bool: + return not self.visible + + @hidden.setter + def hidden(self, value: bool): + self.visible = not value + + @property + def disabled(self) -> bool: + return not self.enabled + + @disabled.setter + def disabled(self, value: bool): + self.enabled = not value + + def __eq__(self, other: Any) -> bool: + return self.compare_to_def(other) + + def __ne__(self, other: Any) -> bool: + return not self.compare_to_def(other) + + def compare_to_def( + self, + other: Any, + ignore_default: Optional[bool] = False, + ignore_enabled: Optional[bool] = False, + ignore_visible: Optional[bool] = False, + ignore_def_type_compare: Optional[bool] = False, + ) -> bool: + if not isinstance(other, self.__class__) or self.key != other.key: + return False + if not ignore_def_type_compare and not self._def_type_compare(other): return False return ( - self.key == other.key - and self.hidden == other.hidden - and self.default == other.default - and self.disabled == other.disabled + (ignore_default or self.default == other.default) + and (ignore_visible or self.visible == other.visible) + and (ignore_enabled or self.enabled == other.enabled) ) - def __ne__(self, other): - return not self.__eq__(other) + @abstractmethod + def is_value_valid(self, value: Any) -> bool: + """Check if value is valid. - @abstractproperty - def type(self): + This should return False if value is not valid based + on definition type. + + Args: + value (Any): Value to validate based on definition type. + + Returns: + bool: True if value is valid. + + """ + pass + + @property + @abstractmethod + def type(self) -> str: """Attribute definition type also used as identifier of class. Returns: str: Type of attribute definition. - """ + """ pass @abstractmethod - def convert_value(self, value): + def convert_value(self, value: Any) -> Any: """Convert value to a valid one. Convert passed value to a valid type. Use default if value can't be converted. - """ + """ pass - def serialize(self): + def serialize(self) -> Dict[str, Any]: """Serialize object to data so it's possible to recreate it. Returns: Dict[str, Any]: Serialized object that can be passed to 'deserialize' method. - """ + """ data = { "type": self.type, "key": self.key, @@ -197,34 +260,51 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): "tooltip": self.tooltip, "default": self.default, "is_label_horizontal": self.is_label_horizontal, - "hidden": self.hidden, - "disabled": self.disabled + "visible": self.visible, + "enabled": self.enabled } for attr in self.type_attributes: data[attr] = getattr(self, attr) return data @classmethod - def deserialize(cls, data): + def deserialize(cls, data: Dict[str, Any]) -> "Self": """Recreate object from data. Data can be received using 'serialize' method. """ + if "type" in data: + data = dict(data) + data.pop("type") return cls(**data) + def _def_type_compare(self, other: "Self") -> bool: + return True + + +AttrDefType = TypeVar("AttrDefType", bound=AbstractAttrDef) # ----------------------------------------- -# UI attribute definitoins won't hold value +# UI attribute definitions won't hold value # ----------------------------------------- class UIDef(AbstractAttrDef): is_value_def = False - def __init__(self, key=None, default=None, *args, **kwargs): - super(UIDef, self).__init__(key, default, *args, **kwargs) + def __init__( + self, + key: Optional[str] = None, + default: Optional[Any] = None, + *args, + **kwargs + ): + super().__init__(key, default, *args, **kwargs) - def convert_value(self, value): + def is_value_valid(self, value: Any) -> bool: + return True + + def convert_value(self, value: Any) -> Any: return value @@ -235,17 +315,15 @@ class UISeparatorDef(UIDef): class UILabelDef(UIDef): type = "label" - def __init__(self, label, key=None): - super(UILabelDef, self).__init__(label=label, key=key) + def __init__(self, label, key=None, *args, **kwargs): + super().__init__(label=label, key=key, *args, **kwargs) - def __eq__(self, other): - if not super(UILabelDef, self).__eq__(other): - return False + def _def_type_compare(self, other: "UILabelDef") -> bool: return self.label == other.label # --------------------------------------- -# Attribute defintioins should hold value +# Attribute definitions should hold value # --------------------------------------- class UnknownDef(AbstractAttrDef): @@ -253,15 +331,18 @@ class UnknownDef(AbstractAttrDef): This attribute can be used to keep existing data unchanged but does not have known definition of type. - """ + """ type = "unknown" - def __init__(self, key, default=None, **kwargs): + def __init__(self, key: str, default: Optional[Any] = None, **kwargs): kwargs["default"] = default - super(UnknownDef, self).__init__(key, **kwargs) + super().__init__(key, **kwargs) - def convert_value(self, value): + def is_value_valid(self, value: Any) -> bool: + return True + + def convert_value(self, value: Any) -> Any: return value @@ -272,16 +353,19 @@ class HiddenDef(AbstractAttrDef): to other attributes (e.g. in multi-page UIs). Keep in mind the value should be possible to parse by json parser. - """ + """ type = "hidden" - def __init__(self, key, default=None, **kwargs): + def __init__(self, key: str, default: Optional[Any] = 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): + def is_value_valid(self, value: Any) -> bool: + return True + + def convert_value(self, value: Any) -> Any: return value @@ -296,8 +380,8 @@ class NumberDef(AbstractAttrDef): maximum(int, float): Maximum possible value. decimals(int): Maximum decimal points of value. default(int, float): Default value for conversion. - """ + """ type = "number" type_attributes = [ "minimum", @@ -306,12 +390,17 @@ class NumberDef(AbstractAttrDef): ] def __init__( - self, key, minimum=None, maximum=None, decimals=None, default=None, + self, + key: str, + minimum: Optional[IntFloatType] = None, + maximum: Optional[IntFloatType] = None, + decimals: Optional[int] = None, + default: Optional[IntFloatType] = None, **kwargs ): minimum = 0 if minimum is None else minimum maximum = 999999 if maximum is None else maximum - # Swap min/max when are passed in opposited order + # Swap min/max when are passed in opposite order if minimum > maximum: maximum, minimum = minimum, maximum @@ -330,23 +419,23 @@ class NumberDef(AbstractAttrDef): elif default > maximum: default = maximum - super(NumberDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) - self.minimum = minimum - self.maximum = maximum - self.decimals = 0 if decimals is None else decimals + self.minimum: IntFloatType = minimum + self.maximum: IntFloatType = maximum + self.decimals: int = 0 if decimals is None else decimals - def __eq__(self, other): - if not super(NumberDef, self).__eq__(other): + def is_value_valid(self, value: Any) -> bool: + if self.decimals == 0: + if not isinstance(value, int): + return False + elif not isinstance(value, float): return False + if self.minimum > value > self.maximum: + return False + return True - return ( - self.decimals == other.decimals - and self.maximum == other.maximum - and self.maximum == other.maximum - ) - - def convert_value(self, value): + def convert_value(self, value: Any) -> IntFloatType: if isinstance(value, str): try: value = float(value) @@ -360,22 +449,29 @@ class NumberDef(AbstractAttrDef): return int(value) return round(float(value), self.decimals) + def _def_type_compare(self, other: "NumberDef") -> bool: + return ( + self.decimals == other.decimals + and self.maximum == other.maximum + and self.maximum == other.maximum + ) + class TextDef(AbstractAttrDef): """Text definition. - Text can have multiline option so endline characters are allowed regex + Text can have multiline option so end-line characters are allowed regex validation can be applied placeholder for UI purposes and default value. - Regex validation is not part of attribute implemntentation. + Regex validation is not part of attribute implementation. Args: multiline(bool): Text has single or multiline support. regex(str, re.Pattern): Regex validation. placeholder(str): UI placeholder for attribute. default(str, None): Default value. Empty string used when not defined. - """ + """ type = "text" type_attributes = [ "multiline", @@ -383,13 +479,18 @@ class TextDef(AbstractAttrDef): ] def __init__( - self, key, multiline=None, regex=None, placeholder=None, default=None, + self, + key: str, + multiline: Optional[bool] = None, + regex: Optional[str] = None, + placeholder: Optional[str] = None, + default: Optional[str] = None, **kwargs ): 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 @@ -402,29 +503,38 @@ class TextDef(AbstractAttrDef): if isinstance(regex, str): regex = re.compile(regex) - self.multiline = multiline - self.placeholder = placeholder - self.regex = regex + self.multiline: bool = multiline + self.placeholder: Optional[str] = placeholder + self.regex: Optional["Pattern"] = regex - def __eq__(self, other): - if not super(TextDef, self).__eq__(other): + def is_value_valid(self, value: Any) -> bool: + if not isinstance(value, str): return False + if self.regex and not self.regex.match(value): + return False + return True - return ( - self.multiline == other.multiline - and self.regex == other.regex - ) - - def convert_value(self, value): + def convert_value(self, value: Any) -> str: if isinstance(value, str): return value return self.default - def serialize(self): - data = super(TextDef, self).serialize() - data["regex"] = self.regex.pattern + def serialize(self) -> Dict[str, Any]: + data = super().serialize() + regex = None + if self.regex is not None: + regex = self.regex.pattern + data["regex"] = regex + data["multiline"] = self.multiline + data["placeholder"] = self.placeholder return data + def _def_type_compare(self, other: "TextDef") -> bool: + return ( + self.multiline == other.multiline + and self.regex == other.regex + ) + class EnumDef(AbstractAttrDef): """Enumeration of items. @@ -433,18 +543,24 @@ class EnumDef(AbstractAttrDef): is enabled. Args: - items (Union[list[str], list[dict[str, Any]]): Items definition that - can be converted using 'prepare_enum_items'. + key (str): Key under which value is stored. + items (EnumItemsInputType): Items definition that can be converted + using 'prepare_enum_items'. default (Optional[Any]): Default value. Must be one key(value) from passed items or list of values for multiselection. multiselection (Optional[bool]): If True, multiselection is allowed. Output is list of selected items. - """ + """ type = "enum" def __init__( - self, key, items, default=None, multiselection=False, **kwargs + self, + key: str, + items: "EnumItemsInputType", + default: "Union[str, List[Any]]" = None, + multiselection: Optional[bool] = False, + **kwargs ): if not items: raise ValueError(( @@ -455,6 +571,9 @@ class EnumDef(AbstractAttrDef): items = self.prepare_enum_items(items) item_values = [item["value"] for item in items] item_values_set = set(item_values) + if multiselection is None: + multiselection = False + if multiselection: if default is None: default = [] @@ -463,20 +582,11 @@ class EnumDef(AbstractAttrDef): elif default not in item_values: default = next(iter(item_values), None) - super(EnumDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) - self.items = items - self._item_values = item_values_set - self.multiselection = multiselection - - def __eq__(self, other): - if not super(EnumDef, self).__eq__(other): - return False - - return ( - self.items == other.items - and self.multiselection == other.multiselection - ) + self.items: List["EnumItemDict"] = items + self._item_values: Set[Any] = item_values_set + self.multiselection: bool = multiselection def convert_value(self, value): if not self.multiselection: @@ -488,14 +598,25 @@ class EnumDef(AbstractAttrDef): return copy.deepcopy(self.default) return list(self._item_values.intersection(value)) + def is_value_valid(self, value: Any) -> bool: + """Check if item is available in possible values.""" + if isinstance(value, list): + if not self.multiselection: + return False + return all(value in self._item_values for value in value) + + if self.multiselection: + return False + return value in self._item_values + def serialize(self): - data = super(EnumDef, self).serialize() + data = super().serialize() data["items"] = copy.deepcopy(self.items) data["multiselection"] = self.multiselection return data @staticmethod - def prepare_enum_items(items): + def prepare_enum_items(items: "EnumItemsInputType") -> List["EnumItemDict"]: """Convert items to unified structure. Output is a list where each item is dictionary with 'value' @@ -511,13 +632,12 @@ class EnumDef(AbstractAttrDef): ``` Args: - items (Union[Dict[str, Any], List[Any], List[Dict[str, Any]]): The - items to convert. + items (EnumItemsInputType): The items to convert. Returns: - List[Dict[str, Any]]: Unified structure of items. - """ + List[EnumItemDict]: Unified structure of items. + """ output = [] if isinstance(items, dict): for value, label in items.items(): @@ -556,30 +676,43 @@ class EnumDef(AbstractAttrDef): return output + def _def_type_compare(self, other: "EnumDef") -> bool: + return ( + self.items == other.items + and self.multiselection == other.multiselection + ) + class BoolDef(AbstractAttrDef): """Boolean representation. Args: default(bool): Default value. Set to `False` if not defined. - """ + """ type = "bool" - def __init__(self, key, default=None, **kwargs): + def __init__(self, key: str, default: Optional[bool] = 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): + def is_value_valid(self, value: Any) -> bool: + return isinstance(value, bool) + + def convert_value(self, value: Any) -> bool: if isinstance(value, bool): return value return self.default -class FileDefItem(object): +class FileDefItem: def __init__( - self, directory, filenames, frames=None, template=None + self, + directory: str, + filenames: List[str], + frames: Optional[List[int]] = None, + template: Optional[str] = None, ): self.directory = directory @@ -608,7 +741,7 @@ class FileDefItem(object): ) @property - def label(self): + def label(self) -> Optional[str]: if self.is_empty: return None @@ -651,7 +784,7 @@ class FileDefItem(object): filename_template, ",".join(ranges) ) - def split_sequence(self): + def split_sequence(self) -> List["Self"]: if not self.is_sequence: raise ValueError("Cannot split single file item") @@ -662,7 +795,7 @@ class FileDefItem(object): return self.from_paths(paths, False) @property - def ext(self): + def ext(self) -> Optional[str]: if self.is_empty: return None _, ext = os.path.splitext(self.filenames[0]) @@ -671,14 +804,14 @@ class FileDefItem(object): return None @property - def lower_ext(self): + def lower_ext(self) -> Optional[str]: ext = self.ext if ext is not None: return ext.lower() return ext @property - def is_dir(self): + def is_dir(self) -> bool: if self.is_empty: return False @@ -687,10 +820,15 @@ class FileDefItem(object): return False return True - def set_directory(self, directory): + def set_directory(self, directory: str): self.directory = directory - def set_filenames(self, filenames, frames=None, template=None): + def set_filenames( + self, + filenames: List[str], + frames: Optional[List[int]] = None, + template: Optional[str] = None, + ): if frames is None: frames = [] is_sequence = False @@ -707,17 +845,21 @@ class FileDefItem(object): self.is_sequence = is_sequence @classmethod - def create_empty_item(cls): + def create_empty_item(cls) -> "Self": return cls("", "") @classmethod - def from_value(cls, value, allow_sequences): + def from_value( + cls, + value: "Union[List[FileDefItemDict], FileDefItemDict]", + allow_sequences: bool, + ) -> List["Self"]: """Convert passed value to FileDefItem objects. Returns: list: Created FileDefItem objects. - """ + """ # Convert single item to iterable if not isinstance(value, (list, tuple, set)): value = [value] @@ -749,7 +891,7 @@ class FileDefItem(object): return output @classmethod - def from_dict(cls, data): + def from_dict(cls, data: "FileDefItemDict") -> "Self": return cls( data["directory"], data["filenames"], @@ -758,7 +900,11 @@ class FileDefItem(object): ) @classmethod - def from_paths(cls, paths, allow_sequences): + def from_paths( + cls, + paths: List[str], + allow_sequences: bool, + ) -> List["Self"]: filenames_by_dir = collections.defaultdict(list) for path in paths: normalized = os.path.normpath(path) @@ -787,7 +933,7 @@ class FileDefItem(object): return output - def to_dict(self): + def to_dict(self) -> "FileDefItemDict": output = { "is_sequence": self.is_sequence, "directory": self.directory, @@ -825,8 +971,15 @@ class FileDef(AbstractAttrDef): ] def __init__( - self, key, single_item=True, folders=None, extensions=None, - allow_sequences=True, extensions_label=None, default=None, **kwargs + self, + key: str, + single_item: Optional[bool] = True, + folders: Optional[bool] = None, + extensions: Optional[Iterable[str]] = None, + allow_sequences: Optional[bool] = True, + extensions_label: Optional[str] = None, + default: Optional["Union[FileDefItemDict, List[str]]"] = None, + **kwargs ): if folders is None and extensions is None: folders = True @@ -843,7 +996,9 @@ class FileDef(AbstractAttrDef): FileDefItem.from_dict(default) elif isinstance(default, str): - default = FileDefItem.from_paths([default.strip()])[0] + default = FileDefItem.from_paths( + [default.strip()], allow_sequences + )[0] else: raise TypeError(( @@ -862,15 +1017,15 @@ class FileDef(AbstractAttrDef): if is_label_horizontal is None: kwargs["is_label_horizontal"] = False - self.single_item = single_item - self.folders = folders - self.extensions = set(extensions) - self.allow_sequences = allow_sequences - self.extensions_label = extensions_label - super(FileDef, self).__init__(key, default=default, **kwargs) + self.single_item: bool = single_item + self.folders: bool = folders + self.extensions: Set[str] = set(extensions) + self.allow_sequences: bool = allow_sequences + self.extensions_label: Optional[str] = extensions_label + super().__init__(key, default=default, **kwargs) - def __eq__(self, other): - if not super(FileDef, self).__eq__(other): + def __eq__(self, other: Any) -> bool: + if not super().__eq__(other): return False return ( @@ -880,7 +1035,32 @@ class FileDef(AbstractAttrDef): and self.allow_sequences == other.allow_sequences ) - def convert_value(self, value): + 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: Any + ) -> "Union[FileDefItemDict, List[FileDefItemDict]]": if isinstance(value, (str, dict)): value = [value] @@ -898,7 +1078,9 @@ class FileDef(AbstractAttrDef): pass if string_paths: - file_items = FileDefItem.from_paths(string_paths) + file_items = FileDefItem.from_paths( + string_paths, self.allow_sequences + ) dict_items.extend([ file_item.to_dict() for file_item in file_items @@ -916,54 +1098,124 @@ class FileDef(AbstractAttrDef): return [] -def serialize_attr_def(attr_def): +def register_attr_def_class(cls: AttrDefType): + """Register attribute definition. + + Currently registered definitions are used to deserialize data to objects. + + Attrs: + cls (AttrDefType): Non-abstract class to be registered with unique + 'type' attribute. + + Raises: + KeyError: When type was already registered. + + """ + if cls.type in _attr_defs_by_type: + raise KeyError("Type \"{}\" was already registered".format(cls.type)) + _attr_defs_by_type[cls.type] = cls + + +def get_attributes_keys( + attribute_definitions: List[AttrDefType] +) -> Set[str]: + """Collect keys from list of attribute definitions. + + Args: + attribute_definitions (List[AttrDefType]): Objects of attribute + definitions. + + Returns: + Set[str]: Keys that will be created using passed attribute definitions. + + """ + keys = set() + if not attribute_definitions: + return keys + + for attribute_def in attribute_definitions: + if not isinstance(attribute_def, UIDef): + keys.add(attribute_def.key) + return keys + + +def get_default_values( + attribute_definitions: List[AttrDefType] +) -> Dict[str, Any]: + """Receive default values for attribute definitions. + + Args: + attribute_definitions (List[AttrDefType]): Attribute definitions + for which default values should be collected. + + Returns: + Dict[str, Any]: Default values for passed attribute definitions. + + """ + output = {} + if not attribute_definitions: + return output + + for attr_def in attribute_definitions: + # Skip UI definitions + if not isinstance(attr_def, UIDef): + output[attr_def.key] = attr_def.default + return output + + +def serialize_attr_def(attr_def: AttrDefType) -> Dict[str, Any]: """Serialize attribute definition to data. Args: - attr_def (AbstractAttrDef): Attribute definition to serialize. + attr_def (AttrDefType): Attribute definition to serialize. Returns: Dict[str, Any]: Serialized data. - """ + """ return attr_def.serialize() -def serialize_attr_defs(attr_defs): +def serialize_attr_defs( + attr_defs: List[AttrDefType] +) -> List[Dict[str, Any]]: """Serialize attribute definitions to data. Args: - attr_defs (List[AbstractAttrDef]): Attribute definitions to serialize. + attr_defs (List[AttrDefType]): Attribute definitions to serialize. Returns: List[Dict[str, Any]]: Serialized data. - """ + """ return [ serialize_attr_def(attr_def) for attr_def in attr_defs ] -def deserialize_attr_def(attr_def_data): +def deserialize_attr_def(attr_def_data: Dict[str, Any]) -> AttrDefType: """Deserialize attribute definition from data. Args: - attr_def (Dict[str, Any]): Attribute definition data to deserialize. - """ + attr_def_data (Dict[str, Any]): Attribute definition data to + deserialize. + """ attr_type = attr_def_data.pop("type") cls = _attr_defs_by_type[attr_type] return cls.deserialize(attr_def_data) -def deserialize_attr_defs(attr_defs_data): +def deserialize_attr_defs( + attr_defs_data: List[Dict[str, Any]] +) -> List[AttrDefType]: """Deserialize attribute definitions. Args: List[Dict[str, Any]]: List of attribute definitions. - """ + """ return [ deserialize_attr_def(attr_def_data) for attr_def_data in attr_defs_data diff --git a/client/ayon_core/lib/ayon_info.py b/client/ayon_core/lib/ayon_info.py index c4333fab95..7e194a824e 100644 --- a/client/ayon_core/lib/ayon_info.py +++ b/client/ayon_core/lib/ayon_info.py @@ -78,6 +78,10 @@ def is_using_ayon_console(): return "ayon_console" in executable_filename +def is_headless_mode_enabled(): + return os.getenv("AYON_HEADLESS_MODE") == "1" + + def is_staging_enabled(): return os.getenv("AYON_USE_STAGING") == "1" diff --git a/client/ayon_core/lib/events.py b/client/ayon_core/lib/events.py index 774790b80a..1965906dda 100644 --- a/client/ayon_core/lib/events.py +++ b/client/ayon_core/lib/events.py @@ -8,7 +8,6 @@ import logging import weakref from uuid import uuid4 -from .python_2_comp import WeakMethod from .python_module_tools import is_func_signature_supported @@ -18,7 +17,7 @@ class MissingEventSystem(Exception): def _get_func_ref(func): if inspect.ismethod(func): - return WeakMethod(func) + return weakref.WeakMethod(func) return weakref.ref(func) @@ -123,7 +122,7 @@ class weakref_partial: ) -class EventCallback(object): +class EventCallback: """Callback registered to a topic. The callback function is registered to a topic. Topic is a string which @@ -380,8 +379,7 @@ class EventCallback(object): self._partial_func = None -# Inherit from 'object' for Python 2 hosts -class Event(object): +class Event: """Base event object. Can be used for any event because is not specific. Only required argument @@ -488,7 +486,7 @@ class Event(object): return obj -class EventSystem(object): +class EventSystem: """Encapsulate event handling into an object. System wraps registered callbacks and triggered events into single object, @@ -568,6 +566,10 @@ class EventSystem(object): 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. diff --git a/client/ayon_core/lib/execute.py b/client/ayon_core/lib/execute.py index e89c8f22ee..95696fd272 100644 --- a/client/ayon_core/lib/execute.py +++ b/client/ayon_core/lib/execute.py @@ -108,6 +108,20 @@ def run_subprocess(*args, **kwargs): | getattr(subprocess, "CREATE_NO_WINDOW", 0) ) + # Escape parentheses for bash + if ( + kwargs.get("shell") is True + and len(args) == 1 + and isinstance(args[0], str) + and os.getenv("SHELL") in ("/bin/bash", "/bin/sh") + ): + new_arg = ( + args[0] + .replace("(", "\\(") + .replace(")", "\\)") + ) + args = (new_arg, ) + # Get environents from kwarg or use current process environments if were # not passed. env = kwargs.get("env") or os.environ @@ -179,7 +193,7 @@ def clean_envs_for_ayon_process(env=None): return env -def run_ayon_launcher_process(*args, **kwargs): +def run_ayon_launcher_process(*args, add_sys_paths=False, **kwargs): """Execute AYON process with passed arguments and wait. Wrapper for 'run_process' which prepends AYON executable arguments @@ -209,29 +223,18 @@ def run_ayon_launcher_process(*args, **kwargs): # - fill more if you find more env = clean_envs_for_ayon_process(os.environ) + if add_sys_paths: + new_pythonpath = list(sys.path) + lookup_set = set(new_pythonpath) + for path in (env.get("PYTHONPATH") or "").split(os.pathsep): + if path and path not in lookup_set: + new_pythonpath.append(path) + lookup_set.add(path) + env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + return run_subprocess(args, env=env, **kwargs) -def run_openpype_process(*args, **kwargs): - """Execute AYON process with passed arguments and wait. - - Wrapper for 'run_process' which prepends AYON executable arguments - before passed arguments and define environments if are not passed. - - Values from 'os.environ' are used for environments if are not passed. - They are cleaned using 'clean_envs_for_ayon_process' function. - - Example: - >>> run_openpype_process("version") - - Args: - *args (tuple): AYON cli arguments. - **kwargs (dict): Keyword arguments for subprocess.Popen. - - """ - return run_ayon_launcher_process(*args, **kwargs) - - def run_detached_process(args, **kwargs): """Execute process with passed arguments as separated process. @@ -318,14 +321,12 @@ def path_to_subprocess_arg(path): def get_ayon_launcher_args(*args): - """Arguments to run ayon-launcher process. + """Arguments to run AYON launcher process. - Arguments for subprocess when need to spawn new pype process. Which may be - needed when new python process for pype scripts must be executed in build - pype. + Arguments for subprocess when need to spawn new AYON launcher process. Reasons: - Ayon-launcher started from code has different executable set to + AYON launcher started from code has different executable set to virtual env python and must have path to script as first argument which is not needed for built application. @@ -333,7 +334,8 @@ def get_ayon_launcher_args(*args): *args (str): Any arguments that will be added after executables. Returns: - list[str]: List of arguments to run ayon-launcher process. + list[str]: List of arguments to run AYON launcher process. + """ executable = os.environ["AYON_EXECUTABLE"] launch_args = [executable] @@ -391,21 +393,3 @@ def get_linux_launcher_args(*args): launch_args.extend(args) return launch_args - - -def get_openpype_execute_args(*args): - """Arguments to run pype command. - - Arguments for subprocess when need to spawn new pype process. Which may be - needed when new python process for pype scripts must be executed in build - pype. - - ## Why is this needed? - Pype executed from code has different executable set to virtual env python - and must have path to script as first argument which is not needed for - build pype. - - It is possible to pass any arguments that will be added after pype - executables. - """ - return get_ayon_launcher_args(*args) diff --git a/client/ayon_core/lib/file_transaction.py b/client/ayon_core/lib/file_transaction.py index 47b10dd994..a502403958 100644 --- a/client/ayon_core/lib/file_transaction.py +++ b/client/ayon_core/lib/file_transaction.py @@ -22,7 +22,7 @@ class DuplicateDestinationError(ValueError): """ -class FileTransaction(object): +class FileTransaction: """File transaction with rollback options. The file transaction is a three-step process. diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index 54432265d9..690781151c 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -3,26 +3,11 @@ import os import json import platform +import configparser +import warnings from datetime import datetime from abc import ABC, abstractmethod - -# disable lru cache in Python 2 -try: - from functools import lru_cache -except ImportError: - def lru_cache(maxsize): - def max_size(func): - def wrapper(*args, **kwargs): - value = func(*args, **kwargs) - return value - return wrapper - return max_size - -# ConfigParser was renamed in python3 to configparser -try: - import configparser -except ImportError: - import ConfigParser as configparser +from functools import lru_cache import appdirs import ayon_api @@ -30,6 +15,87 @@ import ayon_api _PLACEHOLDER = object() +def _get_ayon_appdirs(*args): + return os.path.join( + appdirs.user_data_dir("AYON", "Ynput"), + *args + ) + + +def get_ayon_appdirs(*args): + """Local app data directory of AYON client. + + Deprecated: + Use 'get_launcher_local_dir' or 'get_launcher_storage_dir' based on + use-case. Deprecation added 24/08/09 (0.4.4-dev.1). + + Args: + *args (Iterable[str]): Subdirectories/files in local app data dir. + + Returns: + str: Path to directory/file in local app data dir. + + """ + warnings.warn( + ( + "Function 'get_ayon_appdirs' is deprecated. Should be replaced" + " with 'get_launcher_local_dir' or 'get_launcher_storage_dir'" + " based on use-case." + ), + DeprecationWarning + ) + return _get_ayon_appdirs(*args) + + +def get_launcher_storage_dir(*subdirs: str) -> str: + """Get storage directory for launcher. + + Storage directory is used for storing shims, addons, dependencies, etc. + + It is not recommended, but the location can be shared across + multiple machines. + + Note: + This function should be called at least once on bootstrap. + + Args: + *subdirs (str): Subdirectories relative to storage dir. + + Returns: + str: Path to storage directory. + + """ + storage_dir = os.getenv("AYON_LAUNCHER_STORAGE_DIR") + if not storage_dir: + storage_dir = _get_ayon_appdirs() + + return os.path.join(storage_dir, *subdirs) + + +def get_launcher_local_dir(*subdirs: str) -> str: + """Get local directory for launcher. + + Local directory is used for storing machine or user specific data. + + The location is user specific. + + Note: + This function should be called at least once on bootstrap. + + Args: + *subdirs (str): Subdirectories relative to local dir. + + Returns: + str: Path to local directory. + + """ + storage_dir = os.getenv("AYON_LAUNCHER_LOCAL_DIR") + if not storage_dir: + storage_dir = _get_ayon_appdirs() + + return os.path.join(storage_dir, *subdirs) + + class AYONSecureRegistry: """Store information using keyring. @@ -470,55 +536,17 @@ class JSONSettingRegistry(ASettingRegistry): class AYONSettingsRegistry(JSONSettingRegistry): """Class handling AYON general settings registry. - Attributes: - vendor (str): Name used for path construction. - product (str): Additional name used for path construction. - Args: name (Optional[str]): Name of the registry. """ def __init__(self, name=None): - self.vendor = "Ynput" - self.product = "AYON" if not name: name = "AYON_settings" - path = appdirs.user_data_dir(self.product, self.vendor) + path = get_launcher_storage_dir() super(AYONSettingsRegistry, self).__init__(name, path) -def _create_local_site_id(registry=None): - """Create a local site identifier.""" - from coolname import generate_slug - - if registry is None: - registry = AYONSettingsRegistry() - - new_id = generate_slug(3) - - print("Created local site id \"{}\"".format(new_id)) - - registry.set_item("localId", new_id) - - return new_id - - -def get_ayon_appdirs(*args): - """Local app data directory of AYON client. - - Args: - *args (Iterable[str]): Subdirectories/files in local app data dir. - - Returns: - str: Path to directory/file in local app data dir. - """ - - return os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - *args - ) - - def get_local_site_id(): """Get local site identifier. @@ -529,7 +557,7 @@ def get_local_site_id(): if site_id: return site_id - site_id_path = get_ayon_appdirs("site_id") + site_id_path = get_launcher_local_dir("site_id") if os.path.exists(site_id_path): with open(site_id_path, "r") as stream: site_id = stream.read() @@ -556,11 +584,3 @@ def get_ayon_username(): """ return ayon_api.get_user()["name"] - - -def get_openpype_username(): - return get_ayon_username() - - -OpenPypeSecureRegistry = AYONSecureRegistry -OpenPypeSettingsRegistry = AYONSettingsRegistry diff --git a/client/ayon_core/lib/log.py b/client/ayon_core/lib/log.py index 36c39f9d84..0c2fe5e2d4 100644 --- a/client/ayon_core/lib/log.py +++ b/client/ayon_core/lib/log.py @@ -1,6 +1,5 @@ import os import sys -import uuid import getpass import logging import platform @@ -11,12 +10,12 @@ import copy from . import Terminal -# Check for `unicode` in builtins -USE_UNICODE = hasattr(__builtins__, "unicode") - class LogStreamHandler(logging.StreamHandler): - """ StreamHandler class designed to handle utf errors in python 2.x hosts. + """StreamHandler class. + + This was originally designed to handle UTF errors in python 2.x hosts, + however currently solely remains for backwards compatibility. """ @@ -25,49 +24,27 @@ class LogStreamHandler(logging.StreamHandler): self.enabled = True def enable(self): - """ Enable StreamHandler + """Enable StreamHandler - Used to silence output + Make StreamHandler output again """ self.enabled = True def disable(self): - """ Disable StreamHandler + """Disable StreamHandler - Make StreamHandler output again + Used to silence output """ self.enabled = False def emit(self, record): - if not self.enable: + if not self.enabled or self.stream is None: return try: msg = self.format(record) msg = Terminal.log(msg) stream = self.stream - if stream is None: - return - fs = "%s\n" - # if no unicode support... - if not USE_UNICODE: - stream.write(fs % msg) - else: - try: - if (isinstance(msg, unicode) and # noqa: F821 - getattr(stream, 'encoding', None)): - ufs = u'%s\n' - try: - stream.write(ufs % msg) - except UnicodeEncodeError: - stream.write((ufs % msg).encode(stream.encoding)) - else: - if (getattr(stream, 'encoding', 'utf-8')): - ufs = u'%s\n' - stream.write(ufs % unicode(msg)) # noqa: F821 - else: - stream.write(fs % msg) - except UnicodeError: - stream.write(fs % msg.encode("UTF-8")) + stream.write(f"{msg}\n") self.flush() except (KeyboardInterrupt, SystemExit): raise @@ -141,8 +118,6 @@ class Logger: process_data = None # Cached process name or ability to set different process name _process_name = None - # TODO Remove 'mongo_process_id' in 1.x.x - mongo_process_id = uuid.uuid4().hex @classmethod def get_logger(cls, name=None): diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 01a6985a25..dc88ec956b 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -38,7 +38,7 @@ class TemplateUnsolved(Exception): ) -class StringTemplate(object): +class StringTemplate: """String that can be formatted.""" def __init__(self, template): if not isinstance(template, str): @@ -410,7 +410,7 @@ class TemplatePartResult: self._invalid_types[key] = type(value) -class FormatObject(object): +class FormatObject: """Object that can be used for formatting. This is base that is valid for to be used in 'StringTemplate' value. @@ -460,6 +460,34 @@ class FormattingPart: return True return False + @staticmethod + def validate_key_is_matched(key): + """Validate that opening has closing at correct place. + Future-proof, only square brackets are currently used in keys. + + Example: + >>> is_matched("[]()()(((([])))") + False + >>> is_matched("[](){{{[]}}}") + True + + Returns: + bool: Openings and closing are valid. + + """ + mapping = dict(zip("({[", ")}]")) + opening = set(mapping.keys()) + closing = set(mapping.values()) + queue = [] + + for letter in key: + if letter in opening: + queue.append(mapping[letter]) + elif letter in closing: + if not queue or letter != queue.pop(): + return False + return not queue + def format(self, data, result): """Format the formattings string. @@ -472,6 +500,12 @@ class FormattingPart: result.add_output(result.realy_used_values[key]) return result + # ensure key is properly formed [({})] properly closed. + if not self.validate_key_is_matched(key): + result.add_missing_key(key) + result.add_output(self.template) + return result + # check if key expects subdictionary keys (e.g. project[name]) existence_check = key key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) diff --git a/client/ayon_core/lib/path_tools.py b/client/ayon_core/lib/path_tools.py index a65f0f8e13..31baac168c 100644 --- a/client/ayon_core/lib/path_tools.py +++ b/client/ayon_core/lib/path_tools.py @@ -1,7 +1,6 @@ import os import re import logging -import platform import clique @@ -38,31 +37,7 @@ def create_hard_link(src_path, dst_path): dst_path(str): Full path to a file where a link of source will be added. """ - # Use `os.link` if is available - # - should be for all platforms with newer python versions - if hasattr(os, "link"): - os.link(src_path, dst_path) - return - - # Windows implementation of hardlinks - # - used in Python 2 - if platform.system().lower() == "windows": - import ctypes - from ctypes.wintypes import BOOL - CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW - CreateHardLink.argtypes = [ - ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p - ] - CreateHardLink.restype = BOOL - - res = CreateHardLink(dst_path, src_path, None) - if res == 0: - raise ctypes.WinError() - return - # Raises not implemented error if gets here - raise NotImplementedError( - "Implementation of hardlink for current environment is missing." - ) + os.link(src_path, dst_path) def collect_frames(files): @@ -81,7 +56,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) @@ -207,7 +185,7 @@ def get_last_version_from_path(path_dir, filter): assert isinstance(filter, list) and ( len(filter) != 0), "`filter` argument needs to be list and not empty" - filtred_files = list() + filtered_files = list() # form regex for filtering pattern = r".*".join(filter) @@ -215,10 +193,10 @@ def get_last_version_from_path(path_dir, filter): for file in os.listdir(path_dir): if not re.findall(pattern, file): continue - filtred_files.append(file) + filtered_files.append(file) - if filtred_files: - sorted(filtred_files) - return filtred_files[-1] + if filtered_files: + filtered_files.sort() + return filtered_files[-1] return None diff --git a/client/ayon_core/lib/python_2_comp.py b/client/ayon_core/lib/python_2_comp.py index 091c51a6f6..900db59062 100644 --- a/client/ayon_core/lib/python_2_comp.py +++ b/client/ayon_core/lib/python_2_comp.py @@ -1,44 +1,17 @@ +# Deprecated file +# - the file container 'WeakMethod' implementation for Python 2 which is not +# needed anymore. +import warnings import weakref -WeakMethod = getattr(weakref, "WeakMethod", None) +WeakMethod = weakref.WeakMethod -if WeakMethod is None: - class _WeakCallable: - def __init__(self, obj, func): - self.im_self = obj - self.im_func = func - - def __call__(self, *args, **kws): - if self.im_self is None: - return self.im_func(*args, **kws) - else: - return self.im_func(self.im_self, *args, **kws) - - - class WeakMethod: - """ Wraps a function or, more importantly, a bound method in - a way that allows a bound method's object to be GCed, while - providing the same interface as a normal weak reference. """ - - def __init__(self, fn): - try: - self._obj = weakref.ref(fn.im_self) - self._meth = fn.im_func - except AttributeError: - # It's not a bound method - self._obj = None - self._meth = fn - - def __call__(self): - if self._dead(): - return None - return _WeakCallable(self._getobj(), self._meth) - - def _dead(self): - return self._obj is not None and self._obj() is None - - def _getobj(self): - if self._obj is None: - return None - return self._obj() +warnings.warn( + ( + "'ayon_core.lib.python_2_comp' is deprecated." + "Please use 'weakref.WeakMethod'." + ), + DeprecationWarning, + stacklevel=2 +) diff --git a/client/ayon_core/lib/python_module_tools.py b/client/ayon_core/lib/python_module_tools.py index cb6e4c14c4..d146e069a9 100644 --- a/client/ayon_core/lib/python_module_tools.py +++ b/client/ayon_core/lib/python_module_tools.py @@ -5,43 +5,30 @@ import importlib import inspect import logging -import six - log = logging.getLogger(__name__) def import_filepath(filepath, module_name=None): """Import python file as python module. - Python 2 and Python 3 compatibility. - Args: - filepath(str): Path to python file. - module_name(str): Name of loaded module. Only for Python 3. By default + filepath (str): Path to python file. + module_name (str): Name of loaded module. Only for Python 3. By default is filled with filename of filepath. + """ if module_name is None: module_name = os.path.splitext(os.path.basename(filepath))[0] - # Make sure it is not 'unicode' in Python 2 - module_name = str(module_name) - # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) module.__file__ = filepath - if six.PY3: - # Use loader so module has full specs - module_loader = importlib.machinery.SourceFileLoader( - module_name, filepath - ) - module_loader.exec_module(module) - else: - # Execute module code and store content to module - with open(filepath) as _stream: - # Execute content and store it to module object - six.exec_(_stream.read(), module.__dict__) - + # Use loader so module has full specs + module_loader = importlib.machinery.SourceFileLoader( + module_name, filepath + ) + module_loader.exec_module(module) return module @@ -139,35 +126,31 @@ def classes_from_module(superclass, module): return classes -def _import_module_from_dirpath_py2(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using `imp`.""" +def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): + """Import passed directory as a python module. + + Imported module can be assigned as a child attribute of already loaded + module from `sys.modules` if has support of `setattr`. That is not default + behavior of python modules so parent module must be a custom module with + that ability. + + It is not possible to reimport already cached module. If you need to + reimport module you have to remove it from caches manually. + + Args: + dirpath (str): Parent directory path of loaded folder. + folder_name (str): Folder name which should be imported inside passed + directory. + dst_module_name (str): Parent module name under which can be loaded + module added. + + """ + # Import passed dirpath as python module if dst_module_name: - full_module_name = "{}.{}".format(dst_module_name, module_name) + full_module_name = "{}.{}".format(dst_module_name, folder_name) dst_module = sys.modules[dst_module_name] else: - full_module_name = module_name - dst_module = None - - if full_module_name in sys.modules: - return sys.modules[full_module_name] - - import imp - - fp, pathname, description = imp.find_module(module_name, [dirpath]) - module = imp.load_module(full_module_name, fp, pathname, description) - if dst_module is not None: - setattr(dst_module, module_name, module) - - return module - - -def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): - """Import passed dirpath as python module using Python 3 modules.""" - if dst_module_name: - full_module_name = "{}.{}".format(dst_module_name, module_name) - dst_module = sys.modules[dst_module_name] - else: - full_module_name = module_name + full_module_name = folder_name dst_module = None # Skip import if is already imported @@ -191,7 +174,7 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): # Store module to destination module and `sys.modules` # WARNING this mus be done before module execution if dst_module is not None: - setattr(dst_module, module_name, module) + setattr(dst_module, folder_name, module) sys.modules[full_module_name] = module @@ -201,37 +184,6 @@ def _import_module_from_dirpath_py3(dirpath, module_name, dst_module_name): return module -def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): - """Import passed directory as a python module. - - Python 2 and 3 compatible. - - Imported module can be assigned as a child attribute of already loaded - module from `sys.modules` if has support of `setattr`. That is not default - behavior of python modules so parent module must be a custom module with - that ability. - - It is not possible to reimport already cached module. If you need to - reimport module you have to remove it from caches manually. - - Args: - dirpath(str): Parent directory path of loaded folder. - folder_name(str): Folder name which should be imported inside passed - directory. - dst_module_name(str): Parent module name under which can be loaded - module added. - """ - if six.PY3: - module = _import_module_from_dirpath_py3( - dirpath, folder_name, dst_module_name - ) - else: - module = _import_module_from_dirpath_py2( - dirpath, folder_name, dst_module_name - ) - return module - - def is_func_signature_supported(func, *args, **kwargs): """Check if a function signature supports passed args and kwargs. @@ -275,25 +227,12 @@ def is_func_signature_supported(func, *args, **kwargs): Returns: bool: Function can pass in arguments. + """ - - if hasattr(inspect, "signature"): - # Python 3 using 'Signature' object where we try to bind arg - # or kwarg. Using signature is recommended approach based on - # documentation. - sig = inspect.signature(func) - try: - sig.bind(*args, **kwargs) - return True - except TypeError: - pass - - else: - # In Python 2 'signature' is not available so 'getcallargs' is used - # - 'getcallargs' is marked as deprecated since Python 3.0 - try: - inspect.getcallargs(func, *args, **kwargs) - return True - except TypeError: - pass + sig = inspect.signature(func) + try: + sig.bind(*args, **kwargs) + return True + except TypeError: + pass return False 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]) diff --git a/client/ayon_core/modules/__init__.py b/client/ayon_core/modules/__init__.py index f4e381f4a0..e69de29bb2 100644 --- a/client/ayon_core/modules/__init__.py +++ b/client/ayon_core/modules/__init__.py @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -from . import click_wrap -from .interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayModule, - ITrayAction, - ITrayService, - IHostAddon, -) - -from .base import ( - AYONAddon, - OpenPypeModule, - OpenPypeAddOn, - - load_modules, - - ModulesManager, -) - - -__all__ = ( - "click_wrap", - - "IPluginPaths", - "ITrayAddon", - "ITrayModule", - "ITrayAction", - "ITrayService", - "IHostAddon", - - "AYONAddon", - "OpenPypeModule", - "OpenPypeAddOn", - - "load_modules", - - "ModulesManager", -) diff --git a/client/ayon_core/modules/base.py b/client/ayon_core/modules/base.py deleted file mode 100644 index df412d141e..0000000000 --- a/client/ayon_core/modules/base.py +++ /dev/null @@ -1,25 +0,0 @@ -# Backwards compatibility support -# - TODO should be removed before release 1.0.0 -from ayon_core.addon import ( - AYONAddon, - AddonsManager, - load_addons, -) -from ayon_core.addon.base import ( - OpenPypeModule, - OpenPypeAddOn, -) - -ModulesManager = AddonsManager -load_modules = load_addons - - -__all__ = ( - "AYONAddon", - "AddonsManager", - "load_addons", - "OpenPypeModule", - "OpenPypeAddOn", - "ModulesManager", - "load_modules", -) diff --git a/client/ayon_core/modules/click_wrap.py b/client/ayon_core/modules/click_wrap.py deleted file mode 100644 index 8f68de187a..0000000000 --- a/client/ayon_core/modules/click_wrap.py +++ /dev/null @@ -1 +0,0 @@ -from ayon_core.addon.click_wrap import * diff --git a/client/ayon_core/modules/interfaces.py b/client/ayon_core/modules/interfaces.py deleted file mode 100644 index 4b114b7a0e..0000000000 --- a/client/ayon_core/modules/interfaces.py +++ /dev/null @@ -1,21 +0,0 @@ -from ayon_core.addon.interfaces import ( - IPluginPaths, - ITrayAddon, - ITrayAction, - ITrayService, - IHostAddon, -) - -ITrayModule = ITrayAddon -ILaunchHookPaths = object - - -__all__ = ( - "IPluginPaths", - "ITrayAddon", - "ITrayAction", - "ITrayService", - "IHostAddon", - "ITrayModule", - "ILaunchHookPaths", -) diff --git a/client/ayon_core/pipeline/__init__.py b/client/ayon_core/pipeline/__init__.py index 8fd00ee6b6..8e89029e7b 100644 --- a/client/ayon_core/pipeline/__init__.py +++ b/client/ayon_core/pipeline/__init__.py @@ -3,7 +3,6 @@ from .constants import ( AVALON_INSTANCE_ID, AYON_CONTAINER_ID, AYON_INSTANCE_ID, - HOST_WORKFILE_EXTENSIONS, ) from .anatomy import Anatomy @@ -51,11 +50,11 @@ from .load import ( ) from .publish import ( + KnownPublishError, + PublishError, PublishValidationError, PublishXmlValidationError, - KnownPublishError, AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, ) @@ -77,7 +76,6 @@ from .actions import ( from .context_tools import ( install_ayon_plugins, - install_openpype_plugins, install_host, uninstall_host, is_installed, @@ -115,7 +113,6 @@ __all__ = ( "AVALON_INSTANCE_ID", "AYON_CONTAINER_ID", "AYON_INSTANCE_ID", - "HOST_WORKFILE_EXTENSIONS", # --- Anatomy --- "Anatomy", @@ -164,11 +161,11 @@ __all__ = ( "get_repres_contexts", # --- Publish --- + "KnownPublishError", + "PublishError", "PublishValidationError", "PublishXmlValidationError", - "KnownPublishError", "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", # --- Actions --- @@ -187,7 +184,6 @@ __all__ = ( # --- Process context --- "install_ayon_plugins", - "install_openpype_plugins", "install_host", "uninstall_host", "is_installed", diff --git a/client/ayon_core/pipeline/colorspace.py b/client/ayon_core/pipeline/colorspace.py index 099616ff4a..8c4f97ab1c 100644 --- a/client/ayon_core/pipeline/colorspace.py +++ b/client/ayon_core/pipeline/colorspace.py @@ -699,6 +699,34 @@ def get_ocio_config_views(config_path): ) +def _get_config_path_from_profile_data( + profile, profile_type, template_data +): + """Get config path from profile data. + + Args: + 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 = 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 + } + + def _get_global_config_data( project_name, host_name, @@ -717,7 +745,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,30 +783,36 @@ 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" + 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 + folder_info = template_data.get("folder") if not folder_info: log.warning("Folder info is missing.") - 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["fallback_type"] + return _get_config_path_from_profile_data( + fallback_data, fallback_type, template_data + ) + folder_path = folder_info["path"] - product_name = profile["product_name"] if folder_id is None: folder_entity = ayon_api.get_folder_by_path( project_name, folder_path, fields={"id"} @@ -797,12 +831,13 @@ def _get_global_config_data( fields={"id", "name"} ) } + if not product_entities_by_name: - 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) @@ -837,6 +872,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, 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 @@ AYON_INSTANCE_ID = "ayon.create.instance" # 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/pipeline/context_tools.py b/client/ayon_core/pipeline/context_tools.py index 8b72405048..44c9e5d673 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 @@ -234,16 +237,6 @@ def install_ayon_plugins(project_name=None, host_name=None): register_inventory_action_path(path) -def install_openpype_plugins(project_name=None, host_name=None): - """Install AYON core plugins and make sure the core is initialized. - - Deprecated: - Use `install_ayon_plugins` instead. - - """ - install_ayon_plugins(project_name, host_name) - - def uninstall_host(): """Undo all of what `install()` did""" host = registered_host() diff --git a/client/ayon_core/pipeline/create/__init__.py b/client/ayon_core/pipeline/create/__init__.py index da9cafad5a..ced43528eb 100644 --- a/client/ayon_core/pipeline/create/__init__.py +++ b/client/ayon_core/pipeline/create/__init__.py @@ -4,21 +4,41 @@ from .constants import ( PRE_CREATE_THUMBNAIL_KEY, DEFAULT_VARIANT_VALUE, ) - +from .exceptions import ( + UnavailableSharedData, + ImmutableKeyError, + HostMissRequiredMethod, + ConvertorsOperationFailed, + ConvertorsFindFailed, + ConvertorsConversionFailed, + CreatorError, + CreatorsCreateFailed, + CreatorsCollectionFailed, + CreatorsSaveFailed, + CreatorsRemoveFailed, + CreatorsOperationFailed, + TaskNotSetError, + TemplateFillError, +) +from .structures import ( + CreatedInstance, + ConvertorItem, + AttributeValues, + CreatorAttributeValues, + PublishAttributeValues, + PublishAttributes, +) from .utils import ( get_last_versions_for_instances, get_next_versions_for_instances, ) from .product_name import ( - TaskNotSetError, get_product_name, get_product_name_template, ) from .creator_plugins import ( - CreatorError, - BaseCreator, Creator, AutoCreator, @@ -36,10 +56,7 @@ from .creator_plugins import ( cache_and_get_instances, ) -from .context import ( - CreatedInstance, - CreateContext -) +from .context import CreateContext from .legacy_create import ( LegacyCreator, @@ -53,10 +70,31 @@ __all__ = ( "PRE_CREATE_THUMBNAIL_KEY", "DEFAULT_VARIANT_VALUE", + "UnavailableSharedData", + "ImmutableKeyError", + "HostMissRequiredMethod", + "ConvertorsOperationFailed", + "ConvertorsFindFailed", + "ConvertorsConversionFailed", + "CreatorError", + "CreatorsCreateFailed", + "CreatorsCollectionFailed", + "CreatorsSaveFailed", + "CreatorsRemoveFailed", + "CreatorsOperationFailed", + "TaskNotSetError", + "TemplateFillError", + + "CreatedInstance", + "ConvertorItem", + "AttributeValues", + "CreatorAttributeValues", + "PublishAttributeValues", + "PublishAttributes", + "get_last_versions_for_instances", "get_next_versions_for_instances", - "TaskNotSetError", "get_product_name", "get_product_name_template", @@ -78,7 +116,6 @@ __all__ = ( "cache_and_get_instances", - "CreatedInstance", "CreateContext", "LegacyCreator", diff --git a/client/ayon_core/pipeline/create/changes.py b/client/ayon_core/pipeline/create/changes.py new file mode 100644 index 0000000000..c8b81cac48 --- /dev/null +++ b/client/ayon_core/pipeline/create/changes.py @@ -0,0 +1,313 @@ +import copy + +_EMPTY_VALUE = object() + + +class TrackChangesItem: + """Helper object to track changes in data. + + Has access to full old and new data and will create deep copy of them, + so it is not needed to create copy before passed in. + + Can work as a dictionary if old or new value is a dictionary. In + that case received object is another object of 'TrackChangesItem'. + + Goal is to be able to get old or new value as was or only changed values + or get information about removed/changed keys, and all of that on + any "dictionary level". + + ``` + # Example of possible usages + >>> old_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_1": 1, + ... "key_sub_2": { + ... "enabled": True + ... } + ... }, + ... "key_3": "value_2" + ... } + >>> new_value = { + ... "key_1": "value_1", + ... "key_2": { + ... "key_sub_2": { + ... "enabled": False + ... }, + ... "key_sub_3": 3 + ... }, + ... "key_3": "value_3" + ... } + + >>> changes = TrackChangesItem(old_value, new_value) + >>> changes.changed + True + + >>> changes["key_2"]["key_sub_1"].new_value is None + True + + >>> list(sorted(changes.changed_keys)) + ['key_2', 'key_3'] + + >>> changes["key_2"]["key_sub_2"]["enabled"].changed + True + + >>> changes["key_2"].removed_keys + {'key_sub_1'} + + >>> list(sorted(changes["key_2"].available_keys)) + ['key_sub_1', 'key_sub_2', 'key_sub_3'] + + >>> changes.new_value == new_value + True + + # Get only changed values + only_changed_new_values = { + key: changes[key].new_value + for key in changes.changed_keys + } + ``` + + Args: + old_value (Any): Old value. + new_value (Any): New value. + """ + + def __init__(self, old_value, new_value): + self._changed = old_value != new_value + # Resolve if value is '_EMPTY_VALUE' after comparison of the values + if old_value is _EMPTY_VALUE: + old_value = None + if new_value is _EMPTY_VALUE: + new_value = None + self._old_value = copy.deepcopy(old_value) + self._new_value = copy.deepcopy(new_value) + + self._old_is_dict = isinstance(old_value, dict) + self._new_is_dict = isinstance(new_value, dict) + + self._old_keys = None + self._new_keys = None + self._available_keys = None + self._removed_keys = None + + self._changed_keys = None + + self._sub_items = None + + def __getitem__(self, key): + """Getter looks into subitems if object is dictionary.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items[key] + + def __bool__(self): + """Boolean of object is if old and new value are the same.""" + + return self._changed + + def get(self, key, default=None): + """Try to get sub item.""" + + if self._sub_items is None: + self._prepare_sub_items() + return self._sub_items.get(key, default) + + @property + def old_value(self): + """Get copy of old value. + + Returns: + Any: Whatever old value was. + """ + + return copy.deepcopy(self._old_value) + + @property + def new_value(self): + """Get copy of new value. + + Returns: + Any: Whatever new value was. + """ + + return copy.deepcopy(self._new_value) + + @property + def changed(self): + """Value changed. + + Returns: + bool: If data changed. + """ + + return self._changed + + @property + def is_dict(self): + """Object can be used as dictionary. + + Returns: + bool: When can be used that way. + """ + + return self._old_is_dict or self._new_is_dict + + @property + def changes(self): + """Get changes in raw data. + + This method should be used only if 'is_dict' value is 'True'. + + Returns: + Dict[str, Tuple[Any, Any]]: Changes are by key in tuple + (, ). If 'is_dict' is 'False' then + output is always empty dictionary. + """ + + output = {} + if not self.is_dict: + return output + + old_value = self.old_value + new_value = self.new_value + for key in self.changed_keys: + _old = None + _new = None + if self._old_is_dict: + _old = old_value.get(key) + if self._new_is_dict: + _new = new_value.get(key) + output[key] = (_old, _new) + return output + + # Methods/properties that can be used when 'is_dict' is 'True' + @property + def old_keys(self): + """Keys from old value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from old value. + """ + + if self._old_keys is None: + self._prepare_keys() + return set(self._old_keys) + + @property + def new_keys(self): + """Keys from new value. + + Empty set is returned if old value is not a dict. + + Returns: + Set[str]: Keys from new value. + """ + + if self._new_keys is None: + self._prepare_keys() + return set(self._new_keys) + + @property + def changed_keys(self): + """Keys that has changed from old to new value. + + Empty set is returned if both old and new value are not a dict. + + Returns: + Set[str]: Keys of changed keys. + """ + + if self._changed_keys is None: + self._prepare_sub_items() + return set(self._changed_keys) + + @property + def available_keys(self): + """All keys that are available in old and new value. + + Empty set is returned if both old and new value are not a dict. + Output is Union of 'old_keys' and 'new_keys'. + + Returns: + Set[str]: All keys from old and new value. + """ + + if self._available_keys is None: + self._prepare_keys() + return set(self._available_keys) + + @property + def removed_keys(self): + """Key that are not available in new value but were in old value. + + Returns: + Set[str]: All removed keys. + """ + + if self._removed_keys is None: + self._prepare_sub_items() + return set(self._removed_keys) + + def _prepare_keys(self): + old_keys = set() + new_keys = set() + if self._old_is_dict and self._new_is_dict: + old_keys = set(self._old_value.keys()) + new_keys = set(self._new_value.keys()) + + elif self._old_is_dict: + old_keys = set(self._old_value.keys()) + + elif self._new_is_dict: + new_keys = set(self._new_value.keys()) + + self._old_keys = old_keys + self._new_keys = new_keys + self._available_keys = old_keys | new_keys + self._removed_keys = old_keys - new_keys + + def _prepare_sub_items(self): + sub_items = {} + changed_keys = set() + + old_keys = self.old_keys + new_keys = self.new_keys + new_value = self.new_value + old_value = self.old_value + if self._old_is_dict and self._new_is_dict: + for key in self.available_keys: + item = TrackChangesItem( + old_value.get(key), new_value.get(key) + ) + sub_items[key] = item + if item.changed or key not in old_keys or key not in new_keys: + changed_keys.add(key) + + elif self._old_is_dict: + old_keys = set(old_value.keys()) + available_keys = set(old_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because old value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + old_value.get(key), _EMPTY_VALUE + ) + + elif self._new_is_dict: + new_keys = set(new_value.keys()) + available_keys = set(new_keys) + changed_keys = set(available_keys) + for key in available_keys: + # NOTE Use '_EMPTY_VALUE' because new value could be 'None' + # which would result in "unchanged" item + sub_items[key] = TrackChangesItem( + _EMPTY_VALUE, new_value.get(key) + ) + + self._sub_items = sub_items + self._changed_keys = changed_keys diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 1c64d22733..6bfd64b822 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -5,9 +5,19 @@ import logging import traceback import collections import inspect -from uuid import uuid4 from contextlib import contextmanager -from typing import Optional +import typing +from typing import ( + Optional, + Iterable, + Tuple, + List, + Set, + Dict, + Any, + Callable, + Union, +) import pyblish.logic import pyblish.api @@ -15,93 +25,57 @@ import ayon_api from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported -from ayon_core.lib.attribute_definitions import ( - UnknownDef, - serialize_attr_defs, - deserialize_attr_defs, - get_default_values, -) +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, - AYON_INSTANCE_ID, - AVALON_INSTANCE_ID, -) +from ayon_core.pipeline import Anatomy from ayon_core.pipeline.plugin_discover import DiscoverResult +from .exceptions import ( + CreatorError, + CreatorsCreateFailed, + CreatorsCollectionFailed, + CreatorsSaveFailed, + CreatorsRemoveFailed, + ConvertorsFindFailed, + ConvertorsConversionFailed, + UnavailableSharedData, + HostMissRequiredMethod, +) +from .changes import TrackChangesItem +from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo from .creator_plugins import ( Creator, AutoCreator, discover_creator_plugins, discover_convertor_plugins, - CreatorError, +) +if typing.TYPE_CHECKING: + from .structures import CreatedInstance + +# Import of functions and classes that were moved to different file +# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1 +from .exceptions import ( + ImmutableKeyError, # noqa: F401 + CreatorsOperationFailed, # noqa: F401 + ConvertorsOperationFailed, # noqa: F401 +) +from .structures import ( + AttributeValues, # noqa: F401 + CreatorAttributeValues, # noqa: F401 + PublishAttributeValues, # noqa: F401 ) # Changes of instances and context are send as tuple of 2 information UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) _NOT_SET = object() - -class UnavailableSharedData(Exception): - """Shared data are not available at the moment when are accessed.""" - pass - - -class ImmutableKeyError(TypeError): - """Accessed key is immutable so does not allow changes or removals.""" - - def __init__(self, key, msg=None): - self.immutable_key = key - if not msg: - msg = "Key \"{}\" is immutable and does not allow changes.".format( - key - ) - super(ImmutableKeyError, self).__init__(msg) - - -class HostMissRequiredMethod(Exception): - """Host does not have implemented required functions for creation.""" - - def __init__(self, host, missing_methods): - self.missing_methods = missing_methods - self.host = host - joined_methods = ", ".join( - ['"{}"'.format(name) for name in missing_methods] - ) - dirpath = os.path.dirname( - os.path.normpath(inspect.getsourcefile(host)) - ) - dirpath_parts = dirpath.split(os.path.sep) - host_name = dirpath_parts.pop(-1) - if host_name == "api": - host_name = dirpath_parts.pop(-1) - - msg = "Host \"{}\" does not have implemented method/s {}".format( - host_name, joined_methods - ) - super(HostMissRequiredMethod, self).__init__(msg) - - -class ConvertorsOperationFailed(Exception): - def __init__(self, msg, failed_info): - super(ConvertorsOperationFailed, self).__init__(msg) - self.failed_info = failed_info - - -class ConvertorsFindFailed(ConvertorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to find incompatible products" - super(ConvertorsFindFailed, self).__init__( - msg, failed_info - ) - - -class ConvertorsConversionFailed(ConvertorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to convert incompatible products" - super(ConvertorsConversionFailed, self).__init__( - msg, failed_info - ) +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" def prepare_failed_convertor_operation_info(identifier, exc_info): @@ -117,59 +91,6 @@ def prepare_failed_convertor_operation_info(identifier, exc_info): } -class CreatorsOperationFailed(Exception): - """Raised when a creator process crashes in 'CreateContext'. - - The exception contains information about the creator and error. The data - are prepared using 'prepare_failed_creator_operation_info' and can be - serialized using json. - - Usage is for UI purposes which may not have access to exceptions directly - and would not have ability to catch exceptions 'per creator'. - - Args: - msg (str): General error message. - failed_info (list[dict[str, Any]]): List of failed creators with - exception message and optionally formatted traceback. - """ - - def __init__(self, msg, failed_info): - super(CreatorsOperationFailed, self).__init__(msg) - self.failed_info = failed_info - - -class CreatorsCollectionFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to collect instances" - super(CreatorsCollectionFailed, self).__init__( - msg, failed_info - ) - - -class CreatorsSaveFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed update instance changes" - super(CreatorsSaveFailed, self).__init__( - msg, failed_info - ) - - -class CreatorsRemoveFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to remove instances" - super(CreatorsRemoveFailed, self).__init__( - msg, failed_info - ) - - -class CreatorsCreateFailed(CreatorsOperationFailed): - def __init__(self, failed_info): - msg = "Failed to create instances" - super(CreatorsCreateFailed, self).__init__( - msg, failed_info - ) - - def prepare_failed_creator_operation_info( identifier, label, exc_info, add_traceback=True ): @@ -188,1171 +109,40 @@ def prepare_failed_creator_operation_info( } -_EMPTY_VALUE = object() - - -class TrackChangesItem(object): - """Helper object to track changes in data. - - Has access to full old and new data and will create deep copy of them, - so it is not needed to create copy before passed in. - - Can work as a dictionary if old or new value is a dictionary. In - that case received object is another object of 'TrackChangesItem'. - - Goal is to be able to get old or new value as was or only changed values - or get information about removed/changed keys, and all of that on - any "dictionary level". - - ``` - # Example of possible usages - >>> old_value = { - ... "key_1": "value_1", - ... "key_2": { - ... "key_sub_1": 1, - ... "key_sub_2": { - ... "enabled": True - ... } - ... }, - ... "key_3": "value_2" - ... } - >>> new_value = { - ... "key_1": "value_1", - ... "key_2": { - ... "key_sub_2": { - ... "enabled": False - ... }, - ... "key_sub_3": 3 - ... }, - ... "key_3": "value_3" - ... } - - >>> changes = TrackChangesItem(old_value, new_value) - >>> changes.changed - True - - >>> changes["key_2"]["key_sub_1"].new_value is None - True - - >>> list(sorted(changes.changed_keys)) - ['key_2', 'key_3'] - - >>> changes["key_2"]["key_sub_2"]["enabled"].changed - True - - >>> changes["key_2"].removed_keys - {'key_sub_1'} - - >>> list(sorted(changes["key_2"].available_keys)) - ['key_sub_1', 'key_sub_2', 'key_sub_3'] - - >>> changes.new_value == new_value - True - - # Get only changed values - only_changed_new_values = { - key: changes[key].new_value - for key in changes.changed_keys - } - ``` - - Args: - old_value (Any): Old value. - new_value (Any): New value. - """ - - def __init__(self, old_value, new_value): - self._changed = old_value != new_value - # Resolve if value is '_EMPTY_VALUE' after comparison of the values - if old_value is _EMPTY_VALUE: - old_value = None - if new_value is _EMPTY_VALUE: - new_value = None - self._old_value = copy.deepcopy(old_value) - self._new_value = copy.deepcopy(new_value) - - self._old_is_dict = isinstance(old_value, dict) - self._new_is_dict = isinstance(new_value, dict) - - self._old_keys = None - self._new_keys = None - self._available_keys = None - self._removed_keys = None - - self._changed_keys = None - - self._sub_items = None - - def __getitem__(self, key): - """Getter looks into subitems if object is dictionary.""" - - if self._sub_items is None: - self._prepare_sub_items() - return self._sub_items[key] +class BulkInfo: + def __init__(self): + self._count = 0 + self._data = [] + self._sender = None def __bool__(self): - """Boolean of object is if old and new value are the same.""" + return self._count == 0 - return self._changed + def get_sender(self): + return self._sender - def get(self, key, default=None): - """Try to get sub item.""" + def set_sender(self, sender): + if sender is not None: + self._sender = sender - if self._sub_items is None: - self._prepare_sub_items() - return self._sub_items.get(key, default) + def increase(self): + self._count += 1 - @property - def old_value(self): - """Get copy of old value. + def decrease(self): + self._count -= 1 - Returns: - Any: Whatever old value was. - """ + def append(self, item): + self._data.append(item) - return copy.deepcopy(self._old_value) + def get_data(self): + """Use this method for read-only.""" + return self._data - @property - def new_value(self): - """Get copy of new value. - - Returns: - Any: Whatever new value was. - """ - - return copy.deepcopy(self._new_value) - - @property - def changed(self): - """Value changed. - - Returns: - bool: If data changed. - """ - - return self._changed - - @property - def is_dict(self): - """Object can be used as dictionary. - - Returns: - bool: When can be used that way. - """ - - return self._old_is_dict or self._new_is_dict - - @property - def changes(self): - """Get changes in raw data. - - This method should be used only if 'is_dict' value is 'True'. - - Returns: - Dict[str, Tuple[Any, Any]]: Changes are by key in tuple - (, ). If 'is_dict' is 'False' then - output is always empty dictionary. - """ - - output = {} - if not self.is_dict: - return output - - old_value = self.old_value - new_value = self.new_value - for key in self.changed_keys: - _old = None - _new = None - if self._old_is_dict: - _old = old_value.get(key) - if self._new_is_dict: - _new = new_value.get(key) - output[key] = (_old, _new) - return output - - # Methods/properties that can be used when 'is_dict' is 'True' - @property - def old_keys(self): - """Keys from old value. - - Empty set is returned if old value is not a dict. - - Returns: - Set[str]: Keys from old value. - """ - - if self._old_keys is None: - self._prepare_keys() - return set(self._old_keys) - - @property - def new_keys(self): - """Keys from new value. - - Empty set is returned if old value is not a dict. - - Returns: - Set[str]: Keys from new value. - """ - - if self._new_keys is None: - self._prepare_keys() - return set(self._new_keys) - - @property - def changed_keys(self): - """Keys that has changed from old to new value. - - Empty set is returned if both old and new value are not a dict. - - Returns: - Set[str]: Keys of changed keys. - """ - - if self._changed_keys is None: - self._prepare_sub_items() - return set(self._changed_keys) - - @property - def available_keys(self): - """All keys that are available in old and new value. - - Empty set is returned if both old and new value are not a dict. - Output is Union of 'old_keys' and 'new_keys'. - - Returns: - Set[str]: All keys from old and new value. - """ - - if self._available_keys is None: - self._prepare_keys() - return set(self._available_keys) - - @property - def removed_keys(self): - """Key that are not available in new value but were in old value. - - Returns: - Set[str]: All removed keys. - """ - - if self._removed_keys is None: - self._prepare_sub_items() - return set(self._removed_keys) - - def _prepare_keys(self): - old_keys = set() - new_keys = set() - if self._old_is_dict and self._new_is_dict: - old_keys = set(self._old_value.keys()) - new_keys = set(self._new_value.keys()) - - elif self._old_is_dict: - old_keys = set(self._old_value.keys()) - - elif self._new_is_dict: - new_keys = set(self._new_value.keys()) - - self._old_keys = old_keys - self._new_keys = new_keys - self._available_keys = old_keys | new_keys - self._removed_keys = old_keys - new_keys - - def _prepare_sub_items(self): - sub_items = {} - changed_keys = set() - - old_keys = self.old_keys - new_keys = self.new_keys - new_value = self.new_value - old_value = self.old_value - if self._old_is_dict and self._new_is_dict: - for key in self.available_keys: - item = TrackChangesItem( - old_value.get(key), new_value.get(key) - ) - sub_items[key] = item - if item.changed or key not in old_keys or key not in new_keys: - changed_keys.add(key) - - elif self._old_is_dict: - old_keys = set(old_value.keys()) - available_keys = set(old_keys) - changed_keys = set(available_keys) - for key in available_keys: - # NOTE Use '_EMPTY_VALUE' because old value could be 'None' - # which would result in "unchanged" item - sub_items[key] = TrackChangesItem( - old_value.get(key), _EMPTY_VALUE - ) - - elif self._new_is_dict: - new_keys = set(new_value.keys()) - available_keys = set(new_keys) - changed_keys = set(available_keys) - for key in available_keys: - # NOTE Use '_EMPTY_VALUE' because new value could be 'None' - # which would result in "unchanged" item - sub_items[key] = TrackChangesItem( - _EMPTY_VALUE, new_value.get(key) - ) - - self._sub_items = sub_items - self._changed_keys = changed_keys - - -class InstanceMember: - """Representation of instance member. - - TODO: - Implement and use! - """ - - def __init__(self, instance, name): - self.instance = instance - - instance.add_members(self) - - self.name = name - self._actions = [] - - def add_action(self, label, callback): - self._actions.append({ - "label": label, - "callback": callback - }) - - -class AttributeValues(object): - """Container which keep values of Attribute definitions. - - Goal is to have one object which hold values of attribute definitions for - single instance. - - 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. - """ - - def __init__(self, attr_defs, values, origin_data=None): - if origin_data is None: - origin_data = copy.deepcopy(values) - self._origin_data = origin_data - - attr_defs_by_key = { - attr_def.key: attr_def - for attr_def in attr_defs - if attr_def.is_value_def - } - for key, value in values.items(): - if key not in attr_defs_by_key: - new_def = UnknownDef(key, label=key, default=value) - attr_defs.append(new_def) - attr_defs_by_key[key] = new_def - - self._attr_defs = attr_defs - self._attr_defs_by_key = attr_defs_by_key - - self._data = {} - for attr_def in attr_defs: - value = values.get(attr_def.key) - if value is not None: - self._data[attr_def.key] = value - - def __setitem__(self, key, value): - if key not in self._attr_defs_by_key: - raise KeyError("Key \"{}\" was not found.".format(key)) - - old_value = self._data.get(key) - if old_value == value: - return - self._data[key] = value - - def __getitem__(self, key): - if key not in self._attr_defs_by_key: - return self._data[key] - return self._data.get(key, self._attr_defs_by_key[key].default) - - def __contains__(self, key): - return key in self._attr_defs_by_key - - def get(self, key, default=None): - if key in self._attr_defs_by_key: - return self[key] - return default - - def keys(self): - return self._attr_defs_by_key.keys() - - def values(self): - for key in self._attr_defs_by_key.keys(): - yield self._data.get(key) - - def items(self): - for key in self._attr_defs_by_key.keys(): - yield key, self._data.get(key) - - def update(self, value): - for _key, _value in dict(value): - self[_key] = _value - - def pop(self, key, default=None): - value = self._data.pop(key, default) - # Remove attribute definition if is 'UnknownDef' - # - gives option to get rid of unknown values - attr_def = self._attr_defs_by_key.get(key) - if isinstance(attr_def, UnknownDef): - self._attr_defs_by_key.pop(key) - self._attr_defs.remove(attr_def) - return value - - def reset_values(self): - self._data = {} - - def mark_as_stored(self): - self._origin_data = copy.deepcopy(self._data) - - @property - def attr_defs(self): - """Pointer to attribute definitions. - - Returns: - List[AbstractAttrDef]: Attribute definitions. - """ - - return list(self._attr_defs) - - @property - def origin_data(self): - return copy.deepcopy(self._origin_data) - - def data_to_store(self): - """Create new dictionary with data to store. - - Returns: - Dict[str, Any]: Attribute values that should be stored. - """ - - output = {} - for key in self._data: - output[key] = self[key] - - for key, attr_def in self._attr_defs_by_key.items(): - if key not in output: - output[key] = attr_def.default - return output - - def get_serialized_attr_defs(self): - """Serialize attribute definitions to json serializable types. - - Returns: - List[Dict[str, Any]]: Serialized attribute definitions. - """ - - return serialize_attr_defs(self._attr_defs) - - -class CreatorAttributeValues(AttributeValues): - """Creator specific attribute values of an instance. - - Args: - instance (CreatedInstance): Instance for which are values hold. - """ - - def __init__(self, instance, *args, **kwargs): - self.instance = instance - super(CreatorAttributeValues, self).__init__(*args, **kwargs) - - -class PublishAttributeValues(AttributeValues): - """Publish plugin specific attribute values. - - 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(PublishAttributeValues, self).__init__(*args, **kwargs) - - @property - def parent(self): - return self.publish_attributes.parent - - -class PublishAttributes: - """Wrapper for publish plugin attribute definitions. - - Cares about handling attribute definitions of multiple publish plugins. - Keep information about attribute definitions and their values. - - Args: - 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, 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 = [] - - self.set_publish_plugins(attr_plugins) - - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - def keys(self): - return self._data.keys() - - def values(self): - return self._data.values() - - def items(self): - return self._data.items() - - def pop(self, key, default=None): - """Remove or reset value for plugin. - - Plugin values are reset to defaults if plugin is available but - data of plugin which was not found are removed. - - Args: - key(str): Plugin name. - default: Default value if plugin was not found. - """ - - 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_item = self._data[key] - # Prepare value to return - output = value_item.data_to_store() - # Reset values - 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()) - - 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() - return output - - @property - def origin_data(self): - return copy.deepcopy(self._origin_data) - - def set_publish_plugins(self, attr_plugins): - """Set publish plugins attribute definitions.""" - - self._plugin_names_order = [] - self._missing_plugins = [] - self.attr_plugins = attr_plugins or [] - - origin_data = self._origin_data + def pop_data(self): 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 - - key = plugin.__name__ - added_keys.add(key) - self._plugin_names_order.append(key) - - 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 key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) - - def serialize_attributes(self): - return { - "attr_defs": { - 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 - data = self._data - self._data = {} - - added_keys = set() - for plugin_name, attr_defs_data in attr_defs.items(): - attr_defs = deserialize_attr_defs(attr_defs_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 - ) - - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) - - -class CreatedInstance: - """Instance entity with data that will be stored to workfile. - - I think `data` must be required argument containing all minimum information - about instance like "folderPath" and "task" and all data used for filling - product name as creators may have custom data for product name filling. - - Notes: - Object have 2 possible initialization. One using 'creator' object which - is recommended for api usage. Second by passing information about - creator. - - Args: - product_type (str): Product type that will be created. - 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. - """ - - # Keys that can't be changed or removed from data after loading using - # creator. - # - 'creator_attributes' and 'publish_attributes' can change values of - # their individual children but not on their own - __immutable_keys = ( - "id", - "instance_id", - "product_type", - "creator_identifier", - "creator_attributes", - "publish_attributes" - ) - - def __init__( - self, - product_type, - product_name, - data, - creator=None, - creator_identifier=None, - creator_label=None, - group_label=None, - creator_attr_defs=None, - ): - if creator is not None: - 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 - - # Instance members may have actions on them - # TODO implement members logic - self._members = [] - - # Data that can be used for lifetime of object - self._transient_data = {} - - # Create a copy of passed data to avoid changing them on the fly - data = copy.deepcopy(data or {}) - - # Pop dictionary values that will be converted to objects to be able - # catch changes - orig_creator_attributes = data.pop("creator_attributes", None) or {} - orig_publish_attributes = data.pop("publish_attributes", None) or {} - - # Store original value of passed data - self._orig_data = copy.deepcopy(data) - - # Pop 'productType' and 'productName' to prevent unexpected changes - data.pop("productType", None) - data.pop("productName", None) - # Backwards compatibility with OpenPype instances - data.pop("family", None) - data.pop("subset", None) - - asset_name = data.pop("asset", None) - if "folderPath" not in data: - data["folderPath"] = asset_name - - # QUESTION Does it make sense to have data stored as ordered dict? - self._data = collections.OrderedDict() - # QUESTION Do we need this "id" information on instance? - item_id = data.get("id") - # TODO use only 'AYON_INSTANCE_ID' when all hosts support it - if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: - item_id = AVALON_INSTANCE_ID - self._data["id"] = item_id - self._data["productType"] = product_type - self._data["productName"] = product_name - self._data["active"] = data.get("active", True) - self._data["creator_identifier"] = creator_identifier - - # Pop from source data all keys that are defined in `_data` before - # this moment and through their values away - # - they should be the same and if are not then should not change - # already set values - for key in self._data.keys(): - if key in data: - data.pop(key) - - self._data["variant"] = self._data.get("variant") or "" - # Stored creator specific attribute values - # {key: value} - creator_values = copy.deepcopy(orig_creator_attributes) - - self._data["creator_attributes"] = CreatorAttributeValues( - self, - list(creator_attr_defs), - creator_values, - orig_creator_attributes - ) - - # Stored publish specific attribute values - # {: {key: value}} - # - must be set using 'set_publish_plugins' - self._data["publish_attributes"] = PublishAttributes( - self, orig_publish_attributes, None - ) - if data: - self._data.update(data) - - if not self._data.get("instance_id"): - self._data["instance_id"] = str(uuid4()) - - self._folder_is_valid = self.has_set_folder - self._task_is_valid = self.has_set_task - - def __str__(self): - return ( - " {data}" - ).format( - creator_identifier=self.creator_identifier, - product={"name": self.product_name, "type": self.product_type}, - data=str(self._data) - ) - - # --- Dictionary like methods --- - def __getitem__(self, key): - return self._data[key] - - def __contains__(self, key): - return key in self._data - - 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): - # Raise exception if key is immutable and value has changed - raise ImmutableKeyError(key) - - def get(self, key, default=None): - return self._data.get(key, default) - - def pop(self, key, *args, **kwargs): - # Raise exception if is trying to pop key which is immutable - if key in self.__immutable_keys: - raise ImmutableKeyError(key) - - self._data.pop(key, *args, **kwargs) - - def keys(self): - return self._data.keys() - - def values(self): - return self._data.values() - - def items(self): - return self._data.items() - # ------ - - @property - def product_type(self): - return self._data["productType"] - - @property - def product_name(self): - return self._data["productName"] - - @property - def label(self): - label = self._data.get("label") - if not label: - label = self.product_name - return label - - @property - def group_label(self): - label = self._data.get("group") - if label: - return label - return self._group_label - - @property - def origin_data(self): - output = copy.deepcopy(self._orig_data) - output["creator_attributes"] = self.creator_attributes.origin_data - output["publish_attributes"] = self.publish_attributes.origin_data - return output - - @property - def creator_identifier(self): - return self._data["creator_identifier"] - - @property - def creator_label(self): - return self._creator_label or self.creator_identifier - - @property - def id(self): - """Instance identifier. - - Returns: - str: UUID of instance. - """ - - return self._data["instance_id"] - - @property - def data(self): - """Legacy access to data. - - Access to data is needed to modify values. - - Returns: - CreatedInstance: Object can be used as dictionary but with - validations of immutable keys. - """ - - return self - - @property - def transient_data(self): - """Data stored for lifetime of instance object. - - These data are not stored to scene and will be lost on object - deletion. - - Can be used to store objects. In some host implementations is not - possible to reference to object in scene with some unique identifier - (e.g. node in Fusion.). In that case it is handy to store the object - here. Should be used that way only if instance data are stored on the - node itself. - - Returns: - Dict[str, Any]: Dictionary object where you can store data related - to instance for lifetime of instance object. - """ - - return self._transient_data - - def changes(self): - """Calculate and return changes.""" - - return TrackChangesItem(self.origin_data, self.data_to_store()) - - def mark_as_stored(self): - """Should be called when instance data are stored. - - Origin data are replaced by current data so changes are cleared. - """ - - orig_keys = set(self._orig_data.keys()) - for key, value in self._data.items(): - orig_keys.discard(key) - if key in ("creator_attributes", "publish_attributes"): - continue - self._orig_data[key] = copy.deepcopy(value) - - for key in orig_keys: - self._orig_data.pop(key) - - self.creator_attributes.mark_as_stored() - self.publish_attributes.mark_as_stored() - - @property - def creator_attributes(self): - return self._data["creator_attributes"] - - @property - def creator_attribute_defs(self): - """Attribute definitions defined by creator plugin. - - Returns: - List[AbstractAttrDef]: Attribute definitions. - """ - - return self.creator_attributes.attr_defs - - @property - def publish_attributes(self): - return self._data["publish_attributes"] - - def data_to_store(self): - """Collect data that contain json parsable types. - - It is possible to recreate the instance using these data. - - Todos: - We probably don't need OrderedDict. When data are loaded they - are not ordered anymore. - - Returns: - OrderedDict: Ordered dictionary with instance data. - """ - - output = collections.OrderedDict() - for key, value in self._data.items(): - if key in ("creator_attributes", "publish_attributes"): - continue - output[key] = value - - output["creator_attributes"] = self.creator_attributes.data_to_store() - output["publish_attributes"] = self.publish_attributes.data_to_store() - - return output - - @classmethod - def from_existing(cls, instance_data, creator): - """Convert instance data from workfile to CreatedInstance. - - Args: - instance_data (Dict[str, Any]): Data in a structure ready for - 'CreatedInstance' object. - creator (BaseCreator): Creator plugin which is creating the - instance of for which the instance belong. - """ - - instance_data = copy.deepcopy(instance_data) - - product_type = instance_data.get("productType") - if product_type is None: - product_type = instance_data.get("family") - if product_type is None: - product_type = creator.product_type - product_name = instance_data.get("productName") - if product_name is None: - product_name = instance_data.get("subset") - - return cls( - 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'. - - Args: - attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which - inherit from 'AYONPyblishPluginMixin' and may contain - attribute definitions. - """ - - self.publish_attributes.set_publish_plugins(attr_plugins) - - def add_members(self, members): - """Currently unused method.""" - - for member in 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 - - # Context validation related methods/properties - @property - def has_set_folder(self): - """Folder path is set in data.""" - - return "folderPath" in self._data - - @property - def has_set_task(self): - """Task name is set in data.""" - - return "task" in self._data - - @property - def has_valid_context(self): - """Context data are valid for publishing.""" - - return self.has_valid_folder and self.has_valid_task - - @property - def has_valid_folder(self): - """Folder set in context exists in project.""" - - if not self.has_set_folder: - return False - return self._folder_is_valid - - @property - def has_valid_task(self): - """Task set in context exists in project.""" - - if not self.has_set_task: - return False - return self._task_is_valid - - def set_folder_invalid(self, invalid): - # TODO replace with `set_folder_path` - self._folder_is_valid = not invalid - - def set_task_invalid(self, invalid): - # TODO replace with `set_task_name` - self._task_is_valid = not invalid - - -class ConvertorItem(object): - """Item representing convertor plugin. - - Args: - identifier (str): Identifier of convertor. - label (str): Label which will be shown in UI. - """ - - def __init__(self, identifier, label): - self._id = str(uuid4()) - self.identifier = identifier - self.label = label - - @property - def id(self): - return self._id - - def to_data(self): - return { - "id": self.id, - "identifier": self.identifier, - "label": self.label - } - - @classmethod - def from_data(cls, data): - obj = cls(data["identifier"], data["label"]) - obj._id = data["id"] - return obj + self._data = [] + self._sender = None + return data class CreateContext: @@ -1381,6 +171,7 @@ class CreateContext: # 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, {}) @@ -1438,18 +229,36 @@ class CreateContext: 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 # 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 = { + # 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 + "publish_attrs_change": BulkInfo(), + } + self._bulk_order = [] # Shared data across creators during collection phase self._collection_shared_data = None + # Entities cache + self._folder_entities_by_path = {} + self._task_entities_by_id = {} + self._task_ids_by_folder_path = {} + self._task_names_by_folder_path = {} + self.thumbnail_paths_by_instance_id = {} # Trigger reset if was enabled @@ -1469,17 +278,19 @@ class CreateContext: """Access to global publish attributes.""" return self._publish_attributes - def get_instance_by_id(self, instance_id): + def get_instance_by_id( + self, instance_id: str + ) -> Optional["CreatedInstance"]: """Receive instance by id. Args: instance_id (str): Instance id. Returns: - Union[CreatedInstance, None]: Instance or None if instance with + Optional[CreatedInstance]: Instance or None if instance with given id is not available. - """ + """ return self._instances_by_id.get(instance_id) def get_sorted_creators(self, identifiers=None): @@ -1491,8 +302,8 @@ class CreateContext: Returns: List[BaseCreator]: Sorted creator plugins by 'order' value. - """ + """ if identifiers is not None: identifiers = set(identifiers) creators = [ @@ -1548,12 +359,12 @@ class CreateContext: return self._host_is_valid @property - def host_name(self): + def host_name(self) -> str: if hasattr(self.host, "name"): return self.host.name return os.environ["AYON_HOST_NAME"] - def get_current_project_name(self): + def get_current_project_name(self) -> Optional[str]: """Project name which was used as current context on context reset. Returns: @@ -1562,7 +373,7 @@ class CreateContext: return self._current_project_name - def get_current_folder_path(self): + def get_current_folder_path(self) -> Optional[str]: """Folder path which was used as current context on context reset. Returns: @@ -1571,7 +382,7 @@ class CreateContext: return self._current_folder_path - def get_current_task_name(self): + def get_current_task_name(self) -> Optional[str]: """Task name which was used as current context on context reset. Returns: @@ -1580,7 +391,7 @@ class CreateContext: return self._current_task_name - def get_current_task_type(self): + def get_current_task_type(self) -> Optional[str]: """Task type which was used as current context on context reset. Returns: @@ -1595,7 +406,7 @@ class CreateContext: self._current_task_type = task_type return self._current_task_type - def get_current_project_entity(self): + def get_current_project_entity(self) -> Optional[Dict[str, Any]]: """Project entity for current context project. Returns: @@ -1611,26 +422,21 @@ class CreateContext: self._current_project_entity = project_entity return copy.deepcopy(self._current_project_entity) - def get_current_folder_entity(self): + def get_current_folder_entity(self) -> Optional[Dict[str, Any]]: """Folder entity for current context folder. Returns: - Union[dict[str, Any], None]: Folder entity. + Optional[dict[str, Any]]: Folder entity. """ if self._current_folder_entity is not _NOT_SET: return copy.deepcopy(self._current_folder_entity) - folder_entity = None + folder_path = self.get_current_folder_path() - if folder_path: - project_name = self.get_current_project_name() - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path - ) - self._current_folder_entity = folder_entity + self._current_folder_entity = self.get_folder_entity(folder_path) return copy.deepcopy(self._current_folder_entity) - def get_current_task_entity(self): + def get_current_task_entity(self) -> Optional[Dict[str, Any]]: """Task entity for current context task. Returns: @@ -1639,20 +445,13 @@ class CreateContext: """ if self._current_task_entity is not _NOT_SET: return copy.deepcopy(self._current_task_entity) - task_entity = None - task_name = self.get_current_task_name() - if task_name: - folder_entity = self.get_current_folder_entity() - if folder_entity: - project_name = self.get_current_project_name() - task_entity = ayon_api.get_task_by_name( - project_name, - folder_id=folder_entity["id"], - task_name=task_name - ) - self._current_task_entity = task_entity - return copy.deepcopy(self._current_task_entity) + folder_path = self.get_current_folder_path() + task_name = self.get_current_task_name() + self._current_task_entity = self.get_task_entity( + folder_path, task_name + ) + return copy.deepcopy(self._current_task_entity) def get_current_workfile_path(self): """Workfile path which was opened on context reset. @@ -1724,7 +523,7 @@ class CreateContext: 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() @@ -1735,7 +534,7 @@ class CreateContext: """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() @@ -1760,6 +559,14 @@ class CreateContext: # Give ability to store shared data for collection phase self._collection_shared_data = {} + self._folder_entities_by_path = {} + self._task_entities_by_id = {} + + self._task_ids_by_folder_path = {} + self._task_names_by_folder_path = {} + + self._event_hub.clear_callbacks() + def reset_finalization(self): """Cleanup of attributes after reset.""" @@ -1832,9 +639,6 @@ class CreateContext: publish_plugins_discover ) - # Reset publish plugins - self._attr_plugins_by_product_type = {} - discover_result = DiscoverResult(pyblish.api.Plugin) plugins_with_defs = [] plugins_by_targets = [] @@ -1860,6 +664,24 @@ class CreateContext: 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( + 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 self.publish_plugins = plugins_by_targets @@ -1962,9 +784,203 @@ class CreateContext: 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_attr_defs_for_context (self) + if not attr_defs: + continue + self._publish_attributes.set_publish_plugin_attr_defs( + plugin.__name__, attr_defs + ) + + def add_instances_added_callback(self, 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, ...], + "create_context": CreateContext + } + ``` + + 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 add_instances_removed_callback (self, callback): + """Register callback for removed instances. + + Event is triggered when instances are already removed from context. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + 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 add_value_changed_callback(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" + } + } + } + ], + "create_context": CreateContext + } + ``` + + 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 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 + this event is mainly for publisher UI. + + Data structure of event:: + + ```python + { + "identifiers": ["create_plugin_identifier"], + "create_context": CreateContext + } + ``` + + 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 add_create_attr_defs_change_callback (self, callback): + """Register callback to listen create attribute changes. + + Create plugin changed attribute definitions of instance. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + 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 add_publish_attr_defs_change_callback (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"}, + } + }, + "create_context": CreateContext + } + ``` + + 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 ) def context_data_to_store(self): @@ -1983,7 +999,22 @@ class CreateContext: self._original_context_data, self.context_data_to_store() ) - def creator_adds_instance(self, instance): + 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. Instances should be added only from creators. @@ -2002,16 +1033,11 @@ class CreateContext: return self._instances_by_id[instance.id] = instance - # 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) - # 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(): - self._bulk_instances_to_process.append(instance) + with self.bulk_add_instances() as bulk_info: + bulk_info.append(instance) def _get_creator_in_create(self, identifier): """Creator by identifier with unified error. @@ -2070,8 +1096,8 @@ class CreateContext: Raises: CreatorError: If creator was not found or folder is empty. - """ + """ creator = self._get_creator_in_create(creator_identifier) project_name = self.project_name @@ -2137,51 +1163,13 @@ class CreateContext: active = bool(active) instance_data["active"] = active - return creator.create( - product_name, - instance_data, - _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 - 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 + with self.bulk_add_instances(): + return creator.create( + product_name, + instance_data, + _pre_create_data ) - 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. @@ -2197,8 +1185,8 @@ class CreateContext: 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 ) @@ -2206,13 +1194,10 @@ class CreateContext: 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): + 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: @@ -2220,7 +1205,7 @@ class CreateContext: 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( @@ -2231,31 +1216,167 @@ class CreateContext: self.convertor_items_by_id.pop(convertor_identifier, None) @contextmanager - def bulk_instances_collection(self): - """Validate context of instances in bulk. + def bulk_add_instances(self, sender=None): + with self._bulk_context("add", sender) as bulk_info: + yield bulk_info - This can be used for single instance or for adding multiple instances - which is helpfull on reset. + # 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_attr_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_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: + yield bulk_info + + @contextmanager + 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: + yield bulk_info + + @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 create_plugin_pre_create_attr_defs_changed(self, identifier: str): + """Create plugin pre-create attributes changed. + + Triggered by 'Creator'. + + Args: + identifier (str): Create plugin identifier. - Should not be executed from multiple threads. """ - self._bulk_counter += 1 - try: - yield - finally: - self._bulk_counter -= 1 + with self.bulk_pre_create_attr_defs_change() as bulk_item: + bulk_item.append(identifier) - # Trigger validation if there is no more context manager for bulk - # instance validation - if self._bulk_counter == 0: - ( - self._bulk_instances_to_process, - instances_to_validate - ) = ( - [], - self._bulk_instances_to_process - ) - self.validate_instances_context(instances_to_validate) + def instance_create_attr_defs_changed(self, instance_id: str): + """Instance attribute definitions changed. + + 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: 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: 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: 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, + { + "publish_attributes": { + plugin_name: value, + }, + }, + ) def reset_instances(self): """Reload instances""" @@ -2344,94 +1465,403 @@ class CreateContext: if failed_info: raise CreatorsCreateFailed(failed_info) - def validate_instances_context(self, instances=None): - """Validate 'folder' and 'task' instance context.""" - # Use all instances from context if 'instances' are not passed + def get_folder_entities(self, folder_paths: Iterable[str]): + """Get folder entities by paths. + + Args: + folder_paths (Iterable[str]): Folder paths. + + Returns: + Dict[str, Optional[Dict[str, Any]]]: Folder entities by path. + + """ + output = { + folder_path: None + for folder_path in folder_paths + } + remainder_paths = set() + for folder_path in output: + # Skip invalid folder paths (folder name or empty path) + if not folder_path or "/" not in folder_path: + continue + + if folder_path not in self._folder_entities_by_path: + remainder_paths.add(folder_path) + continue + + output[folder_path] = self._folder_entities_by_path[folder_path] + + if not remainder_paths: + return output + + found_paths = set() + for folder_entity in ayon_api.get_folders( + self.project_name, + folder_paths=remainder_paths, + ): + folder_path = folder_entity["path"] + found_paths.add(folder_path) + output[folder_path] = folder_entity + self._folder_entities_by_path[folder_path] = folder_entity + + # Cache empty folder entities + for path in remainder_paths - found_paths: + self._folder_entities_by_path[path] = None + + return output + + def get_task_entities( + self, + task_names_by_folder_paths: Dict[str, Set[str]] + ) -> Dict[str, Dict[str, Optional[Dict[str, Any]]]]: + """Get task entities by folder path and task name. + + Entities are cached until reset. + + Args: + task_names_by_folder_paths (Dict[str, Set[str]]): Task names by + folder path. + + Returns: + Dict[str, Dict[str, Dict[str, Any]]]: Task entities by folder path + and task name. + + """ + output = {} + for folder_path, task_names in task_names_by_folder_paths.items(): + if folder_path is None: + continue + output[folder_path] = { + task_name: None + for task_name in task_names + if task_name is not None + } + + missing_folder_paths = set() + for folder_path, output_task_entities_by_name in output.items(): + if not output_task_entities_by_name: + continue + + if folder_path not in self._task_ids_by_folder_path: + missing_folder_paths.add(folder_path) + continue + + all_tasks_filled = True + task_ids = self._task_ids_by_folder_path[folder_path] + task_entities_by_name = {} + for task_id in task_ids: + task_entity = self._task_entities_by_id.get(task_id) + if task_entity is None: + all_tasks_filled = False + continue + task_entities_by_name[task_entity["name"]] = task_entity + + any_missing = False + for task_name in set(output_task_entities_by_name): + task_entity = task_entities_by_name.get(task_name) + if task_entity is None: + any_missing = True + continue + + output_task_entities_by_name[task_name] = task_entity + + if any_missing and not all_tasks_filled: + missing_folder_paths.add(folder_path) + + if not missing_folder_paths: + return output + + folder_entities_by_path = self.get_folder_entities( + missing_folder_paths + ) + folder_path_by_id = {} + for folder_path, folder_entity in folder_entities_by_path.items(): + if folder_entity is not None: + folder_path_by_id[folder_entity["id"]] = folder_path + + if not folder_path_by_id: + return output + + task_entities_by_parent_id = collections.defaultdict(list) + for task_entity in ayon_api.get_tasks( + self.project_name, + folder_ids=folder_path_by_id.keys() + ): + folder_id = task_entity["folderId"] + task_entities_by_parent_id[folder_id].append(task_entity) + + for folder_id, task_entities in task_entities_by_parent_id.items(): + folder_path = folder_path_by_id[folder_id] + task_ids = set() + task_names = set() + for task_entity in task_entities: + task_id = task_entity["id"] + task_name = task_entity["name"] + task_ids.add(task_id) + task_names.add(task_name) + self._task_entities_by_id[task_id] = task_entity + + output[folder_path][task_name] = task_entity + self._task_ids_by_folder_path[folder_path] = task_ids + self._task_names_by_folder_path[folder_path] = task_names + + return output + + def get_folder_entity( + self, + folder_path: Optional[str], + ) -> Optional[Dict[str, Any]]: + """Get folder entity by path. + + Entities are cached until reset. + + Args: + folder_path (Optional[str]): Folder path. + + Returns: + Optional[Dict[str, Any]]: Folder entity. + + """ + if not folder_path: + return None + return self.get_folder_entities([folder_path]).get(folder_path) + + def get_task_entity( + self, + folder_path: Optional[str], + task_name: Optional[str], + ) -> Optional[Dict[str, Any]]: + """Get task entity by name and folder path. + + Entities are cached until reset. + + Args: + folder_path (Optional[str]): Folder path. + task_name (Optional[str]): Task name. + + Returns: + Optional[Dict[str, Any]]: Task entity. + + """ + if not folder_path or not task_name: + return None + + output = self.get_task_entities({folder_path: {task_name}}) + return output.get(folder_path, {}).get(task_name) + + def get_instances_folder_entities( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ) -> Dict[str, Optional[Dict[str, Any]]]: if instances is None: - instances = tuple(self._instances_by_id.values()) - - # Skip if instances are empty + instances = self._instances_by_id.values() + instances = list(instances) + output = { + instance.id: None + for instance in instances + } if not instances: - return + return output - project_name = self.project_name + folder_paths = { + instance.get("folderPath") + for instance in instances + } + folder_paths.discard(None) + folder_entities_by_path = self.get_folder_entities(folder_paths) + for instance in instances: + folder_path = instance.get("folderPath") + output[instance.id] = folder_entities_by_path.get(folder_path) + return output - task_names_by_folder_path = {} + def get_instances_task_entities( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ): + """Get task entities for instances. + + Args: + instances (Optional[Iterable[CreatedInstance]]): Instances to + get task entities. If not provided all instances are used. + + Returns: + Dict[str, Optional[Dict[str, Any]]]: Task entity by instance id. + + """ + if instances is None: + instances = self._instances_by_id.values() + instances = list(instances) + + output = { + instance.id: None + for instance in instances + } + if not instances: + return output + + filtered_instances = [] + task_names_by_folder_path = collections.defaultdict(set) for instance in instances: folder_path = instance.get("folderPath") task_name = instance.get("task") - if folder_path: - task_names_by_folder_path[folder_path] = set() - if task_name: - task_names_by_folder_path[folder_path].add(task_name) + if not folder_path or not task_name: + continue + filtered_instances.append(instance) + task_names_by_folder_path[folder_path].add(task_name) + + task_entities_by_folder_path = self.get_task_entities( + task_names_by_folder_path + ) + for instance in filtered_instances: + folder_path = instance["folderPath"] + task_name = instance["task"] + output[instance.id] = ( + task_entities_by_folder_path[folder_path][task_name] + ) + + return output + + def get_instances_context_info( + self, instances: Optional[Iterable["CreatedInstance"]] = None + ) -> Dict[str, InstanceContextInfo]: + """Validate 'folder' and 'task' instance context. + + Args: + instances (Optional[Iterable[CreatedInstance]]): Instances to + validate. If not provided all instances are validated. + + Returns: + Dict[str, InstanceContextInfo]: Validation results by instance id. + + """ + # Use all instances from context if 'instances' are not passed + if instances is None: + instances = self._instances_by_id.values() + instances = tuple(instances) + info_by_instance_id = { + instance.id: InstanceContextInfo( + instance.get("folderPath"), + instance.get("task"), + False, + False, + ) + for instance in instances + } + + # Skip if instances are empty + if not info_by_instance_id: + return info_by_instance_id + + project_name = self.project_name + + to_validate = [] + task_names_by_folder_path = collections.defaultdict(set) + for instance in instances: + context_info = info_by_instance_id[instance.id] + if instance.has_promised_context: + context_info.folder_is_valid = True + context_info.task_is_valid = True + # NOTE missing task type + continue + # TODO allow context promise + folder_path = context_info.folder_path + if not folder_path: + continue + + if folder_path in self._folder_entities_by_path: + folder_entity = self._folder_entities_by_path[folder_path] + if folder_entity is None: + continue + context_info.folder_is_valid = True + + task_name = context_info.task_name + if task_name is not None: + tasks_cache = self._task_names_by_folder_path.get(folder_path) + if tasks_cache is not None: + context_info.task_is_valid = task_name in tasks_cache + continue + + to_validate.append(instance) + task_names_by_folder_path[folder_path].add(task_name) + + if not to_validate: + return info_by_instance_id # Backwards compatibility for cases where folder name is set instead # of folder path - folder_names = set() folder_paths = set() - for folder_path in task_names_by_folder_path.keys(): + task_names_by_folder_name = {} + task_names_by_folder_path_clean = {} + for folder_path, task_names in task_names_by_folder_path.items(): if folder_path is None: - pass - elif "/" in folder_path: - folder_paths.add(folder_path) - else: - folder_names.add(folder_path) + continue - folder_paths_by_id = {} - if folder_paths: + clean_task_names = { + task_name + for task_name in task_names + if task_name + } + + if "/" not in folder_path: + task_names_by_folder_name[folder_path] = clean_task_names + continue + + folder_paths.add(folder_path) + if not clean_task_names: + continue + + task_names_by_folder_path_clean[folder_path] = clean_task_names + + folder_paths_by_name = collections.defaultdict(list) + if task_names_by_folder_name: for folder_entity in ayon_api.get_folders( project_name, - folder_paths=folder_paths, - fields={"id", "path"} + folder_names=task_names_by_folder_name.keys(), + fields={"name", "path"} ): - folder_id = folder_entity["id"] - folder_paths_by_id[folder_id] = folder_entity["path"] - - folder_entities_by_name = collections.defaultdict(list) - if folder_names: - for folder_entity in ayon_api.get_folders( - project_name, - folder_names=folder_names, - fields={"id", "name", "path"} - ): - folder_id = folder_entity["id"] folder_name = folder_entity["name"] - folder_paths_by_id[folder_id] = folder_entity["path"] - folder_entities_by_name[folder_name].append(folder_entity) + folder_path = folder_entity["path"] + folder_paths_by_name[folder_name].append(folder_path) - tasks_entities = ayon_api.get_tasks( - project_name, - folder_ids=folder_paths_by_id.keys(), - fields={"name", "folderId"} + folder_path_by_name = {} + for folder_name, paths in folder_paths_by_name.items(): + if len(paths) != 1: + continue + path = paths[0] + folder_path_by_name[folder_name] = path + folder_paths.add(path) + clean_task_names = task_names_by_folder_name[folder_name] + if not clean_task_names: + continue + folder_task_names = task_names_by_folder_path_clean.setdefault( + path, set() + ) + folder_task_names |= clean_task_names + + folder_entities_by_path = self.get_folder_entities(folder_paths) + task_entities_by_folder_path = self.get_task_entities( + task_names_by_folder_path_clean ) - task_names_by_folder_path = collections.defaultdict(set) - for task_entity in tasks_entities: - folder_id = task_entity["folderId"] - folder_path = folder_paths_by_id[folder_id] - task_names_by_folder_path[folder_path].add(task_entity["name"]) - - for instance in instances: - if not instance.has_valid_folder or not instance.has_valid_task: - continue - + for instance in to_validate: folder_path = instance["folderPath"] + task_name = instance.get("task") if folder_path and "/" not in folder_path: - folder_entities = folder_entities_by_name.get(folder_path) - if len(folder_entities) == 1: - folder_path = folder_entities[0]["path"] - instance["folderPath"] = folder_path + new_folder_path = folder_path_by_name.get(folder_path) + if new_folder_path: + folder_path = new_folder_path + instance["folderPath"] = new_folder_path - if folder_path not in task_names_by_folder_path: - instance.set_folder_invalid(True) + folder_entity = folder_entities_by_path.get(folder_path) + if not folder_entity: continue + context_info = info_by_instance_id[instance.id] + context_info.folder_is_valid = True - task_name = instance["task"] - if not task_name: - continue - - if task_name not in task_names_by_folder_path[folder_path]: - instance.set_task_invalid(True) + if ( + not task_name + or task_name in task_entities_by_folder_path[folder_path] + ): + context_info.task_is_valid = True + return info_by_instance_id def save_changes(self): """Save changes. Update all changed values.""" @@ -2509,18 +1939,19 @@ class CreateContext: 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 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. - """ + sender (Optional[str]): Sender of the event. + """ instances_by_identifier = collections.defaultdict(list) for instance in instances: identifier = instance.creator_identifier @@ -2528,9 +1959,14 @@ class CreateContext: # 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, sender) error_message = "Instances removement of creator \"{}\" failed. {}" failed_info = [] @@ -2555,6 +1991,9 @@ class CreateContext: error_message.format(identifier, exc_info[1]) ) + except (KeyboardInterrupt, SystemExit): + raise + except: # noqa: E722 failed = True add_traceback = True @@ -2574,44 +2013,6 @@ class CreateContext: 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. @@ -2676,3 +2077,269 @@ class CreateContext: 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, sender=None): + 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 + ): + 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 + with self.bulk_add_instances(): + 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 _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 624f1c9588..fe41d2fe65 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,21 +19,12 @@ from .constants import DEFAULT_VARIANT_VALUE 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 - - -class CreatorError(Exception): - """Should be raised when creator failed because of known issue. - - Message of error should be user readable. - """ - - def __init__(self, message): - super(CreatorError, self).__init__(message) + from .context import CreateContext, UpdateData # noqa: F401 class ProductConvertorPlugin(ABC): @@ -214,6 +205,7 @@ class BaseCreator(ABC): self.headless = headless self.apply_settings(project_settings) + self.register_callbacks() @staticmethod def _get_settings_values(project_settings, category_name, plugin_name): @@ -299,6 +291,14 @@ class BaseCreator(ABC): )) 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). @@ -372,6 +372,35 @@ class BaseCreator(ABC): self._log = Logger.get_logger(self.__class__.__name__) return self._log + def _create_instance( + 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, + 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. @@ -561,6 +590,16 @@ class BaseCreator(ABC): 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. @@ -654,7 +693,7 @@ class Creator(BaseCreator): cls._get_default_variant_wrap, cls._set_default_variant_wrap ) - super(Creator, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def show_order(self): @@ -792,6 +831,16 @@ class Creator(BaseCreator): """ 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 diff --git a/client/ayon_core/pipeline/create/exceptions.py b/client/ayon_core/pipeline/create/exceptions.py new file mode 100644 index 0000000000..8910d3fa09 --- /dev/null +++ b/client/ayon_core/pipeline/create/exceptions.py @@ -0,0 +1,127 @@ +import os +import inspect + + +class UnavailableSharedData(Exception): + """Shared data are not available at the moment when are accessed.""" + pass + + +class ImmutableKeyError(TypeError): + """Accessed key is immutable so does not allow changes or removals.""" + + def __init__(self, key, msg=None): + self.immutable_key = key + if not msg: + msg = "Key \"{}\" is immutable and does not allow changes.".format( + key + ) + super().__init__(msg) + + +class HostMissRequiredMethod(Exception): + """Host does not have implemented required functions for creation.""" + + def __init__(self, host, missing_methods): + self.missing_methods = missing_methods + self.host = host + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + dirpath = os.path.dirname( + os.path.normpath(inspect.getsourcefile(host)) + ) + dirpath_parts = dirpath.split(os.path.sep) + host_name = dirpath_parts.pop(-1) + if host_name == "api": + host_name = dirpath_parts.pop(-1) + + msg = "Host \"{}\" does not have implemented method/s {}".format( + host_name, joined_methods + ) + super().__init__(msg) + + +class ConvertorsOperationFailed(Exception): + def __init__(self, msg, failed_info): + super().__init__(msg) + self.failed_info = failed_info + + +class ConvertorsFindFailed(ConvertorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to find incompatible products" + super().__init__(msg, failed_info) + + +class ConvertorsConversionFailed(ConvertorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to convert incompatible products" + super().__init__(msg, failed_info) + + +class CreatorError(Exception): + """Should be raised when creator failed because of known issue. + + Message of error should be artist friendly. + """ + pass + + +class CreatorsOperationFailed(Exception): + """Raised when a creator process crashes in 'CreateContext'. + + The exception contains information about the creator and error. The data + are prepared using 'prepare_failed_creator_operation_info' and can be + serialized using json. + + Usage is for UI purposes which may not have access to exceptions directly + and would not have ability to catch exceptions 'per creator'. + + Args: + msg (str): General error message. + failed_info (list[dict[str, Any]]): List of failed creators with + exception message and optionally formatted traceback. + """ + + def __init__(self, msg, failed_info): + super().__init__(msg) + self.failed_info = failed_info + + +class CreatorsCollectionFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to collect instances" + super().__init__(msg, failed_info) + + +class CreatorsSaveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed update instance changes" + super().__init__(msg, failed_info) + + +class CreatorsRemoveFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to remove instances" + super().__init__(msg, failed_info) + + +class CreatorsCreateFailed(CreatorsOperationFailed): + def __init__(self, failed_info): + msg = "Failed to create instances" + super().__init__(msg, failed_info) + + +class TaskNotSetError(KeyError): + def __init__(self, msg=None): + if not msg: + msg = "Creator's product name template requires task name." + super().__init__(msg) + + +class TemplateFillError(Exception): + def __init__(self, msg=None): + if not msg: + msg = "Creator's product name template is missing key value." + super().__init__(msg) diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py index fc24bcf934..ec9b23ac62 100644 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ b/client/ayon_core/pipeline/create/legacy_create.py @@ -14,7 +14,7 @@ from ayon_core.pipeline.constants import AVALON_INSTANCE_ID from .product_name import get_product_name -class LegacyCreator(object): +class LegacyCreator: """Determine how assets are created""" label = None product_type = None diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 8a08bdc36c..eaeef6500e 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,23 +1,9 @@ import ayon_api - +from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data from ayon_core.settings import get_project_settings -from ayon_core.lib import filter_profiles, prepare_template_data from .constants import DEFAULT_PRODUCT_TEMPLATE - - -class TaskNotSetError(KeyError): - def __init__(self, msg=None): - if not msg: - msg = "Creator's product name template requires task name." - super(TaskNotSetError, self).__init__(msg) - - -class TemplateFillError(Exception): - def __init__(self, msg=None): - if not msg: - msg = "Creator's product name template is missing key value." - super(TemplateFillError, self).__init__(msg) +from .exceptions import TaskNotSetError, TemplateFillError def get_product_name_template( @@ -183,7 +169,10 @@ def get_product_name( fill_pairs[key] = value try: - return template.format(**prepare_template_data(fill_pairs)) + return StringTemplate.format_strict_template( + template=template, + data=prepare_template_data(fill_pairs) + ) except KeyError as exp: raise TemplateFillError( "Value for {} key is missing in template '{}'." diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py new file mode 100644 index 0000000000..a1a4d5f8ef --- /dev/null +++ b/client/ayon_core/pipeline/create/structures.py @@ -0,0 +1,872 @@ +import copy +import collections +from uuid import uuid4 +from typing import Optional, Dict, List, Any + +from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, + UnknownDef, + serialize_attr_defs, + deserialize_attr_defs, +) +from ayon_core.pipeline import ( + AYON_INSTANCE_ID, + AVALON_INSTANCE_ID, +) + +from .exceptions import ImmutableKeyError +from .changes import TrackChangesItem + + +class ConvertorItem: + """Item representing convertor plugin. + + Args: + identifier (str): Identifier of convertor. + label (str): Label which will be shown in UI. + """ + + def __init__(self, identifier, label): + self._id = str(uuid4()) + self.identifier = identifier + self.label = label + + @property + def id(self): + return self._id + + def to_data(self): + return { + "id": self.id, + "identifier": self.identifier, + "label": self.label + } + + @classmethod + def from_data(cls, data): + obj = cls(data["identifier"], data["label"]) + obj._id = data["id"] + return obj + + +class InstanceMember: + """Representation of instance member. + + TODO: + Implement and use! + """ + + def __init__(self, instance, name): + self.instance = instance + + instance.add_members(self) + + self.name = name + self._actions = [] + + def add_action(self, label, callback): + self._actions.append({ + "label": label, + "callback": callback + }) + + +class AttributeValues: + """Container which keep values of Attribute definitions. + + Goal is to have one object which hold values of attribute definitions for + single instance. + + Has dictionary like methods. Not all of them are allowed all the time. + + Args: + 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, 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 + + attr_defs_by_key = { + attr_def.key: attr_def + for attr_def in attr_defs + if attr_def.is_value_def + } + for key, value in values.items(): + if key not in attr_defs_by_key: + new_def = UnknownDef(key, label=key, default=value) + attr_defs.append(new_def) + attr_defs_by_key[key] = new_def + + self._attr_defs = attr_defs + self._attr_defs_by_key = attr_defs_by_key + + self._data = {} + for attr_def in attr_defs: + value = values.get(attr_def.key) + 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): + if key not in self._attr_defs_by_key: + raise KeyError("Key \"{}\" was not found.".format(key)) + + self.update({key: value}) + + def __getitem__(self, key): + if key not in self._attr_defs_by_key: + return self._data[key] + return self._data.get(key, self._attr_defs_by_key[key].default) + + def __contains__(self, key): + return key in self._attr_defs_by_key + + def __iter__(self): + for key in self._attr_defs_by_key: + yield key + + def get(self, key, default=None): + if key in self._attr_defs_by_key: + return self[key] + return default + + def keys(self): + return self._attr_defs_by_key.keys() + + def values(self): + for key in self._attr_defs_by_key.keys(): + yield self._data.get(key) + + 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(): + if _key in self._data and self._data.get(_key) == _value: + continue + 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 + attr_def = self._attr_defs_by_key.get(key) + 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): + self._data = {} + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self._data) + + @property + def attr_defs(self): + """Pointer to attribute definitions. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return list(self._attr_defs) + + @property + def origin_data(self): + return copy.deepcopy(self._origin_data) + + def data_to_store(self): + """Create new dictionary with data to store. + + Returns: + Dict[str, Any]: Attribute values that should be stored. + """ + + output = {} + for key in self._data: + output[key] = self[key] + + for key, attr_def in self._attr_defs_by_key.items(): + if key not in output: + output[key] = attr_def.default + return output + + def get_serialized_attr_defs(self): + """Serialize attribute definitions to json serializable types. + + Returns: + List[Dict[str, Any]]: Serialized attribute definitions. + """ + + return serialize_attr_defs(self._attr_defs) + + +class CreatorAttributeValues(AttributeValues): + """Creator specific attribute values of an instance.""" + + @property + def instance(self): + return self._parent + + +class PublishAttributeValues(AttributeValues): + """Publish plugin specific attribute values. + + Values are for single plugin which can be on `CreatedInstance` + or context values stored on `CreateContext`. + """ + + @property + def publish_attributes(self): + return self._parent + + +class PublishAttributes: + """Wrapper for publish plugin attribute definitions. + + Cares about handling attribute definitions of multiple publish plugins. + Keep information about attribute definitions and their values. + + Args: + 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. + + """ + def __init__(self, parent, origin_data): + self._parent = parent + self._origin_data = copy.deepcopy(origin_data) + + self._data = copy.deepcopy(origin_data) + + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + 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. + + Plugin values are reset to defaults if plugin is available but + data of plugin which was not found are removed. + + Args: + key(str): Plugin name. + default: Default value if plugin was not found. + """ + + if key not in self._data: + return default + + value = self._data[key] + if not isinstance(value, AttributeValues): + self.attribute_value_changed(key, None) + return self._data.pop(key) + + value_item = self._data[key] + # Prepare value to return + output = value_item.data_to_store() + # Reset values + value_item.reset_values() + self.attribute_value_changed( + key, value_item.data_to_store() + ) + return output + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self.data_to_store()) + + def data_to_store(self): + """Convert attribute values to "data to store".""" + output = {} + for key, attr_value in self._data.items(): + if isinstance(attr_value, AttributeValues): + output[key] = attr_value.data_to_store() + else: + output[key] = attr_value + return output + + @property + 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, + attr_defs: List[AbstractAttrDef], + value: Optional[Dict[str, Any]] = None + ): + """Set attribute definitions for plugin. + + Args: + 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'? + if value is None: + value = self._data.get(plugin_name) + + if value is None: + value = {} + + self._data[plugin_name] = PublishAttributeValues( + self, plugin_name, attr_defs, value, value + ) + + def serialize_attributes(self): + return { + "attr_defs": { + plugin_name: attrs_value.get_serialized_attr_defs() + for plugin_name, attrs_value in self._data.items() + }, + } + + def deserialize_attributes(self, data): + attr_defs = deserialize_attr_defs(data["attr_defs"]) + + origin_data = self._origin_data + data = self._data + self._data = {} + + added_keys = set() + for plugin_name, attr_defs_data in attr_defs.items(): + attr_defs = deserialize_attr_defs(attr_defs_data) + value = data.get(plugin_name) or {} + orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) + self._data[plugin_name] = PublishAttributeValues( + self, plugin_name, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._data[key] = value + + +class InstanceContextInfo: + def __init__( + self, + folder_path: Optional[str], + task_name: Optional[str], + folder_is_valid: bool, + task_is_valid: bool, + ): + self.folder_path: Optional[str] = folder_path + self.task_name: Optional[str] = task_name + self.folder_is_valid: bool = folder_is_valid + self.task_is_valid: bool = task_is_valid + + @property + def is_valid(self) -> bool: + return self.folder_is_valid and self.task_is_valid + + +class CreatedInstance: + """Instance entity with data that will be stored to workfile. + + I think `data` must be required argument containing all minimum information + about instance like "folderPath" and "task" and all data used for filling + product name as creators may have custom data for product name filling. + + Notes: + Object have 2 possible initialization. One using 'creator' object which + is recommended for api usage. Second by passing information about + creator. + + Args: + product_type (str): Product type that will be created. + 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 (BaseCreator): Creator responsible for instance. + """ + + # Keys that can't be changed or removed from data after loading using + # creator. + # - 'creator_attributes' and 'publish_attributes' can change values of + # their individual children but not on their own + __immutable_keys = ( + "id", + "instance_id", + "productType", + "creator_identifier", + "creator_attributes", + "publish_attributes" + ) + # Keys that can be changed, but should not be removed from instance + __required_keys = { + "folderPath": None, + "task": None, + "productName": None, + "active": True, + } + + def __init__( + self, + product_type, + product_name, + data, + creator, + ): + 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 + + # Instance members may have actions on them + # TODO implement members logic + self._members = [] + + # Data that can be used for lifetime of object + self._transient_data = {} + + # Create a copy of passed data to avoid changing them on the fly + data = copy.deepcopy(data or {}) + + # Pop dictionary values that will be converted to objects to be able + # catch changes + orig_creator_attributes = data.pop("creator_attributes", None) or {} + orig_publish_attributes = data.pop("publish_attributes", None) or {} + + # Store original value of passed data + self._orig_data = copy.deepcopy(data) + + # Pop 'productType' and 'productName' to prevent unexpected changes + data.pop("productType", None) + data.pop("productName", None) + # Backwards compatibility with OpenPype instances + data.pop("family", None) + data.pop("subset", None) + + asset_name = data.pop("asset", None) + if "folderPath" not in data: + data["folderPath"] = asset_name + + # QUESTION Does it make sense to have data stored as ordered dict? + self._data = collections.OrderedDict() + # QUESTION Do we need this "id" information on instance? + item_id = data.get("id") + # TODO use only 'AYON_INSTANCE_ID' when all hosts support it + if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}: + item_id = AVALON_INSTANCE_ID + self._data["id"] = item_id + self._data["productType"] = product_type + self._data["productName"] = product_name + self._data["active"] = data.get("active", True) + self._data["creator_identifier"] = creator_identifier + + # Pop from source data all keys that are defined in `_data` before + # this moment and through their values away + # - they should be the same and if are not then should not change + # already set values + for key in self._data.keys(): + if key in data: + data.pop(key) + + self._data["variant"] = self._data.get("variant") or "" + # Stored creator specific attribute values + # {key: value} + creator_values = copy.deepcopy(orig_creator_attributes) + + self._data["creator_attributes"] = creator_values + + # Stored publish specific attribute values + # {: {key: value}} + self._data["publish_attributes"] = PublishAttributes( + self, orig_publish_attributes + ) + if data: + self._data.update(data) + + for key, default in self.__required_keys.items(): + self._data.setdefault(key, default) + + if not self._data.get("instance_id"): + self._data["instance_id"] = str(uuid4()) + + creator_attr_defs = creator.get_attr_defs_for_instance(self) + self.set_create_attr_defs( + creator_attr_defs, creator_values + ) + + def __str__(self): + return ( + " {data}" + ).format( + creator_identifier=self.creator_identifier, + product={"name": self.product_name, "type": self.product_type}, + data=str(self._data) + ) + + # --- Dictionary like methods --- + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def __setitem__(self, key, value): + # Validate immutable keys + 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) + + def pop(self, key, *args, **kwargs): + # Raise exception if is trying to pop key which is immutable + if key in self.__immutable_keys: + raise ImmutableKeyError(key) + + has_key = key in self._data + output = self._data.pop(key, *args, **kwargs) + if has_key: + if key in self.__required_keys: + self._data[key] = self.__required_keys[key] + self._create_context.instance_values_changed( + self.id, {key: None} + ) + return output + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + # ------ + + @property + def product_type(self): + return self._data["productType"] + + @property + def product_name(self): + return self._data["productName"] + + @property + def label(self): + label = self._data.get("label") + if not label: + label = self.product_name + return label + + @property + def group_label(self): + label = self._data.get("group") + if label: + return label + return self._group_label + + @property + def origin_data(self): + output = copy.deepcopy(self._orig_data) + output["creator_attributes"] = self.creator_attributes.origin_data + output["publish_attributes"] = self.publish_attributes.origin_data + return output + + @property + def creator_identifier(self): + return self._data["creator_identifier"] + + @property + def creator_label(self): + return self._creator.label or self.creator_identifier + + @property + def id(self): + """Instance identifier. + + Returns: + str: UUID of instance. + """ + + return self._data["instance_id"] + + @property + def data(self): + """Legacy access to data. + + Access to data is needed to modify values. + + Returns: + CreatedInstance: Object can be used as dictionary but with + validations of immutable keys. + """ + + return self + + @property + def transient_data(self): + """Data stored for lifetime of instance object. + + These data are not stored to scene and will be lost on object + deletion. + + Can be used to store objects. In some host implementations is not + possible to reference to object in scene with some unique identifier + (e.g. node in Fusion.). In that case it is handy to store the object + here. Should be used that way only if instance data are stored on the + node itself. + + Returns: + Dict[str, Any]: Dictionary object where you can store data related + to instance for lifetime of instance object. + """ + + return self._transient_data + + def changes(self): + """Calculate and return changes.""" + + return TrackChangesItem(self.origin_data, self.data_to_store()) + + def mark_as_stored(self): + """Should be called when instance data are stored. + + Origin data are replaced by current data so changes are cleared. + """ + + orig_keys = set(self._orig_data.keys()) + for key, value in self._data.items(): + orig_keys.discard(key) + if key in ("creator_attributes", "publish_attributes"): + continue + self._orig_data[key] = copy.deepcopy(value) + + for key in orig_keys: + self._orig_data.pop(key) + + self.creator_attributes.mark_as_stored() + self.publish_attributes.mark_as_stored() + + @property + def creator_attributes(self): + return self._data["creator_attributes"] + + @property + def creator_attribute_defs(self): + """Attribute definitions defined by creator plugin. + + Returns: + List[AbstractAttrDef]: Attribute definitions. + """ + + return self.creator_attributes.attr_defs + + @property + def publish_attributes(self): + return self._data["publish_attributes"] + + @property + def has_promised_context(self) -> bool: + """Get context data that are promised to be set by creator. + + Returns: + bool: Has context that won't bo validated. Artist can't change + value when set to True. + + """ + return self._transient_data.get("has_promised_context", False) + + def data_to_store(self): + """Collect data that contain json parsable types. + + It is possible to recreate the instance using these data. + + Todos: + We probably don't need OrderedDict. When data are loaded they + are not ordered anymore. + + Returns: + OrderedDict: Ordered dictionary with instance data. + """ + + output = collections.OrderedDict() + for key, value in self._data.items(): + if key in ("creator_attributes", "publish_attributes"): + continue + output[key] = value + + 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 + + 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 + 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"] + + 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 = copy.deepcopy(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): + """Convert instance data from workfile to CreatedInstance. + + Args: + instance_data (Dict[str, Any]): Data in a structure ready for + 'CreatedInstance' object. + creator (BaseCreator): Creator plugin which is creating the + instance of for which the instance belong. + """ + + instance_data = copy.deepcopy(instance_data) + + product_type = instance_data.get("productType") + if product_type is None: + product_type = instance_data.get("family") + if product_type is None: + product_type = creator.product_type + product_name = instance_data.get("productName") + if product_name is None: + product_name = instance_data.get("subset") + + return cls( + product_type, product_name, 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): + """Set attribute definitions for 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._create_context.instance_publish_attr_defs_changed( + self.id, plugin_name + ) + + 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, + { + "publish_attributes": { + plugin_name: value, + }, + }, + ) + + def add_members(self, members): + """Currently unused method.""" + + for member in members: + if member not in self._members: + self._members.append(member) + + @property + def _create_context(self): + """Get create context. + + Returns: + CreateContext: Context object which wraps object. + + """ + return self._creator.create_context diff --git a/client/ayon_core/pipeline/delivery.py b/client/ayon_core/pipeline/delivery.py index 029775e1db..366c261e08 100644 --- a/client/ayon_core/pipeline/delivery.py +++ b/client/ayon_core/pipeline/delivery.py @@ -3,11 +3,20 @@ import os import copy import shutil import glob -import clique import collections +from typing import Dict, Any, Iterable + +import clique +import ayon_api from ayon_core.lib import create_hard_link +from .template_data import ( + get_general_template_data, + get_folder_template_data, + get_task_template_data, +) + def _copy_file(src_path, dst_path): """Hardlink file if possible(to save space), copy if not. @@ -327,3 +336,82 @@ def deliver_sequence( uploaded += 1 return report_items, uploaded + + +def _merge_data(data, new_data): + queue = collections.deque() + queue.append((data, new_data)) + while queue: + q_data, q_new_data = queue.popleft() + for key, value in q_new_data.items(): + if key in q_data and isinstance(value, dict): + queue.append((q_data[key], value)) + continue + q_data[key] = value + + +def get_representations_delivery_template_data( + project_name: str, + representation_ids: Iterable[str], +) -> Dict[str, Dict[str, Any]]: + representation_ids = set(representation_ids) + + output = { + repre_id: {} + for repre_id in representation_ids + } + if not representation_ids: + return output + + project_entity = ayon_api.get_project(project_name) + + general_template_data = get_general_template_data() + + repres_hierarchy = ayon_api.get_representations_hierarchy( + project_name, + representation_ids, + project_fields=set(), + folder_fields={"path", "folderType"}, + task_fields={"name", "taskType"}, + product_fields={"name", "productType"}, + version_fields={"version", "productId"}, + representation_fields=None, + ) + for repre_id, repre_hierarchy in repres_hierarchy.items(): + repre_entity = repre_hierarchy.representation + if repre_entity is None: + continue + + template_data = repre_entity["context"] + # Bug in 'ayon_api', 'get_representations_hierarchy' did not fully + # convert representation entity. Fixed in 'ayon_api' 1.0.10. + if isinstance(template_data, str): + con = ayon_api.get_server_api_connection() + repre_entity = con._representation_conversion(repre_entity) + 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"]) + + # Remove roots from template data to auto-fill them with anatomy data + template_data.pop("root", None) + + output[repre_id] = template_data + return output diff --git a/client/ayon_core/pipeline/editorial.py b/client/ayon_core/pipeline/editorial.py index 84bffbe1ec..a49a981d2a 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,11 +173,145 @@ 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 + available_range = otio_clip.available_range() + source_range = otio_clip.source_range + available_range_rate = available_range.start_time.rate + media_in = available_range.start_time.value + + # Temporary. + # Some AYON custom OTIO exporter were implemented with relative + # source range for image sequence. Following code maintain + # backward-compatibility by adjusting media_in + # while we are updating those. + if ( + is_clip_from_media_sequence(otio_clip) + and otio_clip.available_range().start_time.to_frames() == media_ref.start_frame + and source_range.start_time.to_frames() < media_ref.start_frame + ): + media_in = 0 + + 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() - media_in = available_range.start_time.value - media_out = available_range.end_time_inclusive().value + 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 = is_clip_from_media_sequence(otio_clip) + + # 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 + # + # 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. + + # Backward-compatibility for Hiero OTIO exporter. + # NTSC compatibility might introduce floating rates, when these are + # not exactly the same (23.976 vs 23.976024627685547) + # this will cause precision issue in computation. + # Currently round to 2 decimals for comparison, + # but this should always rescale after that. + rounded_av_rate = round(available_range_rate, 2) + rounded_src_rate = round(source_range.start_time.rate, 2) + if rounded_av_rate != rounded_src_rate: + conformed_src_in = source_range.start_time.rescaled_to(available_range_rate) + conformed_src_duration = source_range.duration.rescaled_to(available_range_rate) + conformed_source_range = otio.opentime.TimeRange( + start_time=conformed_src_in, + duration=conformed_src_duration + ) + + else: + conformed_source_range = source_range # modifiers time_scalar = 1. @@ -224,38 +358,51 @@ 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 - if time_scalar < 0: - _offset_in = offset_out - _offset_out = offset_in - offset_in = _offset_in - offset_out = _offset_out - # scale handles handle_start *= abs(time_scalar) handle_end *= abs(time_scalar) - # filip handles if reversed speed + # flip offset and handles if reversed speed if time_scalar < 0: - _handle_start = handle_end - _handle_end = handle_start - handle_start = _handle_start - handle_end = _handle_end + 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 = ( - media_in + source_in + offset_in) - media_out_trimmed = ( - media_in + source_in + ( - ((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 - # calculate available handles + # If media source is an image sequence, returned + # mediaIn/mediaOut have to correspond + # to frame numbers from source sequence. + if is_input_sequence: + # preserve discrete frame numbers + 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 + + # adjust available handles if needed 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) + + # 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 = { @@ -263,16 +410,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end): "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/pipeline/farm/pyblish_functions.py b/client/ayon_core/pipeline/farm/pyblish_functions.py index 72deee185e..16364a17ee 100644 --- a/client/ayon_core/pipeline/farm/pyblish_functions.py +++ b/client/ayon_core/pipeline/farm/pyblish_functions.py @@ -1,5 +1,5 @@ -import os import copy +import os import re import warnings from copy import deepcopy @@ -7,14 +7,11 @@ from copy import deepcopy import attr import ayon_api import clique - -from ayon_core.pipeline import ( - get_current_project_name, - get_representation_path, -) -from ayon_core.lib import Logger -from ayon_core.pipeline.publish import KnownPublishError +from ayon_core.lib import Logger, collect_frames +from ayon_core.pipeline import get_current_project_name, get_representation_path +from ayon_core.pipeline.create import get_product_name from ayon_core.pipeline.farm.patterning import match_aov_pattern +from ayon_core.pipeline.publish import KnownPublishError @attr.s @@ -250,6 +247,9 @@ def create_skeleton_instance( "colorspace": data.get("colorspace") } + if data.get("renderlayer"): + instance_skeleton_data["renderlayer"] = data["renderlayer"] + # skip locking version if we are creating v01 instance_version = data.get("version") # take this if exists if instance_version != 1: @@ -295,11 +295,17 @@ def _add_review_families(families): return families -def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, - skip_integration_repre_list, - do_not_add_review, - context, - color_managed_plugin): +def prepare_representations( + skeleton_data, + exp_files, + anatomy, + aov_filter, + skip_integration_repre_list, + do_not_add_review, + context, + color_managed_plugin, + frames_to_render=None +): """Create representations for file sequences. This will return representations of expected files if they are not @@ -315,6 +321,8 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, skip_integration_repre_list (list): exclude specific extensions, do_not_add_review (bool): explicitly skip review color_managed_plugin (publish.ColormanagedPyblishPluginMixin) + frames_to_render (str): implicit or explicit range of frames to render + this value is sent to Deadline in JobInfo.Frames Returns: list of representations @@ -325,6 +333,14 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, log = Logger.get_logger("farm_publishing") + if frames_to_render is not None: + frames_to_render = _get_real_frames_to_render(frames_to_render) + else: + # Backwards compatibility for older logic + frame_start = int(skeleton_data.get("frameStartHandle")) + frame_end = int(skeleton_data.get("frameEndHandle")) + frames_to_render = list(range(frame_start, frame_end + 1)) + # create representation for every collected sequence for collection in collections: ext = collection.tail.lstrip(".") @@ -361,18 +377,21 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, " This may cause issues on farm." ).format(staging)) - frame_start = int(skeleton_data.get("frameStartHandle")) + frame_start = frames_to_render[0] + frame_end = frames_to_render[-1] if skeleton_data.get("slate"): frame_start -= 1 + files = _get_real_files_to_rendered(collection, frames_to_render) + # explicitly disable review by user preview = preview and not do_not_add_review rep = { "name": ext, "ext": ext, - "files": [os.path.basename(f) for f in list(collection)], + "files": files, "frameStart": frame_start, - "frameEnd": int(skeleton_data.get("frameEndHandle")), + "frameEnd": frame_end, # If expectedFile are absolute, we need only filenames "stagingDir": staging, "fps": skeleton_data.get("fps"), @@ -413,10 +432,13 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, " This may cause issues on farm." ).format(staging)) + files = _get_real_files_to_rendered( + [os.path.basename(remainder)], frames_to_render) + rep = { "name": ext, "ext": ext, - "files": os.path.basename(remainder), + "files": files[0], "stagingDir": staging, } @@ -453,6 +475,53 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, return representations +def _get_real_frames_to_render(frames): + """Returns list of frames that should be rendered. + + Artists could want to selectively render only particular frames + """ + frames_to_render = [] + for frame in frames.split(","): + if "-" in frame: + splitted = frame.split("-") + frames_to_render.extend( + range(int(splitted[0]), int(splitted[1])+1)) + else: + frames_to_render.append(int(frame)) + frames_to_render.sort() + return frames_to_render + + +def _get_real_files_to_rendered(collection, frames_to_render): + """Use expected files based on real frames_to_render. + + Artists might explicitly set frames they want to render via Publisher UI. + This uses this value to filter out files + Args: + frames_to_render (list): of str '1001' + """ + files = [os.path.basename(f) for f in list(collection)] + file_name, extracted_frame = list(collect_frames(files).items())[0] + + if not extracted_frame: + return files + + found_frame_pattern_length = len(extracted_frame) + normalized_frames_to_render = { + str(frame_to_render).zfill(found_frame_pattern_length) + for frame_to_render in frames_to_render + } + + return [ + file_name + for file_name in files + if any( + frame in file_name + for frame in normalized_frames_to_render + ) + ] + + def create_instances_for_aov(instance, skeleton, aov_filter, skip_integration_repre_list, do_not_add_review): @@ -464,7 +533,9 @@ def create_instances_for_aov(instance, skeleton, aov_filter, Args: instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton instance data. + aov_filter (dict): AOV filter. skip_integration_repre_list (list): skip + do_not_add_review (bool): Explicitly disable reviews Returns: list of pyblish.api.Instance: Instances created from @@ -515,6 +586,131 @@ def create_instances_for_aov(instance, skeleton, aov_filter, ) +def _get_legacy_product_name_and_group( + product_type, + source_product_name, + task_name, + dynamic_data): + """Get product name with legacy logic. + + This function holds legacy behaviour of creating product name + that is deprecated. This wasn't using product name templates + at all, only hardcoded values. It shouldn't be used anymore, + but transition to templates need careful checking of the project + and studio settings. + + Deprecated: + since 0.4.4 + + Args: + product_type (str): Product type. + source_product_name (str): Source product name. + task_name (str): Task name. + dynamic_data (dict): Dynamic data (camera, aov, ...) + + Returns: + tuple: product name and group name + + """ + warnings.warn("Using legacy product name for renders", + DeprecationWarning) + + if not source_product_name.startswith(product_type): + resulting_group_name = '{}{}{}{}{}'.format( + product_type, + task_name[0].upper(), task_name[1:], + source_product_name[0].upper(), source_product_name[1:]) + else: + resulting_group_name = source_product_name + + # create product name `` + if not source_product_name.startswith(product_type): + resulting_group_name = '{}{}{}{}{}'.format( + product_type, + task_name[0].upper(), task_name[1:], + source_product_name[0].upper(), source_product_name[1:]) + else: + resulting_group_name = source_product_name + + resulting_product_name = resulting_group_name + camera = dynamic_data.get("camera") + aov = dynamic_data.get("aov") + if camera: + if not aov: + resulting_product_name = '{}_{}'.format( + resulting_group_name, camera) + elif not aov.startswith(camera): + resulting_product_name = '{}_{}_{}'.format( + resulting_group_name, camera, aov) + else: + resulting_product_name = "{}_{}".format( + resulting_group_name, aov) + else: + if aov: + resulting_product_name = '{}_{}'.format( + resulting_group_name, aov) + + return resulting_product_name, resulting_group_name + + +def get_product_name_and_group_from_template( + project_name, + task_entity, + product_type, + variant, + host_name, + dynamic_data=None): + """Get product name and group name from template. + + This will get product name and group name from template based on + data provided. It is doing similar work as + `func::_get_legacy_product_name_and_group` but using templates. + + To get group name, template is called without any dynamic data, so + (depending on the template itself) it should be product name without + aov. + + Todo: + Maybe we should introduce templates for the groups themselves. + + Args: + task_entity (dict): Task entity. + project_name (str): Project name. + host_name (str): Host name. + product_type (str): Product type. + variant (str): Variant. + dynamic_data (dict): Dynamic data (aov, renderlayer, camera, ...). + + Returns: + tuple: product name and group name. + + """ + # remove 'aov' from data used to format group. See todo comment above + # for possible solution. + _dynamic_data = deepcopy(dynamic_data) or {} + _dynamic_data.pop("aov", None) + resulting_group_name = get_product_name( + project_name=project_name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + host_name=host_name, + product_type=product_type, + dynamic_data=_dynamic_data, + variant=variant, + ) + + resulting_product_name = get_product_name( + project_name=project_name, + task_name=task_entity["name"], + task_type=task_entity["taskType"], + host_name=host_name, + product_type=product_type, + dynamic_data=dynamic_data, + variant=variant, + ) + return resulting_product_name, resulting_group_name + + def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, skip_integration_repre_list, do_not_add_review): """Create instance for each AOV found. @@ -526,10 +722,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton data for instance (those needed) later by collector. - additional_data (dict): .. + additional_data (dict): ... skip_integration_repre_list (list): list of extensions that shouldn't be published - do_not_addbe _review (bool): explicitly disable review + do_not_add_review (bool): explicitly disable review Returns: @@ -539,68 +735,70 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, ValueError: """ - # TODO: this needs to be taking the task from context or instance - task = os.environ["AYON_TASK_NAME"] anatomy = instance.context.data["anatomy"] - s_product_name = skeleton["productName"] + source_product_name = skeleton["productName"] cameras = instance.data.get("cameras", []) - exp_files = instance.data["expectedFiles"] + expected_files = instance.data["expectedFiles"] log = Logger.get_logger("farm_publishing") instances = [] # go through AOVs in expected files - for aov, files in exp_files[0].items(): - cols, rem = clique.assemble(files) - # we shouldn't have any reminders. And if we do, it should - # be just one item for single frame renders. - if not cols and rem: - if len(rem) != 1: - raise ValueError("Found multiple non related files " - "to render, don't know what to do " - "with them.") - col = rem[0] - ext = os.path.splitext(col)[1].lstrip(".") - else: - # but we really expect only one collection. - # Nothing else make sense. - if len(cols) != 1: - raise ValueError("Only one image sequence type is expected.") # noqa: E501 - ext = cols[0].tail.lstrip(".") - col = list(cols[0]) + for aov, files in expected_files[0].items(): + collected_files = _collect_expected_files_for_aov(files) - # create product name `` - # TODO refactor/remove me - product_type = skeleton["productType"] - if not s_product_name.startswith(product_type): - group_name = '{}{}{}{}{}'.format( - product_type, - task[0].upper(), task[1:], - s_product_name[0].upper(), s_product_name[1:]) - else: - group_name = s_product_name + expected_filepath = collected_files + if isinstance(collected_files, (list, tuple)): + expected_filepath = collected_files[0] - # if there are multiple cameras, we need to add camera name - expected_filepath = col[0] if isinstance(col, (list, tuple)) else col - cams = [cam for cam in cameras if cam in expected_filepath] - if cams: - for cam in cams: - if not aov: - product_name = '{}_{}'.format(group_name, cam) - elif not aov.startswith(cam): - product_name = '{}_{}_{}'.format(group_name, cam, aov) - else: - product_name = "{}_{}".format(group_name, aov) - else: - if aov: - product_name = '{}_{}'.format(group_name, aov) - else: - product_name = '{}'.format(group_name) + dynamic_data = { + "aov": aov, + "renderlayer": instance.data.get("renderlayer"), + } + + # find if camera is used in the file path + # TODO: this must be changed to be more robust. Any coincidence + # of camera name in the file path will be considered as + # camera name. This is not correct. + camera = [cam for cam in cameras if cam in expected_filepath] + + # Is there just one camera matching? + # TODO: this is not true, we can have multiple cameras in the scene + # and we should be able to detect them all. Currently, we are + # keeping the old behavior, taking the first one found. + if camera: + dynamic_data["camera"] = camera[0] + + project_settings = instance.context.data.get("project_settings") + + use_legacy_product_name = True + try: + use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501 + except KeyError: + warnings.warn( + ("use_legacy_for_renders not found in project settings. " + "Using legacy product name for renders. Please update " + "your ayon-core version."), DeprecationWarning) + use_legacy_product_name = True + + if use_legacy_product_name: + product_name, group_name = _get_legacy_product_name_and_group( + product_type=skeleton["productType"], + source_product_name=source_product_name, + task_name=instance.data["task"], + dynamic_data=dynamic_data) - if isinstance(col, (list, tuple)): - staging = os.path.dirname(col[0]) else: - staging = os.path.dirname(col) + product_name, group_name = get_product_name_and_group_from_template( + task_entity=instance.data["taskEntity"], + project_name=instance.context.data["projectName"], + host_name=instance.context.data["hostName"], + product_type=skeleton["productType"], + variant=instance.data.get("variant", source_product_name), + dynamic_data=dynamic_data + ) + + staging = os.path.dirname(expected_filepath) try: staging = remap_source(staging, anatomy) @@ -611,10 +809,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, app = os.environ.get("AYON_HOST_NAME", "") - if isinstance(col, list): - render_file_name = os.path.basename(col[0]) - else: - render_file_name = os.path.basename(col) + render_file_name = os.path.basename(expected_filepath) + aov_patterns = aov_filter preview = match_aov_pattern(app, aov_patterns, render_file_name) @@ -622,9 +818,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, new_instance = deepcopy(skeleton) new_instance["productName"] = product_name new_instance["productGroup"] = group_name + new_instance["aov"] = aov # toggle preview on if multipart is on - # Because we cant query the multipartExr data member of each AOV we'll + # Because we can't query the multipartExr data member of each AOV we'll # need to have hardcoded rule of excluding any renders with # "cryptomatte" in the file name from being a multipart EXR. This issue # happens with Redshift that forces Cryptomatte renders to be separate @@ -650,10 +847,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, new_instance["review"] = True # create representation - if isinstance(col, (list, tuple)): - files = [os.path.basename(f) for f in col] - else: - files = os.path.basename(col) + ext = os.path.splitext(render_file_name)[-1].lstrip(".") # Copy render product "colorspace" data to representation. colorspace = "" @@ -663,10 +857,15 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, colorspace = product.colorspace break + if isinstance(collected_files, (list, tuple)): + collected_files = [os.path.basename(f) for f in collected_files] + else: + 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 @@ -708,6 +907,35 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, return instances +def _collect_expected_files_for_aov(files): + """Collect expected files. + + Args: + files (list): List of files. + + Returns: + list or str: Collection of files or single file. + + Raises: + ValueError: If there are multiple collections. + + """ + cols, rem = clique.assemble(files) + # we shouldn't have any reminders. And if we do, it should + # be just one item for single frame renders. + if not cols and rem: + if len(rem) != 1: + raise ValueError("Found multiple non related files " + "to render, don't know what to do " + "with them.") + return rem[0] + # but we really expect only one collection. + # Nothing else make sense. + if len(cols) != 1: + raise ValueError("Only one image sequence type is expected.") # noqa: E501 + return list(cols[0]) + + def get_resources(project_name, version_entity, extension=None): """Get the files from the specific version. diff --git a/client/ayon_core/pipeline/farm/pyblish_functions.pyi b/client/ayon_core/pipeline/farm/pyblish_functions.pyi deleted file mode 100644 index fe0ae57da0..0000000000 --- a/client/ayon_core/pipeline/farm/pyblish_functions.pyi +++ /dev/null @@ -1,24 +0,0 @@ -import pyblish.api -from ayon_core.pipeline import Anatomy -from typing import Tuple, List - - -class TimeData: - start: int - end: int - fps: float | int - step: int - handle_start: int - handle_end: int - - def __init__(self, start: int, end: int, fps: float | int, step: int, handle_start: int, handle_end: int): - ... - ... - -def remap_source(source: str, anatomy: Anatomy): ... -def extend_frames(folder_path: str, product_name: str, start: int, end: int) -> Tuple[int, int]: ... -def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ... -def get_transferable_representations(instance: pyblish.api.Instance) -> list: ... -def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ... -def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict, aov_filter: dict) -> List[pyblish.api.Instance]: ... -def attach_instances_to_product(attach_to: list, instances: list) -> list: ... diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 2475800cbb..1fb906fd65 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -242,6 +242,26 @@ class LoaderPlugin(list): if hasattr(self, "_fname"): return self._fname + @classmethod + def get_representation_name_aliases(cls, representation_name: str): + """Return representation names to which switching is allowed from + the input representation name, like an alias replacement of the input + `representation_name`. + + For example, to allow an automated switch on update from representation + `ma` to `mb` or `abc`, then when `representation_name` is `ma` return: + ["mb", "abc"] + + The order of the names in the returned representation names is + important, because the first one existing under the new version will + be chosen. + + Returns: + List[str]: Representation names switching to is allowed on update + if the input representation name is not found on the new version. + """ + return [] + class ProductLoaderPlugin(LoaderPlugin): """Load product into host application diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 9ba407193e..ee2c1af07f 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -505,21 +505,6 @@ def update_container(container, version=-1): project_name, product_entity["folderId"] ) - repre_name = current_representation["name"] - new_representation = ayon_api.get_representation_by_name( - project_name, repre_name, new_version["id"] - ) - if new_representation is None: - raise ValueError( - "Representation '{}' wasn't found on requested version".format( - repre_name - ) - ) - - path = get_representation_path(new_representation) - if not path or not os.path.exists(path): - raise ValueError("Path {} doesn't exist".format(path)) - # Run update on the Loader for this container Loader = _get_container_loader(container) if not Loader: @@ -527,6 +512,39 @@ def update_container(container, version=-1): "Can't update container because loader '{}' was not found." .format(container.get("loader")) ) + + repre_name = current_representation["name"] + new_representation = ayon_api.get_representation_by_name( + project_name, repre_name, new_version["id"] + ) + if new_representation is None: + # The representation name is not found in the new version. + # Allow updating to a 'matching' representation if the loader + # has defined compatible update conversions + repre_name_aliases = Loader.get_representation_name_aliases(repre_name) + if repre_name_aliases: + representations = ayon_api.get_representations( + project_name, + representation_names=repre_name_aliases, + version_ids=[new_version["id"]]) + representations_by_name = { + repre["name"]: repre for repre in representations + } + for name in repre_name_aliases: + if name in representations_by_name: + new_representation = representations_by_name[name] + break + + if new_representation is None: + raise ValueError( + "Representation '{}' wasn't found on requested version".format( + repre_name + ) + ) + + path = get_representation_path(new_representation) + if not path or not os.path.exists(path): + raise ValueError("Path {} doesn't exist".format(path)) project_entity = ayon_api.get_project(project_name) context = { "project": project_entity, diff --git a/client/ayon_core/pipeline/publish/README.md b/client/ayon_core/pipeline/publish/README.md index ee2124dfd3..0aee726ba6 100644 --- a/client/ayon_core/pipeline/publish/README.md +++ b/client/ayon_core/pipeline/publish/README.md @@ -1,20 +1,22 @@ # Publish -AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. +AYON is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. AYON's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. ## Exceptions AYON define few specific exceptions that should be used in publish plugins. +### 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. + +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. -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. - - -### 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). diff --git a/client/ayon_core/pipeline/publish/__init__.py b/client/ayon_core/pipeline/publish/__init__.py index ab19b6e360..ac71239acf 100644 --- a/client/ayon_core/pipeline/publish/__init__.py +++ b/client/ayon_core/pipeline/publish/__init__.py @@ -9,11 +9,12 @@ from .publish_plugins import ( AbstractMetaInstancePlugin, AbstractMetaContextPlugin, + KnownPublishError, + PublishError, PublishValidationError, PublishXmlValidationError, - KnownPublishError, + AYONPyblishPluginMixin, - OpenPypePyblishPluginMixin, OptionalPyblishPluginMixin, RepairAction, @@ -62,11 +63,12 @@ __all__ = ( "AbstractMetaInstancePlugin", "AbstractMetaContextPlugin", + "KnownPublishError", + "PublishError", "PublishValidationError", "PublishXmlValidationError", - "KnownPublishError", + "AYONPyblishPluginMixin", - "OpenPypePyblishPluginMixin", "OptionalPyblishPluginMixin", "RepairAction", diff --git a/client/ayon_core/pipeline/publish/lib.py b/client/ayon_core/pipeline/publish/lib.py index 8b82622e4c..dc2eef3bb9 100644 --- a/client/ayon_core/pipeline/publish/lib.py +++ b/client/ayon_core/pipeline/publish/lib.py @@ -379,7 +379,7 @@ def get_plugin_settings(plugin, project_settings, log, category=None): plugin_kind = split_path[-2] # TODO: change after all plugins are moved one level up - if category_from_file in ("ayon_core", "openpype"): + if category_from_file == "ayon_core": category_from_file = "core" try: diff --git a/client/ayon_core/pipeline/publish/publish_plugins.py b/client/ayon_core/pipeline/publish/publish_plugins.py index 6b1984d92b..57215eff68 100644 --- a/client/ayon_core/pipeline/publish/publish_plugins.py +++ b/client/ayon_core/pipeline/publish/publish_plugins.py @@ -1,9 +1,19 @@ import inspect from abc import ABCMeta +import typing +from typing import Optional + import pyblish.api +import pyblish.logic from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin + from ayon_core.lib import BoolDef +from ayon_core.pipeline.colorspace import ( + get_colorspace_settings_from_publish_context, + set_colorspace_data_to_representation +) + from .lib import ( load_help_content_from_plugin, get_errored_instances_from_context, @@ -11,10 +21,8 @@ from .lib import ( get_instance_staging_dir, ) -from ayon_core.pipeline.colorspace import ( - get_colorspace_settings_from_publish_context, - set_colorspace_data_to_representation -) +if typing.TYPE_CHECKING: + from ayon_core.pipeline.create import CreateContext, CreatedInstance class AbstractMetaInstancePlugin(ABCMeta, MetaPlugin): @@ -25,27 +33,52 @@ class AbstractMetaContextPlugin(ABCMeta, ExplicitMetaPlugin): pass -class PublishValidationError(Exception): - """Validation error happened during publishing. +class KnownPublishError(Exception): + """Publishing crashed because of known error. - This exception should be used when validation publishing failed. + Artist can't affect source of the error. - Has additional UI specific attributes that may be handy for artist. + Deprecated: + Please use `PublishError` instead. Marked as deprecated 24/09/02. + + """ + pass + + +class PublishError(Exception): + """Publishing crashed because of known error. + + 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(PublishError): + """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): @@ -68,15 +101,6 @@ class PublishXmlValidationError(PublishValidationError): ) -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 @@ -109,32 +133,118 @@ class AYONPyblishPluginMixin: # for callback in self._state_change_callbacks: # callback(self) + @classmethod + def register_create_context_callbacks( + cls, create_context: "CreateContext" + ): + """Register callbacks for create context. + + It is possible to register callbacks listening to changes happened + in create context. + + Methods available on create context: + - 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. + + """ + pass + @classmethod def get_attribute_defs(cls): """Publish attribute definitions. Attributes available for all families in plugin's `families` attribute. - Returns: - list: Attribute definitions for plugin. - """ + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ return [] @classmethod - def convert_attribute_values(cls, attribute_values): - if cls.__name__ not in attribute_values: - return attribute_values + def get_attr_defs_for_context(cls, create_context: "CreateContext"): + """Publish attribute definitions for context. - plugin_values = attribute_values[cls.__name__] + Attributes available for all families in plugin's `families` attribute. - 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 + Args: + create_context (CreateContext): Create context. + + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ + if cls.__instanceEnabled__: + return [] + return cls.get_attribute_defs() + + @classmethod + def instance_matches_plugin_families( + cls, instance: Optional["CreatedInstance"] + ): + """Check if instance matches families. + + Args: + instance (Optional[CreatedInstance]): Instance to check. Or None + for context. + + Returns: + bool: True if instance matches plugin families. + + """ + if instance is None: + return not cls.__instanceEnabled__ + + if not cls.__instanceEnabled__: + return False + + families = [instance.product_type] + families.extend(instance.get("families", [])) + for _ in pyblish.logic.plugins_by_families([cls], families): + return True + return False + + @classmethod + 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. + + Args: + create_context (CreateContext): Create context. + instance (CreatedInstance): Instance for which attributes are + collected. + + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ + if not cls.instance_matches_plugin_families(instance): + return [] + return cls.get_attribute_defs() + + @classmethod + def convert_attribute_values( + cls, create_context: "CreateContext", instance: "CreatedInstance" + ): + """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): @@ -165,9 +275,6 @@ class AYONPyblishPluginMixin: return self.get_attr_values_from_data_for_plugin(self.__class__, data) -OpenPypePyblishPluginMixin = AYONPyblishPluginMixin - - class OptionalPyblishPluginMixin(AYONPyblishPluginMixin): """Prepare mixin for optional plugins. diff --git a/client/ayon_core/pipeline/tempdir.py b/client/ayon_core/pipeline/tempdir.py index 29d4659393..d8f42ea60a 100644 --- a/client/ayon_core/pipeline/tempdir.py +++ b/client/ayon_core/pipeline/tempdir.py @@ -25,13 +25,7 @@ def create_custom_tempdir(project_name, anatomy=None): """ env_tmpdir = os.getenv("AYON_TMPDIR") if not env_tmpdir: - env_tmpdir = os.getenv("OPENPYPE_TMPDIR") - if not env_tmpdir: - return - print( - "DEPRECATION WARNING: Used 'OPENPYPE_TMPDIR' environment" - " variable. Please use 'AYON_TMPDIR' instead." - ) + return custom_tempdir = None if "{" in env_tmpdir: diff --git a/client/ayon_core/pipeline/thumbnails.py b/client/ayon_core/pipeline/thumbnails.py index dbb38615d8..401d95f273 100644 --- a/client/ayon_core/pipeline/thumbnails.py +++ b/client/ayon_core/pipeline/thumbnails.py @@ -4,7 +4,7 @@ import collections import ayon_api -from ayon_core.lib.local_settings import get_ayon_appdirs +from ayon_core.lib.local_settings import get_launcher_local_dir FileInfo = collections.namedtuple( @@ -54,7 +54,7 @@ class ThumbnailsCache: """ if self._thumbnails_dir is None: - self._thumbnails_dir = get_ayon_appdirs("thumbnails") + self._thumbnails_dir = get_launcher_local_dir("thumbnails") return self._thumbnails_dir thumbnails_dir = property(get_thumbnails_dir) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 7b15dff049..4412e4489b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -506,55 +506,61 @@ class AbstractTemplateBuilder(ABC): 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 - workfile_creation_enabled (bool): If True, it might create - first version but ignore - process if version is created + 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 (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. """ - 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"] - if keep_placeholders is None: - keep_placeholders = template_preset["keep_placeholder"] - if create_first_version is None: - create_first_version = template_preset["create_first_version"] + # More accurate variable name + # - logic related to workfile creation should be moved out in future + explicit_build_requested = not workfile_creation_enabled - # check if first version is created - created_version_workfile = False - if create_first_version: - created_version_workfile = self.create_first_workfile_version() - - # if first version is created, import template - # and populate placeholders + # Get default values if not provided if ( - create_first_version - and workfile_creation_enabled - and created_version_workfile + 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"] + if keep_placeholders is None: + keep_placeholders: bool = preset["keep_placeholder"] + if create_first_version is None: + create_first_version: bool = preset["create_first_version"] + + # 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() + 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( level_limit, keep_placeholders) - # save workfile after template is populated - self.save_workfile(created_version_workfile) - - # ignore process if first workfile is enabled - # but a version is already created - if workfile_creation_enabled: + # 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 - self.import_template(template_path) - self.populate_scene_placeholders( - level_limit, keep_placeholders) + # If there is no existing workfile, save the first version + 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. @@ -608,29 +614,16 @@ class AbstractTemplateBuilder(ABC): pass - def create_first_workfile_version(self): - """ - Create first version of workfile. + def get_workfile_path(self): + """Return last known workfile path or the first workfile path create. - 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. + Return: + 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 - - # 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 def save_workfile(self, workfile_path): @@ -859,7 +852,7 @@ class AbstractTemplateBuilder(ABC): "Settings\\Profiles" ).format(host_name.title())) - # Try fill path with environments and anatomy roots + # Try to fill path with environments and anatomy roots anatomy = Anatomy(project_name) fill_data = { key: value @@ -872,9 +865,7 @@ class AbstractTemplateBuilder(ABC): "code": anatomy.project_code, } - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() + path = self.resolve_template_path(path, fill_data) if path and os.path.exists(path): self.log.info("Found template at: '{}'".format(path)) @@ -914,6 +905,27 @@ class AbstractTemplateBuilder(ABC): "create_first_version": create_first_version } + def resolve_template_path(self, path, fill_data) -> str: + """Resolve the template path. + + By default, this does nothing except returning the path directly. + + This can be overridden in host integrations to perform additional + resolving over the template. Like, `hou.text.expandString` in Houdini. + + Arguments: + path (str): The input path. + fill_data (dict[str, str]): Data to use for template formatting. + + Returns: + str: The resolved path. + + """ + result = StringTemplate.format_template(path, fill_data) + if result.solved: + path = result.normalized() + return path + def emit_event(self, topic, data=None, source=None) -> Event: return self._event_system.emit(topic, data, source) @@ -1519,9 +1531,10 @@ class PlaceholderLoadMixin(object): if "asset" in placeholder.data: return [] - representation_name = placeholder.data["representation"] - if not representation_name: - return [] + representation_names = None + representation_name: str = placeholder.data["representation"] + if representation_name: + representation_names = [representation_name] project_name = self.builder.project_name current_folder_entity = self.builder.current_folder_entity @@ -1578,7 +1591,7 @@ class PlaceholderLoadMixin(object): ) return list(get_representations( project_name, - representation_names={representation_name}, + representation_names=representation_names, version_ids=version_ids )) diff --git a/client/ayon_core/plugins/actions/open_file_explorer.py b/client/ayon_core/plugins/actions/open_file_explorer.py index 50a3107444..e96392ec00 100644 --- a/client/ayon_core/plugins/actions/open_file_explorer.py +++ b/client/ayon_core/plugins/actions/open_file_explorer.py @@ -99,7 +99,7 @@ class OpenTaskPath(LauncherAction): if platform_name == "windows": args = ["start", path] elif platform_name == "darwin": - args = ["open", "-na", path] + args = ["open", "-R", path] elif platform_name == "linux": args = ["xdg-open", path] else: diff --git a/client/ayon_core/plugins/actions/show_in_ayon.py b/client/ayon_core/plugins/actions/show_in_ayon.py new file mode 100644 index 0000000000..e30eaa2bc9 --- /dev/null +++ b/client/ayon_core/plugins/actions/show_in_ayon.py @@ -0,0 +1,87 @@ +import os +import urllib.parse +import webbrowser + +from ayon_core.pipeline import LauncherAction +from ayon_core.resources import get_ayon_icon_filepath +import ayon_api + + +def get_ayon_entity_uri( + project_name, + entity_id, + entity_type, +) -> str: + """Resolve AYON Entity URI from representation context. + + Note: + The representation context is the `get_representation_context` dict + containing the `project`, `folder, `representation` and so forth. + It is not the representation entity `context` key. + + Arguments: + project_name (str): The project name. + entity_id (str): The entity UUID. + entity_type (str): The entity type, like "folder" or"task". + + Raises: + RuntimeError: Unable to resolve to a single valid URI. + + Returns: + str: The AYON entity URI. + + """ + response = ayon_api.post( + f"projects/{project_name}/uris", + entityType=entity_type, + ids=[entity_id]) + if response.status_code != 200: + raise RuntimeError( + f"Unable to resolve AYON entity URI for '{project_name}' " + f"{entity_type} id '{entity_id}': {response.text}" + ) + uris = response.data["uris"] + if len(uris) != 1: + raise RuntimeError( + f"Unable to resolve AYON entity URI for '{project_name}' " + f"{entity_type} id '{entity_id}' to single URI. " + f"Received data: {response.data}" + ) + return uris[0]["uri"] + + +class ShowInAYON(LauncherAction): + """Open AYON browser page to the current context.""" + name = "showinayon" + label = "Show in AYON" + icon = get_ayon_icon_filepath() + order = 999 + + def process(self, selection, **kwargs): + url = os.environ["AYON_SERVER_URL"] + if selection.is_project_selected: + project_name = selection.project_name + url += f"/projects/{project_name}/browser" + + # Specify entity URI if task or folder is select + entity = None + entity_type = None + if selection.is_task_selected: + entity = selection.get_task_entity() + entity_type = "task" + elif selection.is_folder_selected: + entity = selection.get_folder_entity() + entity_type = "folder" + + if entity and entity_type: + uri = get_ayon_entity_uri( + project_name, + entity_id=entity["id"], + entity_type=entity_type + ) + uri_encoded = urllib.parse.quote_plus(uri) + url += f"?uri={uri_encoded}" + + # Open URL in webbrowser + self.log.info(f"Opening URL: {url}") + webbrowser.open_new_tab(url) diff --git a/client/ayon_core/plugins/load/delivery.py b/client/ayon_core/plugins/load/delivery.py index c7954a18b2..406040d936 100644 --- a/client/ayon_core/plugins/load/delivery.py +++ b/client/ayon_core/plugins/load/delivery.py @@ -1,24 +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_sequence, + get_representations_delivery_template_data, ) @@ -201,20 +199,31 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): format_dict = get_format_dict(self.anatomy, self.root_line_edit.text()) renumber_frame = self.renumber_frame.isChecked() frame_offset = self.first_frame_start.value() + filtered_repres = [] + repre_ids = set() for repre in self._representations: - if repre["name"] not in selected_repres: - continue + if repre["name"] in selected_repres: + filtered_repres.append(repre) + repre_ids.add(repre["id"]) + template_data_by_repre_id = ( + get_representations_delivery_template_data( + self.anatomy.project_name, repre_ids + ) + ) + for repre in filtered_repres: repre_path = get_representation_path_with_anatomy( repre, self.anatomy ) - anatomy_data = copy.deepcopy(repre["context"]) - new_report_items = check_destination_path(repre["id"], - self.anatomy, - anatomy_data, - datetime_data, - template_name) + template_data = template_data_by_repre_id[repre["id"]] + new_report_items = check_destination_path( + repre["id"], + self.anatomy, + template_data, + datetime_data, + template_name + ) report_items.update(new_report_items) if new_report_items: @@ -225,57 +234,61 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): repre, self.anatomy, template_name, - anatomy_data, + template_data, format_dict, report_items, 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) + # 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"]) + 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) + 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 + 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: # 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) + if frame is not None: + if repre["context"].get("frame"): + template_data["frame"] = frame + elif repre["context"].get("udim"): + template_data["udim"] = frame + else: + # Fallback + self.log.warning( + "Representation context has no frame or udim" + " data. Supplying sequence frame to '{frame}'" + " formatting data." + ) + template_data["frame"] = frame + new_report_items, uploaded = deliver_single_file(*args) report_items.update(new_report_items) self._update_progress(uploaded) @@ -339,8 +352,8 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): def _get_selected_repres(self): """Returns list of representation names filtered from checkboxes.""" selected_repres = [] - for repre_name, chckbox in self._representation_checkboxes.items(): - if chckbox.isChecked(): + for repre_name, checkbox in self._representation_checkboxes.items(): + if checkbox.isChecked(): selected_repres.append(repre_name) return selected_repres diff --git a/client/ayon_core/plugins/load/export_otio.py b/client/ayon_core/plugins/load/export_otio.py new file mode 100644 index 0000000000..e7a844aed3 --- /dev/null +++ b/client/ayon_core/plugins/load/export_otio.py @@ -0,0 +1,591 @@ +import logging +import os +from pathlib import Path +from collections import defaultdict + +from qtpy import QtWidgets, QtCore, QtGui +from ayon_api import get_representations + +from ayon_core.pipeline import load, Anatomy +from ayon_core import resources, style +from ayon_core.lib.transcoding import ( + IMAGE_EXTENSIONS, + get_oiio_info_for_input, +) +from ayon_core.lib import ( + get_ffprobe_data, + is_oiio_supported, +) +from ayon_core.pipeline.load import get_representation_path_with_anatomy +from ayon_core.tools.utils import show_message_dialog + +OTIO = None +FRAME_SPLITTER = "__frame_splitter__" + +def _import_otio(): + global OTIO + if OTIO is None: + import opentimelineio + OTIO = opentimelineio + + +class ExportOTIO(load.ProductLoaderPlugin): + """Export selected versions to OpenTimelineIO.""" + + is_multiple_contexts_compatible = True + sequence_splitter = "__sequence_splitter__" + + representations = {"*"} + product_types = {"*"} + tool_names = ["library_loader"] + + label = "Export OTIO" + order = 35 + icon = "save" + color = "#d8d8d8" + + def load(self, contexts, name=None, namespace=None, options=None): + _import_otio() + try: + dialog = ExportOTIOOptionsDialog(contexts, self.log) + dialog.exec_() + except Exception: + self.log.error("Failed to export OTIO.", exc_info=True) + + +class ExportOTIOOptionsDialog(QtWidgets.QDialog): + """Dialog to select template where to deliver selected representations.""" + + def __init__(self, contexts, log=None, parent=None): + # Not all hosts have OpenTimelineIO available. + self.log = log + + super().__init__(parent=parent) + + self.setWindowTitle("AYON - Export OTIO") + icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + + project_name = contexts[0]["project"]["name"] + versions_by_id = { + context["version"]["id"]: context["version"] + for context in contexts + } + repre_entities = list(get_representations( + project_name, version_ids=set(versions_by_id) + )) + version_by_representation_id = { + repre_entity["id"]: versions_by_id[repre_entity["versionId"]] + for repre_entity in repre_entities + } + version_path_by_id = {} + representations_by_version_id = {} + for context in contexts: + version_id = context["version"]["id"] + if version_id in version_path_by_id: + continue + representations_by_version_id[version_id] = [] + version_path_by_id[version_id] = "/".join([ + context["folder"]["path"], + context["product"]["name"], + context["version"]["name"] + ]) + + for repre_entity in repre_entities: + representations_by_version_id[repre_entity["versionId"]].append( + repre_entity + ) + + all_representation_names = list(sorted({ + repo_entity["name"] + for repo_entity in repre_entities + })) + + input_widget = QtWidgets.QWidget(self) + input_layout = QtWidgets.QGridLayout(input_widget) + input_layout.setContentsMargins(8, 8, 8, 8) + + row = 0 + repres_label = QtWidgets.QLabel("Representations:", input_widget) + input_layout.addWidget(repres_label, row, 0) + repre_name_buttons = [] + for idx, name in enumerate(all_representation_names): + repre_name_btn = QtWidgets.QPushButton(name, input_widget) + input_layout.addWidget( + repre_name_btn, row, idx + 1, + alignment=QtCore.Qt.AlignCenter + ) + repre_name_btn.clicked.connect(self._toggle_all) + repre_name_buttons.append(repre_name_btn) + + row += 1 + + representation_widgets = defaultdict(list) + items = representations_by_version_id.items() + for version_id, representations in items: + version_path = version_path_by_id[version_id] + label_widget = QtWidgets.QLabel(version_path, input_widget) + input_layout.addWidget(label_widget, row, 0) + + repres_by_name = { + repre_entity["name"]: repre_entity + for repre_entity in representations + } + radio_group = QtWidgets.QButtonGroup(input_widget) + for idx, name in enumerate(all_representation_names): + if name in repres_by_name: + widget = QtWidgets.QRadioButton(input_widget) + radio_group.addButton(widget) + representation_widgets[name].append( + { + "widget": widget, + "representation": repres_by_name[name] + } + ) + else: + widget = QtWidgets.QLabel("x", input_widget) + + input_layout.addWidget( + widget, row, idx + 1, 1, 1, + alignment=QtCore.Qt.AlignCenter + ) + + row += 1 + + export_widget = QtWidgets.QWidget(self) + + options_widget = QtWidgets.QWidget(export_widget) + + uri_label = QtWidgets.QLabel("URI paths:", options_widget) + uri_path_format = QtWidgets.QCheckBox(options_widget) + uri_path_format.setToolTip( + "Use URI paths (file:///) instead of absolute paths. " + "This is useful when the OTIO file will be used on Foundry Hiero." + ) + + button_output_path = QtWidgets.QPushButton( + "Output Path:", options_widget + ) + button_output_path.setToolTip( + "Click to select the output path for the OTIO file." + ) + + line_edit_output_path = QtWidgets.QLineEdit( + (Path.home() / f"{project_name}.otio").as_posix(), + options_widget + ) + + options_layout = QtWidgets.QHBoxLayout(options_widget) + options_layout.setContentsMargins(0, 0, 0, 0) + options_layout.addWidget(uri_label) + options_layout.addWidget(uri_path_format) + options_layout.addWidget(button_output_path) + options_layout.addWidget(line_edit_output_path) + + button_export = QtWidgets.QPushButton("Export", export_widget) + + export_layout = QtWidgets.QVBoxLayout(export_widget) + export_layout.setContentsMargins(0, 0, 0, 0) + export_layout.addWidget(options_widget, 0) + export_layout.addWidget(button_export, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.addWidget(input_widget, 0) + main_layout.addStretch(1) + # TODO add line spacer? + main_layout.addSpacing(30) + main_layout.addWidget(export_widget, 0) + + button_export.clicked.connect(self._on_export_click) + button_output_path.clicked.connect(self._set_output_path) + + self._project_name = project_name + self._version_path_by_id = version_path_by_id + self._version_by_representation_id = version_by_representation_id + self._representation_widgets = representation_widgets + self._repre_name_buttons = repre_name_buttons + + self._uri_path_format = uri_path_format + self._button_output_path = button_output_path + self._line_edit_output_path = line_edit_output_path + self._button_export = button_export + + self._first_show = True + + def showEvent(self, event): + super().showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + def _toggle_all(self): + representation_name = self.sender().text() + for item in self._representation_widgets[representation_name]: + item["widget"].setChecked(True) + + def _set_output_path(self): + file_path, _ = QtWidgets.QFileDialog.getSaveFileName( + None, "Save OTIO file.", "", "OTIO Files (*.otio)" + ) + if file_path: + self._line_edit_output_path.setText(file_path) + + def _on_export_click(self): + output_path = self._line_edit_output_path.text() + # Validate output path is not empty. + if not output_path: + show_message_dialog( + "Missing output path", + ( + "Output path is empty. Please enter a path to export the " + "OTIO file to." + ), + level="critical", + parent=self + ) + return + + # Validate output path ends with .otio. + if not output_path.endswith(".otio"): + show_message_dialog( + "Wrong extension.", + ( + "Output path needs to end with \".otio\"." + ), + level="critical", + parent=self + ) + return + + representations = [] + for name, items in self._representation_widgets.items(): + for item in items: + if item["widget"].isChecked(): + representations.append(item["representation"]) + + anatomy = Anatomy(self._project_name) + clips_data = {} + for representation in representations: + version = self._version_by_representation_id[ + representation["id"] + ] + name = ( + f'{self._version_path_by_id[version["id"]]}' + f'/{representation["name"]}' + ).replace("/", "_") + + clips_data[name] = { + "representation": representation, + "anatomy": anatomy, + "frames": ( + version["attrib"]["frameEnd"] + - version["attrib"]["frameStart"] + ), + "framerate": version["attrib"]["fps"], + } + + self.export_otio(clips_data, output_path) + + # Feedback about success. + show_message_dialog( + "Success!", + "Export was successful.", + level="info", + parent=self + ) + + self.close() + + def create_clip(self, name, clip_data, timeline_framerate): + representation = clip_data["representation"] + anatomy = clip_data["anatomy"] + frames = clip_data["frames"] + framerate = clip_data["framerate"] + + # Get path to representation with correct frame number + repre_path = get_representation_path_with_anatomy( + representation, anatomy) + + media_start_frame = clip_start_frame = 0 + media_framerate = framerate + if file_metadata := get_image_info_metadata( + repre_path, ["timecode", "duration", "framerate"], self.log + ): + # get media framerate and convert to float with 3 decimal places + media_framerate = file_metadata["framerate"] + media_framerate = float(f"{media_framerate:.4f}") + framerate = float(f"{timeline_framerate:.4f}") + + media_start_frame = self.get_timecode_start_frame( + media_framerate, file_metadata + ) + clip_start_frame = self.get_timecode_start_frame( + timeline_framerate, file_metadata + ) + + if "duration" in file_metadata: + frames = int(float(file_metadata["duration"]) * framerate) + + repre_path = Path(repre_path) + + first_frame = representation["context"].get("frame") + if first_frame is None: + media_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + media_start_frame, media_framerate + ), + duration=OTIO.opentime.RationalTime( + frames, media_framerate), + ) + clip_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + clip_start_frame, timeline_framerate + ), + duration=OTIO.opentime.RationalTime( + frames, timeline_framerate), + ) + + # Use 'repre_path' as single file + media_reference = OTIO.schema.ExternalReference( + available_range=media_range, + target_url=self.convert_to_uri_or_posix(repre_path), + ) + else: + # This is sequence + repre_files = [ + file["path"].format(root=anatomy.roots) + for file in representation["files"] + ] + # Change frame in representation context to get path with frame + # splitter. + representation["context"]["frame"] = FRAME_SPLITTER + frame_repre_path = get_representation_path_with_anatomy( + representation, anatomy + ) + frame_repre_path = Path(frame_repre_path) + repre_dir, repre_filename = ( + frame_repre_path.parent, frame_repre_path.name) + # Get sequence prefix and suffix + file_prefix, file_suffix = repre_filename.split(FRAME_SPLITTER) + # Get frame number from path as string to get frame padding + frame_str = str(repre_path)[len(file_prefix):][:len(file_suffix)] + frame_padding = len(frame_str) + + media_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + media_start_frame, media_framerate + ), + duration=OTIO.opentime.RationalTime( + len(repre_files), media_framerate + ), + ) + clip_range = OTIO.opentime.TimeRange( + start_time=OTIO.opentime.RationalTime( + clip_start_frame, timeline_framerate + ), + duration=OTIO.opentime.RationalTime( + len(repre_files), timeline_framerate + ), + ) + + media_reference = OTIO.schema.ImageSequenceReference( + available_range=media_range, + start_frame=int(first_frame), + frame_step=1, + rate=framerate, + target_url_base=f"{self.convert_to_uri_or_posix(repre_dir)}/", + name_prefix=file_prefix, + name_suffix=file_suffix, + frame_zero_padding=frame_padding, + ) + + return OTIO.schema.Clip( + name=name, media_reference=media_reference, source_range=clip_range + ) + + def convert_to_uri_or_posix(self, path: Path) -> str: + """Convert path to URI or Posix path. + + Args: + path (Path): Path to convert. + + Returns: + str: Path as URI or Posix path. + """ + if self._uri_path_format.isChecked(): + return path.as_uri() + + return path.as_posix() + + def get_timecode_start_frame(self, framerate, file_metadata): + # use otio to convert timecode into frame number + timecode_start_frame = OTIO.opentime.from_timecode( + file_metadata["timecode"], framerate) + return timecode_start_frame.to_frames() + + def export_otio(self, clips_data, output_path): + # first find the highest framerate and set it as default framerate + # for the timeline + timeline_framerate = 0 + for clip_data in clips_data.values(): + framerate = clip_data["framerate"] + if framerate > timeline_framerate: + timeline_framerate = framerate + + # reduce decimal places to 3 - otio does not like more + timeline_framerate = float(f"{timeline_framerate:.4f}") + + # create clips from the representations + clips = [ + self.create_clip(name, clip_data, timeline_framerate) + for name, clip_data in clips_data.items() + ] + timeline = OTIO.schema.timeline_from_clips(clips) + + # set the timeline framerate to the highest framerate + timeline.global_start_time = OTIO.opentime.RationalTime( + 0, timeline_framerate) + + OTIO.adapters.write_to_file(timeline, output_path) + + +def get_image_info_metadata( + path_to_file, + keys=None, + logger=None, +): + """Get flattened metadata from image file + + With combined approach via FFMPEG and OIIOTool. + + At first it will try to detect if the image input is supported by + OpenImageIO. If it is then it gets the metadata from the image using + OpenImageIO. If it is not supported by OpenImageIO then it will try to + get the metadata using FFprobe. + + Args: + path_to_file (str): Path to image file. + keys (list[str]): List of keys that should be returned. If None then + all keys are returned. Keys are expected all lowercase. + Additional keys are: + - "framerate" - will be created from "r_frame_rate" or + "framespersecond" and evaluated to float value. + logger (logging.Logger): Logger used for logging. + """ + if logger is None: + logger = logging.getLogger(__name__) + + def _ffprobe_metadata_conversion(metadata): + """Convert ffprobe metadata unified format.""" + output = {} + for key, val in metadata.items(): + if key in ("tags", "disposition"): + output.update(val) + else: + output[key] = val + return output + + def _get_video_metadata_from_ffprobe(ffprobe_stream): + """Extract video metadata from ffprobe stream. + + Args: + ffprobe_stream (dict): Stream data obtained from ffprobe. + + Returns: + dict: Video metadata extracted from the ffprobe stream. + """ + video_stream = None + for stream in ffprobe_stream["streams"]: + if stream["codec_type"] == "video": + video_stream = stream + break + metadata_stream = _ffprobe_metadata_conversion(video_stream) + return metadata_stream + + metadata_stream = None + ext = os.path.splitext(path_to_file)[-1].lower() + if ext not in IMAGE_EXTENSIONS: + logger.info( + ( + 'File extension "{}" is not supported by OpenImageIO.' + " Trying to get metadata using FFprobe." + ).format(ext) + ) + ffprobe_stream = get_ffprobe_data(path_to_file, logger) + if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0: + metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream) + + if not metadata_stream and is_oiio_supported(): + oiio_stream = get_oiio_info_for_input(path_to_file, logger=logger) + if "attribs" in (oiio_stream or {}): + metadata_stream = {} + for key, val in oiio_stream["attribs"].items(): + if "smpte:" in key.lower(): + key = key.replace("smpte:", "") + metadata_stream[key.lower()] = val + for key, val in oiio_stream.items(): + if key == "attribs": + continue + metadata_stream[key] = val + else: + logger.info( + ( + "OpenImageIO is not supported on this system." + " Trying to get metadata using FFprobe." + ) + ) + ffprobe_stream = get_ffprobe_data(path_to_file, logger) + if "streams" in ffprobe_stream and len(ffprobe_stream["streams"]) > 0: + metadata_stream = _get_video_metadata_from_ffprobe(ffprobe_stream) + + if not metadata_stream: + logger.warning("Failed to get metadata from image file.") + return {} + + if keys is None: + return metadata_stream + + # create framerate key from available ffmpeg:r_frame_rate + # or oiiotool:framespersecond and evaluate its string expression + # value into flaot value + if ( + "r_frame_rate" in metadata_stream + or "framespersecond" in metadata_stream + ): + rate_info = metadata_stream.get("r_frame_rate") + if rate_info is None: + rate_info = metadata_stream.get("framespersecond") + + # calculate framerate from string expression + if "/" in str(rate_info): + time, frame = str(rate_info).split("/") + rate_info = float(time) / float(frame) + + try: + metadata_stream["framerate"] = float(str(rate_info)) + except Exception as e: + logger.warning( + "Failed to evaluate '{}' value to framerate. Error: {}".format( + rate_info, e + ) + ) + + # aggregate all required metadata from prepared metadata stream + output = {} + for key in keys: + for k, v in metadata_stream.items(): + if key == k: + output[key] = v + break + if isinstance(v, dict) and key in v: + output[key] = v[key] + break + + return output diff --git a/client/ayon_core/plugins/publish/collect_addons.py b/client/ayon_core/plugins/publish/collect_addons.py index 9bba9978ab..661cf9cb31 100644 --- a/client/ayon_core/plugins/publish/collect_addons.py +++ b/client/ayon_core/plugins/publish/collect_addons.py @@ -15,5 +15,3 @@ class CollectAddons(pyblish.api.ContextPlugin): manager = AddonsManager() context.data["ayonAddonsManager"] = manager context.data["ayonAddons"] = manager.addons_by_name - # Backwards compatibility - remove - context.data["openPypeModules"] = manager.addons_by_name diff --git a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py index 6eeca6ad29..2a144d3a02 100644 --- a/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py +++ b/client/ayon_core/plugins/publish/collect_anatomy_instance_data.py @@ -138,7 +138,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): folder_path_by_id = {} for instance in context: folder_entity = instance.data.get("folderEntity") - # Skip if instnace does not have filled folder entity + # Skip if instance does not have filled folder entity if not folder_entity: continue folder_id = folder_entity["id"] @@ -217,9 +217,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): joined_paths = ", ".join( ["\"{}\"".format(path) for path in not_found_task_paths] ) - self.log.warning(( - "Not found task entities with paths \"{}\"." - ).format(joined_paths)) + self.log.warning( + f"Not found task entities with paths {joined_paths}.") def fill_latest_versions(self, context, project_name): """Try to find latest version for each instance's product name. @@ -321,7 +320,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): use_context_version = instance.data["followWorkfileVersion"] if use_context_version: - version_number = context.data("version") + version_number = context.data.get("version") # Even if 'follow_workfile_version' is enabled, it may not be set # because workfile version was not collected to 'context.data' @@ -385,8 +384,19 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): json.dumps(anatomy_data, indent=4) )) + # make render layer available in anatomy data + render_layer = instance.data.get("renderlayer") + if render_layer: + anatomy_data["renderlayer"] = render_layer + + # make aov name available in anatomy data + aov = instance.data.get("aov") + if aov: + anatomy_data["aov"] = aov + + def _fill_folder_data(self, instance, project_entity, anatomy_data): - # QUESTION should we make sure that all folder data are poped if + # QUESTION: should we make sure that all folder data are popped if # folder data cannot be found? # - 'folder', 'hierarchy', 'parent', 'folder' folder_entity = instance.data.get("folderEntity") @@ -429,7 +439,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): }) def _fill_task_data(self, instance, task_types_by_name, anatomy_data): - # QUESTION should we make sure that all task data are poped if task + # QUESTION: should we make sure that all task data are popped if task # data cannot be resolved? # - 'task' diff --git a/client/ayon_core/plugins/publish/collect_context_entities.py b/client/ayon_core/plugins/publish/collect_context_entities.py index f340178e4f..4de83f0d53 100644 --- a/client/ayon_core/plugins/publish/collect_context_entities.py +++ b/client/ayon_core/plugins/publish/collect_context_entities.py @@ -53,8 +53,9 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["folderEntity"] = folder_entity context.data["taskEntity"] = task_entity - - folder_attributes = folder_entity["attrib"] + context_attributes = ( + task_entity["attrib"] if task_entity else folder_entity["attrib"] + ) # Task type task_type = None @@ -63,12 +64,12 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["taskType"] = task_type - frame_start = folder_attributes.get("frameStart") + frame_start = context_attributes.get("frameStart") if frame_start is None: frame_start = 1 self.log.warning("Missing frame start. Defaulting to 1.") - frame_end = folder_attributes.get("frameEnd") + frame_end = context_attributes.get("frameEnd") if frame_end is None: frame_end = 2 self.log.warning("Missing frame end. Defaulting to 2.") @@ -76,8 +77,8 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["frameStart"] = frame_start context.data["frameEnd"] = frame_end - handle_start = folder_attributes.get("handleStart") or 0 - handle_end = folder_attributes.get("handleEnd") or 0 + handle_start = context_attributes.get("handleStart") or 0 + handle_end = context_attributes.get("handleEnd") or 0 context.data["handleStart"] = int(handle_start) context.data["handleEnd"] = int(handle_end) @@ -87,7 +88,7 @@ class CollectContextEntities(pyblish.api.ContextPlugin): context.data["frameStartHandle"] = frame_start_h context.data["frameEndHandle"] = frame_end_h - context.data["fps"] = folder_attributes["fps"] + context.data["fps"] = context_attributes["fps"] def _get_folder_entity(self, project_name, folder_path): if not folder_path: @@ -113,4 +114,4 @@ class CollectContextEntities(pyblish.api.ContextPlugin): "Task '{}' was not found in project '{}'.".format( task_path, project_name) ) - return task_entity \ No newline at end of file + return task_entity diff --git a/client/ayon_core/plugins/publish/collect_hierarchy.py b/client/ayon_core/plugins/publish/collect_hierarchy.py index 2ae3cc67f3..00f5c06c0b 100644 --- a/client/ayon_core/plugins/publish/collect_hierarchy.py +++ b/client/ayon_core/plugins/publish/collect_hierarchy.py @@ -13,8 +13,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): label = "Collect Hierarchy" order = pyblish.api.CollectorOrder - 0.076 - families = ["shot"] - hosts = ["resolve", "hiero", "flame"] + hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, context): project_name = context.data["projectName"] @@ -32,36 +31,49 @@ class CollectHierarchy(pyblish.api.ContextPlugin): product_type = instance.data["productType"] families = instance.data["families"] - # exclude other families then self.families with intersection - if not set(self.families).intersection( - set(families + [product_type]) - ): + # exclude other families then "shot" with intersection + if "shot" not in (families + [product_type]): + self.log.debug("Skipping not a shot: {}".format(families)) continue - # exclude if not masterLayer True + # Skip if is not a hero track if not instance.data.get("heroTrack"): + self.log.debug("Skipping not a shot from hero track") continue shot_data = { "entity_type": "folder", - # WARNING Default folder type is hardcoded - # suppose that all instances are Shots - "folder_type": "Shot", + # WARNING unless overwritten, default folder type is hardcoded to shot + "folder_type": instance.data.get("folder_type") or "Shot", "tasks": instance.data.get("tasks") or {}, "comments": instance.data.get("comments", []), - "attributes": { - "handleStart": instance.data["handleStart"], - "handleEnd": instance.data["handleEnd"], - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], - "clipIn": instance.data["clipIn"], - "clipOut": instance.data["clipOut"], - "fps": instance.data["fps"], - "resolutionWidth": instance.data["resolutionWidth"], - "resolutionHeight": instance.data["resolutionHeight"], - "pixelAspect": instance.data["pixelAspect"], - }, } + + shot_data["attributes"] = {} + SHOT_ATTRS = ( + "handleStart", + "handleEnd", + "frameStart", + "frameEnd", + "clipIn", + "clipOut", + "fps", + "resolutionWidth", + "resolutionHeight", + "pixelAspect", + ) + for shot_attr in SHOT_ATTRS: + attr_value = instance.data.get(shot_attr) + if attr_value is None: + # Shot attribute might not be defined (e.g. CSV ingest) + self.log.debug( + "%s shot attribute is not defined for instance.", + shot_attr + ) + continue + + shot_data["attributes"][shot_attr] = attr_value + # Split by '/' for AYON where asset is a path name = instance.data["folderPath"].split("/")[-1] actual = {name: shot_data} diff --git a/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py b/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py index b9fe97b80b..f8311f7dfb 100644 --- a/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py +++ b/client/ayon_core/plugins/publish/collect_input_representations_to_versions.py @@ -7,7 +7,7 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): """Converts collected input representations to input versions. Any data in `instance.data["inputRepresentations"]` gets converted into - `instance.data["inputVersions"]` as supported in OpenPype v3. + `instance.data["inputVersions"]` as supported in OpenPype. """ # This is a ContextPlugin because then we can query the database only once diff --git a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py index d1c8d03212..62b4cefec6 100644 --- a/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py +++ b/client/ayon_core/plugins/publish/collect_otio_frame_ranges.py @@ -29,6 +29,10 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): otio_range_with_handles ) + if not instance.data.get("otioClip"): + self.log.debug("Skipping collect OTIO frame range.") + return + # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] diff --git a/client/ayon_core/plugins/publish/collect_otio_review.py b/client/ayon_core/plugins/publish/collect_otio_review.py index 69cf9199e7..4708b0a97c 100644 --- a/client/ayon_core/plugins/publish/collect_otio_review.py +++ b/client/ayon_core/plugins/publish/collect_otio_review.py @@ -95,9 +95,42 @@ class CollectOtioReview(pyblish.api.InstancePlugin): instance.data["label"] = label + " (review)" instance.data["families"] += ["review", "ftrack"] instance.data["otioReviewClips"] = otio_review_clips + self.log.info( "Creating review track: {}".format(otio_review_clips)) + # get colorspace from metadata if available + # get metadata from first clip with media reference + r_otio_cl = next( + ( + clip + for clip in otio_review_clips + if ( + isinstance(clip, otio.schema.Clip) + and clip.media_reference + ) + ), + None + ) + if r_otio_cl is not None: + media_ref = r_otio_cl.media_reference + media_metadata = media_ref.metadata + + # TODO: we might need some alternative method since + # native OTIO exports do not support ayon metadata + review_colorspace = media_metadata.get( + "ayon.source.colorspace" + ) + if review_colorspace is None: + # Backwards compatibility for older scenes + review_colorspace = media_metadata.get( + "openpype.source.colourtransform" + ) + if review_colorspace: + instance.data["reviewColorspace"] = review_colorspace + self.log.info( + "Review colorspace: {}".format(review_colorspace)) + self.log.debug( "_ instance.data: {}".format(pformat(instance.data))) self.log.debug( diff --git a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py index 37a5e87a7a..c142036b83 100644 --- a/client/ayon_core/plugins/publish/collect_otio_subset_resources.py +++ b/client/ayon_core/plugins/publish/collect_otio_subset_resources.py @@ -10,12 +10,16 @@ import os import clique import pyblish.api +from ayon_core.pipeline import publish from ayon_core.pipeline.publish import ( get_publish_template_name ) -class CollectOtioSubsetResources(pyblish.api.InstancePlugin): +class CollectOtioSubsetResources( + pyblish.api.InstancePlugin, + publish.ColormanagedPyblishPluginMixin +): """Get Resources for a product version""" label = "Collect OTIO Subset Resources" @@ -190,9 +194,13 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): instance.data["originalDirname"] = self.staging_dir if repre: + colorspace = instance.data.get("colorspace") + # add colorspace data to representation + self.set_representation_colorspace( + repre, instance.context, colorspace) + # add representation to instance data instance.data["representations"].append(repre) - self.log.debug(">>>>>>>> {}".format(repre)) self.log.debug(instance.data) diff --git a/client/ayon_core/plugins/publish/collect_rendered_files.py b/client/ayon_core/plugins/publish/collect_rendered_files.py index 8a60e7619d..42ba096d14 100644 --- a/client/ayon_core/plugins/publish/collect_rendered_files.py +++ b/client/ayon_core/plugins/publish/collect_rendered_files.py @@ -138,10 +138,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): def process(self, context): self._context = context - publish_data_paths = ( - os.environ.get("AYON_PUBLISH_DATA") - or os.environ.get("OPENPYPE_PUBLISH_DATA") - ) + publish_data_paths = os.environ.get("AYON_PUBLISH_DATA") if not publish_data_paths: raise KnownPublishError("Missing `AYON_PUBLISH_DATA`") diff --git a/client/ayon_core/plugins/publish/collect_scene_version.py b/client/ayon_core/plugins/publish/collect_scene_version.py index ea4823d62a..8d643062bc 100644 --- a/client/ayon_core/plugins/publish/collect_scene_version.py +++ b/client/ayon_core/plugins/publish/collect_scene_version.py @@ -47,8 +47,9 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): return if not context.data.get('currentFile'): - raise KnownPublishError("Cannot get current workfile path. " - "Make sure your scene is saved.") + self.log.error("Cannot get current workfile path. " + "Make sure your scene is saved.") + return filename = os.path.basename(context.data.get('currentFile')) diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 58a032a030..2007240d3d 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -199,7 +199,7 @@ class ExtractBurnin(publish.Extractor): if not burnins_per_repres: self.log.debug( "Skipped instance. No representations found matching a burnin" - "definition in: %s", burnin_defs + " definition in: %s", burnin_defs ) return @@ -399,7 +399,7 @@ class ExtractBurnin(publish.Extractor): add_repre_files_for_cleanup(instance, new_repre) - # Cleanup temp staging dir after procesisng of output definitions + # Cleanup temp staging dir after processing of output definitions if do_convert: temp_dir = repre["stagingDir"] shutil.rmtree(temp_dir) @@ -420,6 +420,12 @@ class ExtractBurnin(publish.Extractor): self.log.debug("Removed: \"{}\"".format(filepath)) def _get_burnin_options(self): + """Get the burnin options from `ExtractBurnin` settings. + + Returns: + dict[str, Any]: Burnin options. + + """ # Prepare burnin options burnin_options = copy.deepcopy(self.default_options) if self.options: @@ -696,7 +702,7 @@ class ExtractBurnin(publish.Extractor): """Prepare data for representation. Args: - instance (Instance): Currently processed Instance. + instance (pyblish.api.Instance): Currently processed Instance. repre (dict): Currently processed representation. burnin_data (dict): Copy of basic burnin data based on instance data. @@ -752,9 +758,11 @@ class ExtractBurnin(publish.Extractor): Args: profile (dict): Profile from presets matching current context. + instance (pyblish.api.Instance): Publish instance. Returns: - list: Contain all valid output definitions. + list[dict[str, Any]]: Contain all valid output definitions. + """ filtered_burnin_defs = [] @@ -773,12 +781,11 @@ class ExtractBurnin(publish.Extractor): if not self.families_filter_validation( families, families_filters ): - self.log.debug(( - "Skipped burnin definition \"{}\". Family" - " filters ({}) does not match current instance families: {}" - ).format( - filename_suffix, str(families_filters), str(families) - )) + self.log.debug( + f"Skipped burnin definition \"{filename_suffix}\"." + f" Family filters ({families_filters}) does not match" + f" current instance families: {families}" + ) continue # Burnin values diff --git a/client/ayon_core/plugins/publish/extract_color_transcode.py b/client/ayon_core/plugins/publish/extract_color_transcode.py index a28a761e7e..56d5d33ea4 100644 --- a/client/ayon_core/plugins/publish/extract_color_transcode.py +++ b/client/ayon_core/plugins/publish/extract_color_transcode.py @@ -5,7 +5,6 @@ import pyblish.api from ayon_core.pipeline import publish from ayon_core.lib import ( - is_oiio_supported, ) @@ -122,13 +121,22 @@ class ExtractOIIOTranscode(publish.Extractor): 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 @@ -145,12 +153,15 @@ class ExtractOIIOTranscode(publish.Extractor): files_to_convert = self._translate_to_sequence( files_to_convert) + self.log.debug("Files to convert: {}".format(files_to_convert)) for file_name in files_to_convert: + self.log.debug("Transcoding file: `{}`".format(file_name)) input_path = os.path.join(original_staging_dir, file_name) output_path = self._get_output_file_path(input_path, new_staging_dir, output_extension) + convert_colorspace( input_path, output_path, @@ -192,7 +203,7 @@ class ExtractOIIOTranscode(publish.Extractor): 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/extract_colorspace_data.py b/client/ayon_core/plugins/publish/extract_colorspace_data.py index 7da4890748..0ffa0f3035 100644 --- a/client/ayon_core/plugins/publish/extract_colorspace_data.py +++ b/client/ayon_core/plugins/publish/extract_colorspace_data.py @@ -37,6 +37,9 @@ class ExtractColorspaceData(publish.Extractor, # get colorspace settings context = instance.context + # colorspace name could be kept in instance.data + colorspace = instance.data.get("colorspace") + # loop representations for representation in representations: # skip if colorspaceData is already at representation @@ -44,5 +47,4 @@ class ExtractColorspaceData(publish.Extractor, continue self.set_representation_colorspace( - representation, context - ) + representation, context, colorspace) diff --git a/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py b/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py index 60c92aa8b1..25467fd94f 100644 --- a/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py +++ b/client/ayon_core/plugins/publish/extract_hierarchy_to_ayon.py @@ -22,7 +22,6 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): order = pyblish.api.ExtractorOrder - 0.01 label = "Extract Hierarchy To AYON" - families = ["clip", "shot"] def process(self, context): if not context.data.get("hierarchyContext"): @@ -154,7 +153,9 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): # TODO check if existing entity have 'task' type if task_entity is None: task_entity = entity_hub.add_new_task( - task_info["type"], + task_type=task_info["type"], + # TODO change 'parent_id' to 'folder_id' when ayon api + # is updated parent_id=entity.id, name=task_name ) @@ -182,7 +183,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin): folder_type = "Folder" child_entity = entity_hub.add_new_folder( - folder_type, + folder_type=folder_type, parent_id=entity.id, name=child_name ) diff --git a/client/ayon_core/plugins/publish/extract_otio_review.py b/client/ayon_core/plugins/publish/extract_otio_review.py index be365520c7..b222c6efc3 100644 --- a/client/ayon_core/plugins/publish/extract_otio_review.py +++ b/client/ayon_core/plugins/publish/extract_otio_review.py @@ -26,7 +26,10 @@ from ayon_core.lib import ( from ayon_core.pipeline import publish -class ExtractOTIOReview(publish.Extractor): +class ExtractOTIOReview( + publish.Extractor, + publish.ColormanagedPyblishPluginMixin +): """ Extract OTIO timeline into one concuted image sequence file. @@ -49,7 +52,6 @@ class ExtractOTIOReview(publish.Extractor): hosts = ["resolve", "hiero", "flame"] # plugin default attributes - temp_file_head = "tempFile." to_width = 1280 to_height = 720 output_ext = ".jpg" @@ -58,24 +60,33 @@ class ExtractOTIOReview(publish.Extractor): # 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 + make_sequence_collection, + remap_range_on_file_sequence, + is_clip_from_media_sequence ) + # TODO refactor from using instance variable + self.temp_file_head = self._get_folder_name_based_prefix(instance) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip # TODO: what if handles are different in `versionData`? handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] - otio_review_clips = instance.data["otioReviewClips"] + otio_review_clips = instance.data.get("otioReviewClips") + + if otio_review_clips is None: + self.log.info(f"Instance `{instance}` has no 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)) + # NOTE: padding has to be converted from + # end frame since start could be lower then 1000 + self.padding = len(str(instance.data.get("frameEnd", 1001))) self.used_frames.append(self.workfile_start) self.to_width = instance.data.get( "resolutionWidth") or self.to_width @@ -83,8 +94,10 @@ class ExtractOTIOReview(publish.Extractor): "resolutionHeight") or self.to_height # skip instance if no reviewable data available - if (not isinstance(otio_review_clips[0], otio.schema.Clip)) \ - and (len(otio_review_clips) == 1): + if ( + not isinstance(otio_review_clips[0], otio.schema.Clip) + and len(otio_review_clips) == 1 + ): self.log.warning( "Instance `{}` has nothing to process".format(instance)) return @@ -97,84 +110,110 @@ class ExtractOTIOReview(publish.Extractor): 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 - otio_media = r_otio_cl.media_reference - media_metadata = otio_media.metadata - - # get from media reference metadata source - if media_metadata.get("openpype.source.width"): - width = int(media_metadata.get("openpype.source.width")) - if media_metadata.get("openpype.source.height"): - height = int(media_metadata.get("openpype.source.height")) - - # compare and reset - if width != self.to_width: - self.to_width = width - if height != self.to_height: - self.to_height = height - - 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): + # 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 + start = src_range.start_time.rescaled_to(self.actual_fps) + duration = src_range.duration.rescaled_to(self.actual_fps) + + # 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_clip_from_media_sequence(r_otio_cl) + and available_range.start_time.to_frames() == media_ref.start_frame + and src_range.start_time.to_frames() < media_ref.start_frame + ): + available_range = otio.opentime.TimeRange( + otio.opentime.RationalTime(0, rate=self.actual_fps), + available_range.duration, + ) + + # 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. + clip_handle_start = otio.opentime.RationalTime( + handle_start, + rate=self.actual_fps, + ) + clip_handle_end = otio.opentime.RationalTime( + handle_end, + rate=self.actual_fps, + ) # 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): + 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: - 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, @@ -183,8 +222,8 @@ class ExtractOTIOReview(publish.Extractor): collection.indexes.update( [i for i in range(first, (last + 1))]) # render segment - self._render_seqment( - sequence=[dirname, collection]) + self._render_segment( + sequence=[dirname, collection, input_fps]) # generate used frames self._generate_used_frames( len(collection.indexes)) @@ -193,33 +232,54 @@ class ExtractOTIOReview(publish.Extractor): # `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]) + self._render_segment( + 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]) + self._render_segment( + 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 - self._render_seqment(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) + + # add colorspace data to representation + if colorspace := instance.data.get("reviewColorspace"): + self.set_representation_colorspace( + representation, instance.context, colorspace + ) + instance.data["representations"].append(representation) self.log.info("Adding representation: {}".format(representation)) @@ -265,7 +325,7 @@ class ExtractOTIOReview(publish.Extractor): }) 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. @@ -274,69 +334,87 @@ class ExtractOTIOReview(publish.Extractor): 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) + def _round_to_frame(rational_time): + """ Handle rounding duration to frame. + """ + # OpentimelineIO >= 0.16.0 + try: + return rational_time.round().to_frames() - self.need_offset = bool(avl_start != 0 and src_start != 0) + # OpentimelineIO < 0.16.0 + except AttributeError: + return otio.opentime.RationalTime( + round(rational_time.value), + rate=rational_time.rate, + ).to_frames() - # if media start is les then clip requires - if src_start < avl_start: - # calculate gap - gap_duration = avl_start - src_start + avl_start = avl_range.start_time + + # 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 + duration -= gap_duration + gap_duration = _round_to_frame(gap_duration) # create gap data to disk - self._render_seqment(gap=gap_duration) + self._render_segment(gap=gap_duration) # generate used frames self._generate_used_frames(gap_duration) - # fix start and end to correct values - start = 0 + # 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: + gap_duration = end_point - avl_end_point duration -= gap_duration - - # 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 + gap_duration = _round_to_frame(gap_duration) # create gap data to disk - self._render_seqment(gap=gap_duration, end_offset=avl_durtation) + self._render_segment( + gap=gap_duration, + end_offset=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, + end_offset=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, + 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. 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 @@ -358,7 +436,7 @@ class ExtractOTIOReview(publish.Extractor): 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 @@ -366,9 +444,28 @@ class ExtractOTIOReview(publish.Extractor): 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 + + 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), + "-framerate", str(sequence_fps), "-i", input_path ]) @@ -443,16 +540,11 @@ class ExtractOTIOReview(publish.Extractor): 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)) @@ -491,3 +583,20 @@ class ExtractOTIOReview(publish.Extractor): out_frame_start = self.used_frames[-1] return output_path, out_frame_start + + def _get_folder_name_based_prefix(self, instance): + """Creates 'unique' human readable file prefix to differentiate. + + Multiple instances might share same temp folder, but each instance + would be differentiated by asset, eg. folder name. + + It ix expected that there won't be multiple instances for same asset. + """ + folder_path = instance.data["folderPath"] + folder_name = folder_path.split("/")[-1] + folder_path = folder_path.replace("/", "_").lstrip("_") + + file_prefix = f"{folder_path}_{folder_name}." + self.log.debug(f"file_prefix::{file_prefix}") + + return file_prefix 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..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 @@ class ExtractOTIOTrimmingVideo(publish.Extractor): 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) @@ -84,11 +81,8 @@ class ExtractOTIOTrimmingVideo(publish.Extractor): 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([ diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index c2793f98a2..06b451bfbe 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -95,7 +95,7 @@ class ExtractReview(pyblish.api.InstancePlugin): ] # Supported extensions - image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga"] + image_exts = ["exr", "jpg", "jpeg", "png", "dpx", "tga", "tiff", "tif"] video_exts = ["mov", "mp4"] supported_exts = image_exts + video_exts @@ -1900,7 +1900,7 @@ class OverscanCrop: string_value = re.sub(r"([ ]+)?px", " ", string_value) string_value = re.sub(r"([ ]+)%", "%", string_value) # Make sure +/- sign at the beginning of string is next to number - string_value = re.sub(r"^([\+\-])[ ]+", "\g<1>", string_value) + string_value = re.sub(r"^([\+\-])[ ]+", r"\g<1>", string_value) # Make sure +/- sign in the middle has zero spaces before number under # which belongs string_value = re.sub( diff --git a/client/ayon_core/plugins/publish/extract_thumbnail.py b/client/ayon_core/plugins/publish/extract_thumbnail.py index d1b6e4e0cc..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 @@ -455,6 +456,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): # output file jpeg_items.append(path_to_subprocess_arg(dst_path)) subprocess_command = " ".join(jpeg_items) + try: run_subprocess( subprocess_command, shell=True, logger=self.log diff --git a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py index 162b7d3d41..180cb8bbf1 100644 --- a/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py +++ b/client/ayon_core/plugins/publish/extract_usd_layer_contributions.py @@ -4,7 +4,10 @@ import os from typing import Dict import pyblish.api -from pxr import Sdf +try: + from pxr import Sdf +except ImportError: + Sdf = None from ayon_core.lib import ( TextDef, @@ -13,21 +16,24 @@ from ayon_core.lib import ( UILabelDef, EnumDef ) -from ayon_core.pipeline.usdlib import ( - get_or_define_prim_spec, - add_ordered_reference, - variant_nested_prim_path, - setup_asset_layer, - add_ordered_sublayer, - set_layer_defaults -) +try: + from ayon_core.pipeline.usdlib import ( + get_or_define_prim_spec, + add_ordered_reference, + variant_nested_prim_path, + setup_asset_layer, + add_ordered_sublayer, + set_layer_defaults + ) +except ImportError: + pass from ayon_core.pipeline.entity_uri import ( construct_ayon_entity_uri, parse_ayon_entity_uri ) from ayon_core.pipeline.load.utils import get_representation_path_by_names from ayon_core.pipeline.publish.lib import get_instance_expected_output_path -from ayon_core.pipeline import publish +from ayon_core.pipeline import publish, KnownPublishError # This global toggle is here mostly for debugging purposes and should usually @@ -77,7 +83,7 @@ def get_representation_path_in_publish_context( Allow resolving 'latest' paths from a publishing context's instances as if they will exist after publishing without them being integrated yet. - + Use first instance that has same folder path and product name, and contains representation with passed name. @@ -138,13 +144,14 @@ def get_instance_uri_path( folder_path = instance.data["folderPath"] product_name = instance.data["productName"] project_name = context.data["projectName"] + version_name = instance.data["version"] # Get the layer's published path path = construct_ayon_entity_uri( project_name=project_name, folder_path=folder_path, product=product_name, - version="latest", + version=version_name, representation_name="usd" ) @@ -231,7 +238,7 @@ def add_representation(instance, name, class CollectUSDLayerContributions(pyblish.api.InstancePlugin, - publish.OpenPypePyblishPluginMixin): + publish.AYONPyblishPluginMixin): """Collect the USD Layer Contributions and create dependent instances. Our contributions go to the layer @@ -451,7 +458,18 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, return new_instance @classmethod - def get_attribute_defs(cls): + def get_attr_defs_for_instance(cls, create_context, instance): + # Filtering of instance, if needed, can be customized + if not cls.instance_matches_plugin_families(instance): + return [] + + # Attributes logic + publish_attributes = instance["publish_attributes"].get( + cls.__name__, {}) + + visible = publish_attributes.get("contribution_enabled", True) + variant_visible = visible and publish_attributes.get( + "contribution_apply_as_variant", True) return [ UISeparatorDef("usd_container_settings1"), @@ -477,7 +495,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "the contribution itself will be added to the " "department layer." ), - default="usdAsset"), + default="usdAsset", + visible=visible), EnumDef("contribution_target_product_init", label="Initialize as", tooltip=( @@ -488,7 +507,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "setting will do nothing." ), items=["asset", "shot"], - default="asset"), + default="asset", + visible=visible), # Asset layer, e.g. model.usd, look.usd, rig.usd EnumDef("contribution_layer", @@ -500,7 +520,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "the list) will contribute as a stronger opinion." ), items=list(cls.contribution_layers.keys()), - default="model"), + default="model", + visible=visible), BoolDef("contribution_apply_as_variant", label="Add as variant", tooltip=( @@ -511,13 +532,16 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "appended to as a sublayer to the department layer " "instead." ), - default=True), + default=True, + visible=visible), TextDef("contribution_variant_set_name", label="Variant Set Name", - default="{layer}"), + default="{layer}", + visible=variant_visible), TextDef("contribution_variant", label="Variant Name", - default="{variant}"), + default="{variant}", + visible=variant_visible), BoolDef("contribution_variant_is_default", label="Set as default variant selection", tooltip=( @@ -528,10 +552,41 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin, "The behavior is unpredictable if multiple instances " "for the same variant set have this enabled." ), - default=False), + default=False, + visible=variant_visible), UISeparatorDef("usd_container_settings3"), ] + @classmethod + def register_create_context_callbacks(cls, create_context): + create_context.add_value_changed_callback(cls.on_values_changed) + + @classmethod + def on_values_changed(cls, event): + """Update instance attribute definitions on attribute changes.""" + + # Update attributes if any of the following plug-in attributes + # change: + keys = ["contribution_enabled", "contribution_apply_as_variant"] + + for instance_change in event["changes"]: + instance = instance_change["instance"] + if not cls.instance_matches_plugin_families(instance): + continue + value_changes = instance_change["changes"] + plugin_attribute_changes = ( + value_changes.get("publish_attributes", {}) + .get(cls.__name__, {})) + + if not any(key in plugin_attribute_changes for key in keys): + continue + + # Update the attribute definitions + new_attrs = cls.get_attr_defs_for_instance( + event["create_context"], instance + ) + instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs) + class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): """ @@ -544,9 +599,12 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): label = CollectUSDLayerContributions.label + " (Look)" @classmethod - def get_attribute_defs(cls): - defs = super(CollectUSDLayerContributionsHoudiniLook, - cls).get_attribute_defs() + def get_attr_defs_for_instance(cls, create_context, instance): + # Filtering of instance, if needed, can be customized + if not cls.instance_matches_plugin_families(instance): + return [] + + defs = super().get_attr_defs_for_instance(create_context, instance) # Update default for department layer to look layer_def = next(d for d in defs if d.key == "contribution_layer") @@ -555,12 +613,24 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions): return defs +class ValidateUSDDependencies(pyblish.api.InstancePlugin): + families = ["usdLayer"] + + order = pyblish.api.ValidatorOrder + + def process(self, instance): + if Sdf is None: + raise KnownPublishError("USD library 'Sdf' is not available.") + + class ExtractUSDLayerContribution(publish.Extractor): families = ["usdLayer"] label = "Extract USD Layer Contributions (Asset/Shot)" order = pyblish.api.ExtractorOrder + 0.45 + use_ayon_entity_uri = False + def process(self, instance): folder_path = instance.data["folderPath"] @@ -578,7 +648,8 @@ class ExtractUSDLayerContribution(publish.Extractor): contributions = instance.data.get("usd_contributions", []) for contribution in sorted(contributions, key=attrgetter("order")): - path = get_instance_uri_path(contribution.instance) + path = get_instance_uri_path(contribution.instance, + resolve=not self.use_ayon_entity_uri) if isinstance(contribution, VariantContribution): # Add contribution as a reference inside a variant self.log.debug(f"Adding variant: {contribution}") @@ -652,14 +723,14 @@ class ExtractUSDLayerContribution(publish.Extractor): ) def remove_previous_reference_contribution(self, - prim_spec: Sdf.PrimSpec, + prim_spec: "Sdf.PrimSpec", instance: pyblish.api.Instance): # Remove existing contributions of the same product - ignoring # the picked version and representation. We assume there's only ever # one version of a product you want to have referenced into a Prim. remove_indices = set() for index, ref in enumerate(prim_spec.referenceList.prependedItems): - ref: Sdf.Reference # type hint + ref: "Sdf.Reference" uri = ref.customData.get("ayon_uri") if uri and self.instance_match_ayon_uri(instance, uri): @@ -674,8 +745,8 @@ class ExtractUSDLayerContribution(publish.Extractor): ] def add_reference_contribution(self, - layer: Sdf.Layer, - prim_path: Sdf.Path, + layer: "Sdf.Layer", + prim_path: "Sdf.Path", filepath: str, contribution: VariantContribution): instance = contribution.instance @@ -720,6 +791,8 @@ class ExtractUSDAssetContribution(publish.Extractor): label = "Extract USD Asset/Shot Contributions" order = ExtractUSDLayerContribution.order + 0.01 + use_ayon_entity_uri = False + def process(self, instance): folder_path = instance.data["folderPath"] @@ -795,15 +868,15 @@ class ExtractUSDAssetContribution(publish.Extractor): layer_id = layer_instance.data["usd_layer_id"] order = layer_instance.data["usd_layer_order"] - path = get_instance_uri_path(instance=layer_instance) + path = get_instance_uri_path(instance=layer_instance, + resolve=not self.use_ayon_entity_uri) add_ordered_sublayer(target_layer, contribution_path=path, layer_id=layer_id, order=order, # Add the sdf argument metadata which allows # us to later detect whether another path - # has the same layer id, so we can replace it - # it. + # has the same layer id, so we can replace it. add_sdf_arguments_metadata=True) # Save the file diff --git a/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml b/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml index e163fc39fe..96b07979b7 100644 --- a/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml +++ b/client/ayon_core/plugins/publish/help/validate_unique_subsets.xml @@ -11,7 +11,11 @@ Multiples instances from your scene are set to publish into the same folder > pr ### How to repair? -Remove the offending instances or rename to have a unique name. +Remove the offending instances or rename to have a unique name. Also, please + check your product name templates to ensure that resolved names are + sufficiently unique. You can find that settings: + + ayon+settings://core/tools/creator/product_name_profiles - \ No newline at end of file + diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 69c14465eb..e8fe09bab7 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -509,8 +509,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin): 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 @@ -744,6 +747,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if not is_udim: repre_context["frame"] = first_index_padded + # store renderlayer in context if it exists + # to be later used for example by delivery templates + if instance.data.get("renderlayer"): + repre_context["renderlayer"] = instance.data["renderlayer"] + # Update the destination indexes and padding dst_collection = clique.assemble(dst_filepaths)[0][0] dst_collection.padding = destination_padding diff --git a/client/ayon_core/plugins/publish/integrate_inputlinks.py b/client/ayon_core/plugins/publish/integrate_inputlinks.py index 16aef09a39..a3b6a228d6 100644 --- a/client/ayon_core/plugins/publish/integrate_inputlinks.py +++ b/client/ayon_core/plugins/publish/integrate_inputlinks.py @@ -9,7 +9,14 @@ from ayon_api import ( 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 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): 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 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): 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 @@ -97,7 +118,7 @@ class IntegrateInputLinksAYON(pyblish.api.ContextPlugin): instance.data["versionEntity"]["id"], ) - loaded_versions = workfile_instance.context.get("loadedVersions") + loaded_versions = workfile_instance.context.data.get("loadedVersions") if not loaded_versions: return diff --git a/client/ayon_core/plugins/publish/validate_file_saved.py b/client/ayon_core/plugins/publish/validate_file_saved.py index d459ba7ed4..f52998cef3 100644 --- a/client/ayon_core/plugins/publish/validate_file_saved.py +++ b/client/ayon_core/plugins/publish/validate_file_saved.py @@ -1,17 +1,60 @@ +import inspect + import pyblish.api from ayon_core.pipeline.publish import PublishValidationError +from ayon_core.tools.utils.host_tools import show_workfiles +from ayon_core.pipeline.context_tools import version_up_current_workfile + + +class SaveByVersionUpAction(pyblish.api.Action): + """Save Workfile.""" + label = "Save Workfile" + on = "failed" + icon = "save" + + def process(self, context, plugin): + version_up_current_workfile() + + +class ShowWorkfilesAction(pyblish.api.Action): + """Save Workfile.""" + label = "Show Workfiles Tool..." + on = "failed" + icon = "files-o" + + def process(self, context, plugin): + show_workfiles() class ValidateCurrentSaveFile(pyblish.api.ContextPlugin): - """File must be saved before publishing""" + """File must be saved before publishing + + This does not validate for unsaved changes. It only validates whether + the current context was able to identify any 'currentFile'. + """ label = "Validate File Saved" order = pyblish.api.ValidatorOrder - 0.1 - hosts = ["maya", "houdini", "nuke"] + hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter", + "cinema4d"] + actions = [SaveByVersionUpAction, ShowWorkfilesAction] def process(self, context): current_file = context.data["currentFile"] if not current_file: - raise PublishValidationError("File not saved") + raise PublishValidationError( + "Workfile is not saved. Please save your scene to continue.", + title="File not saved", + description=self.get_description()) + + def get_description(self): + return inspect.cleandoc(""" + ### File not saved + + Your workfile must be saved to continue publishing. + + The **Save Workfile** action will save it for you with the first + available workfile version number in your current context. + """) diff --git a/client/ayon_core/resources/__init__.py b/client/ayon_core/resources/__init__.py index 2a98cc1968..ea8bf7ca6c 100644 --- a/client/ayon_core/resources/__init__.py +++ b/client/ayon_core/resources/__init__.py @@ -70,19 +70,3 @@ def get_ayon_splash_filepath(staging=None): else: splash_file_name = "AYON_splash.png" return get_resource("icons", splash_file_name) - - -def get_openpype_production_icon_filepath(): - return get_ayon_production_icon_filepath() - - -def get_openpype_staging_icon_filepath(): - return get_ayon_staging_icon_filepath() - - -def get_openpype_icon_filepath(staging=None): - return get_ayon_icon_filepath(staging) - - -def get_openpype_splash_filepath(staging=None): - return get_ayon_splash_filepath(staging) diff --git a/client/ayon_core/resources/images/popout.png b/client/ayon_core/resources/images/popout.png new file mode 100644 index 0000000000..838c29483e Binary files /dev/null and b/client/ayon_core/resources/images/popout.png differ diff --git a/client/ayon_core/scripts/slates/slate_base/items.py b/client/ayon_core/scripts/slates/slate_base/items.py index 6d19fc6a0c..ec3358ed5e 100644 --- a/client/ayon_core/scripts/slates/slate_base/items.py +++ b/client/ayon_core/scripts/slates/slate_base/items.py @@ -486,11 +486,11 @@ class TableField(BaseItem): line = self.ellide_text break - for idx, char in enumerate(_word): + for char_index, char in enumerate(_word): _line = line + char + self.ellide_text _line_width = font.getsize(_line)[0] if _line_width > max_width: - if idx == 0: + if char_index == 0: line = _line break line = line + char diff --git a/client/ayon_core/settings/local_settings.md b/client/ayon_core/settings/local_settings.md deleted file mode 100644 index fbb5cf3df1..0000000000 --- a/client/ayon_core/settings/local_settings.md +++ /dev/null @@ -1,79 +0,0 @@ -# Structure of local settings -- local settings do not have any validation schemas right now this should help to see what is stored to local settings and how it works -- they are stored by identifier site_id which should be unified identifier of workstation -- all keys may and may not available on load -- contain main categories: `general`, `applications`, `projects` - -## Categories -### General -- ATM contain only label of site -```json -{ - "general": { - "site_label": "MySite" - } -} -``` - -### Applications -- modifications of application executables -- output should match application groups and variants -```json -{ - "applications": { - "": { - "": { - "executable": "/my/path/to/nuke_12_2" - } - } - } -} -``` - -### Projects -- project specific modifications -- default project is stored under constant key defined in `pype.settings.contants` -```json -{ - "projects": { - "": { - "active_site": "", - "remote_site": "", - "roots": { - "": { - "": "" - } - } - } - } -} -``` - -## Final document -```json -{ - "_id": "", - "site_id": "", - "general": { - "site_label": "MySite" - }, - "applications": { - "": { - "": { - "executable": "" - } - } - }, - "projects": { - "": { - "active_site": "", - "remote_site": "", - "roots": { - "": { - "": "" - } - } - } - } -} -``` diff --git a/client/ayon_core/style/data.json b/client/ayon_core/style/data.json index 7389387d97..24629ec085 100644 --- a/client/ayon_core/style/data.json +++ b/client/ayon_core/style/data.json @@ -60,7 +60,11 @@ "icon-alert-tools": "#AA5050", "icon-entity-default": "#bfccd6", "icon-entity-disabled": "#808080", + "font-entity-deprecated": "#666666", + + "font-overridden": "#91CDFC", + "overlay-messages": { "close-btn": "#D3D8DE", "bg-success": "#458056", diff --git a/client/ayon_core/style/style.css b/client/ayon_core/style/style.css index 607fd1fa31..bd96a3aeed 100644 --- a/client/ayon_core/style/style.css +++ b/client/ayon_core/style/style.css @@ -739,6 +739,31 @@ OverlayMessageWidget QWidget { background: transparent; } +/* Hinted Line Edit */ +HintedLineEditInput { + border-radius: 0.2em; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border: 1px solid {color:border}; +} +HintedLineEditInput:hover { + border-color: {color:border-hover}; +} +HintedLineEditInput:focus{ + border-color: {color:border-focus}; +} +HintedLineEditInput:disabled { + background: {color:bg-inputs-disabled}; +} +HintedLineEditButton { + border: none; + border-radius: 0.2em; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; + padding: 0px; + qproperty-iconSize: 11px 11px; +} + /* Password dialog*/ #PasswordBtn { border: none; @@ -969,17 +994,6 @@ PixmapButton:disabled { #PublishLogConsole { font-family: "Noto Sans Mono"; } -#VariantInputsWidget QLineEdit { - border-bottom-right-radius: 0px; - border-top-right-radius: 0px; -} -#VariantInputsWidget QToolButton { - border-bottom-left-radius: 0px; - border-top-left-radius: 0px; - padding-top: 0.5em; - padding-bottom: 0.5em; - width: 0.5em; -} #VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { border-color: {color:publisher:success}; } @@ -1104,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}; } @@ -1231,6 +1245,15 @@ ValidationArtistMessage QLabel { background: transparent; } +#PluginDetailsContent { + background: {color:bg-inputs}; + border-radius: 0.2em; +} +#PluginDetailsContent #PluginLabel { + font-size: 14pt; + font-weight: bold; +} + CreateNextPageOverlay { font-size: 32pt; } @@ -1449,14 +1472,6 @@ CreateNextPageOverlay { border-radius: 5px; } -#OpenPypeVersionLabel[state="success"] { - color: {color:settings:version-exists}; -} - -#OpenPypeVersionLabel[state="warning"] { - color: {color:settings:version-not-found}; -} - #ShadowWidget { font-size: 36pt; } @@ -1570,6 +1585,10 @@ CreateNextPageOverlay { } /* Attribute Definition widgets */ +AttributeDefinitionsLabel[overridden="1"] { + color: {color:font-overridden}; +} + AttributeDefinitionsWidget QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit { padding: 1px; } diff --git a/client/ayon_core/tests/conftest.py b/client/ayon_core/tests/conftest.py new file mode 100644 index 0000000000..f66af706e1 --- /dev/null +++ b/client/ayon_core/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest +from pathlib import Path + +collect_ignore = ["vendor", "resources"] + +RESOURCES_PATH = 'resources' + + +@pytest.fixture +def resources_path_factory(): + def factory(*args): + dirpath = Path(__file__).parent / RESOURCES_PATH + for arg in args: + dirpath = dirpath / arg + return dirpath + return factory diff --git a/client/ayon_core/tests/plugins/load/test_export_otio.py b/client/ayon_core/tests/plugins/load/test_export_otio.py new file mode 100644 index 0000000000..cdcb15033a --- /dev/null +++ b/client/ayon_core/tests/plugins/load/test_export_otio.py @@ -0,0 +1,52 @@ +import pytest +import logging +from pathlib import Path +from ayon_core.plugins.load.export_otio import get_image_info_metadata + +logger = logging.getLogger('test_transcoding') + + +@pytest.mark.parametrize( + "resources_path_factory, metadata, expected, test_id", + [ + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.1013.exr", + ["timecode", "framerate"], + {"timecode": "01:00:06:03", "framerate": 23.976023976023978}, + "test_01", + ), + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.1013.exr", + ["timecode", "width", "height", "duration"], + {"timecode": "01:00:06:03", "width": 1920, "height": 1080}, + "test_02", + ), + ( + Path(__file__).parent.parent + / "resources" + / "lib" + / "transcoding" + / "a01vfxd_sh010_plateP01_v002.mov", + ["width", "height", "duration"], + {"width": 1920, "height": 1080, "duration": "0.041708"}, + "test_03", + ), + ], +) +def test_get_image_info_metadata_happy_path( + resources_path_factory, metadata, expected, test_id +): + path_to_file = resources_path_factory.as_posix() + + returned_data = get_image_info_metadata(path_to_file, metadata, logger) + logger.info(f"Returned data: {returned_data}") + + assert returned_data == expected diff --git a/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr new file mode 100644 index 0000000000..9f7bc625bc Binary files /dev/null and b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.1013.exr differ diff --git a/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov new file mode 100644 index 0000000000..7b477114b3 Binary files /dev/null and b/client/ayon_core/tests/resources/lib/transcoding/a01vfxd_sh010_plateP01_v002.mov differ diff --git a/client/ayon_core/tools/attribute_defs/__init__.py b/client/ayon_core/tools/attribute_defs/__init__.py index f991fdec3d..7f6cbb41be 100644 --- a/client/ayon_core/tools/attribute_defs/__init__.py +++ b/client/ayon_core/tools/attribute_defs/__init__.py @@ -1,6 +1,7 @@ from .widgets import ( create_widget_for_attr_def, AttributeDefinitionsWidget, + AttributeDefinitionsLabel, ) from .dialog import ( @@ -11,6 +12,7 @@ from .dialog import ( __all__ = ( "create_widget_for_attr_def", "AttributeDefinitionsWidget", + "AttributeDefinitionsLabel", "AttributeDefinitionsDialog", ) diff --git a/client/ayon_core/tools/attribute_defs/_constants.py b/client/ayon_core/tools/attribute_defs/_constants.py new file mode 100644 index 0000000000..b58a05bac6 --- /dev/null +++ b/client/ayon_core/tools/attribute_defs/_constants.py @@ -0,0 +1 @@ +REVERT_TO_DEFAULT_LABEL = "Revert to default" diff --git a/client/ayon_core/tools/attribute_defs/files_widget.py b/client/ayon_core/tools/attribute_defs/files_widget.py index 95091bed5a..46399c5fce 100644 --- a/client/ayon_core/tools/attribute_defs/files_widget.py +++ b/client/ayon_core/tools/attribute_defs/files_widget.py @@ -17,6 +17,8 @@ from ayon_core.tools.utils import ( PixmapLabel ) +from ._constants import REVERT_TO_DEFAULT_LABEL + ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2 ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3 @@ -598,7 +600,7 @@ class FilesView(QtWidgets.QListView): """View showing instances and their groups.""" remove_requested = QtCore.Signal() - context_menu_requested = QtCore.Signal(QtCore.QPoint) + context_menu_requested = QtCore.Signal(QtCore.QPoint, bool) def __init__(self, *args, **kwargs): super(FilesView, self).__init__(*args, **kwargs) @@ -690,9 +692,8 @@ class FilesView(QtWidgets.QListView): def _on_context_menu_request(self, pos): index = self.indexAt(pos) - if index.isValid(): - point = self.viewport().mapToGlobal(pos) - self.context_menu_requested.emit(point) + point = self.viewport().mapToGlobal(pos) + self.context_menu_requested.emit(point, index.isValid()) def _on_selection_change(self): self._remove_btn.setEnabled(self.has_selected_item_ids()) @@ -721,27 +722,34 @@ class FilesView(QtWidgets.QListView): class FilesWidget(QtWidgets.QFrame): value_changed = QtCore.Signal() + revert_requested = QtCore.Signal() def __init__(self, single_item, allow_sequences, extensions_label, parent): - super(FilesWidget, self).__init__(parent) + super().__init__(parent) self.setAcceptDrops(True) + wrapper_widget = QtWidgets.QWidget(self) + empty_widget = DropEmpty( - single_item, allow_sequences, extensions_label, self + single_item, allow_sequences, extensions_label, wrapper_widget ) files_model = FilesModel(single_item, allow_sequences) files_proxy_model = FilesProxyModel() files_proxy_model.setSourceModel(files_model) - files_view = FilesView(self) + files_view = FilesView(wrapper_widget) files_view.setModel(files_proxy_model) - layout = QtWidgets.QStackedLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) - layout.addWidget(empty_widget) - layout.addWidget(files_view) - layout.setCurrentWidget(empty_widget) + wrapper_layout = QtWidgets.QStackedLayout(wrapper_widget) + wrapper_layout.setContentsMargins(0, 0, 0, 0) + wrapper_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + wrapper_layout.addWidget(empty_widget) + wrapper_layout.addWidget(files_view) + wrapper_layout.setCurrentWidget(empty_widget) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(wrapper_widget, 1) files_proxy_model.rowsInserted.connect(self._on_rows_inserted) files_proxy_model.rowsRemoved.connect(self._on_rows_removed) @@ -761,7 +769,11 @@ class FilesWidget(QtWidgets.QFrame): self._widgets_by_id = {} - self._layout = layout + self._wrapper_widget = wrapper_widget + self._wrapper_layout = wrapper_layout + + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) def _set_multivalue(self, multivalue): if self._multivalue is multivalue: @@ -770,7 +782,7 @@ class FilesWidget(QtWidgets.QFrame): self._files_view.set_multivalue(multivalue) self._files_model.set_multivalue(multivalue) self._files_proxy_model.set_multivalue(multivalue) - self.setEnabled(not multivalue) + self._wrapper_widget.setEnabled(not multivalue) def set_value(self, value, multivalue): self._in_set_value = True @@ -888,22 +900,28 @@ class FilesWidget(QtWidgets.QFrame): if items_to_delete: self._remove_item_by_ids(items_to_delete) - def _on_context_menu_requested(self, pos): - if self._multivalue: - return + def _on_context_menu(self, pos): + self._on_context_menu_requested(pos, False) + def _on_context_menu_requested(self, pos, valid_index): menu = QtWidgets.QMenu(self._files_view) + if valid_index and not self._multivalue: + if self._files_view.has_selected_sequence(): + split_action = QtWidgets.QAction("Split sequence", menu) + split_action.triggered.connect(self._on_split_request) + menu.addAction(split_action) - if self._files_view.has_selected_sequence(): - split_action = QtWidgets.QAction("Split sequence", menu) - split_action.triggered.connect(self._on_split_request) - menu.addAction(split_action) + remove_action = QtWidgets.QAction("Remove", menu) + remove_action.triggered.connect(self._on_remove_requested) + menu.addAction(remove_action) - remove_action = QtWidgets.QAction("Remove", menu) - remove_action.triggered.connect(self._on_remove_requested) - menu.addAction(remove_action) + if not valid_index: + revert_action = QtWidgets.QAction(REVERT_TO_DEFAULT_LABEL, menu) + revert_action.triggered.connect(self.revert_requested) + menu.addAction(revert_action) - menu.popup(pos) + if menu.actions(): + menu.popup(pos) def dragEnterEvent(self, event): if self._multivalue: @@ -1011,5 +1029,5 @@ class FilesWidget(QtWidgets.QFrame): current_widget = self._files_view else: current_widget = self._empty_widget - self._layout.setCurrentWidget(current_widget) + self._wrapper_layout.setCurrentWidget(current_widget) self._files_view.update_remove_btn_visibility() diff --git a/client/ayon_core/tools/attribute_defs/widgets.py b/client/ayon_core/tools/attribute_defs/widgets.py index 5ead3f46a6..93f63730f5 100644 --- a/client/ayon_core/tools/attribute_defs/widgets.py +++ b/client/ayon_core/tools/attribute_defs/widgets.py @@ -1,4 +1,6 @@ import copy +import typing +from typing import Optional from qtpy import QtWidgets, QtCore @@ -20,58 +22,123 @@ from ayon_core.tools.utils import ( FocusSpinBox, FocusDoubleSpinBox, MultiSelectionComboBox, + set_style_property, ) from ayon_core.tools.utils import NiceCheckbox +from ._constants import REVERT_TO_DEFAULT_LABEL from .files_widget import FilesWidget +if typing.TYPE_CHECKING: + from typing import Union -def create_widget_for_attr_def(attr_def, parent=None): - widget = _create_widget_for_attr_def(attr_def, parent) - if attr_def.hidden: + +def create_widget_for_attr_def( + attr_def: AbstractAttrDef, + parent: Optional[QtWidgets.QWidget] = None, + handle_revert_to_default: Optional[bool] = True, +): + widget = _create_widget_for_attr_def( + attr_def, parent, handle_revert_to_default + ) + if not attr_def.visible: widget.setVisible(False) - if attr_def.disabled: + if not attr_def.enabled: widget.setEnabled(False) return widget -def _create_widget_for_attr_def(attr_def, parent=None): +def _create_widget_for_attr_def( + attr_def: AbstractAttrDef, + parent: "Union[QtWidgets.QWidget, None]", + handle_revert_to_default: bool, +): if not isinstance(attr_def, AbstractAttrDef): raise TypeError("Unexpected type \"{}\" expected \"{}\"".format( str(type(attr_def)), AbstractAttrDef )) + cls = None if isinstance(attr_def, NumberDef): - return NumberAttrWidget(attr_def, parent) + cls = NumberAttrWidget - if isinstance(attr_def, TextDef): - return TextAttrWidget(attr_def, parent) + elif isinstance(attr_def, TextDef): + cls = TextAttrWidget - if isinstance(attr_def, EnumDef): - return EnumAttrWidget(attr_def, parent) + elif isinstance(attr_def, EnumDef): + cls = EnumAttrWidget - if isinstance(attr_def, BoolDef): - return BoolAttrWidget(attr_def, parent) + elif isinstance(attr_def, BoolDef): + cls = BoolAttrWidget - if isinstance(attr_def, UnknownDef): - return UnknownAttrWidget(attr_def, parent) + elif isinstance(attr_def, UnknownDef): + cls = UnknownAttrWidget - if isinstance(attr_def, HiddenDef): - return HiddenAttrWidget(attr_def, parent) + elif isinstance(attr_def, HiddenDef): + cls = HiddenAttrWidget - if isinstance(attr_def, FileDef): - return FileAttrWidget(attr_def, parent) + elif isinstance(attr_def, FileDef): + cls = FileAttrWidget - if isinstance(attr_def, UISeparatorDef): - return SeparatorAttrWidget(attr_def, parent) + elif isinstance(attr_def, UISeparatorDef): + cls = SeparatorAttrWidget - if isinstance(attr_def, UILabelDef): - return LabelAttrWidget(attr_def, parent) + elif isinstance(attr_def, UILabelDef): + cls = LabelAttrWidget - raise ValueError("Unknown attribute definition \"{}\"".format( - str(type(attr_def)) - )) + if cls is None: + raise ValueError("Unknown attribute definition \"{}\"".format( + str(type(attr_def)) + )) + + return cls(attr_def, parent, handle_revert_to_default) + + +class AttributeDefinitionsLabel(QtWidgets.QLabel): + """Label related to value attribute definition. + + Label is used to show attribute definition label and to show if value + is overridden. + + Label can be right-clicked to revert value to default. + """ + revert_to_default_requested = QtCore.Signal(str) + + def __init__( + self, + attr_id: str, + label: str, + parent: QtWidgets.QWidget, + ): + super().__init__(label, parent) + + self._attr_id = attr_id + self._overridden = False + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + self.customContextMenuRequested.connect(self._on_context_menu) + + def set_overridden(self, overridden: bool): + if self._overridden == overridden: + return + self._overridden = overridden + set_style_property( + self, + "overridden", + "1" if overridden else "" + ) + + def _on_context_menu(self, point: QtCore.QPoint): + menu = QtWidgets.QMenu(self) + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self._request_revert_to_default) + menu.addAction(action) + menu.exec_(self.mapToGlobal(point)) + + def _request_revert_to_default(self): + self.revert_to_default_requested.emit(self._attr_id) class AttributeDefinitionsWidget(QtWidgets.QWidget): @@ -83,16 +150,18 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): """ def __init__(self, attr_defs=None, parent=None): - super(AttributeDefinitionsWidget, self).__init__(parent) + super().__init__(parent) - self._widgets = [] + self._widgets_by_id = {} + self._labels_by_id = {} self._current_keys = set() self.set_attr_defs(attr_defs) def clear_attr_defs(self): """Remove all existing widgets and reset layout if needed.""" - self._widgets = [] + self._widgets_by_id = {} + self._labels_by_id = {} self._current_keys = set() layout = self.layout() @@ -133,9 +202,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): self._current_keys.add(attr_def.key) widget = create_widget_for_attr_def(attr_def, self) - self._widgets.append(widget) + self._widgets_by_id[attr_def.id] = widget - if attr_def.hidden: + if not attr_def.visible: continue expand_cols = 2 @@ -145,7 +214,13 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): col_num = 2 - expand_cols if attr_def.is_value_def and attr_def.label: - label_widget = QtWidgets.QLabel(attr_def.label, self) + label_widget = AttributeDefinitionsLabel( + attr_def.id, attr_def.label, self + ) + label_widget.revert_to_default_requested.connect( + self._on_revert_request + ) + self._labels_by_id[attr_def.id] = label_widget tooltip = attr_def.tooltip if tooltip: label_widget.setToolTip(tooltip) @@ -160,6 +235,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): if not attr_def.is_label_horizontal: row += 1 + if attr_def.is_value_def: + widget.value_changed.connect(self._on_value_change) + layout.addWidget( widget, row, col_num, 1, expand_cols ) @@ -168,7 +246,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def set_value(self, value): new_value = copy.deepcopy(value) unused_keys = set(new_value.keys()) - for widget in self._widgets: + for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if attr_def.key not in new_value: continue @@ -181,22 +259,42 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget): def current_value(self): output = {} - for widget in self._widgets: + for widget in self._widgets_by_id.values(): attr_def = widget.attr_def if not isinstance(attr_def, UIDef): output[attr_def.key] = widget.current_value() return output + def _on_revert_request(self, attr_id): + widget = self._widgets_by_id.get(attr_id) + if widget is not None: + widget.set_value(widget.attr_def.default) + + def _on_value_change(self, value, attr_id): + widget = self._widgets_by_id.get(attr_id) + if widget is None: + return + label = self._labels_by_id.get(attr_id) + if label is not None: + label.set_overridden(value != widget.attr_def.default) + class _BaseAttrDefWidget(QtWidgets.QWidget): # Type 'object' may not work with older PySide versions value_changed = QtCore.Signal(object, str) + revert_to_default_requested = QtCore.Signal(str) - def __init__(self, attr_def, parent): - super(_BaseAttrDefWidget, self).__init__(parent) + def __init__( + self, + attr_def: AbstractAttrDef, + parent: "Union[QtWidgets.QWidget, None]", + handle_revert_to_default: Optional[bool] = True, + ): + super().__init__(parent) - self.attr_def = attr_def + self.attr_def: AbstractAttrDef = attr_def + self._handle_revert_to_default: bool = handle_revert_to_default main_layout = QtWidgets.QHBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) @@ -205,6 +303,15 @@ class _BaseAttrDefWidget(QtWidgets.QWidget): self._ui_init() + def revert_to_default_value(self): + if not self.attr_def.is_value_def: + return + + if self._handle_revert_to_default: + self.set_value(self.attr_def.default) + else: + self.revert_to_default_requested.emit(self.attr_def.id) + def _ui_init(self): raise NotImplementedError( "Method '_ui_init' is not implemented. {}".format( @@ -255,7 +362,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): clicked = QtCore.Signal() def __init__(self, text, parent): - super(ClickableLineEdit, self).__init__(parent) + super().__init__(parent) self.setText(text) self.setReadOnly(True) @@ -264,7 +371,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._mouse_pressed = True - super(ClickableLineEdit, self).mousePressEvent(event) + super().mousePressEvent(event) def mouseReleaseEvent(self, event): if self._mouse_pressed: @@ -272,7 +379,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): if self.rect().contains(event.pos()): self.clicked.emit() - super(ClickableLineEdit, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) class NumberAttrWidget(_BaseAttrDefWidget): @@ -284,6 +391,9 @@ class NumberAttrWidget(_BaseAttrDefWidget): else: input_widget = FocusSpinBox(self) + # Override context menu event to add revert to default action + input_widget.contextMenuEvent = self._input_widget_context_event + if self.attr_def.tooltip: input_widget.setToolTip(self.attr_def.tooltip) @@ -321,6 +431,16 @@ class NumberAttrWidget(_BaseAttrDefWidget): self._set_multiselection_visible(True) return False + def _input_widget_context_event(self, event): + line_edit = self._input_widget.lineEdit() + menu = line_edit.createStandardContextMenu() + menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + menu.popup(event.globalPos()) + def current_value(self): return self._input_widget.value() @@ -386,6 +506,9 @@ class TextAttrWidget(_BaseAttrDefWidget): else: input_widget = QtWidgets.QLineEdit(self) + # Override context menu event to add revert to default action + input_widget.contextMenuEvent = self._input_widget_context_event + if ( self.attr_def.placeholder and hasattr(input_widget, "setPlaceholderText") @@ -407,6 +530,15 @@ class TextAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) + def _input_widget_context_event(self, event): + menu = self._input_widget.createStandardContextMenu() + menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + menu.popup(event.globalPos()) + def _on_value_change(self): if self.multiline: new_value = self._input_widget.toPlainText() @@ -459,6 +591,20 @@ class BoolAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) self.main_layout.addStretch(1) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) + + def _on_context_menu(self, pos): + self._menu = QtWidgets.QMenu(self) + + action = QtWidgets.QAction(self._menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + self._menu.addAction(action) + + global_pos = self.mapToGlobal(pos) + self._menu.exec_(global_pos) + def _on_value_change(self): new_value = self._input_widget.isChecked() self.value_changed.emit(new_value, self.attr_def.id) @@ -487,7 +633,7 @@ class BoolAttrWidget(_BaseAttrDefWidget): class EnumAttrWidget(_BaseAttrDefWidget): def __init__(self, *args, **kwargs): self._multivalue = False - super(EnumAttrWidget, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) @property def multiselection(self): @@ -522,6 +668,20 @@ class EnumAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) + input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + input_widget.customContextMenuRequested.connect(self._on_context_menu) + + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + + global_pos = self.mapToGlobal(pos) + menu.exec_(global_pos) + def _on_value_change(self): new_value = self.current_value() if self._multivalue: @@ -614,7 +774,7 @@ class HiddenAttrWidget(_BaseAttrDefWidget): def setVisible(self, visible): if visible: visible = False - super(HiddenAttrWidget, self).setVisible(visible) + super().setVisible(visible) def current_value(self): if self._multivalue: @@ -650,10 +810,25 @@ class FileAttrWidget(_BaseAttrDefWidget): self.main_layout.addWidget(input_widget, 0) + input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + input_widget.customContextMenuRequested.connect(self._on_context_menu) + input_widget.revert_requested.connect(self.revert_to_default_value) + def _on_value_change(self): new_value = self.current_value() self.value_changed.emit(new_value, self.attr_def.id) + def _on_context_menu(self, pos): + menu = QtWidgets.QMenu(self) + + action = QtWidgets.QAction(menu) + action.setText(REVERT_TO_DEFAULT_LABEL) + action.triggered.connect(self.revert_to_default_value) + menu.addAction(action) + + global_pos = self.mapToGlobal(pos) + menu.exec_(global_pos) + def current_value(self): return self._input_widget.current_value() diff --git a/client/ayon_core/tools/creator/widgets.py b/client/ayon_core/tools/creator/widgets.py index 53a2ee1080..96ce899881 100644 --- a/client/ayon_core/tools/creator/widgets.py +++ b/client/ayon_core/tools/creator/widgets.py @@ -104,7 +104,7 @@ class ProductNameValidator(RegularExpressionValidatorClass): def validate(self, text, pos): results = super(ProductNameValidator, self).validate(text, pos) - if results[0] == self.Invalid: + if results[0] == RegularExpressionValidatorClass.Invalid: self.invalid.emit(self.invalid_chars(text)) return results diff --git a/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py new file mode 100644 index 0000000000..33de4bf036 --- /dev/null +++ b/client/ayon_core/tools/experimental_tools/pyblish_debug_stepper.py @@ -0,0 +1,273 @@ +""" +Brought from https://gist.github.com/BigRoy/1972822065e38f8fae7521078e44eca2 +Code Credits: [BigRoy](https://github.com/BigRoy) + +Requirement: + It requires pyblish version >= 1.8.12 + +How it works: + This tool makes use of pyblish event `pluginProcessed` to: + 1. Pause the publishing. + 2. Collect some info about the plugin. + 3. Show that info to the tool's window. + 4. Continue publishing on clicking `step` button. + +How to use it: + 1. Launch the tool from AYON experimental tools window. + 2. Launch the publisher tool and click validate. + 3. Click Step to run plugins one by one. + +Note : + Pyblish debugger also works when triggering the validation or + publishing from code. + Here's an example about validating from code: + https://github.com/MustafaJafar/ayon-recipes/blob/main/validate_from_code.py + +""" + +import copy +import json +from qtpy import QtWidgets, QtCore, QtGui + +import pyblish.api +from ayon_core import style + +TAB = 4* " " +HEADER_SIZE = "15px" + +KEY_COLOR = QtGui.QColor("#ffffff") +NEW_KEY_COLOR = QtGui.QColor("#00ff00") +VALUE_TYPE_COLOR = QtGui.QColor("#ffbbbb") +NEW_VALUE_TYPE_COLOR = QtGui.QColor("#ff4444") +VALUE_COLOR = QtGui.QColor("#777799") +NEW_VALUE_COLOR = QtGui.QColor("#DDDDCC") +CHANGED_VALUE_COLOR = QtGui.QColor("#CCFFCC") + +MAX_VALUE_STR_LEN = 100 + + +def failsafe_deepcopy(data): + """Allow skipping the deepcopy for unsupported types""" + try: + return copy.deepcopy(data) + except TypeError: + if isinstance(data, dict): + return { + key: failsafe_deepcopy(value) + for key, value in data.items() + } + elif isinstance(data, list): + return data.copy() + return data + + +class DictChangesModel(QtGui.QStandardItemModel): + # TODO: Replace this with a QAbstractItemModel + def __init__(self, *args, **kwargs): + super(DictChangesModel, self).__init__(*args, **kwargs) + self._data = {} + + columns = ["Key", "Type", "Value"] + self.setColumnCount(len(columns)) + for i, label in enumerate(columns): + self.setHeaderData(i, QtCore.Qt.Horizontal, label) + + def _update_recursive(self, data, parent, previous_data): + for key, value in data.items(): + + # Find existing item or add new row + parent_index = parent.index() + for row in range(self.rowCount(parent_index)): + # Update existing item if it exists + index = self.index(row, 0, parent_index) + if index.data() == key: + item = self.itemFromIndex(index) + type_item = self.itemFromIndex(self.index(row, 1, parent_index)) # noqa + value_item = self.itemFromIndex(self.index(row, 2, parent_index)) # noqa + break + else: + item = QtGui.QStandardItem(key) + type_item = QtGui.QStandardItem() + value_item = QtGui.QStandardItem() + parent.appendRow([item, type_item, value_item]) + + # Key + key_color = NEW_KEY_COLOR if key not in previous_data else KEY_COLOR # noqa + item.setData(key_color, QtCore.Qt.ForegroundRole) + + # Type + type_str = type(value).__name__ + type_color = VALUE_TYPE_COLOR + if ( + key in previous_data + and type(previous_data[key]).__name__ != type_str + ): + type_color = NEW_VALUE_TYPE_COLOR + + type_item.setText(type_str) + type_item.setData(type_color, QtCore.Qt.ForegroundRole) + + # Value + value_changed = False + if key not in previous_data or previous_data[key] != value: + value_changed = True + value_color = NEW_VALUE_COLOR if value_changed else VALUE_COLOR + + value_item.setData(value_color, QtCore.Qt.ForegroundRole) + if value_changed: + value_str = str(value) + if len(value_str) > MAX_VALUE_STR_LEN: + value_str = value_str[:MAX_VALUE_STR_LEN] + "..." + value_item.setText(value_str) + + # Preferably this is deferred to only when the data gets + # requested since this formatting can be slow for very large + # data sets like project settings and system settings + # This will also be MUCH faster if we don't clear the + # items on each update but only updated/add/remove changed + # items so that this also runs much less often + value_item.setData( + json.dumps(value, default=str, indent=4), + QtCore.Qt.ToolTipRole + ) + + if isinstance(value, dict): + previous_value = previous_data.get(key, {}) + if previous_data.get(key) != value: + # Update children if the value is not the same as before + self._update_recursive(value, + parent=item, + previous_data=previous_value) + else: + # TODO: Ensure all children are updated to be not marked + # as 'changed' in the most optimal way possible + self._update_recursive(value, + parent=item, + previous_data=previous_value) + + self._data = data + + def update(self, data): + parent = self.invisibleRootItem() + + data = failsafe_deepcopy(data) + previous_data = self._data + self._update_recursive(data, parent, previous_data) + self._data = data # store previous data for next update + + +class DebugUI(QtWidgets.QDialog): + + def __init__(self, parent=None): + super(DebugUI, self).__init__(parent=parent) + self.setStyleSheet(style.load_stylesheet()) + + self._set_window_title() + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowMinimizeButtonHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowStaysOnTopHint + ) + + layout = QtWidgets.QVBoxLayout(self) + text_edit = QtWidgets.QTextEdit() + text_edit.setFixedHeight(65) + font = QtGui.QFont("NONEXISTENTFONT") + font.setStyleHint(QtGui.QFont.TypeWriter) + text_edit.setFont(font) + text_edit.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) + + step = QtWidgets.QPushButton("Step") + step.setEnabled(False) + + model = DictChangesModel() + proxy = QtCore.QSortFilterProxyModel() + proxy.setRecursiveFilteringEnabled(True) + proxy.setSourceModel(model) + view = QtWidgets.QTreeView() + view.setModel(proxy) + view.setSortingEnabled(True) + + filter_field = QtWidgets.QLineEdit() + filter_field.setPlaceholderText("Filter keys...") + filter_field.textChanged.connect(proxy.setFilterFixedString) + + layout.addWidget(text_edit) + layout.addWidget(filter_field) + layout.addWidget(view) + layout.addWidget(step) + + step.clicked.connect(self.on_step) + + self._pause = False + self.model = model + self.filter = filter_field + self.proxy = proxy + self.view = view + self.text = text_edit + self.step = step + self.resize(700, 500) + + self._previous_data = {} + + def _set_window_title(self, plugin=None): + title = "Pyblish Debug Stepper" + if plugin is not None: + plugin_label = plugin.label or plugin.__name__ + title += f" | {plugin_label}" + self.setWindowTitle(title) + + def pause(self, state): + self._pause = state + self.step.setEnabled(state) + + def on_step(self): + self.pause(False) + + def showEvent(self, event): + print("Registering callback..") + pyblish.api.register_callback("pluginProcessed", + self.on_plugin_processed) + + def hideEvent(self, event): + self.pause(False) + print("Deregistering callback..") + pyblish.api.deregister_callback("pluginProcessed", + self.on_plugin_processed) + + def on_plugin_processed(self, result): + self.pause(True) + + self._set_window_title(plugin=result["plugin"]) + + print(10*"<", result["plugin"].__name__, 10*">") + + plugin_order = result["plugin"].order + plugin_name = result["plugin"].__name__ + duration = result['duration'] + plugin_instance = result["instance"] + context = result["context"] + + msg = "" + msg += f"Order: {plugin_order}
" + msg += f"Plugin: {plugin_name}" + if plugin_instance is not None: + msg += f" -> instance: {plugin_instance}" + msg += "
" + msg += f"Duration: {duration} ms
" + self.text.setHtml(msg) + + data = { + "context": context.data + } + for instance in context: + data[instance.name] = instance.data + self.model.update(data) + + app = QtWidgets.QApplication.instance() + while self._pause: + # Allow user interaction with the UI + app.processEvents() diff --git a/client/ayon_core/tools/experimental_tools/tools_def.py b/client/ayon_core/tools/experimental_tools/tools_def.py index 7def3551de..30e5211b41 100644 --- a/client/ayon_core/tools/experimental_tools/tools_def.py +++ b/client/ayon_core/tools/experimental_tools/tools_def.py @@ -1,4 +1,5 @@ import os +from .pyblish_debug_stepper import DebugUI # Constant key under which local settings are stored LOCAL_EXPERIMENTAL_KEY = "experimental_tools" @@ -95,6 +96,12 @@ class ExperimentalTools: "hiero", "resolve", ] + ), + ExperimentalHostTool( + "pyblish_debug_stepper", + "Pyblish Debug Stepper", + "Debug Pyblish plugins step by step.", + self._show_pyblish_debugger, ) ] @@ -162,9 +169,16 @@ class ExperimentalTools: local_settings.get(LOCAL_EXPERIMENTAL_KEY) ) or {} - for identifier, eperimental_tool in self.tools_by_identifier.items(): + # Enable the following tools by default. + # Because they will always be disabled due + # to the fact their settings don't exist. + experimental_settings.update({ + "pyblish_debug_stepper": True, + }) + + for identifier, experimental_tool in self.tools_by_identifier.items(): enabled = experimental_settings.get(identifier, False) - eperimental_tool.set_enabled(enabled) + experimental_tool.set_enabled(enabled) def _show_publisher(self): if self._publisher_tool is None: @@ -175,3 +189,7 @@ class ExperimentalTools: ) self._publisher_tool.show() + + def _show_pyblish_debugger(self): + window = DebugUI(parent=self._parent_widget) + window.show() diff --git a/client/ayon_core/tools/loader/models/sitesync.py b/client/ayon_core/tools/loader/models/sitesync.py index 02504c2ad3..c7f0038df4 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 @@ -509,18 +512,19 @@ class SiteSyncModel: "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, @@ -575,7 +579,7 @@ class SiteSyncModel: 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(): @@ -584,9 +588,6 @@ class SiteSyncModel: 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) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 9753da37af..fba9b5b3ca 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -222,6 +222,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor = VersionComboBox(product_id, parent) editor.setProperty("itemId", item_id) + editor.setFocusPolicy(QtCore.Qt.NoFocus) editor.value_changed.connect(self._on_editor_change) editor.destroyed.connect(self._on_destroy) diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 768f4b052f..4ed91813d3 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -13,8 +13,10 @@ from typing import ( from ayon_core.lib import AbstractAttrDef from ayon_core.host import HostBase -from ayon_core.pipeline.create import CreateContext, CreatedInstance -from ayon_core.pipeline.create.context import ConvertorItem +from ayon_core.pipeline.create import ( + CreateContext, + ConvertorItem, +) from ayon_core.tools.common_models import ( FolderItem, TaskItem, @@ -23,7 +25,7 @@ from ayon_core.tools.common_models import ( ) if TYPE_CHECKING: - from .models import CreatorItem + from .models import CreatorItem, PublishErrorInfo, InstanceItem class CardMessageTypes: @@ -75,7 +77,7 @@ class AbstractPublisherCommon(ABC): 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. """ @@ -200,7 +202,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): 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: @@ -263,6 +265,11 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): """ 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"]: @@ -274,6 +281,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): """ 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 @@ -304,19 +326,37 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): 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 + def get_instances_context_info( + self, instance_ids: Optional[Iterable[str]] = None + ): + pass + + @abstractmethod + def set_instances_context_info( + self, changes_by_instance_id: Dict[str, Dict[str, Any]] + ): + pass + + @abstractmethod + def set_instances_active_state( + self, active_state_by_id: Dict[str, bool] + ): pass @abstractmethod @@ -325,22 +365,55 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): @abstractmethod def get_creator_attribute_definitions( - self, instances: List[CreatedInstance] - ) -> List[Tuple[AbstractAttrDef, List[CreatedInstance], List[Any]]]: + self, instance_ids: Iterable[str] + ) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]: + pass + + @abstractmethod + def set_instances_create_attr_values( + self, instance_ids: Iterable[str], key: str, value: Any + ): + pass + + @abstractmethod + def revert_instances_create_attr_values( + self, + instance_ids: List["Union[str, None]"], + key: str, + ): pass @abstractmethod def get_publish_attribute_definitions( self, - instances: List[CreatedInstance], + instance_ids: Iterable[str], include_context: bool ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[CreatedInstance, Any]]] + Dict[str, List[Tuple[str, Any, Any]]] ]]: pass + @abstractmethod + def set_instances_publish_attr_values( + self, + instance_ids: Iterable[str], + plugin_name: str, + key: str, + value: Any + ): + pass + + @abstractmethod + def revert_instances_publish_attr_values( + self, + instance_ids: List["Union[str, None]"], + plugin_name: str, + key: str, + ): + pass + @abstractmethod def get_product_name( self, @@ -374,7 +447,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): ): """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. @@ -437,8 +510,8 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): """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 @@ -534,14 +607,13 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): pass @abstractmethod - def get_publish_error_msg(self) -> Union[str, None]: + def get_publish_error_info(self) -> Optional["PublishErrorInfo"]: """Current error message which cause fail of publishing. Returns: - Union[str, None]: Message which will be showed to artist or - None. - """ + Optional[PublishErrorInfo]: Error info or None. + """ pass @abstractmethod @@ -549,7 +621,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): pass @abstractmethod - def get_validation_errors(self): + def get_publish_errors_report(self): pass @abstractmethod @@ -578,7 +650,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon): @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. diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index 257b45de08..98fdda08cf 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. @@ -172,23 +192,36 @@ class PublisherController( """ 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. - - Deprecated: - Use 'get_instances' instead. Kept for backwards compatibility with - traypublisher. - - """ - return self.get_instances() + return self.get_instance_items() def get_instances(self): - """Current instances in create context.""" - return self._create_model.get_instances() + return self.get_instance_items() - def get_instances_by_id(self, instance_ids=None): - return self._create_model.get_instances_by_id(instance_ids) + def get_instances_by_id(self, *args, **kwargs): + return self.get_instance_items_by_id(*args, **kwargs) + + # --- + + 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) + + def set_instances_context_info(self, changes_by_instance_id): + return self._create_model.set_instances_context_info( + 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() @@ -362,29 +395,53 @@ class PublisherController( 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 get_publish_attribute_definitions(self, instances, include_context): + 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 revert_instances_create_attr_values(self, instance_ids, key): + self._create_model.revert_instances_create_attr_values( + instance_ids, key + ) + + 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 revert_instances_publish_attr_values( + self, instance_ids, plugin_name, key + ): + return self._create_model.revert_instances_publish_attr_values( + instance_ids, plugin_name, key ) def get_product_name( @@ -493,14 +550,14 @@ class PublisherController( 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() - 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/control_qt.py b/client/ayon_core/tools/publisher/control_qt.py index b42b9afea3..7d1c661603 100644 --- a/client/ayon_core/tools/publisher/control_qt.py +++ b/client/ayon_core/tools/publisher/control_qt.py @@ -60,9 +60,8 @@ class MainThreadProcess(QtCore.QObject): self._timer.stop() def clear(self): - if self._timer.isActive(): - self._timer.stop() self._items_to_process = collections.deque() + self.stop() class QtPublisherController(PublisherController): @@ -77,21 +76,32 @@ class QtPublisherController(PublisherController): self.register_event_callback( "publish.process.stopped", self._qt_on_publish_stop ) + # Capture if '_next_publish_item_process' is in + # '_main_thread_processor' loop + self._item_process_in_loop = False def reset(self): self._main_thread_processor.clear() + self._item_process_in_loop = False super().reset() def _start_publish(self, up_validation): self._publish_model.set_publish_up_validation(up_validation) self._publish_model.start_publish(wait=False) - self._process_main_thread_item( - MainThreadItem(self._next_publish_item_process) - ) + # Make sure '_next_publish_item_process' is only once in + # the '_main_thread_processor' loop + if not self._item_process_in_loop: + self._process_main_thread_item( + MainThreadItem(self._next_publish_item_process) + ) def _next_publish_item_process(self): if not self._publish_model.is_running(): + # This removes '_next_publish_item_process' from loop + self._item_process_in_loop = False return + + self._item_process_in_loop = True func = self._publish_model.get_next_process_func() self._process_main_thread_item(MainThreadItem(func)) self._process_main_thread_item( @@ -105,4 +115,6 @@ class QtPublisherController(PublisherController): self._main_thread_processor.start() def _qt_on_publish_stop(self): - self._main_thread_processor.stop() + self._process_main_thread_item( + MainThreadItem(self._main_thread_processor.stop) + ) diff --git a/client/ayon_core/tools/publisher/models/__init__.py b/client/ayon_core/tools/publisher/models/__init__.py index bd593be29b..26eeb3cdbb 100644 --- a/client/ayon_core/tools/publisher/models/__init__.py +++ b/client/ayon_core/tools/publisher/models/__init__.py @@ -1,10 +1,12 @@ -from .create import CreateModel, CreatorItem -from .publish import PublishModel +from .create import CreateModel, CreatorItem, InstanceItem +from .publish import PublishModel, PublishErrorInfo __all__ = ( "CreateModel", "CreatorItem", + "InstanceItem", "PublishModel", + "PublishErrorInfo", ) diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index ab2bf07614..9644af43e0 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -1,11 +1,21 @@ import logging import re -from typing import Union, List, Dict, Tuple, Any, Optional, Iterable, Pattern +from typing import ( + Union, + List, + Dict, + Tuple, + Any, + Optional, + Iterable, + Pattern, +) from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, AbstractAttrDef, + EnumDef, ) from ayon_core.lib.profiles_filtering import filter_profiles from ayon_core.lib.attribute_definitions import UIDef @@ -17,8 +27,9 @@ from ayon_core.pipeline.create import ( Creator, CreateContext, CreatedInstance, + AttributeValues, ) -from ayon_core.pipeline.create.context import ( +from ayon_core.pipeline.create import ( CreatorsOperationFailed, ConvertorsOperationFailed, ConvertorItem, @@ -29,6 +40,7 @@ from ayon_core.tools.publisher.abstract import ( ) CREATE_EVENT_SOURCE = "publisher.create.model" +_DEFAULT_VALUE = object() class CreatorType: @@ -192,7 +204,192 @@ class 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 or "N/A", + instance.group_label, + instance.product_type, + instance.product_name, + instance["variant"], + instance["folderPath"], + instance["task"], + instance["active"], + instance.has_promised_context, + ) + + +def _merge_attr_defs( + attr_def_src: AbstractAttrDef, attr_def_new: AbstractAttrDef +) -> Optional[AbstractAttrDef]: + if not attr_def_src.enabled and attr_def_new.enabled: + attr_def_src.enabled = True + if not attr_def_src.visible and attr_def_new.visible: + attr_def_src.visible = True + + if not isinstance(attr_def_src, EnumDef): + return None + if attr_def_src.items == attr_def_new.items: + return None + + src_item_values = { + item["value"] + for item in attr_def_src.items + } + for item in attr_def_new.items: + if item["value"] not in src_item_values: + attr_def_src.items.append(item) + + +def merge_attr_defs(attr_defs: List[List[AbstractAttrDef]]): + if not attr_defs: + return [] + if len(attr_defs) == 1: + return attr_defs[0] + + # Pop first and create clone of attribute definitions + defs_union: List[AbstractAttrDef] = [ + attr_def.clone() + for attr_def in attr_defs.pop(0) + ] + for instance_attr_defs in attr_defs: + idx = 0 + for attr_idx, attr_def in enumerate(instance_attr_defs): + # QUESTION should we merge NumberDef too? Use lowest min and + # biggest max... + is_enum = isinstance(attr_def, EnumDef) + match_idx = None + match_attr = None + for union_idx, union_def in enumerate(defs_union): + if is_enum and ( + not isinstance(union_def, EnumDef) + or union_def.multiselection != attr_def.multiselection + ): + continue + + if ( + attr_def.compare_to_def( + union_def, + ignore_default=True, + ignore_enabled=True, + ignore_visible=True, + ignore_def_type_compare=is_enum + ) + ): + match_idx = union_idx + match_attr = union_def + break + + if match_attr is not None: + new_attr_def = _merge_attr_defs(match_attr, attr_def) + if new_attr_def is not None: + defs_union[match_idx] = new_attr_def + idx = match_idx + 1 + continue + + defs_union.insert(idx, attr_def.clone()) + idx += 1 + return defs_union + + class CreateModel: + _CONTEXT_KEYS = { + "active", + "folderPath", + "task", + "variant", + "productName", + } + def __init__(self, controller: AbstractPublisherBackend): self._log = None self._controller: AbstractPublisherBackend = controller @@ -258,12 +455,34 @@ class CreateModel: self._creator_items = None self._reset_instances() + + self._emit_event("create.model.reset") + + self._create_context.add_instances_added_callback( + self._cc_added_instance + ) + self._create_context.add_instances_removed_callback ( + self._cc_removed_instance + ) + self._create_context.add_value_changed_callback( + self._cc_value_changed + ) + self._create_context.add_pre_create_attr_defs_change_callback ( + self._cc_pre_create_attr_changed + ) + self._create_context.add_create_attr_defs_change_callback ( + self._cc_create_attr_changed + ) + self._create_context.add_publish_attr_defs_change_callback ( + self._cc_publish_attr_changed + ) + self._create_context.reset_finalization() 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( @@ -287,25 +506,68 @@ class CreateModel: 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() + return self._create_context.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 + self._emit_event( + "create.model.instances.context.changed", + { + "instance_ids": list(changes_by_instance_id.keys()) + } + ) + + def set_instances_active_state( + self, active_state_by_id: Dict[str, bool] + ): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id, active in active_state_by_id.items(): + instance = self._create_context.get_instance_by_id(instance_id) + instance["active"] = active + + self._emit_event( + "create.model.instances.context.changed", + { + "instance_ids": set(active_state_by_id.keys()) + } + ) + def get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id @@ -333,7 +595,7 @@ class CreateModel: 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( @@ -388,9 +650,10 @@ class CreateModel: 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 @@ -402,7 +665,6 @@ class CreateModel: } ) - self._on_create_instance_change() return success def trigger_convertor_items(self, convertor_identifiers: List[str]): @@ -490,23 +752,30 @@ class CreateModel: # 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): + self._set_instances_create_attr_values(instance_ids, key, value) + + def revert_instances_create_attr_values(self, instance_ids, key): + self._set_instances_create_attr_values( + instance_ids, key, _DEFAULT_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, Dict[str, Dict[str, 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(): @@ -517,29 +786,55 @@ class CreateModel: 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], [value])) + output.append(( + attr_def, + { + instance_id: { + "value": value, + "default": attr_def.default + } + } + )) _attr_defs[idx] = attr_def else: - item = output[found_idx] - item[1].append(instance) - item[2].append(value) + _, info_by_id = output[found_idx] + info_by_id[instance_id] = { + "value": value, + "default": attr_def.default + } + return output + def set_instances_publish_attr_values( + self, instance_ids, plugin_name, key, value + ): + self._set_instances_publish_attr_values( + instance_ids, plugin_name, key, value + ) + + def revert_instances_publish_attr_values( + self, instance_ids, plugin_name, key + ): + self._set_instances_publish_attr_values( + instance_ids, plugin_name, key, _DEFAULT_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, 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. @@ -548,30 +843,41 @@ class CreateModel: 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(): + if not isinstance(attr_val, AttributeValues): + continue attr_defs = attr_val.attr_defs if not attr_defs: continue - if plugin_name not in all_defs_by_plugin_name: - all_defs_by_plugin_name[plugin_name] = attr_val.attr_defs - + plugin_attr_defs = all_defs_by_plugin_name.setdefault( + plugin_name, [] + ) plugin_values = all_plugin_values.setdefault(plugin_name, {}) + plugin_attr_defs.append(attr_defs) + for attr_def in attr_defs: if isinstance(attr_def, UIDef): continue - attr_values = plugin_values.setdefault(attr_def.key, []) + attr_values.append( + (item_id, attr_val[attr_def.key], attr_def.default) + ) - value = attr_val[attr_def.key] - attr_values.append((item, 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: @@ -580,8 +886,8 @@ class CreateModel: continue output.append(( plugin_name, - all_defs_by_plugin_name[plugin_name], - all_plugin_values + attr_defs_by_plugin_name[plugin_name], + all_plugin_values[plugin_name], )) return output @@ -612,8 +918,12 @@ class CreateModel: } ) - def _emit_event(self, topic: str, data: Optional[Dict[str, Any]] = None): - self._controller.emit_event(topic, data) + def _emit_event( + self, + topic: str, + data: Optional[Dict[str, Any]] = None + ): + self._controller.emit_event(topic, data, CREATE_EVENT_SOURCE) def _get_current_project_settings(self) -> Dict[str, Any]: """Current project settings. @@ -630,11 +940,26 @@ class CreateModel: 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.""" 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: @@ -669,8 +994,6 @@ class CreateModel: } ) - 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 = [ @@ -688,9 +1011,6 @@ class CreateModel: } ) - 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 = {} @@ -712,6 +1032,145 @@ class CreateModel: 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 _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: + instance = self._get_instance_by_id(instance_id) + creator_attributes = instance["creator_attributes"] + 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 + ): + continue + + if value is _DEFAULT_VALUE: + creator_attributes[key] = attr_def.default + + elif attr_def.is_value_valid(value): + creator_attributes[key] = value + + def _set_instances_publish_attr_values( + self, instance_ids, plugin_name, 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] + 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 + ): + continue + + if value is _DEFAULT_VALUE: + plugin_val[key] = attr_def.default + + elif attr_def.is_value_valid(value): + plugin_val[key] = value + + 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_changes = {} + context_changed_ids = set() + for item in event.data["changes"]: + instance_id = None + if item["instance"]: + instance_id = item["instance"].id + 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"] + 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 + 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): + instance_changes = event.data["instance_changes"] + event_data = { + instance_id: instance_data["plugin_names"] + for instance_id, instance_data in instance_changes.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/models/publish.py b/client/ayon_core/tools/publisher/models/publish.py index ef207bfb79..97a956b18f 100644 --- a/client/ayon_core/tools/publisher/models/publish.py +++ b/client/ayon_core/tools/publisher/models/publish.py @@ -1,21 +1,27 @@ import uuid import copy import inspect +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, 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, + PublishError, +) from ayon_core.tools.publisher.abstract import AbstractPublisherBackend PUBLISH_EVENT_SOURCE = "publisher.publish.model" @@ -23,6 +29,72 @@ PUBLISH_EVENT_SOURCE = "publisher.publish.model" PLUGIN_ORDER_OFFSET = 0.5 +class MessageHandler(logging.Handler): + 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() + except Exception: + record.msg = str(record.msg) + self._records.append(record) + + def get_records(self): + return self._records + + +class PublishErrorInfo: + def __init__( + self, + message: str, + is_unknown_error: bool, + description: Optional[str] = None, + title: Optional[str] = None, + detail: Optional[str] = None, + ): + self.message: str = message + self.is_unknown_error = is_unknown_error + 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: + if not isinstance(other, PublishErrorInfo): + 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 + ) + + def __ne__(self, other: Any) -> bool: + return not self.__eq__(other) + + @classmethod + def from_exception(cls, exc) -> "PublishErrorInfo": + if isinstance(exc, PublishError): + return cls( + exc.message, + False, + exc.description, + title=exc.title, + detail=exc.detail, + ) + if isinstance(exc, KnownPublishError): + msg = str(exc) + else: + msg = ( + "Something went wrong. Send report" + " to your supervisor or Ynput team." + ) + return cls(msg, True) + class PublishReportMaker: """Report for single publishing process. @@ -172,7 +244,7 @@ class PublishReportMaker: "crashed_file_paths": crashed_file_paths, "id": uuid.uuid4().hex, "created_at": now.isoformat(), - "report_version": "1.0.1", + "report_version": "1.1.0", } def _add_plugin_data_item(self, plugin: pyblish.api.Plugin): @@ -194,11 +266,23 @@ class PublishReportMaker: if hasattr(plugin, "label"): label = plugin.label + plugin_type = "instance" if plugin.__instanceEnabled__ else "context" + # Get docstring + # NOTE we do care only about docstring from the plugin so we can't + # use 'inspect.getdoc' which also looks for docstring in parent + # classes. + docstring = getattr(plugin, "__doc__", None) + if docstring: + docstring = inspect.cleandoc(docstring) return { "id": plugin.id, "name": plugin.__name__, "label": label, "order": plugin.order, + "filepath": inspect.getfile(plugin), + "docstring": docstring, + "plugin_type": plugin_type, + "families": list(plugin.families), "targets": list(plugin.targets), "instances_data": [], "actions_data": [], @@ -467,10 +551,10 @@ class PublishPluginsProxy: ) -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 @@ -478,11 +562,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. @@ -493,7 +577,8 @@ class ValidationErrorItem: 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 @@ -501,7 +586,8 @@ class ValidationErrorItem: 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 @@ -517,7 +603,8 @@ class ValidationErrorItem: "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, @@ -527,13 +614,13 @@ class ValidationErrorItem: 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 @@ -549,6 +636,7 @@ class ValidationErrorItem: instance_label, plugin_id, instance is None, + isinstance(error, PublishValidationError), error.title, error.description, error.detail, @@ -559,11 +647,11 @@ class ValidationErrorItem: 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. @@ -572,7 +660,7 @@ class PublishValidationErrorsReport: 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 @@ -646,7 +734,7 @@ class PublishValidationErrorsReport: @classmethod def from_data( cls, data: Dict[str, Any] - ) -> "PublishValidationErrorsReport": + ) -> "PublishErrorsReport": """Recreate object from data. Args: @@ -654,11 +742,11 @@ class PublishValidationErrorsReport: 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 = {} @@ -670,12 +758,12 @@ class PublishValidationErrorsReport: 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] ] = {} @@ -701,29 +789,29 @@ class PublishValidationErrors: 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. """ @@ -738,7 +826,7 @@ class PublishValidationErrors: 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 @@ -784,12 +872,16 @@ 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 # 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 @@ -812,10 +904,8 @@ class PublishModel: 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. @@ -829,15 +919,25 @@ class PublishModel: ) # Plugin iterator - self._main_thread_iter: Iterable[partial] = [] + self._main_thread_iter: collections.abc.Generator[partial] = ( + self._default_iterator() + ) + + 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 - 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) @@ -867,7 +967,7 @@ class PublishModel: ) 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)) @@ -895,29 +995,30 @@ class PublishModel: func() def get_next_process_func(self) -> partial: - # Validations of progress before using iterator - # - same conditions may be inside iterator but they may be used - # only in specific cases (e.g. when it happens for a first time) + # Raise error if this function is called when publishing + # is not running + if not self._publish_is_running: + raise ValueError("Publish is not running") + # Validations of progress before using iterator + # Any unexpected error happened + # - everything should stop + if self._publish_has_crashed: + return partial(self.stop_publish) + + # Stop if validation is over and validation errors happened + # or publishing should stop at validation if ( - self._main_thread_iter is None - # There are validation errors and validation is passed - # - can't do any progree - or ( - self._publish_has_validated - and self._publish_has_validation_errors + self._publish_has_validated + and ( + self._publish_has_validation_errors + or self._publish_up_validation ) - # Any unexpected error happened - # - everything should stop - or self._publish_has_crashed ): - item = partial(self.stop_publish) + return partial(self.stop_publish) # Everything is ok so try to get new processing item - else: - item = next(self._main_thread_iter) - - return item + return next(self._main_thread_iter) def stop_publish(self): if self._publish_is_running: @@ -959,11 +1060,11 @@ class PublishModel: 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_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 @@ -1062,14 +1163,27 @@ class PublishModel: {"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} ) + def _default_iterator(self): + """Iterator used on initialization. + + Should be replaced by real iterator when 'reset' is called. + + Returns: + collections.abc.Generator[partial]: Generator with partial + functions that should be called in main thread. + + """ + while True: + yield partial(self.stop_publish) + def _start_publish(self): """Start or continue in publishing.""" if self._publish_is_running: @@ -1101,22 +1215,16 @@ class PublishModel: self._publish_progress = idx # Check if plugin is over validation order - if not self._publish_has_validated: - self._set_has_validated( - plugin.order >= self._validation_order - ) - - # Stop if plugin is over validation order and process - # should process up to validation. - if self._publish_up_validation and self._publish_has_validated: - yield partial(self.stop_publish) - - # Stop if validation is over and validation errors happened if ( - self._publish_has_validated - and self.has_validation_errors() + not self._publish_has_validated + and plugin.order >= self._validation_order ): - yield partial(self.stop_publish) + self._set_has_validated(True) + if ( + self._publish_up_validation + or self._publish_has_validation_errors + ): + yield partial(self.stop_publish) # Add plugin to publish report self._publish_report.add_plugin_iter( @@ -1192,43 +1300,79 @@ class PublishModel: 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 ): - result = pyblish.plugin.process( - plugin, self._publish_context, instance - ) + with self._log_manager(plugin) as log_handler: + result = pyblish.plugin.process( + plugin, self._publish_context, instance + ) + if log_handler is not None: + 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: - 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, KnownPublishError): - msg = str(exception) - else: - msg = ( - "Something went wrong. Send report" - " to your supervisor or Ynput team." - ) - self._set_publish_error_msg(msg) + if isinstance(exception, PublishError): + if not exception.title: + exception.title = plugin.label or plugin.__name__ + 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/publish_report_viewer/report_items.py b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py index 206f999bac..a3c5a7a2fd 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/report_items.py @@ -13,8 +13,16 @@ class PluginItem: self.skipped = plugin_data["skipped"] self.passed = plugin_data["passed"] + # Introduced in report '1.1.0' + self.docstring = plugin_data.get("docstring") + self.filepath = plugin_data.get("filepath") + self.plugin_type = plugin_data.get("plugin_type") + self.families = plugin_data.get("families") + errored = False + process_time = 0.0 for instance_data in plugin_data["instances_data"]: + process_time += instance_data["process_time"] for log_item in instance_data["logs"]: errored = log_item["type"] == "error" if errored: @@ -22,6 +30,7 @@ class PluginItem: if errored: break + self.process_time = process_time self.errored = errored @property diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py index 61a52533ba..5fa1c04dc0 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/widgets.py @@ -1,7 +1,15 @@ from math import ceil from qtpy import QtWidgets, QtCore, QtGui -from ayon_core.tools.utils import NiceCheckbox +from ayon_core.tools.utils import ( + NiceCheckbox, + ElideLabel, + SeparatorWidget, + IconButton, + paint_image_with_color, +) +from ayon_core.resources import get_image_path +from ayon_core.style import get_objected_colors # from ayon_core.tools.utils import DeselectableTreeView from .constants import ( @@ -22,33 +30,89 @@ TRACEBACK_ROLE = QtCore.Qt.UserRole + 2 IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 -class PluginLoadReportModel(QtGui.QStandardItemModel): - def set_report(self, report): - parent = self.invisibleRootItem() - parent.removeRows(0, parent.rowCount()) +def get_pretty_milliseconds(value): + if value < 1000: + return f"{value:.3f}ms" + value /= 1000 + if value < 60: + return f"{value:.2f}s" + seconds = int(value % 60) + value /= 60 + if value < 60: + return f"{value:.2f}m {seconds:.2f}s" + minutes = int(value % 60) + value /= 60 + return f"{value:.2f}h {minutes:.2f}m" + +class PluginLoadReportModel(QtGui.QStandardItemModel): + def __init__(self): + super().__init__() + self._traceback_by_filepath = {} + self._items_by_filepath = {} + self._is_active = True + self._need_refresh = False + + def set_active(self, is_active): + if self._is_active is is_active: + return + self._is_active = is_active + self._update_items() + + def set_report(self, report): + self._need_refresh = True if report is None: + self._traceback_by_filepath.clear() + self._update_items() + return + + filepaths = set(report.crashed_plugin_paths.keys()) + to_remove = set(self._traceback_by_filepath) - filepaths + for filepath in filepaths: + self._traceback_by_filepath[filepath] = ( + report.crashed_plugin_paths[filepath] + ) + + for filepath in to_remove: + self._traceback_by_filepath.pop(filepath) + self._update_items() + + def _update_items(self): + if not self._is_active or not self._need_refresh: + return + parent = self.invisibleRootItem() + if not self._traceback_by_filepath: + parent.removeRows(0, parent.rowCount()) return new_items = [] new_items_by_filepath = {} - for filepath in report.crashed_plugin_paths.keys(): + to_remove = ( + set(self._items_by_filepath) - set(self._traceback_by_filepath) + ) + for filepath in self._traceback_by_filepath: + if filepath in self._items_by_filepath: + continue item = QtGui.QStandardItem(filepath) new_items.append(item) new_items_by_filepath[filepath] = item + self._items_by_filepath[filepath] = item - if not new_items: - return + if new_items: + parent.appendRows(new_items) - parent.appendRows(new_items) for filepath, item in new_items_by_filepath.items(): - traceback_txt = report.crashed_plugin_paths[filepath] + traceback_txt = self._traceback_by_filepath[filepath] detail_item = QtGui.QStandardItem() detail_item.setData(filepath, FILEPATH_ROLE) detail_item.setData(traceback_txt, TRACEBACK_ROLE) detail_item.setData(True, IS_DETAIL_ITEM_ROLE) item.appendRow(detail_item) + for filepath in to_remove: + item = self._items_by_filepath.pop(filepath) + parent.removeRow(item.row()) + class DetailWidget(QtWidgets.QTextEdit): def __init__(self, text, *args, **kwargs): @@ -95,10 +159,12 @@ class PluginLoadReportWidget(QtWidgets.QWidget): self._model = model self._widgets_by_filepath = {} - def _on_expand(self, index): - for row in range(self._model.rowCount(index)): - child_index = self._model.index(row, index.column(), index) - self._create_widget(child_index) + def set_active(self, is_active): + self._model.set_active(is_active) + + def set_report(self, report): + self._widgets_by_filepath = {} + self._model.set_report(report) def showEvent(self, event): super().showEvent(event) @@ -108,6 +174,11 @@ class PluginLoadReportWidget(QtWidgets.QWidget): super().resizeEvent(event) self._update_widgets_size_hints() + def _on_expand(self, index): + for row in range(self._model.rowCount(index)): + child_index = self._model.index(row, index.column(), index) + self._create_widget(child_index) + def _update_widgets_size_hints(self): for item in self._widgets_by_filepath.values(): widget, index = item @@ -136,10 +207,6 @@ class PluginLoadReportWidget(QtWidgets.QWidget): self._view.setIndexWidget(index, widget) self._widgets_by_filepath[filepath] = (widget, index) - def set_report(self, report): - self._widgets_by_filepath = {} - self._model.set_report(report) - class ZoomPlainText(QtWidgets.QPlainTextEdit): min_point_size = 1.0 @@ -229,6 +296,8 @@ class DetailsWidget(QtWidgets.QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(output_widget) + self._is_active = True + self._need_refresh = False self._output_widget = output_widget self._report_item = None self._instance_filter = set() @@ -237,21 +306,33 @@ class DetailsWidget(QtWidgets.QWidget): def clear(self): self._output_widget.setPlainText("") + def set_active(self, is_active): + if self._is_active is is_active: + return + self._is_active = is_active + self._update_logs() + def set_report(self, report): self._report_item = report self._plugin_filter = set() self._instance_filter = set() + self._need_refresh = True self._update_logs() def set_plugin_filter(self, plugin_filter): self._plugin_filter = plugin_filter + self._need_refresh = True self._update_logs() def set_instance_filter(self, instance_filter): self._instance_filter = instance_filter + self._need_refresh = True self._update_logs() def _update_logs(self): + if not self._is_active or not self._need_refresh: + return + if not self._report_item: self._output_widget.setPlainText("") return @@ -294,6 +375,242 @@ class DetailsWidget(QtWidgets.QWidget): self._output_widget.setPlainText(text) +class PluginDetailsWidget(QtWidgets.QWidget): + def __init__(self, plugin_item, parent): + super().__init__(parent) + + content_widget = QtWidgets.QFrame(self) + content_widget.setObjectName("PluginDetailsContent") + + plugin_label_widget = QtWidgets.QLabel(content_widget) + plugin_label_widget.setObjectName("PluginLabel") + + plugin_doc_widget = QtWidgets.QLabel(content_widget) + plugin_doc_widget.setWordWrap(True) + + form_separator = SeparatorWidget(parent=content_widget) + + plugin_class_label = QtWidgets.QLabel("Class:") + plugin_class_widget = QtWidgets.QLabel(content_widget) + + plugin_order_label = QtWidgets.QLabel("Order:") + plugin_order_widget = QtWidgets.QLabel(content_widget) + + plugin_families_label = QtWidgets.QLabel("Families:") + plugin_families_widget = QtWidgets.QLabel(content_widget) + plugin_families_widget.setWordWrap(True) + + plugin_path_label = QtWidgets.QLabel("File Path:") + plugin_path_widget = ElideLabel(content_widget) + plugin_path_widget.set_elide_mode(QtCore.Qt.ElideLeft) + + plugin_time_label = QtWidgets.QLabel("Time:") + plugin_time_widget = QtWidgets.QLabel(content_widget) + + # Set interaction flags + for label_widget in ( + plugin_label_widget, + plugin_doc_widget, + plugin_class_widget, + plugin_order_widget, + plugin_families_widget, + plugin_time_widget, + ): + label_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + # Change style of form labels + for label_widget in ( + plugin_class_label, + plugin_order_label, + plugin_families_label, + plugin_path_label, + plugin_time_label, + ): + label_widget.setObjectName("PluginFormLabel") + + plugin_label = plugin_item.label or plugin_item.name + if plugin_item.plugin_type: + plugin_label += " ({})".format( + plugin_item.plugin_type.capitalize() + ) + + time_label = "Not started" + if plugin_item.passed: + time_label = get_pretty_milliseconds(plugin_item.process_time) + elif plugin_item.skipped: + time_label = "Skipped plugin" + + families = "N/A" + if plugin_item.families: + families = ", ".join(plugin_item.families) + + order = "N/A" + if plugin_item.order is not None: + order = str(plugin_item.order) + + plugin_label_widget.setText(plugin_label) + plugin_doc_widget.setText(plugin_item.docstring or "N/A") + plugin_class_widget.setText(plugin_item.name or "N/A") + plugin_order_widget.setText(order) + plugin_families_widget.setText(families) + plugin_path_widget.setText(plugin_item.filepath or "N/A") + plugin_path_widget.setToolTip(plugin_item.filepath or None) + plugin_time_widget.setText(time_label) + + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setContentsMargins(8, 8, 8, 8) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + row = 0 + + content_layout.addWidget(plugin_label_widget, row, 0, 1, 2) + row += 1 + + # Hide docstring if it is empty + if plugin_item.docstring: + content_layout.addWidget(plugin_doc_widget, row, 0, 1, 2) + row += 1 + else: + plugin_doc_widget.setVisible(False) + + content_layout.addWidget(form_separator, row, 0, 1, 2) + row += 1 + + for label_widget, value_widget in ( + (plugin_class_label, plugin_class_widget), + (plugin_order_label, plugin_order_widget), + (plugin_families_label, plugin_families_widget), + (plugin_path_label, plugin_path_widget), + (plugin_time_label, plugin_time_widget), + ): + content_layout.addWidget(label_widget, row, 0) + content_layout.addWidget(value_widget, row, 1) + row += 1 + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(content_widget, 0) + + +class PluginsDetailsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super().__init__(parent) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + + scroll_content_widget = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(scroll_content_widget) + + empty_label = QtWidgets.QLabel( + "

Select plugins to view more information...", + scroll_content_widget + ) + empty_label.setAlignment(QtCore.Qt.AlignCenter) + + content_widget = QtWidgets.QWidget(scroll_content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(10) + + scroll_content_layout = QtWidgets.QVBoxLayout(scroll_content_widget) + scroll_content_layout.setContentsMargins(0, 0, 0, 0) + scroll_content_layout.addWidget(empty_label, 0) + scroll_content_layout.addWidget(content_widget, 0) + scroll_content_layout.addStretch(1) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(scroll_area, 1) + + content_widget.setVisible(False) + + self._scroll_area = scroll_area + self._empty_label = empty_label + self._content_layout = content_layout + self._content_widget = content_widget + + self._widgets_by_plugin_id = {} + self._stretch_item_index = 0 + + self._is_active = True + self._need_refresh = False + + self._report_item = None + self._plugin_filter = set() + self._plugin_ids = None + + def set_active(self, is_active): + if self._is_active is is_active: + return + self._is_active = is_active + self._update_widgets() + + def set_plugin_filter(self, plugin_filter): + self._need_refresh = True + self._plugin_filter = plugin_filter + self._update_widgets() + + def set_report(self, report): + self._plugin_ids = None + self._plugin_filter = set() + self._need_refresh = True + self._report_item = report + self._update_widgets() + + def _get_plugin_ids(self): + if self._plugin_ids is not None: + return self._plugin_ids + + # Clear layout and clear widgets + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + + self._widgets_by_plugin_id.clear() + + plugin_ids = [] + if self._report_item is not None: + plugin_ids = list(self._report_item.plugins_id_order) + self._plugin_ids = plugin_ids + return plugin_ids + + def _update_widgets(self): + if not self._is_active or not self._need_refresh: + return + + self._need_refresh = False + + # Hide content widget before updating + # - add widgets to layout can happen without recalculating + # the layout and widget size hints + self._content_widget.setVisible(False) + + any_visible = False + for plugin_id in self._get_plugin_ids(): + widget = self._widgets_by_plugin_id.get(plugin_id) + if widget is None: + plugin_item = self._report_item.plugins_items_by_id[plugin_id] + widget = PluginDetailsWidget(plugin_item, self._content_widget) + self._widgets_by_plugin_id[plugin_id] = widget + self._content_layout.addWidget(widget, 0) + + is_visible = plugin_id in self._plugin_filter + widget.setVisible(is_visible) + if is_visible: + any_visible = True + + self._content_widget.setVisible(any_visible) + self._empty_label.setVisible(not any_visible) + + class DeselectableTreeView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" @@ -337,11 +654,15 @@ class DetailsPopup(QtWidgets.QDialog): def showEvent(self, event): layout = self.layout() + cw_size = self._center_widget.size() layout.insertWidget(0, self._center_widget) - super().showEvent(event) if self._first_show: self._first_show = False - self.resize(700, 400) + self.resize( + max(cw_size.width(), 700), + max(cw_size.height(), 400) + ) + super().showEvent(event) def closeEvent(self, event): super().closeEvent(event) @@ -410,20 +731,42 @@ class PublishReportViewerWidget(QtWidgets.QFrame): details_widget = QtWidgets.QWidget(self) details_tab_widget = QtWidgets.QTabWidget(details_widget) - details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget) + + btns_widget = QtWidgets.QWidget(details_widget) + + popout_image = QtGui.QImage(get_image_path("popout.png")) + popout_color = get_objected_colors("font") + popout_icon = QtGui.QIcon( + paint_image_with_color(popout_image, popout_color.get_qcolor()) + ) + details_popup_btn = IconButton(btns_widget) + details_popup_btn.setIcon(popout_icon) + details_popup_btn.setToolTip("Pop Out") + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(details_popup_btn, 0) details_layout = QtWidgets.QVBoxLayout(details_widget) details_layout.setContentsMargins(0, 0, 0, 0) details_layout.addWidget(details_tab_widget, 1) - details_layout.addWidget(details_popup_btn, 0) + details_layout.addWidget(btns_widget, 0) details_popup = DetailsPopup(self, details_tab_widget) logs_text_widget = DetailsWidget(details_tab_widget) plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget) + plugins_details_widget = PluginsDetailsWidget(details_tab_widget) + + plugin_load_report_widget.set_active(False) + plugins_details_widget.set_active(False) details_tab_widget.addTab(logs_text_widget, "Logs") - details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins") + details_tab_widget.addTab(plugins_details_widget, "Plugins Details") + details_tab_widget.addTab( + plugin_load_report_widget, "Crashed plugins" + ) middle_widget = QtWidgets.QWidget(self) middle_layout = QtWidgets.QGridLayout(middle_widget) @@ -440,6 +783,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame): layout.addWidget(middle_widget, 0) layout.addWidget(details_widget, 1) + details_tab_widget.currentChanged.connect(self._on_tab_change) instances_view.selectionModel().selectionChanged.connect( self._on_instance_change ) @@ -458,10 +802,12 @@ class PublishReportViewerWidget(QtWidgets.QFrame): details_popup_btn.clicked.connect(self._on_details_popup) details_popup.closed.connect(self._on_popup_close) + self._current_tab_idx = 0 self._ignore_selection_changes = False self._report_item = None self._logs_text_widget = logs_text_widget self._plugin_load_report_widget = plugin_load_report_widget + self._plugins_details_widget = plugins_details_widget self._removed_instances_check = removed_instances_check self._instances_view = instances_view @@ -498,6 +844,14 @@ class PublishReportViewerWidget(QtWidgets.QFrame): else: self._plugins_view.expand(index) + def set_active(self, active): + for idx in range(self._details_tab_widget.count()): + widget = self._details_tab_widget.widget(idx) + widget.set_active(active and idx == self._current_tab_idx) + + if not active: + self.close_details_popup() + def set_report_data(self, report_data): report = PublishReport(report_data) self.set_report(report) @@ -511,12 +865,22 @@ class PublishReportViewerWidget(QtWidgets.QFrame): self._plugins_model.set_report(report) self._logs_text_widget.set_report(report) self._plugin_load_report_widget.set_report(report) + self._plugins_details_widget.set_report(report) self._ignore_selection_changes = False self._instances_view.expandAll() self._plugins_view.expandAll() + def _on_tab_change(self, new_idx): + if self._current_tab_idx == new_idx: + return + old_widget = self._details_tab_widget.widget(self._current_tab_idx) + new_widget = self._details_tab_widget.widget(new_idx) + self._current_tab_idx = new_idx + old_widget.set_active(False) + new_widget.set_active(True) + def _on_instance_change(self, *_args): if self._ignore_selection_changes: return @@ -538,6 +902,7 @@ class PublishReportViewerWidget(QtWidgets.QFrame): plugin_ids.add(index.data(ITEM_ID_ROLE)) self._logs_text_widget.set_plugin_filter(plugin_ids) + self._plugins_details_widget.set_plugin_filter(plugin_ids) def _on_skipped_plugin_check(self): self._plugins_proxy.set_ignore_skipped( diff --git a/client/ayon_core/tools/publisher/publish_report_viewer/window.py b/client/ayon_core/tools/publisher/publish_report_viewer/window.py index aedc3b9e31..6921c5d162 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/window.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/window.py @@ -2,11 +2,11 @@ import os import json import uuid -import appdirs import arrow from qtpy import QtWidgets, QtCore, QtGui from ayon_core import style +from ayon_core.lib import get_launcher_local_dir from ayon_core.resources import get_ayon_icon_filepath from ayon_core.tools import resources from ayon_core.tools.utils import ( @@ -35,12 +35,8 @@ def get_reports_dir(): str: Path to directory where reports are stored. """ - report_dir = os.path.join( - appdirs.user_data_dir("AYON", "Ynput"), - "publish_report_viewer" - ) - if not os.path.exists(report_dir): - os.makedirs(report_dir) + report_dir = get_launcher_local_dir("publish_report_viewer") + os.makedirs(report_dir, exist_ok=True) return report_dir @@ -576,8 +572,7 @@ class LoadedFilesWidget(QtWidgets.QWidget): filepaths = [] for url in mime_data.urls(): filepath = url.toLocalFile() - ext = os.path.splitext(filepath)[-1] - if os.path.exists(filepath) and ext == ".json": + if os.path.exists(filepath): filepaths.append(filepath) self._add_filepaths(filepaths) event.accept() 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 d67252e302..095a4eae7c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -22,6 +22,7 @@ Only one item can be selected at a time. import re import collections +from typing import Dict from qtpy import QtWidgets, QtCore @@ -217,26 +218,35 @@ class InstanceGroupWidget(BaseGroupWidget): def update_icons(self, group_icons): self._group_icons = group_icons - def update_instance_values(self): + def update_instance_values( + self, context_info_by_id, instance_items_by_id, instance_ids + ): """Trigger update on instance widgets.""" - for widget in self._widgets_by_id.values(): - widget.update_instance_values() + for instance_id, widget in self._widgets_by_id.items(): + 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): + def update_instances(self, instances, context_info_by_id): """Update instances for the group. Args: - instances(list): 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. + """ # Store instances by id and by product name instances_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 @@ -249,13 +259,14 @@ class InstanceGroupWidget(BaseGroupWidget): widget_idx = 1 for product_names in sorted_product_names: for instance in instances_by_product_name[product_names]: + context_info = context_info_by_id[instance.id] if instance.id in self._widgets_by_id: widget = self._widgets_by_id[instance.id] - widget.update_instance(instance) + widget.update_instance(instance, context_info) else: group_icon = self._group_icons[instance.creator_identifier] widget = InstanceCardWidget( - instance, group_icon, self + instance, context_info, group_icon, self ) widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self._on_active_changed) @@ -304,8 +315,9 @@ class CardWidget(BaseClickableFrame): def set_selected(self, selected): """Set card as selected.""" - if selected == self._selected: + if selected is self._selected: return + self._selected = selected state = "selected" if selected else "" self.setProperty("state", state) @@ -388,16 +400,13 @@ class ConvertorItemCardWidget(CardWidget): self._icon_widget = icon_widget self._label_widget = label_widget - def update_instance_values(self): - pass - class InstanceCardWidget(CardWidget): """Card widget representing instance.""" active_changed = QtCore.Signal(str, bool) - def __init__(self, instance, group_icon, parent): + def __init__(self, instance, context_info, group_icon, parent): super().__init__(parent) self._id = instance.id @@ -458,7 +467,7 @@ class InstanceCardWidget(CardWidget): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self.update_instance_values() + self._update_instance_values(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -467,32 +476,25 @@ class InstanceCardWidget(CardWidget): def is_active(self): return self._active_checkbox.isChecked() - def set_active(self, new_value): + def _set_active(self, new_value): """Set instance as active.""" checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance["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 - if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) - def update_instance(self, instance): + def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance - self.update_instance_values() + self._update_instance_values(context_info) - def _validate_context(self): - valid = self.instance.has_valid_context + def _validate_context(self, context_info): + valid = context_info.is_valid self._icon_widget.setVisible(valid) 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 @@ -519,11 +521,11 @@ class InstanceCardWidget(CardWidget): QtCore.Qt.NoTextInteraction ) - def update_instance_values(self): + def _update_instance_values(self, context_info): """Update instance data""" self._update_product_name() - self.set_active(self.instance["active"]) - self._validate_context() + self._set_active(self.instance.is_active) + self._validate_context(context_info) def _set_expanded(self, expanded=None): if expanded is None: @@ -532,11 +534,10 @@ class InstanceCardWidget(CardWidget): 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.active_changed.emit(self._id, new_value) def _on_expend_clicked(self): @@ -593,7 +594,7 @@ class InstanceCardView(AbstractInstanceView): self._context_widget = None self._convertor_items_group = None self._active_toggle_enabled = True - self._widgets_by_group = {} + self._widgets_by_group: Dict[str, InstanceGroupWidget] = {} self._ordered_groups = [] self._explicitly_selected_instance_ids = [] @@ -622,24 +623,25 @@ class InstanceCardView(AbstractInstanceView): return widgets = self._get_selected_widgets() - changed = False + active_state_by_id = {} for widget in widgets: if not isinstance(widget, InstanceCardWidget): continue + instance_id = widget.id is_active = widget.is_active if value == -1: - widget.set_active(not is_active) - changed = True + active_state_by_id[instance_id] = not is_active continue _value = bool(value) if is_active is not _value: - widget.set_active(_value) - changed = True + active_state_by_id[instance_id] = _value - if changed: - self.active_changed.emit() + if not active_state_by_id: + return + + self._controller.set_instances_active_state(active_state_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: @@ -694,10 +696,12 @@ class InstanceCardView(AbstractInstanceView): self._update_convertor_items_group() + context_info_by_id = self._controller.get_instances_context_info() + # 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( @@ -747,7 +751,7 @@ class InstanceCardView(AbstractInstanceView): widget_idx += 1 group_widget.update_instances( - instances_by_group[group_name] + instances_by_group[group_name], context_info_by_id ) group_widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -812,22 +816,31 @@ class InstanceCardView(AbstractInstanceView): self._convertor_items_group.update_items(convertor_items) - def refresh_instance_states(self): + def refresh_instance_states(self, instance_ids=None): """Trigger update of instances on group widgets.""" + if instance_ids is not None: + instance_ids = set(instance_ids) + context_info_by_id = self._controller.get_instances_context_info() + instance_items_by_id = self._controller.get_instance_items_by_id( + instance_ids + ) for widget in self._widgets_by_group.values(): - widget.update_instance_values() + widget.update_instance_values( + context_info_by_id, instance_items_by_id, instance_ids + ) def _on_active_changed(self, group_name, instance_id, value): group_widget = self._widgets_by_group[group_name] instance_widget = group_widget.get_widget_by_item_id(instance_id) - if instance_widget.is_selected: + active_state_by_id = {} + if not instance_widget.is_selected: + active_state_by_id[instance_id] = value + else: for widget in self._get_selected_widgets(): if isinstance(widget, InstanceCardWidget): - widget.set_active(value) - else: - self._select_item_clear(instance_id, group_name, instance_widget) - self.selection_changed.emit() - self.active_changed.emit() + active_state_by_id[widget.id] = value + + self._controller.set_instances_active_state(active_state_by_id) def _on_widget_selection(self, instance_id, group_name, selection_type): """Select specific item by instance id. diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index 479a63ebc9..aecea2ec44 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -19,6 +19,7 @@ from ayon_core.tools.publisher.constants import ( INPUTS_LAYOUT_HSPACING, INPUTS_LAYOUT_VSPACING, ) +from ayon_core.tools.utils import HintedLineEdit from .thumbnail_widget import ThumbnailWidget from .widgets import ( @@ -28,8 +29,6 @@ from .widgets import ( from .create_context_widgets import CreateContextWidget from .precreate_widget import PreCreateWidget -SEPARATORS = ("---separator---", "---") - class ResizeControlWidget(QtWidgets.QWidget): resized = QtCore.Signal() @@ -112,7 +111,7 @@ class CreateWidget(QtWidgets.QWidget): self._folder_path = None self._product_names = None - self._selected_creator = None + self._selected_creator_identifier = None self._prereq_available = False @@ -168,25 +167,9 @@ class CreateWidget(QtWidgets.QWidget): product_variant_widget = QtWidgets.QWidget(creator_basics_widget) # Variant and product input - variant_widget = ResizeControlWidget(product_variant_widget) - variant_widget.setObjectName("VariantInputsWidget") - - variant_input = QtWidgets.QLineEdit(variant_widget) - variant_input.setObjectName("VariantInput") - variant_input.setToolTip(VARIANT_TOOLTIP) - - variant_hints_btn = QtWidgets.QToolButton(variant_widget) - variant_hints_btn.setArrowType(QtCore.Qt.DownArrow) - variant_hints_btn.setIconSize(QtCore.QSize(12, 12)) - - variant_hints_menu = QtWidgets.QMenu(variant_widget) - variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) - - variant_layout = QtWidgets.QHBoxLayout(variant_widget) - variant_layout.setContentsMargins(0, 0, 0, 0) - variant_layout.setSpacing(0) - variant_layout.addWidget(variant_input, 1) - variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter) + variant_widget = HintedLineEdit(parent=product_variant_widget) + variant_widget.set_text_widget_object_name("VariantInput") + variant_widget.setToolTip(VARIANT_TOOLTIP) product_name_input = QtWidgets.QLineEdit(product_variant_widget) product_name_input.setEnabled(False) @@ -262,15 +245,12 @@ class CreateWidget(QtWidgets.QWidget): prereq_timer.timeout.connect(self._invalidate_prereq) create_btn.clicked.connect(self._on_create) - variant_widget.resized.connect(self._on_variant_widget_resize) creator_basics_widget.resized.connect(self._on_creator_basics_resize) - variant_input.returnPressed.connect(self._on_create) - variant_input.textChanged.connect(self._on_variant_change) + variant_widget.returnPressed.connect(self._on_create) + variant_widget.textChanged.connect(self._on_variant_change) creators_view.selectionModel().currentChanged.connect( self._on_creator_item_change ) - variant_hints_btn.clicked.connect(self._on_variant_btn_click) - variant_hints_menu.triggered.connect(self._on_variant_action) context_widget.folder_changed.connect(self._on_folder_change) context_widget.task_changed.connect(self._on_task_change) thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) @@ -282,6 +262,10 @@ class CreateWidget(QtWidgets.QWidget): 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 @@ -291,10 +275,7 @@ class CreateWidget(QtWidgets.QWidget): self.product_name_input = product_name_input - self.variant_input = variant_input - self.variant_hints_btn = variant_hints_btn - self.variant_hints_menu = variant_hints_menu - self.variant_hints_group = variant_hints_group + self._variant_widget = variant_widget self._creators_model = creators_model self._creators_sort_model = creators_sort_model @@ -314,6 +295,7 @@ class CreateWidget(QtWidgets.QWidget): self._last_current_context_folder_path = None self._last_current_context_task = None self._use_current_context = True + self._current_creator_variant_hints = [] def get_current_folder_path(self): return self._controller.get_current_folder_path() @@ -438,8 +420,7 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn.setEnabled(prereq_available) - self.variant_input.setEnabled(prereq_available) - self.variant_hints_btn.setEnabled(prereq_available) + self._variant_widget.setEnabled(prereq_available) tooltip = "" if creator_btn_tooltips: @@ -535,6 +516,15 @@ class CreateWidget(QtWidgets.QWidget): # 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(): @@ -586,12 +576,13 @@ class CreateWidget(QtWidgets.QWidget): 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() @@ -611,48 +602,28 @@ class CreateWidget(QtWidgets.QWidget): if not default_variant: default_variant = default_variants[0] - for action in tuple(self.variant_hints_menu.actions()): - self.variant_hints_menu.removeAction(action) - action.deleteLater() - - for variant in default_variants: - if variant in SEPARATORS: - self.variant_hints_menu.addSeparator() - elif variant: - self.variant_hints_menu.addAction(variant) + self._current_creator_variant_hints = list(default_variants) + self._variant_widget.set_options(default_variants) variant_text = default_variant or DEFAULT_VARIANT_VALUE # Make sure product name is updated to new plugin - if variant_text == self.variant_input.text(): + if variant_text == self._variant_widget.text(): self._on_variant_change() else: - self.variant_input.setText(variant_text) - - def _on_variant_widget_resize(self): - self.variant_hints_btn.setFixedHeight(self.variant_input.height()) - - def _on_variant_btn_click(self): - pos = self.variant_hints_btn.rect().bottomLeft() - point = self.variant_hints_btn.mapToGlobal(pos) - self.variant_hints_menu.popup(point) - - def _on_variant_action(self, action): - value = action.text() - if self.variant_input.text() != value: - self.variant_input.setText(value) + self._variant_widget.setText(variant_text) def _on_variant_change(self, variant_value=None): if not self._prereq_available: 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 if variant_value is None: - variant_value = self.variant_input.text() + variant_value = self._variant_widget.text() if not self._compiled_name_pattern.match(variant_value): self._create_btn.setEnabled(False) @@ -668,11 +639,13 @@ class CreateWidget(QtWidgets.QWidget): 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) @@ -707,20 +680,12 @@ class CreateWidget(QtWidgets.QWidget): if _result: variant_hints |= set(_result.groups()) - # Remove previous hints from menu - for action in tuple(self.variant_hints_group.actions()): - self.variant_hints_group.removeAction(action) - self.variant_hints_menu.removeAction(action) - action.deleteLater() - - # Add separator if there are hints and menu already has actions - if variant_hints and self.variant_hints_menu.actions(): - self.variant_hints_menu.addSeparator() - + options = list(self._current_creator_variant_hints) + if options: + options.append("---") + options.extend(variant_hints) # Add hints to actions - for variant_hint in variant_hints: - action = self.variant_hints_menu.addAction(variant_hint) - self.variant_hints_group.addAction(action) + self._variant_widget.set_options(options) # Indicate product existence if not variant_value: @@ -741,10 +706,7 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn.setEnabled(variant_is_valid) def _set_variant_state_property(self, state): - current_value = self.variant_input.property("state") - if current_value != state: - self.variant_input.setProperty("state", state) - self.variant_input.style().polish(self.variant_input) + self._variant_widget.set_text_widget_property("state", state) def _on_first_show(self): width = self.width() @@ -776,7 +738,7 @@ class CreateWidget(QtWidgets.QWidget): index = indexes[0] creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE) product_type = index.data(PRODUCT_TYPE_ROLE) - variant = self.variant_input.text() + variant = self._variant_widget.text() # Care about product name only if context change is enabled product_name = None folder_path = None @@ -809,8 +771,8 @@ class CreateWidget(QtWidgets.QWidget): ) if success: - self._set_creator(self._selected_creator) - self.variant_input.setText(variant) + 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 self._thumbnail_widget.set_current_thumbnails() 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 930d6bb88c..bc3353ba5e 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -110,15 +110,15 @@ class ListItemDelegate(QtWidgets.QStyledItemDelegate): class InstanceListItemWidget(QtWidgets.QWidget): """Widget with instance info drawn over delegate paint. - This is required to be able use custom checkbox on custom place. + This is required to be able to use custom checkbox on custom place. """ active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() - def __init__(self, instance, parent): + 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: @@ -131,7 +131,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): 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() @@ -151,7 +151,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._has_valid_context = None - self._set_valid_property(instance.has_valid_context) + self._set_valid_property(context_info.is_valid) def mouseDoubleClickEvent(self, event): widget = self.childAt(event.pos()) @@ -171,47 +171,34 @@ class InstanceListItemWidget(QtWidgets.QWidget): def is_active(self): """Instance is activated.""" - return self.instance["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["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["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): + def update_instance(self, instance, context_info): """Update instance object.""" - self.instance = instance - self.update_instance_values() - - def update_instance_values(self): - """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["active"]) + self.set_active(instance.is_active) # Check valid states - self._set_valid_property(self.instance.has_valid_context) + self._set_valid_property(context_info.is_valid) def _on_active_change(self): - new_value = self._active_checkbox.isChecked() - old_value = self.instance["active"] - if new_value == old_value: - return - - self.instance["active"] = new_value - self.active_changed.emit(self.instance.id, new_value) + self.active_changed.emit( + self._instance_id, self._active_checkbox.isChecked() + ) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -245,8 +232,8 @@ class ListContextWidget(QtWidgets.QFrame): class InstanceListGroupWidget(QtWidgets.QFrame): """Widget representing group of instances. - Has collapse/expand indicator, label of group and checkbox modifying all of - it's children. + Has collapse/expand indicator, label of group and checkbox modifying all + of its children. """ expand_changed = QtCore.Signal(str, bool) toggle_requested = QtCore.Signal(str, int) @@ -392,7 +379,7 @@ class InstanceTreeView(QtWidgets.QTreeView): def _mouse_press(self, event): """Store index of pressed group. - This is to be able change state of group and process mouse + This is to be able to change state of group and process mouse "double click" as 2x "single click". """ if event.button() != QtCore.Qt.LeftButton: @@ -583,10 +570,12 @@ class InstanceListView(AbstractInstanceView): self._update_convertor_items_group() + context_info_by_id = self._controller.get_instances_context_info() + # 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) @@ -610,7 +599,7 @@ class InstanceListView(AbstractInstanceView): # Mapping of existing instances under group item existing_mapping = {} - # Get group index to be able get children indexes + # Get group index to be able to get children indexes group_index = self._instance_model.index( group_item.row(), group_item.column() ) @@ -637,25 +626,27 @@ class InstanceListView(AbstractInstanceView): 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] + self._group_by_instance_id[instance_id] = group_name # Remove instance id from `to_remove` if already exists and # trigger update of widget if instance_id in to_remove: to_remove.remove(instance_id) widget = self._widgets_by_id[instance_id] - widget.update_instance(instance) + widget.update_instance(instance, context_info) continue # 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)) @@ -695,7 +686,8 @@ class InstanceListView(AbstractInstanceView): group_item.appendRows(new_items) for item, instance in new_items_with_instance: - if not instance.has_valid_context: + context_info = context_info_by_id[instance.id] + if not context_info.is_valid: expand_groups.add(group_name) item_index = self._instance_model.index( item.row(), @@ -704,7 +696,7 @@ class InstanceListView(AbstractInstanceView): ) proxy_index = self._proxy_model.mapFromSource(item_index) widget = InstanceListItemWidget( - instance, self._instance_view + instance, context_info, self._instance_view ) widget.set_active_toggle_enabled( self._active_toggle_enabled @@ -868,28 +860,40 @@ class InstanceListView(AbstractInstanceView): widget = self._group_widgets.pop(group_name) widget.deleteLater() - def refresh_instance_states(self): + def refresh_instance_states(self, instance_ids=None): """Trigger update of all instances.""" - for widget in self._widgets_by_id.values(): - widget.update_instance_values() + 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(): + if instance_ids is not None and instance_id not in instance_ids: + continue + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + ) def _on_active_changed(self, changed_instance_id, new_value): selected_instance_ids, _, _ = self.get_selected_items() - selected_ids = set() + active_by_id = {} found = False for instance_id in selected_instance_ids: - selected_ids.add(instance_id) + active_by_id[instance_id] = new_value if not found and instance_id == changed_instance_id: found = True if not found: - selected_ids = set() - selected_ids.add(changed_instance_id) + active_by_id = {changed_instance_id: new_value} - self._change_active_instances(selected_ids, new_value) + self._controller.set_instances_active_state(active_by_id) + + self._change_active_instances(active_by_id, new_value) group_names = set() - for instance_id in selected_ids: + for instance_id in active_by_id: group_name = self._group_by_instance_id.get(instance_id) if group_name is not None: group_names.add(group_name) @@ -901,16 +905,11 @@ class InstanceListView(AbstractInstanceView): if not instance_ids: return - changed_ids = set() for instance_id in instance_ids: widget = self._widgets_by_id.get(instance_id) if widget: - changed_ids.add(instance_id) widget.set_active(new_value) - if changed_ids: - self.active_changed.emit() - def _on_selection_change(self, *_args): self.selection_changed.emit() @@ -949,14 +948,16 @@ class InstanceListView(AbstractInstanceView): if not group_item: return - instance_ids = set() + active_by_id = {} for row in range(group_item.rowCount()): item = group_item.child(row) instance_id = item.data(INSTANCE_ID_ROLE) if instance_id is not None: - instance_ids.add(instance_id) + active_by_id[instance_id] = active - self._change_active_instances(instance_ids, active) + self._controller.set_instances_active_state(active_by_id) + + self._change_active_instances(active_by_id, active) proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index 52a45d0881..a09ee80ed5 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -6,17 +6,15 @@ from .border_label_widget import BorderedLabelWidget 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): - active_changed = QtCore.Signal() - instance_context_changed = QtCore.Signal() create_requested = QtCore.Signal() convert_requested = QtCore.Signal() publish_tab_requested = QtCore.Signal() @@ -61,7 +59,7 @@ class OverviewWidget(QtWidgets.QFrame): 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) @@ -126,17 +124,7 @@ class OverviewWidget(QtWidgets.QFrame): product_view_cards.double_clicked.connect( self.publish_tab_requested ) - # Active instances changed - product_list_view.active_changed.connect( - self._on_active_changed - ) - product_view_cards.active_changed.connect( - self._on_active_changed - ) # Instance context has changed - product_attributes_widget.instance_context_changed.connect( - self._on_instance_context_change - ) product_attributes_widget.convert_requested.connect( self._on_convert_requested ) @@ -152,7 +140,20 @@ class OverviewWidget(QtWidgets.QFrame): "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 + ) + controller.register_event_callback( + "create.model.instances.context.changed", + self._on_instance_context_change ) self._product_content_widget = product_content_widget @@ -303,11 +304,6 @@ class OverviewWidget(QtWidgets.QFrame): instances, context_selected, convertor_identifiers ) - def _on_active_changed(self): - if self._refreshing_instances: - return - self.active_changed.emit() - def _on_change_anim(self, value): self._create_widget.setVisible(True) self._product_attributes_wrap.setVisible(True) @@ -353,7 +349,7 @@ class OverviewWidget(QtWidgets.QFrame): self._current_state == "publish" ) - def _on_instance_context_change(self): + def _on_instance_context_change(self, event): current_idx = self._product_views_layout.currentIndex() for idx in range(self._product_views_layout.count()): if idx == current_idx: @@ -363,9 +359,7 @@ class OverviewWidget(QtWidgets.QFrame): widget.set_refreshed(False) current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states() - - self.instance_context_changed.emit() + current_widget.refresh_instance_states(event["instance_ids"]) def _on_convert_requested(self): self.convert_requested.emit() @@ -387,7 +381,7 @@ class OverviewWidget(QtWidgets.QFrame): Returns: list[str]: Selected legacy convertor identifiers. - Example: ['io.openpype.creators.houdini.legacy'] + Example: ['io.ayon.creators.houdini.legacy'] """ _, _, convertor_identifiers = self.get_selected_items() @@ -436,6 +430,12 @@ class OverviewWidget(QtWidgets.QFrame): # 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 +461,11 @@ class OverviewWidget(QtWidgets.QFrame): self._controller.is_host_valid() ) - def _on_instances_refresh(self): - """Controller refreshed instances.""" - + def _on_create_model_reset(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_added(self): + self._refresh_instances() + + def _on_instances_removed(self): + self._refresh_instances() 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..2b9f316d41 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -0,0 +1,504 @@ +import typing +from typing import Dict, List, Any + +from qtpy import QtWidgets, QtCore + +from ayon_core.lib.attribute_definitions import AbstractAttrDef, UnknownDef +from ayon_core.tools.attribute_defs import ( + create_widget_for_attr_def, + AttributeDefinitionsLabel, +) +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.constants import ( + INPUTS_LAYOUT_HSPACING, + INPUTS_LAYOUT_VSPACING, +) + +if typing.TYPE_CHECKING: + from typing import Union + + +class _CreateAttrDefInfo: + """Helper class to store information about create attribute definition.""" + def __init__( + self, + attr_def: AbstractAttrDef, + instance_ids: List["Union[str, None]"], + defaults: List[Any], + label_widget: "Union[AttributeDefinitionsLabel, None]", + ): + self.attr_def: AbstractAttrDef = attr_def + self.instance_ids: List["Union[str, None]"] = instance_ids + self.defaults: List[Any] = defaults + self.label_widget: "Union[AttributeDefinitionsLabel, None]" = ( + label_widget + ) + + +class _PublishAttrDefInfo: + """Helper class to store information about publish attribute definition.""" + def __init__( + self, + attr_def: AbstractAttrDef, + plugin_name: str, + instance_ids: List["Union[str, None]"], + defaults: List[Any], + label_widget: "Union[AttributeDefinitionsLabel, None]", + ): + self.attr_def: AbstractAttrDef = attr_def + self.plugin_name: str = plugin_name + self.instance_ids: List["Union[str, None]"] = instance_ids + self.defaults: List[Any] = defaults + self.label_widget: "Union[AttributeDefinitionsLabel, None]" = ( + label_widget + ) + + +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 + ) + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) + + self._main_layout = main_layout + + self._controller: AbstractPublisherFrontend = controller + self._scroll_area = scroll_area + + self._attr_def_info_by_id: Dict[str, _CreateAttrDefInfo] = {} + 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) + self._refresh_content() + + def _refresh_content(self): + 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_info_by_id = {} + + result = self._controller.get_creator_attribute_definitions( + self._current_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, info_by_id in result: + widget = create_widget_for_attr_def( + attr_def, content_widget, handle_revert_to_default=False + ) + default_values = [] + if attr_def.is_value_def: + values = [] + for item in info_by_id.values(): + values.append(item["value"]) + # 'set' cannot be used for default values because they can + # be unhashable types, e.g. 'list'. + default = item["default"] + if default not in default_values: + default_values.append(default) + + 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) + widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) + attr_def_info = _CreateAttrDefInfo( + attr_def, list(info_by_id), default_values, None + ) + self._attr_def_info_by_id[attr_def.id] = attr_def_info + + if not attr_def.visible: + 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 + is_overriden = False + if attr_def.is_value_def: + is_overriden = any( + item["value"] != item["default"] + for item in info_by_id.values() + ) + label = attr_def.label or attr_def.key + + if label: + label_widget = AttributeDefinitionsLabel( + attr_def.id, 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 + attr_def_info.label_widget = label_widget + label_widget.set_overridden(is_overriden) + label_widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) + + 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): + for instance_id in event.data["instance_ids"]: + if instance_id in self._current_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 "creator_attributes" in changes + ): + self._refresh_content() + break + + def _input_value_changed(self, value, attr_id): + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: + return + + if attr_def_info.label_widget is not None: + defaults = attr_def_info.defaults + is_overriden = len(defaults) != 1 or value not in defaults + attr_def_info.label_widget.set_overridden(is_overriden) + + self._controller.set_instances_create_attr_values( + attr_def_info.instance_ids, + attr_def_info.attr_def.key, + value + ) + + def _on_request_revert_to_default(self, attr_id): + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: + return + self._controller.revert_instances_create_attr_values( + attr_def_info.instance_ids, + attr_def_info.attr_def.key, + ) + self._refresh_content() + + +class PublishPluginAttrsWidget(QtWidgets.QWidget): + """Widget showing publish plugin attributes for selected instances. + + 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. + + 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) + + 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._current_instance_ids = set() + self._context_selected = False + + self._main_layout = main_layout + + self._controller: AbstractPublisherFrontend = controller + self._scroll_area = scroll_area + + self._attr_def_info_by_id: Dict[str, _PublishAttrDefInfo] = {} + + # 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.""" + + self._current_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() + prev_content_widget.hide() + prev_content_widget.deleteLater() + + self._content_widget = None + + self._attr_def_info_by_id = {} + + result = self._controller.get_publish_attribute_definitions( + self._current_instance_ids, self._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, plugin_values in result: + for attr_def in attr_defs: + widget = create_widget_for_attr_def( + attr_def, content_widget, handle_revert_to_default=False + ) + visible_widget = attr_def.visible + # Hide unknown values of publish plugins + # - The keys in most of the cases does not represent what + # would label represent + if isinstance(attr_def, UnknownDef): + widget.setVisible(False) + visible_widget = False + + label_widget = None + if visible_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 = AttributeDefinitionsLabel( + attr_def.id, label, content_widget + ) + label_widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) + 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) + widget.revert_to_default_requested.connect( + self._on_request_revert_to_default + ) + + instance_ids = [] + values = [] + default_values = [] + is_overriden = False + for (instance_id, value, default_value) in ( + plugin_values.get(attr_def.key, []) + ): + instance_ids.append(instance_id) + values.append(value) + if not is_overriden and value != default_value: + is_overriden = True + # 'set' cannot be used for default values because they can + # be unhashable types, e.g. 'list'. + if default_value not in default_values: + default_values.append(default_value) + + multivalue = len(values) > 1 + + self._attr_def_info_by_id[attr_def.id] = _PublishAttrDefInfo( + attr_def, + plugin_name, + instance_ids, + default_values, + label_widget, + ) + + if multivalue: + widget.set_value(values, multivalue) + else: + widget.set_value(values[0]) + + if label_widget is not None: + label_widget.set_overridden(is_overriden) + + self._scroll_area.setWidget(content_widget) + self._content_widget = content_widget + + def _input_value_changed(self, value, attr_id): + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: + return + + if attr_def_info.label_widget is not None: + defaults = attr_def_info.defaults + is_overriden = len(defaults) != 1 or value not in defaults + attr_def_info.label_widget.set_overridden(is_overriden) + + self._controller.set_instances_publish_attr_values( + attr_def_info.instance_ids, + attr_def_info.plugin_name, + attr_def_info.attr_def.key, + value + ) + + def _on_request_revert_to_default(self, attr_id): + attr_def_info = self._attr_def_info_by_id.get(attr_id) + if attr_def_info is None: + return + + self._controller.revert_instances_publish_attr_values( + attr_def_info.instance_ids, + attr_def_info.plugin_name, + attr_def_info.attr_def.key, + ) + self._refresh_content() + + 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._current_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" in changes + ): + self._refresh_content() + break 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..04c9ca7e56 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -0,0 +1,933 @@ +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] + """ + + 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_by_id = {} + self._invalid_task_item_ids = set() + + 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) + + 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 + 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 = [] + changes_by_id = {} + for item in self._current_instances_by_id.values(): + # Ignore instances that have promised context + if item.has_promised_context: + continue + + instance_changes = {} + 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 + + 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 or None + new_task_name = task_name or None + + folder_paths.append(new_folder_path) + try: + new_product_name = self._controller.get_product_name( + item.creator_identifier, + new_variant_value, + new_task_name, + 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 + + product_names.add(new_product_name) + if item.product_name != new_product_name: + instance_changes["productName"] = new_product_name + + if instance_changes: + changes_by_id[item.id] = instance_changes + + 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._controller.set_instances_context_info(changes_by_id) + self._refresh_items() + + 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_by_id = { + instance.id: instance + for instance in instances + } + self._invalid_task_item_ids = set() + 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() + product_types = set() + product_names = set() + + editable = True + if len(self._current_instances_by_id) == 0: + editable = False + + 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: + editable = False + + 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(item.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 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." + 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) + + 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(): + if instance_id not in self._current_instances_by_id: + continue + + for key in ( + "folderPath", + "task", + "variant", + "productType", + "productName", + ): + if key in changes: + changed = True + break + if changed: + break + + if changed: + self._refresh_items() + self._refresh_content() 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..27b7aacf38 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_info.py @@ -0,0 +1,288 @@ +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 │ + └───────────────────────────────┘ + """ + 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 = [] + self._context_selected = False + self._all_instances_valid = True + + 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 + ) + + 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 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. + + """ + 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 self._current_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 + + self._all_instances_valid = all_valid + + 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, self._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_instance_context_change(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) + + def _on_convert_click(self): + self.convert_requested.emit() + + 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/publish_frame.py b/client/ayon_core/tools/publisher/widgets/publish_frame.py index 6eaeb6daf2..55051d2c40 100644 --- a/client/ayon_core/tools/publisher/widgets/publish_frame.py +++ b/client/ayon_core/tools/publisher/widgets/publish_frame.py @@ -411,10 +411,13 @@ class PublishFrame(QtWidgets.QWidget): """Show error message to artist on publish crash.""" self._set_main_label("Error happened") + error_info = self._controller.get_publish_error_info() - self._message_label_top.setText( - self._controller.get_publish_error_msg() - ) + 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) diff --git a/client/ayon_core/tools/publisher/widgets/report_page.py b/client/ayon_core/tools/publisher/widgets/report_page.py index ecf1376ec0..b7afcf470a 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 @@ from ayon_core.tools.publisher.constants import ( CONTEXT_LABEL, ) -from .widgets import IconValuePixmapLabel +from .widgets import PublishPixmapLabel, IconValuePixmapLabel from .icons import ( get_pixmap, get_image, @@ -42,7 +42,7 @@ INFO_VISIBLE = 1 << 6 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 @@ class ActionButton(BaseClickableFrame): ) -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 @@ class ValidateActionsWidget(QtWidgets.QFrame): 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 @@ class ValidationErrorInstanceList(QtWidgets.QListView): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): 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 @@ class ValidationErrorsView(QtWidgets.QWidget): # 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 @@ class ValidationErrorsView(QtWidgets.QWidget): """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 @@ class InstancesLogsView(QtWidgets.QFrame): 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) @@ -1454,6 +1469,78 @@ class InstancesLogsView(QtWidgets.QFrame): self._update_instances() +class ErrorDetailWidget(QtWidgets.QWidget): + def __init__(self, parent): + super().__init__(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_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_r_widget, 9) + + 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_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 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) + 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() + self._error_detail_input.setVisible( + not self._error_detail_expand_btn.collapsed + ) + + class CrashWidget(QtWidgets.QWidget): """Widget shown when publishing crashes. @@ -1488,6 +1575,8 @@ class CrashWidget(QtWidgets.QWidget): "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,11 +1584,13 @@ class CrashWidget(QtWidgets.QWidget): btns_layout.addStretch(1) layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + layout.setSpacing(0) 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.addStretch(2) @@ -1517,7 +1608,7 @@ class CrashWidget(QtWidgets.QWidget): "export_report.request", {}, "report_page") -class ErrorDetailsWidget(QtWidgets.QWidget): +class PublishFailWidget(QtWidgets.QWidget): def __init__(self, parent): super().__init__(parent) @@ -1530,34 +1621,7 @@ class ErrorDetailsWidget(QtWidgets.QWidget): ) # 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 +1634,8 @@ class ErrorDetailsWidget(QtWidgets.QWidget): 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 +1644,18 @@ class ErrorDetailsWidget(QtWidgets.QWidget): 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): @@ -1622,7 +1672,7 @@ class ReportsWidget(QtWidgets.QWidget): │ │ │ │ │ │ └──────┴───────────────────┘ - # Validation errors layout + # Publish errors layout ┌──────┬─────────┬─────────┐ │Views │ Actions │ │ │ ├─────────┤ Details │ @@ -1641,12 +1691,12 @@ class ReportsWidget(QtWidgets.QWidget): 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) @@ -1655,10 +1705,13 @@ class ReportsWidget(QtWidgets.QWidget): details_widget.setObjectName("PublishInstancesDetails") # Actions widget - actions_widget = ValidateActionsWidget(controller, details_widget) + actions_widget = PublishActionsWidget(controller, details_widget) pages_widget = QtWidgets.QWidget(details_widget) + # Crash information + crash_widget = CrashWidget(controller, details_widget) + # Logs view logs_view = InstancesLogsView(pages_widget) @@ -1671,30 +1724,24 @@ class ReportsWidget(QtWidgets.QWidget): 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) 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) - 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) @@ -1704,12 +1751,12 @@ class ReportsWidget(QtWidgets.QWidget): 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 @@ -1720,7 +1767,7 @@ class ReportsWidget(QtWidgets.QWidget): 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() @@ -1750,40 +1797,50 @@ class ReportsWidget(QtWidgets.QWidget): return instance_items def update_data(self): - view = self._instances_view - validation_error_mode = False - if ( - not self._controller.publish_has_crashed() - and self._controller.publish_has_validation_errors() - ): - view = self._validation_error_view - validation_error_mode = True + 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 has_crashed: + error_info = self._controller.get_publish_error_info() + + publish_error_mode = False + 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._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) - is_crashed = self._controller.publish_has_crashed() - self._crash_widget.setVisible(is_crashed) - self._logs_view.setVisible(not is_crashed) + 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 has_finished or not has_crashed + self._logs_view.setVisible(logs_visible) + self._crash_widget.setVisible(not logs_visible) # 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() @@ -1791,8 +1848,8 @@ class ReportsWidget(QtWidgets.QWidget): 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) @@ -1820,12 +1877,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__( diff --git a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py index 08a0a790b7..0706299f32 100644 --- a/client/ayon_core/tools/publisher/widgets/screenshot_widget.py +++ b/client/ayon_core/tools/publisher/widgets/screenshot_widget.py @@ -1,10 +1,171 @@ import os import tempfile +import uuid from qtpy import QtCore, QtGui, QtWidgets -class ScreenMarquee(QtWidgets.QDialog): +class ScreenMarqueeDialog(QtWidgets.QDialog): + mouse_moved = QtCore.Signal() + mouse_pressed = QtCore.Signal(QtCore.QPoint, str) + mouse_released = QtCore.Signal(QtCore.QPoint) + close_requested = QtCore.Signal() + + def __init__(self, screen: QtCore.QObject, screen_id: str): + super().__init__() + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.FramelessWindowHint + | QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.CustomizeWindowHint + ) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setCursor(QtCore.Qt.CrossCursor) + self.setMouseTracking(True) + + screen.geometryChanged.connect(self._fit_screen_geometry) + + self._screen = screen + self._opacity = 100 + self._click_pos = None + self._screen_id = screen_id + + def set_click_pos(self, pos): + self._click_pos = pos + self.repaint() + + def convert_end_pos(self, pos): + glob_pos = self.mapFromGlobal(pos) + new_pos = self._convert_pos(glob_pos) + return self.mapToGlobal(new_pos) + + def paintEvent(self, event): + """Paint event""" + # Convert click and current mouse positions to local space. + mouse_pos = self._convert_pos(self.mapFromGlobal(QtGui.QCursor.pos())) + + rect = event.rect() + fill_path = QtGui.QPainterPath() + fill_path.addRect(rect) + + capture_rect = None + if self._click_pos is not None: + click_pos = self.mapFromGlobal(self._click_pos) + capture_rect = QtCore.QRect(click_pos, mouse_pos) + + # Clear the capture area + sub_path = QtGui.QPainterPath() + sub_path.addRect(capture_rect) + fill_path = fill_path.subtracted(sub_path) + + painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + + # Draw background. Aside from aesthetics, this makes the full + # tool region accept mouse events. + painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) + painter.setPen(QtCore.Qt.NoPen) + painter.drawPath(fill_path) + + # Draw cropping markers at current mouse position + pen_color = QtGui.QColor(255, 255, 255, self._opacity) + pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) + painter.setPen(pen) + painter.drawLine( + rect.left(), mouse_pos.y(), + rect.right(), mouse_pos.y() + ) + painter.drawLine( + mouse_pos.x(), rect.top(), + mouse_pos.x(), rect.bottom() + ) + + # Draw rectangle around selection area + if capture_rect is not None: + pen_color = QtGui.QColor(92, 173, 214) + pen = QtGui.QPen(pen_color, 2) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + l_x = capture_rect.left() + r_x = capture_rect.right() + if l_x > r_x: + l_x, r_x = r_x, l_x + t_y = capture_rect.top() + b_y = capture_rect.bottom() + if t_y > b_y: + t_y, b_y = b_y, t_y + + # -1 to draw 1px over the border + r_x -= 1 + b_y -= 1 + sel_rect = QtCore.QRect( + QtCore.QPoint(l_x, t_y), + QtCore.QPoint(r_x, b_y) + ) + painter.drawRect(sel_rect) + + painter.end() + + def mousePressEvent(self, event): + """Mouse click event""" + + if event.button() == QtCore.Qt.LeftButton: + # Begin click drag operation + self._click_pos = event.globalPos() + self.mouse_pressed.emit(self._click_pos, self._screen_id) + + def mouseReleaseEvent(self, event): + """Mouse release event""" + if event.button() == QtCore.Qt.LeftButton: + # End click drag operation and commit the current capture rect + self._click_pos = None + self.mouse_released.emit(event.globalPos()) + + def mouseMoveEvent(self, event): + """Mouse move event""" + self.mouse_moved.emit() + + def keyPressEvent(self, event): + """Mouse press event""" + if event.key() == QtCore.Qt.Key_Escape: + self._click_pos = None + event.accept() + self.close_requested.emit() + return + return super().keyPressEvent(event) + + def showEvent(self, event): + super().showEvent(event) + self._fit_screen_geometry() + + def closeEvent(self, event): + self._click_pos = None + super().closeEvent(event) + + def _convert_pos(self, pos): + geo = self.geometry() + if pos.x() > geo.width(): + pos.setX(geo.width() - 1) + elif pos.x() < 0: + pos.setX(0) + + if pos.y() > geo.height(): + pos.setY(geo.height() - 1) + elif pos.y() < 0: + pos.setY(0) + return pos + + def _fit_screen_geometry(self): + # On macOs it is required to set screen explicitly + if hasattr(self, "setScreen"): + self.setScreen(self._screen) + self.setGeometry(self._screen.geometry()) + + +class ScreenMarquee(QtCore.QObject): """Dialog to interactively define screen area. This allows to select a screen area through a marquee selection. @@ -17,187 +178,186 @@ class ScreenMarquee(QtWidgets.QDialog): def __init__(self, parent=None): super().__init__(parent=parent) - self.setWindowFlags( - QtCore.Qt.Window - | QtCore.Qt.FramelessWindowHint - | QtCore.Qt.WindowStaysOnTopHint - | QtCore.Qt.CustomizeWindowHint - ) - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.setCursor(QtCore.Qt.CrossCursor) - self.setMouseTracking(True) - - app = QtWidgets.QApplication.instance() - if hasattr(app, "screenAdded"): - app.screenAdded.connect(self._on_screen_added) - app.screenRemoved.connect(self._fit_screen_geometry) - elif hasattr(app, "desktop"): - desktop = app.desktop() - desktop.screenCountChanged.connect(self._fit_screen_geometry) - + screens_by_id = {} for screen in QtWidgets.QApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + screen_id = uuid.uuid4().hex + screen_dialog = ScreenMarqueeDialog(screen, screen_id) + screens_by_id[screen_id] = screen_dialog + screen_dialog.mouse_moved.connect(self._on_mouse_move) + screen_dialog.mouse_pressed.connect(self._on_mouse_press) + screen_dialog.mouse_released.connect(self._on_mouse_release) + screen_dialog.close_requested.connect(self._on_close_request) - self._opacity = 50 - self._click_pos = None - self._capture_rect = None + self._screens_by_id = screens_by_id + self._finished = False + self._captured = False + self._start_pos = None + self._end_pos = None + self._start_screen_id = None + self._pix = None def get_captured_pixmap(self): - if self._capture_rect is None: + if self._pix is None: return QtGui.QPixmap() + return self._pix - return self.get_desktop_pixmap(self._capture_rect) + def _close_dialogs(self): + for dialog in self._screens_by_id.values(): + dialog.close() - def paintEvent(self, event): - """Paint event""" + def _on_close_request(self): + self._close_dialogs() + self._finished = True - # Convert click and current mouse positions to local space. - mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) - click_pos = None - if self._click_pos is not None: - click_pos = self.mapFromGlobal(self._click_pos) - - painter = QtGui.QPainter(self) - painter.setRenderHints( - QtGui.QPainter.Antialiasing - | QtGui.QPainter.SmoothPixmapTransform - ) - - # Draw background. Aside from aesthetics, this makes the full - # tool region accept mouse events. - painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) - painter.setPen(QtCore.Qt.NoPen) - rect = event.rect() - fill_path = QtGui.QPainterPath() - fill_path.addRect(rect) - - # Clear the capture area - if click_pos is not None: - sub_path = QtGui.QPainterPath() - capture_rect = QtCore.QRect(click_pos, mouse_pos) - sub_path.addRect(capture_rect) - fill_path = fill_path.subtracted(sub_path) - - painter.drawPath(fill_path) - - pen_color = QtGui.QColor(255, 255, 255, self._opacity) - pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) - painter.setPen(pen) - - # Draw cropping markers at click position - if click_pos is not None: - painter.drawLine( - rect.left(), click_pos.y(), - rect.right(), click_pos.y() - ) - painter.drawLine( - click_pos.x(), rect.top(), - click_pos.x(), rect.bottom() - ) - - # Draw cropping markers at current mouse position - painter.drawLine( - rect.left(), mouse_pos.y(), - rect.right(), mouse_pos.y() - ) - painter.drawLine( - mouse_pos.x(), rect.top(), - mouse_pos.x(), rect.bottom() - ) - painter.end() - - def mousePressEvent(self, event): - """Mouse click event""" - - if event.button() == QtCore.Qt.LeftButton: - # Begin click drag operation - self._click_pos = event.globalPos() - - def mouseReleaseEvent(self, event): - """Mouse release event""" - if ( - self._click_pos is not None - and event.button() == QtCore.Qt.LeftButton - ): - # End click drag operation and commit the current capture rect - self._capture_rect = QtCore.QRect( - self._click_pos, event.globalPos() - ).normalized() - self._click_pos = None - self.close() - - def mouseMoveEvent(self, event): - """Mouse move event""" - self.repaint() - - def keyPressEvent(self, event): - """Mouse press event""" - if event.key() == QtCore.Qt.Key_Escape: - self._click_pos = None - self._capture_rect = None - event.accept() - self.close() + def _on_mouse_release(self, pos): + start_screen_dialog = self._screens_by_id.get(self._start_screen_id) + if start_screen_dialog is None: + self._finished = True + self._captured = False return - return super().keyPressEvent(event) - def showEvent(self, event): - self._fit_screen_geometry() + end_pos = start_screen_dialog.convert_end_pos(pos) - def _fit_screen_geometry(self): - # Compute the union of all screen geometries, and resize to fit. - workspace_rect = QtCore.QRect() - for screen in QtWidgets.QApplication.screens(): - workspace_rect = workspace_rect.united(screen.geometry()) - self.setGeometry(workspace_rect) + self._close_dialogs() + self._end_pos = end_pos + self._finished = True + self._captured = True - def _on_screen_added(self): - for screen in QtGui.QGuiApplication.screens(): - screen.geometryChanged.connect(self._fit_screen_geometry) + def _on_mouse_press(self, pos, screen_id): + self._start_pos = pos + self._start_screen_id = screen_id + + def _on_mouse_move(self): + for dialog in self._screens_by_id.values(): + dialog.repaint() + + def start_capture(self): + for dialog in self._screens_by_id.values(): + dialog.show() + # Activate so Escape event is not ignored. + dialog.setWindowState(QtCore.Qt.WindowActive) + + app = QtWidgets.QApplication.instance() + while not self._finished: + app.processEvents() + + # Give time to cloe dialogs + for _ in range(2): + app.processEvents() + + if self._captured: + self._pix = self.get_desktop_pixmap( + self._start_pos, self._end_pos + ) @classmethod - def get_desktop_pixmap(cls, rect): + def get_desktop_pixmap(cls, pos_start, pos_end): """Performs a screen capture on the specified rectangle. Args: - rect (QtCore.QRect): The rectangle to capture. + pos_start (QtCore.QPoint): Start of screen capture. + pos_end (QtCore.QPoint): End of screen capture. Returns: QtGui.QPixmap: Captured pixmap image - """ + """ + # Unify start and end points + # - start is top left + # - end is bottom right + if pos_start.y() > pos_end.y(): + pos_start, pos_end = pos_end, pos_start + + if pos_start.x() > pos_end.x(): + new_start = QtCore.QPoint(pos_end.x(), pos_start.y()) + new_end = QtCore.QPoint(pos_start.x(), pos_end.y()) + pos_start = new_start + pos_end = new_end + + # Validate if the rectangle is valid + rect = QtCore.QRect(pos_start, pos_end) if rect.width() < 1 or rect.height() < 1: return QtGui.QPixmap() - screen_pixes = [] - for screen in QtWidgets.QApplication.screens(): - screen_geo = screen.geometry() - if not screen_geo.intersects(rect): - continue + screen = QtWidgets.QApplication.screenAt(pos_start) + return screen.grabWindow( + 0, + pos_start.x() - screen.geometry().x(), + pos_start.y() - screen.geometry().y(), + pos_end.x() - pos_start.x(), + pos_end.y() - pos_start.y() + ) + # Multiscreen capture that does not work + # - does not handle pixel aspect ratio and positioning of screens - screen_pix_rect = screen_geo.intersected(rect) - screen_pix = screen.grabWindow( - 0, - screen_pix_rect.x() - screen_geo.x(), - screen_pix_rect.y() - screen_geo.y(), - screen_pix_rect.width(), screen_pix_rect.height() - ) - paste_point = QtCore.QPoint( - screen_pix_rect.x() - rect.x(), - screen_pix_rect.y() - rect.y() - ) - screen_pixes.append((screen_pix, paste_point)) - - output_pix = QtGui.QPixmap(rect.width(), rect.height()) - output_pix.fill(QtCore.Qt.transparent) - pix_painter = QtGui.QPainter() - pix_painter.begin(output_pix) - for item in screen_pixes: - (screen_pix, offset) = item - pix_painter.drawPixmap(offset, screen_pix) - - pix_painter.end() - - return output_pix + # most_left = None + # most_top = None + # for screen in QtWidgets.QApplication.screens(): + # screen_geo = screen.geometry() + # if most_left is None or most_left > screen_geo.x(): + # most_left = screen_geo.x() + # + # if most_top is None or most_top > screen_geo.y(): + # most_top = screen_geo.y() + # + # most_left = most_left or 0 + # most_top = most_top or 0 + # + # screen_pixes = [] + # for screen in QtWidgets.QApplication.screens(): + # screen_geo = screen.geometry() + # if not screen_geo.intersects(rect): + # continue + # + # pos_l_x = screen_geo.x() + # pos_l_y = screen_geo.y() + # pos_r_x = screen_geo.x() + screen_geo.width() + # pos_r_y = screen_geo.y() + screen_geo.height() + # if pos_start.x() > pos_l_x: + # pos_l_x = pos_start.x() + # + # if pos_start.y() > pos_l_y: + # pos_l_y = pos_start.y() + # + # if pos_end.x() < pos_r_x: + # pos_r_x = pos_end.x() + # + # if pos_end.y() < pos_r_y: + # pos_r_y = pos_end.y() + # + # capture_pos_x = pos_l_x - screen_geo.x() + # capture_pos_y = pos_l_y - screen_geo.y() + # capture_screen_width = pos_r_x - pos_l_x + # capture_screen_height = pos_r_y - pos_l_y + # screen_pix = screen.grabWindow( + # 0, + # capture_pos_x, capture_pos_y, + # capture_screen_width, capture_screen_height + # ) + # paste_point = QtCore.QPoint( + # (pos_l_x - screen_geo.x()) - rect.x(), + # (pos_l_y - screen_geo.y()) - rect.y() + # ) + # screen_pixes.append((screen_pix, paste_point)) + # + # output_pix = QtGui.QPixmap(rect.width(), rect.height()) + # output_pix.fill(QtCore.Qt.transparent) + # pix_painter = QtGui.QPainter() + # pix_painter.begin(output_pix) + # render_hints = ( + # QtGui.QPainter.Antialiasing + # | QtGui.QPainter.SmoothPixmapTransform + # ) + # if hasattr(QtGui.QPainter, "HighQualityAntialiasing"): + # render_hints |= QtGui.QPainter.HighQualityAntialiasing + # pix_painter.setRenderHints(render_hints) + # for item in screen_pixes: + # (screen_pix, offset) = item + # pix_painter.drawPixmap(offset, screen_pix) + # + # pix_painter.end() + # + # return output_pix @classmethod def capture_to_pixmap(cls): @@ -209,12 +369,8 @@ class ScreenMarquee(QtWidgets.QDialog): Returns: QtGui.QPixmap: Captured pixmap image. """ - tool = cls() - # Activate so Escape event is not ignored. - tool.setWindowState(QtCore.Qt.WindowActive) - # Exec dialog and return captured pixmap. - tool.exec_() + tool.start_capture() return tool.get_captured_pixmap() @classmethod diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index 1f782ddc67..a9d34c4c66 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 @@ -321,7 +298,6 @@ class ChangeViewBtn(PublishIconBtn): class AbstractInstanceView(QtWidgets.QWidget): """Abstract class for instance view in creation part.""" selection_changed = QtCore.Signal() - active_changed = QtCore.Signal() # Refreshed attribute is not changed by view itself # - widget which triggers `refresh` is changing the state # TODO store that information in widget which cares about refreshing @@ -426,583 +402,6 @@ class ClickableLineEdit(QtWidgets.QLineEdit): 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,815 +479,6 @@ class MultipleItemWidget(QtWidgets.QWidget): 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: - new_variant_value = instance.get("variant") - new_folder_path = instance.get("folderPath") - new_task_name = instance.get("task") - 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 - instance.set_task_invalid(True) - product_names.add(instance["productName"]) - continue - - product_names.add(new_product_name) - if variant_value is not None: - instance["variant"] = variant_value - - if folder_path is not None: - instance["folderPath"] = folder_path - instance.set_folder_invalid(False) - - if task_name is not None: - instance["task"] = task_name or None - instance.set_task_invalid(False) - - instance["productName"] = 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[CreatedInstance]): 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 = [] - for instance in instances: - # NOTE I'm not sure how this can even happen? - 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 "" - folder_paths.add(folder_path) - folder_task_combinations.append((folder_path, task_name)) - product_names.add(instance.get("productName") or self.unknown_value) - - 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(editable) - self.task_value_widget.setEnabled(editable) - - -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 `~/openpype/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) - - 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 = {} - - # 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, instances): - """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 = {} - - result = self._controller.get_creator_attribute_definitions( - instances - ) - - 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, attr_instances, 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] = attr_instances - 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 _input_value_changed(self, value, attr_id): - instances = 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: - return - - for instance in instances: - creator_attributes = instance["creator_attributes"] - if attr_def.key in creator_attributes: - creator_attributes[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, instances, 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( - instances, 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): - instances = 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: - return - - for instance in instances: - plugin_val = instance.publish_attributes[plugin_name] - plugin_val[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): - all_valid = True - for instance in self._current_instances: - if not instance.has_valid_context: - 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[CreatedInstance]): List of currently selected - instances. - context_selected(bool): Is context selected. - convertor_identifiers(List[str]): Identifiers of convert items. - """ - - all_valid = True - for instance in instances: - if not instance.has_valid_context: - 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(instances) - self.publish_attrs_widget.set_current_instances( - instances, 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() diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 1218221420..a912495d4e 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -253,12 +253,6 @@ class PublisherWindow(QtWidgets.QDialog): help_btn.clicked.connect(self._on_help_click) tabs_widget.tab_changed.connect(self._on_tab_change) - overview_widget.active_changed.connect( - self._on_context_or_active_change - ) - overview_widget.instance_context_changed.connect( - self._on_context_or_active_change - ) overview_widget.create_requested.connect( self._on_create_request ) @@ -281,7 +275,19 @@ class PublisherWindow(QtWidgets.QDialog): ) controller.register_event_callback( - "instances.refresh.finished", self._on_instances_refresh + "create.model.reset", self._on_create_model_reset + ) + controller.register_event_callback( + "create.context.added.instance", + self._event_callback_validate_instances + ) + controller.register_event_callback( + "create.context.removed.instance", + self._event_callback_validate_instances + ) + controller.register_event_callback( + "create.model.instances.context.changed", + self._event_callback_validate_instances ) controller.register_event_callback( "publish.reset.finished", self._on_publish_reset @@ -439,10 +445,13 @@ class PublisherWindow(QtWidgets.QDialog): 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) @@ -687,13 +696,14 @@ class PublisherWindow(QtWidgets.QDialog): def _on_tab_change(self, old_tab, new_tab): if old_tab == "details": - self._publish_details_widget.close_details_popup() + self._publish_details_widget.set_active(False) if new_tab == "details": self._content_stacked_layout.setCurrentWidget( self._publish_details_widget ) self._update_publish_details_widget() + self._publish_details_widget.set_active(True) elif new_tab == "report": self._content_stacked_layout.setCurrentWidget( @@ -912,12 +922,18 @@ class PublisherWindow(QtWidgets.QDialog): self._set_footer_enabled(True) return + active_instances_by_id = { + instance.id: instance + 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() + ) all_valid = None - for instance in self._controller.get_instances(): - if not instance["active"]: - continue - - if not instance.has_valid_context: + for instance_id, instance in active_instances_by_id.items(): + context_info = context_info_by_id[instance_id] + if not context_info.is_valid: all_valid = False break @@ -926,13 +942,16 @@ class PublisherWindow(QtWidgets.QDialog): self._set_footer_enabled(bool(all_valid)) - def _on_instances_refresh(self): + def _on_create_model_reset(self): self._validate_create_instances() context_title = self._controller.get_context_title() self.set_context_label(context_title) self._update_publish_details_widget() + def _event_callback_validate_instances(self, _event): + self._validate_create_instances() + def _set_comment_input_visiblity(self, visible): self._comment_input.setVisible(visible) self._footer_spacer.setVisible(not visible) diff --git a/client/ayon_core/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 @@ class ProjectPushItemProcess: 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 @@ class ProjectPushItemProcess: 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 @@ class ProjectPushItemProcess: 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 @@ class ProjectPushItemProcess: 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({ diff --git a/client/ayon_core/tools/pyblish_pype/util.py b/client/ayon_core/tools/pyblish_pype/util.py index 09a370c6e4..d24b07a409 100644 --- a/client/ayon_core/tools/pyblish_pype/util.py +++ b/client/ayon_core/tools/pyblish_pype/util.py @@ -135,7 +135,6 @@ class OrderGroups: def env_variable_to_bool(env_key, default=False): """Boolean based on environment variable value.""" - # TODO: move to pype lib value = os.environ.get(env_key) if value is not None: value = value.lower() diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index a40d110476..bdcd183c99 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -194,14 +194,14 @@ class InventoryModel(QtGui.QStandardItemModel): group_items = [] for repre_id, container_items in items_by_repre_id.items(): repre_info = repre_info_by_id[repre_id] - version_label = "N/A" version_color = None - is_latest = False - is_hero = False - status_name = None if not repre_info.is_valid: + version_label = "N/A" group_name = "< Entity N/A >" item_icon = invalid_item_icon + is_latest = False + is_hero = False + status_name = None else: group_name = "{}_{}: ({})".format( @@ -218,9 +218,7 @@ class InventoryModel(QtGui.QStandardItemModel): version_label = format_version(version_item.version) is_hero = version_item.version < 0 is_latest = version_item.is_latest - # TODO maybe use different colors for last approved and last - # version? Or don't care about color at all? - if not is_latest and not version_item.is_last_approved: + if not version_item.is_latest: version_color = self.OUTDATED_COLOR status_name = version_item.status @@ -428,7 +426,7 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel): state = bool(state) if state != self._filter_outdated: - self._filter_outdated = bool(state) + self._filter_outdated = state self.invalidateFilter() def set_hierarchy_view(self, state): diff --git a/client/ayon_core/tools/sceneinventory/models/containers.py b/client/ayon_core/tools/sceneinventory/models/containers.py index 871455c96b..4f3ddf1ded 100644 --- a/client/ayon_core/tools/sceneinventory/models/containers.py +++ b/client/ayon_core/tools/sceneinventory/models/containers.py @@ -383,7 +383,6 @@ class ContainersModel: container_items_by_id[item.item_id] = item container_items.append(item) - self._containers_by_id = containers_by_id self._container_items_by_id = container_items_by_id self._items_cache = container_items diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index ad190482a8..13ee1eea5c 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -3,18 +3,20 @@ import sys import json import hashlib import platform -import subprocess -import csv import time import signal -import locale -from typing import Optional, Dict, Tuple, Any +from typing import Optional, List, Dict, Tuple, Any -import ayon_api import requests +from ayon_api.utils import get_default_settings_variant -from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process -from ayon_core.lib.local_settings import get_ayon_appdirs +from ayon_core.lib import ( + Logger, + get_ayon_launcher_args, + run_detached_process, + get_ayon_username, +) +from ayon_core.lib.local_settings import get_launcher_local_dir class TrayState: @@ -34,7 +36,7 @@ def _get_default_server_url() -> str: def _get_default_variant() -> str: """Get default settings variant.""" - return ayon_api.get_default_settings_variant() + return get_default_settings_variant() def _get_server_and_variant( @@ -48,15 +50,101 @@ def _get_server_and_variant( return server_url, variant +def _windows_get_pid_args(pid: int) -> Optional[List[str]]: + import ctypes + from ctypes import wintypes + + # Define constants + PROCESS_COMMANDLINE_INFO = 60 + STATUS_NOT_FOUND = 0xC0000225 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + + # Define the UNICODE_STRING structure + class UNICODE_STRING(ctypes.Structure): + _fields_ = [ + ("Length", wintypes.USHORT), + ("MaximumLength", wintypes.USHORT), + ("Buffer", wintypes.LPWSTR) + ] + + shell32 = ctypes.WinDLL("shell32", use_last_error=True) + + CommandLineToArgvW = shell32.CommandLineToArgvW + CommandLineToArgvW.argtypes = [ + wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int) + ] + CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) + + output = None + # Open the process + handle = ctypes.windll.kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, False, pid + ) + if not handle: + return output + + try: + buffer_len = wintypes.ULONG() + # Get the right buffer size first + status = ctypes.windll.ntdll.NtQueryInformationProcess( + handle, + PROCESS_COMMANDLINE_INFO, + ctypes.c_void_p(None), + 0, + ctypes.byref(buffer_len) + ) + + if status == STATUS_NOT_FOUND: + return output + + # Create buffer with collected size + buffer = ctypes.create_string_buffer(buffer_len.value) + + # Get the command line + status = ctypes.windll.ntdll.NtQueryInformationProcess( + handle, + PROCESS_COMMANDLINE_INFO, + buffer, + buffer_len, + ctypes.byref(buffer_len) + ) + if status: + return output + # Build the string + tmp = ctypes.cast(buffer, ctypes.POINTER(UNICODE_STRING)).contents + size = tmp.Length // 2 + 1 + cmdline_buffer = ctypes.create_unicode_buffer(size) + ctypes.cdll.msvcrt.wcscpy(cmdline_buffer, tmp.Buffer) + + args_len = ctypes.c_int() + args = CommandLineToArgvW( + cmdline_buffer, ctypes.byref(args_len) + ) + output = [args[idx] for idx in range(args_len.value)] + ctypes.windll.kernel32.LocalFree(args) + + finally: + ctypes.windll.kernel32.CloseHandle(handle) + return output + + def _windows_pid_is_running(pid: int) -> bool: - args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"] - output = subprocess.check_output(args) - encoding = locale.getpreferredencoding() - csv_content = csv.DictReader(output.decode(encoding).splitlines()) - # if "PID" not in csv_content.fieldnames: - # return False - for _ in csv_content: + args = _windows_get_pid_args(pid) + if not args: + return False + executable_path = args[0] + + filename = os.path.basename(executable_path).lower() + if "ayon" in filename: return True + + # Try to handle tray running from code + # - this might be potential danger that kills other python process running + # 'start.py' script (low chance, but still) + if "python" in filename and len(args) > 1: + script_filename = os.path.basename(args[1].lower()) + if script_filename == "start.py": + return True return False @@ -141,18 +229,7 @@ def get_tray_storage_dir() -> str: str: Tray storage directory where metadata files are stored. """ - return get_ayon_appdirs("tray") - - -def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: - if not tray_url: - return None - try: - response = requests.get(f"{tray_url}/tray") - response.raise_for_status() - return response.json() - except (requests.HTTPError, requests.ConnectionError): - return None + return get_launcher_local_dir("tray") def _get_tray_info_filepath( @@ -165,6 +242,51 @@ def _get_tray_info_filepath( return os.path.join(hash_dir, filename) +def _get_tray_file_info( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[Optional[Dict[str, Any]], Optional[float]]: + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return None, None + file_modified = os.path.getmtime(filepath) + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except Exception: + return None, file_modified + + return data, file_modified + + +def _remove_tray_server_url( + server_url: Optional[str], + variant: Optional[str], + file_modified: Optional[float], +): + """Remove tray information file. + + Called from tray logic, do not use on your own. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + file_modified (Optional[float]): File modified timestamp. Is validated + against current state of file. + + """ + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return + + if ( + file_modified is not None + and os.path.getmtime(filepath) != file_modified + ): + return + os.remove(filepath) + + def get_tray_file_info( server_url: Optional[str] = None, variant: Optional[str] = None @@ -182,15 +304,156 @@ def get_tray_file_info( Optional[Dict[str, Any]]: Tray information. """ - filepath = _get_tray_info_filepath(server_url, variant) - if not os.path.exists(filepath): + file_info, _ = _get_tray_file_info(server_url, variant) + return file_info + + +def _get_tray_rest_information(tray_url: str) -> Optional[Dict[str, Any]]: + if not tray_url: return None try: - with open(filepath, "r") as stream: - data = json.load(stream) - except Exception: + response = requests.get(f"{tray_url}/tray") + response.raise_for_status() + return response.json() + except (requests.HTTPError, requests.ConnectionError): return None - return data + + +class TrayInfo: + def __init__( + self, + server_url: str, + variant: str, + timeout: Optional[int] = None + ): + self.server_url = server_url + self.variant = variant + + if timeout is None: + timeout = 10 + + self._timeout = timeout + + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._file_state = None + self._state = None + + @classmethod + def new( + cls, + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None, + wait_to_start: Optional[bool] = True + ) -> "TrayInfo": + server_url, variant = _get_server_and_variant(server_url, variant) + obj = cls(server_url, variant, timeout=timeout) + if wait_to_start: + obj.wait_to_start() + return obj + + def get_pid(self) -> Optional[int]: + file_info = self.get_file_info() + if file_info: + return file_info.get("pid") + return None + + def reset(self): + self._file_modified = None + self._file_info = None + self._file_info_cached = False + self._tray_info = None + self._tray_info_cached = False + self._state = None + self._file_state = None + + def get_file_info(self) -> Optional[Dict[str, Any]]: + if not self._file_info_cached: + file_info, file_modified = _get_tray_file_info( + self.server_url, self.variant + ) + self._file_info = file_info + self._file_modified = file_modified + self._file_info_cached = True + return self._file_info + + def get_file_url(self) -> Optional[str]: + file_info = self.get_file_info() + if file_info: + return file_info.get("url") + return None + + def get_tray_url(self) -> Optional[str]: + info = self.get_tray_info() + if info: + return self.get_file_url() + return None + + def get_tray_info(self) -> Optional[Dict[str, Any]]: + if self._tray_info_cached: + return self._tray_info + + tray_url = self.get_file_url() + tray_info = None + if tray_url: + tray_info = _get_tray_rest_information(tray_url) + + self._tray_info = tray_info + self._tray_info_cached = True + return self._tray_info + + def get_file_state(self) -> int: + if self._file_state is not None: + return self._file_state + + state = TrayState.NOT_RUNNING + file_info = self.get_file_info() + if file_info: + state = TrayState.STARTING + if file_info.get("started") is True: + state = TrayState.RUNNING + self._file_state = state + return self._file_state + + def get_state(self) -> int: + if self._state is not None: + return self._state + + state = self.get_file_state() + if state == TrayState.RUNNING and not self.get_tray_info(): + state = TrayState.NOT_RUNNING + pid = self.pid + if pid: + _kill_tray_process(pid) + # Remove the file as tray is not running anymore and update + # the state of this object. + _remove_tray_server_url( + self.server_url, self.variant, self._file_modified + ) + self.reset() + + self._state = state + return self._state + + def get_ayon_username(self) -> Optional[str]: + tray_info = self.get_tray_info() + if tray_info: + return tray_info.get("username") + return None + + def wait_to_start(self) -> bool: + _wait_for_starting_tray( + self.server_url, self.variant, self._timeout + ) + self.reset() + return self.get_file_state() == TrayState.RUNNING + + pid = property(get_pid) + state = property(get_state) def get_tray_server_url( @@ -214,25 +477,12 @@ def get_tray_server_url( Optional[str]: Tray server url. """ - data = get_tray_file_info(server_url, variant) - if data is None: - return None - - if data.get("started") is False: - data = _wait_for_starting_tray(server_url, variant, timeout) - if data is None: - return None - - url = data.get("url") - if not url: - return None - - if not validate: - return url - - if _get_tray_information(url): - return url - return None + tray_info = TrayInfo.new( + server_url, variant, timeout, wait_to_start=True + ) + if validate: + return tray_info.get_tray_url() + return tray_info.get_file_url() def set_tray_server_url(tray_url: Optional[str], started: bool): @@ -246,10 +496,13 @@ def set_tray_server_url(tray_url: Optional[str], started: bool): that tray is starting up. """ - file_info = get_tray_file_info() - if file_info and file_info["pid"] != os.getpid(): - if not file_info["started"] or _get_tray_information(file_info["url"]): - raise TrayIsRunningError("Tray is already running.") + info = TrayInfo.new(wait_to_start=False) + if ( + info.pid + and info.pid != os.getpid() + and info.state in (TrayState.RUNNING, TrayState.STARTING) + ): + raise TrayIsRunningError("Tray is already running.") filepath = _get_tray_info_filepath() os.makedirs(os.path.dirname(filepath), exist_ok=True) @@ -292,20 +545,21 @@ def remove_tray_server_url(force: Optional[bool] = False): def get_tray_information( server_url: Optional[str] = None, - variant: Optional[str] = None -) -> Optional[Dict[str, Any]]: + variant: Optional[str] = None, + timeout: Optional[int] = None, +) -> TrayInfo: """Get information about tray. Args: server_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + timeout (Optional[int]): Timeout for tray start-up. Returns: - Optional[Dict[str, Any]]: Tray information. + TrayInfo: Tray information. """ - tray_url = get_tray_server_url(server_url, variant) - return _get_tray_information(tray_url) + return TrayInfo.new(server_url, variant, timeout) def get_tray_state( @@ -322,20 +576,8 @@ def get_tray_state( int: Tray state. """ - file_info = get_tray_file_info(server_url, variant) - if file_info is None: - return TrayState.NOT_RUNNING - - if file_info.get("started") is False: - return TrayState.STARTING - - tray_url = file_info.get("url") - info = _get_tray_information(tray_url) - if not info: - # Remove the information as the tray is not running - remove_tray_server_url(force=True) - return TrayState.NOT_RUNNING - return TrayState.RUNNING + tray_info = get_tray_information(server_url, variant) + return tray_info.state def is_tray_running( @@ -392,6 +634,7 @@ def show_message_in_tray( def make_sure_tray_is_running( ayon_url: Optional[str] = None, variant: Optional[str] = None, + username: Optional[str] = None, env: Optional[Dict[str, str]] = None ): """Make sure that tray for AYON url and variant is running. @@ -399,23 +642,26 @@ def make_sure_tray_is_running( Args: ayon_url (Optional[str]): AYON server url. variant (Optional[str]): Settings variant. + username (Optional[str]): Username under which should be tray running. env (Optional[Dict[str, str]]): Environment variables for the process. """ - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: - return + tray_info = TrayInfo.new( + ayon_url, variant, wait_to_start=False + ) + if tray_info.state == TrayState.STARTING: + tray_info.wait_to_start() - if state == TrayState.STARTING: - _wait_for_starting_tray(ayon_url, variant) - state = get_tray_state(ayon_url, variant) - if state == TrayState.RUNNING: + if tray_info.state == TrayState.RUNNING: + if not username: + username = get_ayon_username() + if tray_info.get_ayon_username() == username: return args = get_ayon_launcher_args("tray", "--force") if env is None: env = os.environ.copy() - + # Make sure 'QT_API' is not set env.pop("QT_API", None) @@ -435,38 +681,51 @@ def main(force=False): Logger.set_process_name("Tray") - state = get_tray_state() - if force and state in (TrayState.RUNNING, TrayState.STARTING): - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + tray_info = TrayInfo.new(wait_to_start=False) + + file_state = tray_info.get_file_state() + if force and file_state in (TrayState.RUNNING, TrayState.STARTING): + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) - state = TrayState.NOT_RUNNING + file_state = TrayState.NOT_RUNNING - if state == TrayState.RUNNING: - show_message_in_tray( - "Tray is already running", - "Your AYON tray application is already running." - ) - print("Tray is already running.") - return + if file_state in (TrayState.RUNNING, TrayState.STARTING): + expected_username = get_ayon_username() + username = tray_info.get_ayon_username() + # TODO probably show some message to the user??? + if expected_username != username: + pid = tray_info.pid + if pid is not None: + _kill_tray_process(pid) + remove_tray_server_url(force=True) + file_state = TrayState.NOT_RUNNING - if state == TrayState.STARTING: + if file_state == TrayState.RUNNING: + if tray_info.get_state() == TrayState.RUNNING: + show_message_in_tray( + "Tray is already running", + "Your AYON tray application is already running." + ) + print("Tray is already running.") + return + file_state = tray_info.get_file_state() + + if file_state == TrayState.STARTING: print("Tray is starting. Waiting for it to start.") - _wait_for_starting_tray() - state = get_tray_state() - if state == TrayState.RUNNING: + tray_info.wait_to_start() + file_state = tray_info.get_file_state() + if file_state == TrayState.RUNNING: print("Tray started. Exiting.") return - if state == TrayState.STARTING: + if file_state == TrayState.STARTING: print( "Tray did not start in expected time." " Killing the process and starting new." ) - file_info = get_tray_file_info() or {} - pid = file_info.get("pid") + pid = tray_info.pid if pid is not None: _kill_tray_process(pid) remove_tray_server_url(force=True) diff --git a/client/ayon_core/tools/tray/ui/addons_manager.py b/client/ayon_core/tools/tray/ui/addons_manager.py index 3fe4bb8dd8..2e6f0c0aae 100644 --- a/client/ayon_core/tools/tray/ui/addons_manager.py +++ b/client/ayon_core/tools/tray/ui/addons_manager.py @@ -237,11 +237,8 @@ class TrayAddonsManager(AddonsManager): webserver_url = self.webserver_url statics_url = f"{webserver_url}/res" + # Deprecated # TODO stop using these env variables # - function 'get_tray_server_url' should be used instead os.environ[self.webserver_url_env] = webserver_url os.environ["AYON_STATICS_SERVER"] = statics_url - - # Deprecated - os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url - os.environ["OPENPYPE_STATICS_SERVER"] = statics_url diff --git a/client/ayon_core/tools/utils/__init__.py b/client/ayon_core/tools/utils/__init__.py index 4b5fbeaf67..4714e76ea3 100644 --- a/client/ayon_core/tools/utils/__init__.py +++ b/client/ayon_core/tools/utils/__init__.py @@ -5,6 +5,8 @@ from .widgets import ( ComboBox, CustomTextComboBox, PlaceholderLineEdit, + ElideLabel, + HintedLineEdit, ExpandingTextEdit, BaseClickableFrame, ClickableFrame, @@ -36,7 +38,6 @@ from .lib import ( qt_app_context, get_qt_app, get_ayon_qt_app, - get_openpype_qt_app, get_qt_icon, ) @@ -88,6 +89,8 @@ __all__ = ( "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "ElideLabel", + "HintedLineEdit", "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", @@ -118,7 +121,6 @@ __all__ = ( "qt_app_context", "get_qt_app", "get_ayon_qt_app", - "get_openpype_qt_app", "get_qt_icon", "RecursiveSortFilterProxyModel", diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index 8689a97451..200e281664 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -196,10 +196,6 @@ def get_ayon_qt_app(): return app -def get_openpype_qt_app(): - return get_ayon_qt_app() - - def iter_model_rows(model, column=0, include_root=False): """Iterate over all row indices in a model""" indexes_queue = collections.deque() diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 28331fbc35..4c2b418c41 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -1,4 +1,5 @@ import logging +from typing import Optional, List, Set, Any from qtpy import QtWidgets, QtCore, QtGui import qargparse @@ -11,7 +12,7 @@ from ayon_core.style import ( ) from ayon_core.lib.attribute_definitions import AbstractAttrDef -from .lib import get_qta_icon_by_name_and_color +from .lib import get_qta_icon_by_name_and_color, set_style_property log = logging.getLogger(__name__) @@ -104,6 +105,253 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class ElideLabel(QtWidgets.QLabel): + """Label which elide text. + + By default, elide happens on right side. Can be changed with + 'set_elide_mode' method. + + It is not possible to use other features of QLabel like word wrap or + interactive text. This is a simple label which elide text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Preferred + ) + # Store text set during init + self._text = self.text() + # Define initial elide mode + self._elide_mode = QtCore.Qt.ElideRight + # Make sure that text of QLabel is empty + super().setText("") + + def setText(self, text): + # Update private text attribute and force update + self._text = text + self.update() + + def setWordWrap(self, word_wrap): + # Word wrap is not supported in 'ElideLabel' + if word_wrap: + raise ValueError("Word wrap is not supported in 'ElideLabel'.") + + def contextMenuEvent(self, event): + menu = self.create_context_menu(event.pos()) + if menu is None: + event.ignore() + return + event.accept() + menu.setAttribute(QtCore.Qt.WA_DeleteOnClose) + menu.popup(event.globalPos()) + + def create_context_menu(self, pos): + if not self._text: + return None + menu = QtWidgets.QMenu(self) + + # Copy text action + copy_action = menu.addAction("Copy") + copy_action.setObjectName("edit-copy") + icon = QtGui.QIcon.fromTheme("edit-copy") + if not icon.isNull(): + copy_action.setIcon(icon) + + copy_action.triggered.connect(self._on_copy_text) + return menu + + def set_set(self, text): + self.setText(text) + + def set_elide_mode(self, elide_mode): + """Change elide type. + + Args: + elide_mode: Possible elide type. Available in 'QtCore.Qt' + 'ElideLeft', 'ElideRight' and 'ElideMiddle'. + + """ + if elide_mode == QtCore.Qt.ElideNone: + raise ValueError( + "Invalid elide type. 'ElideNone' is not supported." + ) + + if elide_mode not in ( + QtCore.Qt.ElideLeft, + QtCore.Qt.ElideRight, + QtCore.Qt.ElideMiddle, + ): + raise ValueError(f"Unknown value '{elide_mode}'") + self._elide_mode = elide_mode + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QtGui.QPainter(self) + fm = painter.fontMetrics() + elided_line = fm.elidedText( + self._text, self._elide_mode, self.width() + ) + painter.drawText(QtCore.QPoint(0, fm.ascent()), elided_line) + + def _on_copy_text(self): + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(self._text) + + +class _LocalCache: + down_arrow_icon = None + + +def get_down_arrow_icon() -> QtGui.QIcon: + if _LocalCache.down_arrow_icon is not None: + return _LocalCache.down_arrow_icon + + normal_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow") + ) + on_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow_on") + ) + disabled_pixmap = QtGui.QPixmap( + get_style_image_path("down_arrow_disabled") + ) + icon = QtGui.QIcon(normal_pixmap) + icon.addPixmap(on_pixmap, QtGui.QIcon.Active) + icon.addPixmap(disabled_pixmap, QtGui.QIcon.Disabled) + _LocalCache.down_arrow_icon = icon + return icon + + +# These are placeholders for adding style +class HintedLineEditInput(PlaceholderLineEdit): + pass + + +class HintedLineEditButton(QtWidgets.QPushButton): + pass + + +class HintedLineEdit(QtWidgets.QWidget): + SEPARATORS: Set[str] = {"---", "---separator---"} + returnPressed = QtCore.Signal() + textChanged = QtCore.Signal(str) + textEdited = QtCore.Signal(str) + + def __init__( + self, + options: Optional[List[str]] = None, + parent: Optional[QtWidgets.QWidget] = None + ): + super().__init__(parent) + + text_input = HintedLineEditInput(self) + options_button = HintedLineEditButton(self) + options_button.setIcon(get_down_arrow_icon()) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(text_input, 1) + main_layout.addWidget(options_button, 0) + + # Expand line edit and button vertically so they have same height + for widget in (text_input, options_button): + w_size_policy = widget.sizePolicy() + w_size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(w_size_policy) + + # Set size hint of this frame to fixed so size hint height is + # used as fixed height + size_policy = self.sizePolicy() + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Fixed) + self.setSizePolicy(size_policy) + + text_input.returnPressed.connect(self.returnPressed) + text_input.textChanged.connect(self.textChanged) + text_input.textEdited.connect(self.textEdited) + options_button.clicked.connect(self._on_options_button_clicked) + + self._text_input = text_input + self._options_button = options_button + self._options = None + + # Set default state + self.set_options(options) + + def text(self) -> str: + return self._text_input.text() + + def setText(self, text: str): + self._text_input.setText(text) + + def setPlaceholderText(self, text: str): + self._text_input.setPlaceholderText(text) + + def placeholderText(self) -> str: + return self._text_input.placeholderText() + + def setReadOnly(self, state: bool): + self._text_input.setReadOnly(state) + + def setIcon(self, icon: QtGui.QIcon): + self._options_button.setIcon(icon) + + def setToolTip(self, text: str): + self._text_input.setToolTip(text) + + def set_button_tool_tip(self, text: str): + self._options_button.setToolTip(text) + + def set_options(self, options: Optional[List[str]] = None): + self._options = options + self._options_button.setEnabled(bool(options)) + + def sizeHint(self) -> QtCore.QSize: + hint = super().sizeHint() + tsz = self._text_input.sizeHint() + bsz = self._options_button.sizeHint() + hint.setHeight(max(tsz.height(), bsz.height())) + return hint + + # Adds ability to change style of the widgets + # - because style change of the 'HintedLineEdit' may not propagate + # correctly 'HintedLineEditInput' and 'HintedLineEditButton' + def set_text_widget_object_name(self, name: str): + self._text_input.setObjectName(name) + + def set_text_widget_property(self, name: str, value: Any): + set_style_property(self._text_input, name, value) + + def set_button_widget_object_name(self, name: str): + self._text_input.setObjectName(name) + + def set_button_widget_property(self, name: str, value: Any): + set_style_property(self._options_button, name, value) + + def _on_options_button_clicked(self): + if not self._options: + return + + menu = QtWidgets.QMenu(self) + menu.triggered.connect(self._on_option_action) + for option in self._options: + if option in self.SEPARATORS: + menu.addSeparator() + else: + menu.addAction(option) + + rect = self._options_button.rect() + pos = self._options_button.mapToGlobal(rect.bottomLeft()) + menu.exec_(pos) + + def _on_option_action(self, action): + self.setText(action.text()) + + class ExpandingTextEdit(QtWidgets.QTextEdit): """QTextEdit which does not have sroll area but expands height.""" @@ -206,6 +454,8 @@ class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" state_changed = QtCore.Signal() + branch_closed_path = get_style_image_path("branch_closed") + branch_open_path = get_style_image_path("branch_open") def __init__(self, parent): super(ExpandBtnLabel, self).__init__(parent) @@ -216,14 +466,10 @@ class ExpandBtnLabel(QtWidgets.QLabel): self._collapsed = True def _create_collapsed_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("branch_closed") - ) + return QtGui.QPixmap(self.branch_closed_path) def _create_expanded_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("branch_open") - ) + return QtGui.QPixmap(self.branch_open_path) @property def collapsed(self): @@ -291,15 +537,41 @@ class ExpandBtn(ClickableFrame): class ClassicExpandBtnLabel(ExpandBtnLabel): - def _create_collapsed_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("right_arrow") + 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 self._normalize_pixmap(QtGui.QPixmap(self.right_arrow_path)) def _create_expanded_pixmap(self): - return QtGui.QPixmap( - get_style_image_path("down_arrow") - ) + return self._normalize_pixmap(QtGui.QPixmap(self.down_arrow_path)) class ClassicExpandBtn(ExpandBtn): diff --git a/client/ayon_core/vendor/python/scriptsmenu/action.py b/client/ayon_core/vendor/python/scriptsmenu/action.py index 49b08788f9..3ba281fed7 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/action.py +++ b/client/ayon_core/vendor/python/scriptsmenu/action.py @@ -1,6 +1,6 @@ import os -from qtpy import QtWidgets +from qtpy import QtWidgets, QT6 class Action(QtWidgets.QAction): @@ -112,20 +112,21 @@ module.{module_name}()""" Run the command of the instance or copy the command to the active shelf based on the current modifiers. - If callbacks have been registered with fouind modifier integer the + If callbacks have been registered with found modifier integer the function will trigger all callbacks. When a callback function returns a non zero integer it will not execute the action's command - """ # get the current application and its linked keyboard modifiers app = QtWidgets.QApplication.instance() modifiers = app.keyboardModifiers() + if not QT6: + modifiers = int(modifiers) # If the menu has a callback registered for the current modifier # we run the callback instead of the action itself. registered = self._root.registered_callbacks - callbacks = registered.get(int(modifiers), []) + callbacks = registered.get(modifiers, []) for callback in callbacks: signal = callback(self) if signal != 0: diff --git a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py index c8b0c777de..a5503bc63e 100644 --- a/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py +++ b/client/ayon_core/vendor/python/scriptsmenu/launchformaya.py @@ -4,7 +4,7 @@ import maya.cmds as cmds import maya.mel as mel import scriptsmenu -from qtpy import QtCore, QtWidgets +from qtpy import QtCore, QtWidgets, QT6 log = logging.getLogger(__name__) @@ -130,7 +130,7 @@ def main(title="Scripts", parent=None, objectName=None): # Register control + shift callback to add to shelf (maya behavior) modifiers = QtCore.Qt.ControlModifier | QtCore.Qt.ShiftModifier - if int(cmds.about(version=True)) <= 2025: + if not QT6: modifiers = int(modifiers) menu.register_callback(modifiers, to_shelf) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 55a14ba567..ab8c9424fa 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.4-dev.1" +"""Package declaring AYON addon 'core' version.""" +__version__ = "1.0.9+dev" 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 @@ client side code zipped in `private` subfolder. import os import sys import re +import io import shutil -import argparse import platform +import argparse import logging import collections import zipfile -import hashlib +import subprocess +from typing import Optional, Iterable, Pattern, Union, List, Tuple -from typing import Optional +import package -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) +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) -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__ = "{}" +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 @@ IGNORE_DIR_PATTERNS = [ ] # 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 @@ IGNORE_FILE_PATTERNS = [ ] -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 @@ class ZipFileLongPaths(zipfile.ZipFile): 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): +def update_client_version(logger): + """Update version in client code if version.py is present.""" + if not ADDON_CLIENT_DIR: + return + + 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 + + logger.info("Updating client version") + with open(version_path, "w") as stream: + stream.write(VERSION_PY_CONTENT) + + +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 + + new_lines.append(line) + + with open(filepath, "w") as stream: + stream.write("".join(new_lines)) + + +def build_frontend(): + yarn_executable = _get_yarn_executable() + if yarn_executable is None: + raise RuntimeError("Yarn executable was not found.") + + 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." + ) + + +def get_client_files_mapping() -> List[Tuple[str, str]]: + """Mapping of source client code files to destination paths. + + 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. + + """ + # 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) + ] + + 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") + 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' Args: - addon_output_dir (str): package dir in addon repo dir - current_dir (str): addon repo dir + output_dir (str): Output directory path. log (logging.Logger) + """ + log.info(f"Copying client for {ADDON_NAME}-{ADDON_VERSION}") - log.info("Copying server content") + 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) - 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)) - - # Copy files - for src_path, dst_path in filepaths_to_copy: + 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) - -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. - - Args: - client_addon_dir (str): Directory path of client addon. - """ - - 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)) + log.info("Client copy finished") -def zip_client_side(addon_package_dir, current_dir, log): - """Copy and zip `client` content into 'addon_package_dir'. - - Args: - addon_package_dir (str): Output package directory path. - current_dir (str): Directory path of addon source. - log (logging.Logger): Logger object. - """ - - 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}'" - ) - - log.info("Preparing client code zip") - private_dir = os.path.join(addon_package_dir, "private") - - if not os.path.exists(private_dir): - os.makedirs(private_dir) - - _update_client_version(client_addon_dir) - - 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) - - shutil.copy(os.path.join(client_dir, "pyproject.toml"), private_dir) - - -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("Creating server package") + """ + 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) + + 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 + # 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) - 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) - - 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 os.path.isdir(new_created_version_dir) and clear_output_dir: - log.info(f"Purging {new_created_version_dir}") - shutil.rmtree(output_dir) + if only_client: + if not has_client_code: + raise RuntimeError("Client code is not available. Skipping") + + 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") @@ -333,23 +474,6 @@ if __name__ == "__main__": " server folder structure." ) ) - parser.add_argument( - "--keep-sources", - dest="keep_sources", - action="store_true", - help=( - "Keep folder structure when server package is created." - ) - ) - parser.add_argument( - "-c", "--clear-output-dir", - dest="clear_output_dir", - action="store_true", - help=( - "Clear output directory before package creation." - ) - ) - parser.add_argument( "-o", "--output", dest="output_dir", @@ -359,11 +483,25 @@ if __name__ == "__main__": " (Will be purged if already exists!)" ) ) + parser.add_argument( + "--only-client", + dest="only_client", + action="store_true", + help=( + "Extract only client code. This is useful for development." + " Requires '-o', '--output' argument to be filled." + ) + ) + parser.add_argument( + "--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) diff --git a/package.py b/package.py index ca4006425d..b90db4cde4 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.4-dev.1" +version = "1.0.9+dev" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index f8f840d2c9..f2d09d925d 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.9+dev" description = "" authors = ["Ynput Team "] readme = "README.md" @@ -23,6 +23,7 @@ ayon-python-api = "^1.0" ruff = "^0.3.3" pre-commit = "^3.6.2" codespell = "^2.2.6" +semver = "^3.0.2" [tool.ruff] @@ -67,7 +68,7 @@ target-version = "py39" [tool.ruff.lint] # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -select = ["E4", "E7", "E9", "F"] +select = ["E4", "E7", "E9", "F", "W"] ignore = [] # Allow fix for all enabled rules (when `--fix`) is provided. @@ -84,7 +85,6 @@ exclude = [ [tool.ruff.lint.per-file-ignores] "client/ayon_core/lib/__init__.py" = ["E402"] -"client/ayon_core/hosts/max/startup/startup.py" = ["E402"] [tool.ruff.format] # Like Black, use double quotes for strings. @@ -114,3 +114,12 @@ quiet-level = 3 [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + + +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "INFO" +addopts = "-ra -q" +testpaths = [ + "client/ayon_core/tests" +] diff --git a/server/settings/conversion.py b/server/settings/conversion.py index f513738603..34820b5b32 100644 --- a/server/settings/conversion.py +++ b/server/settings/conversion.py @@ -4,6 +4,29 @@ from typing import Any from .publish_plugins import DEFAULT_PUBLISH_VALUES +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 + ocio_config_profiles = imageio_overrides.get("ocio_config_profiles") + if not ocio_config_profiles: + return + + 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"), + "fallback": { + "type": "builtin_path", + "builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", + }, + } + + 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 {} @@ -71,10 +94,43 @@ def _convert_validate_version_0_3_3(publish_overrides): validate_version["plugin_state_profiles"] = [profile] -def _conver_publish_plugins(overrides): +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( @@ -82,5 +138,6 @@ def convert_settings_overrides( overrides: dict[str, Any], ) -> dict[str, Any]: _convert_imageio_configs_0_3_1(overrides) - _conver_publish_plugins(overrides) + _convert_imageio_configs_0_4_5(overrides) + _convert_publish_plugins(overrides) return overrides diff --git a/server/settings/main.py b/server/settings/main.py index 0972ccdfb9..249bab85fd 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" + fallback_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 @@ DEFAULT_VALUES = { "type": "builtin_path", "builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", "custom_path": "", - "product_name": "", + "published_product": { + "product_name": "", + "fallback": { + "fallback_type": "builtin_path", + "builtin_path": "ACES 1.2", + "custom_path": "" + } + } } ], "file_rules": { diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index a5ea7bd762..16b1f37187 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" @@ -84,6 +84,17 @@ class CollectUSDLayerContributionsModel(BaseSettingsModel): return value +class AyonEntityURIModel(BaseSettingsModel): + use_ayon_entity_uri: bool = SettingsField( + title="Use AYON Entity URI", + description=( + "When enabled the USD paths written using the contribution " + "workflow will use ayon entity URIs instead of resolved published " + "paths. You can only load these if you use the AYON USD Resolver." + ) + ) + + class PluginStateByHostModelProfile(BaseSettingsModel): _layout = "expanded" # Filtering @@ -257,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): @@ -274,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="Colorspace") - display: str = SettingsField("", title="Display") - view: str = SettingsField("", title="View") + 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 + ) + 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." ) @@ -317,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, @@ -360,7 +435,7 @@ class ExtractReviewFFmpegModel(BaseSettingsModel): def extract_review_filter_enum(): return [ { - "value": "everytime", + "value": "everytime", # codespell:ignore everytime "label": "Always" }, { @@ -382,10 +457,10 @@ 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" + "Use output **always** / only if input **is 1 frame**" + " image / only if has **2+ frames** or **is video**" ), enum_resolver=extract_review_filter_enum ) @@ -562,12 +637,12 @@ class ExtractBurninDef(BaseSettingsModel): _isGroup = True _layout = "expanded" name: str = SettingsField("") - TOP_LEFT: str = SettingsField("", topic="Top Left") - TOP_CENTERED: str = SettingsField("", topic="Top Centered") - TOP_RIGHT: str = SettingsField("", topic="Top Right") - BOTTOM_LEFT: str = SettingsField("", topic="Bottom Left") - BOTTOM_CENTERED: str = SettingsField("", topic="Bottom Centered") - BOTTOM_RIGHT: str = SettingsField("", topic="Bottom Right") + TOP_LEFT: str = SettingsField("", title="Top Left") + TOP_CENTERED: str = SettingsField("", title="Top Centered") + TOP_RIGHT: str = SettingsField("", title="Top Right") + BOTTOM_LEFT: str = SettingsField("", title="Bottom Left") + BOTTOM_CENTERED: str = SettingsField("", title="Bottom Centered") + BOTTOM_RIGHT: str = SettingsField("", title="Bottom Right") filter: ExtractBurninDefFilter = SettingsField( default_factory=ExtractBurninDefFilter, title="Additional filtering" @@ -780,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)" ) @@ -857,6 +932,14 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ExtractBurninModel, title="Extract Burnin" ) + ExtractUSDAssetContribution: AyonEntityURIModel = SettingsField( + default_factory=AyonEntityURIModel, + title="Extract USD Asset Contribution", + ) + ExtractUSDLayerContribution: AyonEntityURIModel = SettingsField( + default_factory=AyonEntityURIModel, + title="Extract USD Layer Contribution", + ) PreIntegrateThumbnails: PreIntegrateThumbnailsModel = SettingsField( default_factory=PreIntegrateThumbnailsModel, title="Override Integrate Thumbnail Representations" @@ -1066,7 +1149,7 @@ DEFAULT_PUBLISH_VALUES = { "output": [ "-pix_fmt yuv420p", "-crf 18", - "-c:a acc", + "-c:a aac", "-b:a 192k", "-g 1", "-movflags faststart" @@ -1167,6 +1250,12 @@ DEFAULT_PUBLISH_VALUES = { } ] }, + "ExtractUSDAssetContribution": { + "use_ayon_entity_uri": False, + }, + "ExtractUSDLayerContribution": { + "use_ayon_entity_uri": False, + }, "PreIntegrateThumbnails": { "enabled": True, "integrate_profiles": [] @@ -1200,7 +1289,7 @@ DEFAULT_PUBLISH_VALUES = { "use_hardlinks": False }, "CleanUp": { - "paterns": [], + "paterns": [], # codespell:ignore paterns "remove_temp_renders": False }, "CleanUpFarm": { diff --git a/server/settings/tools.py b/server/settings/tools.py index 85a66f6a70..a2785c1edf 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -22,6 +22,7 @@ class ProductTypeSmartSelectModel(BaseSettingsModel): class ProductNameProfile(BaseSettingsModel): _layout = "expanded" + product_types: list[str] = SettingsField( default_factory=list, title="Product types" ) @@ -65,6 +66,15 @@ class CreatorToolModel(BaseSettingsModel): title="Create Smart Select" ) ) + # TODO: change to False in next releases + use_legacy_product_names_for_renders: bool = SettingsField( + True, + title="Use legacy product names for renders", + description="Use product naming templates for renders. " + "This is for backwards compatibility enabled by default." + "When enabled, it will ignore any templates for renders " + "that are set in the product name profiles.") + product_name_profiles: list[ProductNameProfile] = SettingsField( default_factory=list, title="Product name profiles" 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/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_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_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_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/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/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/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/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/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_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_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/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 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..ea31e1a260 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/test_extract_otio_review.py @@ -0,0 +1,330 @@ +import mock +import os +import pytest # noqa +from typing import NamedTuple + +import opentimelineio as otio + +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_ffmpeg_executable(self, _): + return ["/path/to/ffmpeg"] + + +def run_process(file_name: str, instance_data: dict = None): + """ + """ + # Prepare dummy instance and capture call object + capture_call = CaptureFFmpegCalls() + processor = extract_otio_review.ExtractOTIOReview() + Anatomy = NamedTuple("Anatomy", project_name=str) + + 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, + } + + 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( + extract_otio_review, + "get_ffmpeg_tool_args", + side_effect=capture_call.get_ffmpeg_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="output." + ): + 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 + + +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 " + f"C:\\exr_embedded_tc{os.sep}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 " + f"C:\\tif_seq{os.sep}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_short_movie_head_gap_handles(): + """ + Qt movie clip. + 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 + + +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 + +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 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..7f9256c6d8 --- /dev/null +++ b/tests/client/ayon_core/pipeline/editorial/test_media_range_with_retimes.py @@ -0,0 +1,189 @@ +import os + +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 + } + } + _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-91120.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 + ) + + +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 + ) + +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, + ) 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..9a9a9a2eff 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 a poetry command in the repository environment" -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 diff --git a/tools/manage.sh b/tools/manage.sh index 923953bf96..6b0a4d6978 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 a poetry command in the repository environment${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