From 787b663b879f1c9b89c2a5bdbe6ba898f1f4df7c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 4 Apr 2024 02:04:28 +0200 Subject: [PATCH 001/163] Improve typing --- .../pipeline/create/creator_plugins.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index e0b30763d0..0bb3bb9122 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import copy import collections +from typing import TYPE_CHECKING, List, Dict, Any, Optional, Union from abc import ABCMeta, abstractmethod @@ -22,6 +23,11 @@ from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator +if TYPE_CHECKING: + from .context import CreateContext, CreatedInstance, UpdateData + ProjectSettings = Dict[str, Any] # typing alias + + class CreatorError(Exception): """Should be raised when creator failed because of known issue. @@ -36,9 +42,9 @@ class CreatorError(Exception): class ProductConvertorPlugin(object): """Helper for conversion of instances created using legacy creators. - Conversion from legacy creators would mean to loose legacy instances, + Conversion from legacy creators would mean to lose legacy instances, convert them automatically or write a script which must user run. All of - these solutions are workign but will happen without asking or user must + these solutions are working but will happen without asking or user must know about them. This plugin can be used to show legacy instances in Publisher and give user ability to run conversion script. @@ -57,7 +63,7 @@ class ProductConvertorPlugin(object): can store any information to it's object for conversion purposes. Args: - create_context + create_context (CreateContext) Context which initialized the plugin. """ _log = None @@ -158,7 +164,7 @@ class BaseCreator: to `self` if it's not Plugin specific. Args: - project_settings (Dict[str, Any]): Project settings. + project_settings (ProjectSettings): Project settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ @@ -192,13 +198,13 @@ class BaseCreator: # - used on all hosts when set to 'None' for Backwards compatibility # - was added afterwards # QUESTION make this required? - host_name = None + host_name: Optional[str] = None # Settings auto-apply helpers # Root key in project settings (mandatory for auto-apply to work) - settings_category = None + settings_category: Optional[str] = None # Name of plugin in create settings > class name is used if not set - settings_name = None + settings_name: Optional[str] = None def __init__( self, project_settings, create_context, headless=False @@ -207,7 +213,7 @@ class BaseCreator: self.create_context = create_context self.project_settings = project_settings - # Creator is running in headless mode (without UI elemets) + # Creator is running in headless mode (without UI elements) # - we may use UI inside processing this attribute should be checked self.headless = headless @@ -218,7 +224,7 @@ class BaseCreator: """Helper method to get settings values. Args: - project_settings (dict[str, Any]): Project settings. + project_settings (ProjectSettings): Project settings. category_name (str): Category of settings. plugin_name (str): Name of settings. @@ -269,7 +275,7 @@ class BaseCreator: } Args: - project_settings (dict[str, Any]): Project settings. + project_settings (ProjectSettings): Project settings. """ settings_category = self.settings_category @@ -441,7 +447,7 @@ class BaseCreator: """Store changes of existing instances so they can be recollected. Args: - update_list(List[UpdateData]): Gets list of tuples. Each item + update_list (List[UpdateData]): Gets list of tuples. Each item contain changed instance and it's changes. """ From 183100445f0d1e681704f5493cf2c4bb382933ed Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:13:29 +0200 Subject: [PATCH 002/163] Remove alias --- client/ayon_core/pipeline/create/creator_plugins.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 0bb3bb9122..f6ffe0fa85 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -25,7 +25,6 @@ from .legacy_create import LegacyCreator if TYPE_CHECKING: from .context import CreateContext, CreatedInstance, UpdateData - ProjectSettings = Dict[str, Any] # typing alias class CreatorError(Exception): @@ -164,7 +163,7 @@ class BaseCreator: to `self` if it's not Plugin specific. Args: - project_settings (ProjectSettings): Project settings. + project_settings (Dict[str, Any]): Project settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ @@ -224,7 +223,7 @@ class BaseCreator: """Helper method to get settings values. Args: - project_settings (ProjectSettings): Project settings. + project_settings (Dict[str, Any]): Project settings. category_name (str): Category of settings. plugin_name (str): Name of settings. @@ -275,7 +274,7 @@ class BaseCreator: } Args: - project_settings (ProjectSettings): Project settings. + project_settings (Dict[str, Any]): Project settings. """ settings_category = self.settings_category From 9595dd46ee25e46761d50aef41ea697b994bdd05 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:14:44 +0200 Subject: [PATCH 003/163] Cosmetics --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index f6ffe0fa85..e9eebb0bd5 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -62,7 +62,7 @@ class ProductConvertorPlugin(object): can store any information to it's object for conversion purposes. Args: - create_context (CreateContext) Context which initialized the plugin. + create_context (CreateContext): Context which initialized the plugin. """ _log = None From 166f9ad8b07dc7b0c78f8f066f92702d6c384071 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:15:47 +0200 Subject: [PATCH 004/163] Remove unused import --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index e9eebb0bd5..749699a019 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, List, Dict, Any, Optional, Union +from typing import TYPE_CHECKING, List, Dict, Optional, Union from abc import ABCMeta, abstractmethod From c74f9cb56809a927d4c99d8eb04a485415b625a8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:17:30 +0200 Subject: [PATCH 005/163] Fix comment --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 749699a019..ea02540ce8 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -190,7 +190,7 @@ class BaseCreator: # Instance attribute definitions that can be changed per instance # - returns list of attribute definitions from - # `ayon_core.pipeline.attribute_definitions` + # `ayon_core.lib.attribute_definitions` instance_attr_defs = [] # Filtering by host name - can be used to be filtered by host name From 71b2412a8e5f4158e2f18a6799bfb1d7cd40e1a5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:19:33 +0200 Subject: [PATCH 006/163] Fix `AbstractAttrDef` type hints --- client/ayon_core/pipeline/create/creator_plugins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index ea02540ce8..516c1eb5e1 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -25,6 +25,7 @@ from .legacy_create import LegacyCreator if TYPE_CHECKING: from .context import CreateContext, CreatedInstance, UpdateData + from ayon_core.lib import AbstractAttrDef class CreatorError(Exception): @@ -191,7 +192,7 @@ class BaseCreator: # Instance attribute definitions that can be changed per instance # - returns list of attribute definitions from # `ayon_core.lib.attribute_definitions` - instance_attr_defs = [] + instance_attr_defs: List[AbstractAttrDef] = [] # Filtering by host name - can be used to be filtered by host name # - used on all hosts when set to 'None' for Backwards compatibility From 2a631a2d4eb8ad503f90416cb0788daba392276c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:19:59 +0200 Subject: [PATCH 007/163] Fix docstring --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 516c1eb5e1..b3e310104a 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -461,7 +461,7 @@ class BaseCreator: 'True' if did so. Args: - instance(List[CreatedInstance]): Instance objects which should be + instances (List[CreatedInstance]): Instance objects which should be removed. """ From ee00c8b7b52021c77008ba6de18e98e892936743 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:21:04 +0200 Subject: [PATCH 008/163] Fix docstring --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index ca9896fb3f..9ac14a9d33 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2501,7 +2501,7 @@ class CreateContext: def collection_shared_data(self): """Access to shared data that can be used during creator's collection. - Retruns: + Returns: Dict[str, Any]: Shared data. Raises: From 60772e853feab179f6b4e72d32e65e81bdc269c1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:22:57 +0200 Subject: [PATCH 009/163] Fix docstring typo --- client/ayon_core/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 9ac14a9d33..20c9fcbc29 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -45,7 +45,7 @@ class UnavailableSharedData(Exception): class ImmutableKeyError(TypeError): - """Accessed key is immutable so does not allow changes or removements.""" + """Accessed key is immutable so does not allow changes or removals.""" def __init__(self, key, msg=None): self.immutable_key = key From 0eadba5bafae4e78f365014a2f4a33e74bfb51ee Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:26:20 +0200 Subject: [PATCH 010/163] Add args to docstring --- client/ayon_core/pipeline/create/legacy_create.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ayon_core/pipeline/create/legacy_create.py b/client/ayon_core/pipeline/create/legacy_create.py index ab939343c9..fc24bcf934 100644 --- a/client/ayon_core/pipeline/create/legacy_create.py +++ b/client/ayon_core/pipeline/create/legacy_create.py @@ -112,6 +112,13 @@ class LegacyCreator(object): This method can be modified to prefill some values just keep in mind it is class method. + Args: + project_name (str): Context's project name. + folder_entity (dict[str, Any]): Folder entity. + task_entity (dict[str, Any]): Task entity. + variant (str): What is entered by user in creator tool. + host_name (str): Name of host. + Returns: dict: Fill data for product name template. """ From 0fe8006ec0997ac94bef27fcb94779388d12977a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:28:30 +0200 Subject: [PATCH 011/163] Fix typo --- client/ayon_core/pipeline/create/product_name.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index fecda867e5..2b88963089 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -37,7 +37,7 @@ def get_product_name_template( task_name (str): Name of task in which context the product is created. task_type (str): Type of task in which context the product is created. default_template (Union[str, None]): Default template which is used if - settings won't find any matching possitibility. Constant + settings won't find any matching possibility. Constant 'DEFAULT_PRODUCT_TEMPLATE' is used if not defined. project_settings (Union[Dict[str, Any], None]): Prepared settings for project. Settings are queried if not passed. From db915c43156a2b9c18d5e3deff72a30f78c85826 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:29:35 +0200 Subject: [PATCH 012/163] Typos in comments/docstring --- client/ayon_core/modules/job_queue/addon.py | 2 +- client/ayon_core/tools/utils/widgets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/modules/job_queue/addon.py b/client/ayon_core/modules/job_queue/addon.py index 0fa54eb2f0..c93156926d 100644 --- a/client/ayon_core/modules/job_queue/addon.py +++ b/client/ayon_core/modules/job_queue/addon.py @@ -30,7 +30,7 @@ workstations know where to send or receive jobs. ### start_worker - start worker which will process jobs -- has required possitional argument which is application name from AYON +- has required positional argument which is application name from AYON settings e.g. 'tvpaint/11-5' ('tvpaint' is group '11-5' is variant) - it is possible to specify server url but url from settings is used when not passed (this is added mainly for developing purposes) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 1d4f85246f..4db909ad53 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -769,7 +769,7 @@ class SeparatorWidget(QtWidgets.QFrame): if self._orientation == orientation: return - # Reset min/max sizes in opossite direction + # Reset min/max sizes in oppossite direction if self._orientation == QtCore.Qt.Vertical: self.setMinimumHeight(0) self.setMaximumHeight(self._maximum_height) From 99486542f276e1c0dc09fef0845b5c531df3535b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 17 Apr 2024 22:30:16 +0200 Subject: [PATCH 013/163] Actually fix the typo :) --- client/ayon_core/tools/utils/widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/widgets.py b/client/ayon_core/tools/utils/widgets.py index 4db909ad53..40a8463523 100644 --- a/client/ayon_core/tools/utils/widgets.py +++ b/client/ayon_core/tools/utils/widgets.py @@ -769,7 +769,7 @@ class SeparatorWidget(QtWidgets.QFrame): if self._orientation == orientation: return - # Reset min/max sizes in oppossite direction + # Reset min/max sizes in opposite direction if self._orientation == QtCore.Qt.Vertical: self.setMinimumHeight(0) self.setMaximumHeight(self._maximum_height) From 20aa98912cb095b9a872980155c0e010948e1899 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 10:19:09 +0200 Subject: [PATCH 014/163] Fix docstring argument --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index e0b30763d0..b6ba5011f6 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -455,7 +455,7 @@ class BaseCreator: 'True' if did so. Args: - instance(List[CreatedInstance]): Instance objects which should be + instances (List[CreatedInstance]): Instance objects which should be removed. """ From 7d9d9a5218e8f8f37a8f9365db1804a9eda03e2c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 10:21:53 +0200 Subject: [PATCH 015/163] Fix imports for type hints --- client/ayon_core/pipeline/create/creator_plugins.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index b6ba5011f6..ce1c1dfe85 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import copy import collections +import typing from abc import ABCMeta, abstractmethod @@ -21,6 +22,10 @@ from .product_name import get_product_name from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator +if typing.TYPE_CHECKING: + # Avoid cyclic imports + from .context import CreateContext, UpdateData + class CreatorError(Exception): """Should be raised when creator failed because of known issue. @@ -441,7 +446,7 @@ class BaseCreator: """Store changes of existing instances so they can be recollected. Args: - update_list(List[UpdateData]): Gets list of tuples. Each item + update_list (list[UpdateData]): Gets list of tuples. Each item contain changed instance and it's changes. """ From 6eeb0495ec0fb4b5357ac35c27002e6cfe055a00 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 10:22:43 +0200 Subject: [PATCH 016/163] Update `List` to `list` type hints (newer style) --- client/ayon_core/pipeline/create/creator_plugins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index ce1c1dfe85..bf8382d430 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -460,7 +460,7 @@ class BaseCreator: 'True' if did so. Args: - instances (List[CreatedInstance]): Instance objects which should be + instances (list[CreatedInstance]): Instance objects which should be removed. """ @@ -716,7 +716,7 @@ class Creator(BaseCreator): By default, returns `default_variants` value. Returns: - List[str]: Whisper variants for user input. + list[str]: Whisper variants for user input. """ return copy.deepcopy(self.default_variants) @@ -789,7 +789,7 @@ class Creator(BaseCreator): updating keys/values when plugin attributes change. Returns: - List[AbstractAttrDef]: Attribute definitions that can be tweaked + list[AbstractAttrDef]: Attribute definitions that can be tweaked for created instance. """ return self.pre_create_attr_defs From 739bd216427546a98d3bd8f9d666da744ad16072 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 10:23:11 +0200 Subject: [PATCH 017/163] Fix typo `removement` -> `removal` --- client/ayon_core/pipeline/create/creator_plugins.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index bf8382d430..2de9c6f8a6 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -394,7 +394,7 @@ class BaseCreator: """Helper method to remove instance from create context. Instances must be removed from DCC workfile metadat aand from create - context in which plugin is existing at the moment of removement to + context in which plugin is existing at the moment of removal to propagate the change without restarting create context. Args: @@ -454,7 +454,7 @@ class BaseCreator: @abstractmethod def remove_instances(self, instances): - """Method called on instance removement. + """Method called on instance removal. Can also remove instance metadata from context but should return 'True' if did so. @@ -808,7 +808,7 @@ class AutoCreator(BaseCreator): """ def remove_instances(self, instances): - """Skip removement.""" + """Skip removal.""" pass From 9486ab5ec44ece6a3566ab5e24585784a5409677 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 10:23:34 +0200 Subject: [PATCH 018/163] Fix grammar --- client/ayon_core/pipeline/create/creator_plugins.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 2de9c6f8a6..57a6e6a25f 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -485,8 +485,7 @@ class BaseCreator: ): """Dynamic data for product name filling. - These may be get dynamically created based on current context of - workfile. + These may be dynamically created based on current context of workfile. """ return {} From 2383274cbcd4c4d120793924b461dac23022ba9f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 10:27:04 +0200 Subject: [PATCH 019/163] Add type hint --- client/ayon_core/pipeline/create/context.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0d8722dab1..91a98cbec4 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -7,6 +7,7 @@ import collections import inspect from uuid import uuid4 from contextlib import contextmanager +from typing import Optional import pyblish.logic import pyblish.api @@ -1431,7 +1432,7 @@ class CreateContext: self.convertors_plugins = {} self.convertor_items_by_id = {} - self.publish_discover_result = None + self.publish_discover_result: Optional[DiscoverResult] = None self.publish_plugins_mismatch_targets = [] self.publish_plugins = [] self.plugins_with_defs = [] From 4ecbccbcfa74152ea7791e2f0f278531d97a3948 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 15:56:34 +0200 Subject: [PATCH 020/163] Make `dict` type hint lowercase throughout the file --- .../ayon_core/pipeline/create/creator_plugins.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 23fdf33d27..9267587586 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, List, Dict, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union from abc import ABCMeta, abstractmethod @@ -128,7 +128,7 @@ class ProductConvertorPlugin(object): """Access to shared data that can be used during 'find_instances'. Retruns: - Dict[str, Any]: Shared data. + dict[str, Any]: Shared data. Raises: UnavailableSharedData: When called out of collection phase. @@ -163,7 +163,7 @@ class BaseCreator: to `self` if it's not Plugin specific. Args: - project_settings (Dict[str, Any]): Project settings. + project_settings (dict[str, Any]): Project settings. create_context (CreateContext): Context which initialized creator. headless (bool): Running in headless mode. """ @@ -223,7 +223,7 @@ class BaseCreator: """Helper method to get settings values. Args: - project_settings (Dict[str, Any]): Project settings. + project_settings (dict[str, Any]): Project settings. category_name (str): Category of settings. plugin_name (str): Name of settings. @@ -274,7 +274,7 @@ class BaseCreator: } Args: - project_settings (Dict[str, Any]): Project settings. + project_settings (dict[str, Any]): Project settings. """ settings_category = self.settings_category @@ -567,7 +567,7 @@ class BaseCreator: """Access to shared data that can be used during creator's collection. Retruns: - Dict[str, Any]: Shared data. + dict[str, Any]: Shared data. Raises: UnavailableSharedData: When called out of collection phase. @@ -596,7 +596,7 @@ class BaseCreator: versions. Returns: - Dict[str, int]: Next versions by instance id. + dict[str, int]: Next versions by instance id. """ return get_next_versions_for_instances( @@ -920,7 +920,7 @@ def cache_and_get_instances(creator, shared_key, list_instances_func): if data were not yet stored under 'shared_key'. Returns: - Dict[str, Dict[str, Any]]: Cached instances by creator identifier from + dict[str, dict[str, Any]]: Cached instances by creator identifier from result of passed function. """ From 3c23524944fc54433f4794056909f017e9242508 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 15:58:28 +0200 Subject: [PATCH 021/163] Also use Py3.9+ `list` type hint but ensure backwards compatibility by putting into a string --- client/ayon_core/pipeline/create/creator_plugins.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 9267587586..3fce821b8b 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- import copy import collections -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING, Optional, Union from abc import ABCMeta, abstractmethod import six from ayon_core.settings import get_project_settings -from ayon_core.lib import Logger, AbstractAttrDef +from ayon_core.lib import Logger from ayon_core.pipeline.plugin_discover import ( discover, register_plugin, @@ -25,6 +25,7 @@ from .legacy_create import LegacyCreator if TYPE_CHECKING: # Avoid cyclic imports from .context import CreateContext, CreatedInstance, UpdateData + from ayon_core.lib import AbstractAttrDef class CreatorError(Exception): @@ -191,7 +192,7 @@ class BaseCreator: # Instance attribute definitions that can be changed per instance # - returns list of attribute definitions from # `ayon_core.lib.attribute_definitions` - instance_attr_defs: List[AbstractAttrDef] = [] + instance_attr_defs: "list[AbstractAttrDef]" = [] # Filtering by host name - can be used to be filtered by host name # - used on all hosts when set to 'None' for Backwards compatibility @@ -556,7 +557,7 @@ class BaseCreator: keys/values when plugin attributes change. Returns: - List[AbstractAttrDef]: Attribute definitions that can be tweaked + list[AbstractAttrDef]: Attribute definitions that can be tweaked for created instance. """ From 37d88559a94c56129c11d0fe5e5ec30cf809ddc4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 16:02:01 +0200 Subject: [PATCH 022/163] Cosmetic --- client/ayon_core/pipeline/create/creator_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 3fce821b8b..b1278ae228 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -49,8 +49,8 @@ class ProductConvertorPlugin(object): Publisher and give user ability to run conversion script. Convertor logic should be very simple. Method 'find_instances' is to - look for legacy instances in scene a possibly call - pre-implemented 'add_convertor_item'. + look for legacy instances in scene and possibly call pre-implemented + 'add_convertor_item'. User will have ability to trigger conversion which is executed by calling 'convert' which should call 'remove_convertor_item' when is done. From 463b37d5a7c041090ff4ec0169bece789f0dfe7c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 16:04:19 +0200 Subject: [PATCH 023/163] Remove usage of `Union` in favor of `Optional`. This could also be `dict[str, Any] | None` but my IDE still displayed that as `Optional[dict[str, Any]]` so I guess this is just as fine. --- client/ayon_core/pipeline/create/creator_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index b1278ae228..c832a9c3a0 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, Union +from typing import TYPE_CHECKING, Optional from abc import ABCMeta, abstractmethod @@ -229,7 +229,7 @@ class BaseCreator: plugin_name (str): Name of settings. Returns: - Union[dict[str, Any], None]: Settings values or None. + Optional[dict[str, Any]]: Settings values or None. """ settings = project_settings.get(category_name) From 987d358a9a50c172cfdb843804f68fd9be5c3b42 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 16:05:36 +0200 Subject: [PATCH 024/163] Fix more typos --- client/ayon_core/pipeline/create/creator_plugins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index c832a9c3a0..e8ed40f684 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -128,7 +128,7 @@ class ProductConvertorPlugin(object): def collection_shared_data(self): """Access to shared data that can be used during 'find_instances'. - Retruns: + Returns: dict[str, Any]: Shared data. Raises: @@ -567,7 +567,7 @@ class BaseCreator: def collection_shared_data(self): """Access to shared data that can be used during creator's collection. - Retruns: + Returns: dict[str, Any]: Shared data. Raises: From 21d9c3c96dcbe46f7ae30352282448bc1040f65a Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jun 2024 16:06:06 +0200 Subject: [PATCH 025/163] Grammar --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index e8ed40f684..1b90b142c0 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -156,7 +156,7 @@ class ProductConvertorPlugin(object): class BaseCreator: """Plugin that create and modify instance data before publishing process. - We should maybe find better name as creation is only one part of it's logic + We should maybe find better name as creation is only one part of its logic and to avoid expectations that it is the same as `avalon.api.Creator`. Single object should be used for multiple instances instead of single From 5d7dc8ca015e39e96d99dcf8d51c97dd58bb22ae Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 27 Jun 2024 11:28:42 +0200 Subject: [PATCH 026/163] Update client/ayon_core/pipeline/create/creator_plugins.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 1b90b142c0..f97c996eaf 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -23,9 +23,9 @@ from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator if TYPE_CHECKING: + from ayon_core.lib import AbstractAttrDef # Avoid cyclic imports from .context import CreateContext, CreatedInstance, UpdateData - from ayon_core.lib import AbstractAttrDef class CreatorError(Exception): From 4f4a695567a985f3b032ca7fe50ee4db6159fa9a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 1 Jul 2024 15:27:49 +0200 Subject: [PATCH 027/163] AY-5714 - added default deadline username and password to Settings These values should be used if studio has only single credentials for communicating with Deadline webservice. --- server_addon/deadline/server/settings/main.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/server_addon/deadline/server/settings/main.py b/server_addon/deadline/server/settings/main.py index 47ad72a86f..53d2234ac7 100644 --- a/server_addon/deadline/server/settings/main.py +++ b/server_addon/deadline/server/settings/main.py @@ -6,6 +6,7 @@ from ayon_server.settings import ( SettingsField, ensure_unique_names, ) +from ayon_server.settings.enum import secrets_enum if TYPE_CHECKING: from ayon_server.addons import BaseServerAddon @@ -34,13 +35,26 @@ async def defined_deadline_ws_name_enum_resolver( class ServerItemSubmodel(BaseSettingsModel): """Connection info about configured DL servers.""" - _layout = "compact" + _layout = "expanded" name: str = SettingsField(title="Name") value: str = SettingsField(title="Url") require_authentication: bool = SettingsField( False, title="Require authentication") not_verify_ssl: bool = SettingsField( False, title="Don't verify SSL") + default_username: str = SettingsField( + title="Default user name", + description="Webservice username, 'Require authentication' must be " + "enabled." + ) + default_password: str = SettingsField( + "", + placeholder="Select password from Ayon secrets", + enum_resolver=secrets_enum, + title="Default password", + description="Webservice password, 'Require authentication' must be " + "enabled." + ) class DeadlineSettings(BaseSettingsModel): @@ -77,7 +91,10 @@ DEFAULT_VALUES = { "name": "default", "value": "http://127.0.0.1:8082", "require_authentication": False, - "not_verify_ssl": False + "not_verify_ssl": False, + "default_username": "", + "default_password": "" + } ], "deadline_server": "default", From e9f1e7475709ed0521eb57bf4168c3558adc50da Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 1 Jul 2024 15:28:28 +0200 Subject: [PATCH 028/163] AY-5714 - collect default deadline username and password from Settings These values should be used if studio has only single credentials for communicating with Deadline webservice. Could be overridden by values from Site Settings. --- .../publish/collect_user_credentials.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py index ab96ba5828..765f018846 100644 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py +++ b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py @@ -12,13 +12,18 @@ Provides: """ import pyblish.api -from ayon_api import get_server_api_connection +from ayon_api import get_server_api_connection, get_secret +from ayon_core.pipeline import KnownPublishError from ayon_deadline.lib import FARM_FAMILIES class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin): """Collects user name and password for artist if DL requires authentication + + If Deadline server is marked to require authentication, it looks first for + default values in 'Studio Settings', which could be overriden by artist + dependent values from 'Site settings`. """ order = pyblish.api.CollectorOrder + 0.250 label = "Collect Deadline User Credentials" @@ -72,6 +77,18 @@ class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin): addons_manager = instance.context.data["ayonAddonsManager"] deadline_addon = addons_manager["deadline"] + + default_username = deadline_info["default_username"] + secret_name = deadline_info["default_password"] + secret = get_secret(secret_name) + if not secret: + raise KnownPublishError(f"'{secret_name}' secret not found") + default_password = secret["value"] + if default_username and default_password: + self.log.debug("Setting credentials from defaults") + instance.data["deadline"]["auth"] = (default_username, + default_password) + # TODO import 'get_addon_site_settings' when available # in public 'ayon_api' local_settings = get_server_api_connection().get_addon_site_settings( @@ -79,5 +96,8 @@ class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin): local_settings = local_settings["local_settings"] for server_info in local_settings: if deadline_server_name == server_info["server_name"]: - instance.data["deadline"]["auth"] = (server_info["username"], - server_info["password"]) + if server_info["username"] and server_info["password"]: + self.log.debug("Setting credentials from Site Settings") + instance.data["deadline"]["auth"] = \ + (server_info["username"], server_info["password"]) + break From 5b1c785eacdecdc766f7bbdafbe26dae9a7e5131 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 2 Jul 2024 13:52:30 +0200 Subject: [PATCH 029/163] Bump version of deadline Added default user credentials --- server_addon/deadline/package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/deadline/package.py b/server_addon/deadline/package.py index dcc61e3d46..8fcc007850 100644 --- a/server_addon/deadline/package.py +++ b/server_addon/deadline/package.py @@ -1,6 +1,6 @@ name = "deadline" title = "Deadline" -version = "0.2.2" +version = "0.2.3" client_dir = "ayon_deadline" From 550cb51169a781ce21c3378741ca9de1059481e3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 2 Jul 2024 14:20:52 +0200 Subject: [PATCH 030/163] Fix default value for default_username --- server_addon/deadline/server/settings/main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server_addon/deadline/server/settings/main.py b/server_addon/deadline/server/settings/main.py index 53d2234ac7..dcd7cd75dd 100644 --- a/server_addon/deadline/server/settings/main.py +++ b/server_addon/deadline/server/settings/main.py @@ -43,6 +43,7 @@ class ServerItemSubmodel(BaseSettingsModel): not_verify_ssl: bool = SettingsField( False, title="Don't verify SSL") default_username: str = SettingsField( + "", title="Default user name", description="Webservice username, 'Require authentication' must be " "enabled." From 00f5bd022678fca4dde9db6a8cb464e9f2ad9dc2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 2 Jul 2024 14:21:59 +0200 Subject: [PATCH 031/163] Bump even client version --- server_addon/deadline/client/ayon_deadline/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server_addon/deadline/client/ayon_deadline/version.py b/server_addon/deadline/client/ayon_deadline/version.py index e131427f12..96262d7186 100644 --- a/server_addon/deadline/client/ayon_deadline/version.py +++ b/server_addon/deadline/client/ayon_deadline/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring AYON addon 'deadline' version.""" -__version__ = "0.2.2" +__version__ = "0.2.3" From 1ab079778fad343949ac45ecbc5722e50e964112 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 2 Jul 2024 17:59:31 +0200 Subject: [PATCH 032/163] Reverted use of secrets for deadline password Regular artists don't have access to AYON secret, therefore it cannot be used. Encrypted or at least crossed field for passwords doesn't exist so far. --- .../plugins/publish/collect_user_credentials.py | 9 ++------- server_addon/deadline/server/settings/main.py | 3 --- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py index 765f018846..1c59c178d3 100644 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py +++ b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py @@ -12,8 +12,7 @@ Provides: """ import pyblish.api -from ayon_api import get_server_api_connection, get_secret -from ayon_core.pipeline import KnownPublishError +from ayon_api import get_server_api_connection from ayon_deadline.lib import FARM_FAMILIES @@ -79,11 +78,7 @@ class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin): deadline_addon = addons_manager["deadline"] default_username = deadline_info["default_username"] - secret_name = deadline_info["default_password"] - secret = get_secret(secret_name) - if not secret: - raise KnownPublishError(f"'{secret_name}' secret not found") - default_password = secret["value"] + default_password = deadline_info["default_password"] if default_username and default_password: self.log.debug("Setting credentials from defaults") instance.data["deadline"]["auth"] = (default_username, diff --git a/server_addon/deadline/server/settings/main.py b/server_addon/deadline/server/settings/main.py index dcd7cd75dd..edb8a16e35 100644 --- a/server_addon/deadline/server/settings/main.py +++ b/server_addon/deadline/server/settings/main.py @@ -6,7 +6,6 @@ from ayon_server.settings import ( SettingsField, ensure_unique_names, ) -from ayon_server.settings.enum import secrets_enum if TYPE_CHECKING: from ayon_server.addons import BaseServerAddon @@ -50,8 +49,6 @@ class ServerItemSubmodel(BaseSettingsModel): ) default_password: str = SettingsField( "", - placeholder="Select password from Ayon secrets", - enum_resolver=secrets_enum, title="Default password", description="Webservice password, 'Require authentication' must be " "enabled." From 95980b10f32d59d645c81ef0dc38d2e9903e4936 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 3 Jul 2024 12:49:19 +0800 Subject: [PATCH 033/163] add resource paths for oxrig product type --- client/ayon_core/plugins/publish/collect_resources_path.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/collect_resources_path.py b/client/ayon_core/plugins/publish/collect_resources_path.py index 63c6bf6345..7a80d0054c 100644 --- a/client/ayon_core/plugins/publish/collect_resources_path.py +++ b/client/ayon_core/plugins/publish/collect_resources_path.py @@ -65,7 +65,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "xgen", "yeticacheUE", "tycache", - "usd" + "usd", + "oxrig" ] def process(self, instance): From c5a68379ca8a6559c8548f726c2b1df5763376fd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 Jul 2024 15:49:41 +0200 Subject: [PATCH 034/163] Fix for dirmap for non local nor studio sites Site Sync addon throws ValueError for no local/studio sites as it doesn't make sense to override roots for sftp or gdrive. This handles gracefully this exception. --- client/ayon_core/host/dirmap.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/host/dirmap.py b/client/ayon_core/host/dirmap.py index 8766e7485d..fa3996e5fb 100644 --- a/client/ayon_core/host/dirmap.py +++ b/client/ayon_core/host/dirmap.py @@ -181,25 +181,23 @@ class HostDirmap(object): cached=False) # overrides for roots set in `Site Settings` - active_roots = sitesync_addon.get_site_root_overrides( - project_name, active_site) - remote_roots = sitesync_addon.get_site_root_overrides( - project_name, remote_site) + active_roots_overrides = self._get_site_root_overrides( + sitesync_addon, project_name, active_site) - self.log.debug("active roots overrides {}".format(active_roots)) - self.log.debug("remote roots overrides {}".format(remote_roots)) + remote_roots_overrides = self._get_site_root_overrides( + sitesync_addon, project_name, remote_site) current_platform = platform.system().lower() remote_provider = sitesync_addon.get_provider_for_site( project_name, remote_site ) # dirmap has sense only with regular disk provider, in the workfile - # won't be root on cloud or sftp provider + # won't be root on cloud or sftp provider so fallback to studio if remote_provider != "local_drive": remote_site = "studio" - for root_name, active_site_dir in active_roots.items(): + for root_name, active_site_dir in active_roots_overrides.items(): remote_site_dir = ( - remote_roots.get(root_name) + remote_roots_overrides.get(root_name) or sync_settings["sites"][remote_site]["root"][root_name] ) @@ -220,3 +218,19 @@ class HostDirmap(object): self.log.debug("local sync mapping:: {}".format(mapping)) return mapping + + def _get_site_root_overrides( + self, sitesync_addon, project_name, site_name): + """Safely handle root overrides. + + SiteSync raises ValueError for non local or studio sites. + """ + try: + site_roots_overrides = sitesync_addon.get_site_root_overrides( + project_name, site_name) + except ValueError: + site_roots_overrides = {} + self.log.debug("{} roots overrides {}".format( + site_name, site_roots_overrides)) + + return site_roots_overrides From 6467df96f45b434227b674d96cad810cebb878f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 4 Jul 2024 13:43:53 +0200 Subject: [PATCH 035/163] fill task short name using project entity --- .../ayon_core/pipeline/create/product_name.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index fecda867e5..6f8a43cdbe 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -1,3 +1,5 @@ +import ayon_api + from ayon_core.settings import get_project_settings from ayon_core.lib import filter_profiles, prepare_template_data @@ -88,6 +90,7 @@ def get_product_name( dynamic_data=None, project_settings=None, product_type_filter=None, + project_entity=None, ): """Calculate product name based on passed context and AYON settings. @@ -120,6 +123,8 @@ def get_product_name( product_type_filter (Optional[str]): Use different product type for product template filtering. Value of `product_type` is used when not passed. + project_entity (Optional[Dict[str, Any]]): Project entity used when + task short name is required by template. Raises: TemplateFillError: If filled template contains placeholder key which @@ -150,6 +155,18 @@ def get_product_name( if "{task}" in template.lower(): task_value = task_name + elif "{task[short]}" in template.lower(): + # NOTE this is very inefficient approach + # - project entity should be required + if project_entity is None: + project_entity = ayon_api.get_project(project_name) + task_types_by_name = { + task["name"]: task for task in + project_entity["taskTypes"] + } + task_short = task_types_by_name.get(task_type, {}).get("shortName") + task_value["short"] = task_short + fill_pairs = { "variant": variant, "family": product_type, From 2388fccc127d06854d31fc333f4e976c58c9edf0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:27:53 +0200 Subject: [PATCH 036/163] prepare basic multiselect combobox --- .../tools/loader/ui/_multicombobox.py | 596 ++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 client/ayon_core/tools/loader/ui/_multicombobox.py diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py new file mode 100644 index 0000000000..c209993bdc --- /dev/null +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -0,0 +1,596 @@ +from qtpy import QtWidgets, QtCore, QtGui + +from ayon_core.tools.utils.lib import ( + checkstate_int_to_enum, + checkstate_enum_to_int, +) +from ayon_core.tools.utils.constants import ( + CHECKED_INT, + UNCHECKED_INT, + ITEM_IS_USER_TRISTATE, +) + + +class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): + """Delegate showing status name and short name.""" + _checked_value = checkstate_enum_to_int(QtCore.Qt.Checked) + _checked_bg_color = QtGui.QColor("#2C3B4C") + + def __init__( + self, + text_role, + short_text_role, + text_color_role, + icon_role, + parent=None + ): + super().__init__(parent) + self._text_role = text_role + self._text_color_role = text_color_role + self._short_text_role = short_text_role + self._icon_role = icon_role + + def paint(self, painter, option, index): + if option.widget: + style = option.widget.style() + else: + style = QtWidgets.QApplication.style() + + self.initStyleOption(option, index) + + mode = QtGui.QIcon.Normal + if not (option.state & QtWidgets.QStyle.State_Enabled): + mode = QtGui.QIcon.Disabled + elif option.state & QtWidgets.QStyle.State_Selected: + mode = QtGui.QIcon.Selected + state = QtGui.QIcon.Off + if option.state & QtWidgets.QStyle.State_Open: + state = QtGui.QIcon.On + icon = self._get_index_icon(index) + option.features |= QtWidgets.QStyleOptionViewItem.HasDecoration + + # Disable visible check indicator + # - checkstate is displayed by background color + option.features &= ( + ~QtWidgets.QStyleOptionViewItem.HasCheckIndicator + ) + + option.icon = icon + act_size = icon.actualSize(option.decorationSize, mode, state) + option.decorationSize = QtCore.QSize( + min(option.decorationSize.width(), act_size.width()), + min(option.decorationSize.height(), act_size.height()) + ) + + text = self._get_index_name(index) + if text: + option.features |= QtWidgets.QStyleOptionViewItem.HasDisplay + option.text = text + + painter.save() + painter.setClipRect(option.rect) + + is_checked = ( + index.data(QtCore.Qt.CheckStateRole) == self._checked_value + ) + if is_checked: + painter.fillRect(option.rect, self._checked_bg_color) + + icon_rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemDecoration, + option, + option.widget + ) + text_rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemText, + option, + option.widget + ) + + # Draw background + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_PanelItemViewItem, + option, + painter, + option.widget + ) + + # Draw icon + option.icon.paint( + painter, + icon_rect, + option.decorationAlignment, + mode, + state + ) + fm = QtGui.QFontMetrics(option.font) + if text_rect.width() < fm.width(text): + text = self._get_index_short_name(index) + if not text or text_rect.width() < fm.width(text): + text = "" + + fg_color = self._get_index_text_color(index) + pen = painter.pen() + pen.setColor(fg_color) + painter.setPen(pen) + + painter.drawText( + text_rect, + option.displayAlignment, + text + ) + + if option.state & QtWidgets.QStyle.State_HasFocus: + focus_opt = QtWidgets.QStyleOptionFocusRect() + focus_opt.state = option.state + focus_opt.direction = option.direction + focus_opt.rect = option.rect + focus_opt.fontMetrics = option.fontMetrics + focus_opt.palette = option.palette + + focus_opt.rect = style.subElementRect( + QtWidgets.QCommonStyle.SE_ItemViewItemFocusRect, + option, + option.widget + ) + focus_opt.state |= ( + QtWidgets.QStyle.State_KeyboardFocusChange + | QtWidgets.QStyle.State_Item + ) + focus_opt.backgroundColor = option.palette.color( + ( + QtGui.QPalette.Normal + if option.state & QtWidgets.QStyle.State_Enabled + else QtGui.QPalette.Disabled + ), + ( + QtGui.QPalette.Highlight + if option.state & QtWidgets.QStyle.State_Selected + else QtGui.QPalette.Window + ) + ) + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_FrameFocusRect, + focus_opt, + painter, + option.widget + ) + + painter.restore() + + def _get_index_name(self, index): + return index.data(self._text_role) + + def _get_index_short_name(self, index): + if self._short_text_role is None: + return None + return index.data(self._short_text_role) + + def _get_index_text_color(self, index): + color = None + if self._text_color_role is not None: + color = index.data(self._text_color_role) + if color is not None: + return QtGui.QColor(color) + return QtGui.QColor(QtCore.Qt.white) + + def _get_index_icon(self, index): + icon = None + if self._icon_role is not None: + icon = index.data(self._icon_role) + if icon is None: + return QtGui.QIcon() + return icon + + +# class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): +# def paintEvent(self, event): +# painter = QtWidgets.QStylePainter(self) +# option = QtWidgets.QStyleOptionComboBox() +# self.initStyleOption(option) +# painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) +# idx = self.currentIndex() +# status_name = self.itemData(idx, self._name_role) +# if status_name is None: +# painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) +# return +# +# painter.save() +# +# status_icon = self.itemData(idx, self._icon_role) +# content_field_rect = self.style().subControlRect( +# QtWidgets.QStyle.CC_ComboBox, +# option, +# QtWidgets.QStyle.SC_ComboBoxEditField +# ).adjusted(1, 0, -1, 0) +# +# metrics = option.fontMetrics +# version_text_width = metrics.width(option.currentText) + 2 +# version_text_rect = QtCore.QRect(content_field_rect) +# version_text_rect.setWidth(version_text_width) +# +# painter.drawText( +# version_text_rect, +# QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, +# option.currentText +# ) +# +# status_text_rect = QtCore.QRect(content_field_rect) +# status_text_rect.setLeft(version_text_rect.right() + 2) +# if status_icon is not None and not status_icon.isNull(): +# icon_rect = QtCore.QRect(status_text_rect) +# diff = icon_rect.height() - metrics.height() +# if diff < 0: +# diff = 0 +# top_offset = diff // 2 +# bottom_offset = diff - top_offset +# icon_rect.adjust(0, top_offset, 0, -bottom_offset) +# icon_rect.setWidth(metrics.height()) +# status_icon.paint( +# painter, +# icon_rect, +# QtCore.Qt.AlignCenter, +# QtGui.QIcon.Normal, +# QtGui.QIcon.On +# ) +# status_text_rect.setLeft(icon_rect.right() + 2) +# +# if status_text_rect.width() <= 0: +# return +# +# if status_text_rect.width() < metrics.width(status_name): +# status_name = self.itemData(idx, self._short_name_role) +# if status_text_rect.width() < metrics.width(status_name): +# status_name = "" +# +# color = QtGui.QColor(self.itemData(idx, self._text_color_role)) +# +# pen = painter.pen() +# pen.setColor(color) +# painter.setPen(pen) +# painter.drawText( +# status_text_rect, +# QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, +# status_name +# ) +# painter.restore() +# painter.end() + + +class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + focused_in = QtCore.Signal() + + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End, + } + + def __init__( + self, + name_role, + short_name_role, + text_color_role, + icon_role, + value_role=None, + model=None, + placeholder=None, + parent=None, + ): + super().__init__(parent=parent) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + if model is not None: + self.setModel(model) + + combo_view = QtWidgets.QListView(self) + + self.setView(combo_view) + + item_delegate = CustomPaintDelegate( + name_role, + short_name_role, + text_color_role, + icon_role, + ) + combo_view.setItemDelegateForColumn(0, item_delegate) + + if value_role is None: + value_role = name_role + + self._combo_view = combo_view + self._item_delegate = item_delegate + self._value_role = value_role + self._name_role = name_role + self._short_name_role = short_name_role + self._text_color_role = text_color_role + self._icon_role = icon_role + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._placeholder_text = placeholder + + self._custom_text = None + + def get_placeholder_text(self): + return self._placeholder_text + + def set_placeholder_text(self, text): + self._placeholder_text = text + self.repaint() + + def set_custom_text(self, text): + self._custom_text = text + self.repaint() + + def focusInEvent(self, event): + self.focused_in.emit() + return super().focusInEvent(event) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super().mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super().showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super().hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = checkstate_int_to_enum( + current_index.data(QtCore.Qt.CheckStateRole) + ) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Checked: + new_state = UNCHECKED_INT + else: + new_state = CHECKED_INT + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & ITEM_IS_USER_TRISTATE + ): + new_state = (checkstate_enum_to_int(state) + 1) % 3 + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + # toggle the current items check state + if state != QtCore.Qt.Checked: + new_state = CHECKED_INT + else: + new_state = UNCHECKED_INT + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self.repaint() + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super().eventFilter(obj, event) + + def addItem(self, *args, **kwargs): + idx = self.count() + super().addItem(*args, **kwargs) + self.model().item(idx).setCheckable(True) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + idxs = self._get_checked_idx() + # draw the icon and text + draw_text = True + combotext = None + if self._custom_text is not None: + combotext = self._custom_text + elif not idxs: + combotext = self._placeholder_text + else: + draw_text = False + + content_field_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxEditField + ).adjusted(1, 0, -1, 0) + + if draw_text: + color = option.palette.color(QtGui.QPalette.Text) + color.setAlpha(67) + pen = painter.pen() + pen.setColor(color) + painter.setPen(pen) + painter.drawText( + content_field_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + combotext + ) + else: + self._paint_items(painter, idxs, content_field_rect) + + painter.end() + + def _paint_items(self, painter, indexes, content_rect): + origin_rect = QtCore.QRect(content_rect) + + metrics = self.fontMetrics() + model = self.model() + available_width = content_rect.width() + total_used_width = 0 + + painter.save() + + spacing = 2 + + for idx in indexes: + index = model.index(idx, 0) + if not index.isValid(): + continue + + icon = index.data(self._icon_role) + # TODO handle this case + if icon.isNull(): + continue + + icon_rect = QtCore.QRect(content_rect) + diff = icon_rect.height() - metrics.height() + if diff < 0: + diff = 0 + top_offset = diff // 2 + bottom_offset = diff - top_offset + icon_rect.adjust(0, top_offset, 0, -bottom_offset) + icon_rect.setWidth(metrics.height()) + icon.paint( + painter, + icon_rect, + QtCore.Qt.AlignCenter, + QtGui.QIcon.Normal, + QtGui.QIcon.On + ) + content_rect.setLeft(icon_rect.right() + spacing) + if total_used_width > 0: + total_used_width += spacing + total_used_width += icon_rect.width() + if total_used_width > available_width: + break + + painter.restore() + + if total_used_width > available_width: + ellide_dots = chr(0x2026) + painter.drawText(origin_rect, QtCore.Qt.AlignRight, ellide_dots) + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values, role=None): + if role is None: + role = self._value_role + + for idx in range(self.count()): + value = self.itemData(idx, role=role) + if value in values: + check_state = CHECKED_INT + else: + check_state = UNCHECKED_INT + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + self.repaint() + + def get_value(self, role=None): + if role is None: + role = self._value_role + items = [] + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + items.append(self.itemData(idx, role=role)) + return items + + def get_all_value_info(self, role=None): + if role is None: + role = self._value_role + items = [] + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + items.append( + ( + self.itemData(idx, role=role), + state == QtCore.Qt.Checked + ) + ) + return items + + def _get_checked_idx(self): + indexes = [] + for idx in range(self.count()): + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + indexes.append(idx) + return indexes + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super().keyPressEvent(event) \ No newline at end of file From 35e55a234f2b9df6c76bbe3daea95451a26f79f3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:28:06 +0200 Subject: [PATCH 037/163] prepare statuses combobox --- .../tools/loader/ui/statuses_combo.py | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 client/ayon_core/tools/loader/ui/statuses_combo.py diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py new file mode 100644 index 0000000000..dbd8a7d37c --- /dev/null +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -0,0 +1,155 @@ +from typing import List, Dict + +from qtpy import QtWidgets, QtCore, QtGui + +from ayon_core.tools.utils import get_qt_icon +from ayon_core.tools.common_models import StatusItem + +from ._multicombobox import CustomPaintMultiselectComboBox + + +STATUSES_FILTER_SENDER = "loader.statuses_filter" +STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 +STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2 +STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3 +STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4 + + +class StatusesQtModel(QtGui.QStandardItemModel): + def __init__(self, controller): + self._controller = controller + self._items_by_name: Dict[str, QtGui.QStandardItem] = {} + self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {} + self._last_project = None + super().__init__() + + def refresh(self, project_name): + # New project was selected + # status filter is reset to show all statuses + check_all = False + if project_name != self._last_project: + self._last_project = project_name + check_all = True + + status_items: List[StatusItem] = ( + self._controller.get_project_status_items( + project_name, sender=STATUSES_FILTER_SENDER + ) + ) + items_to_remove = set(self._items_by_name) + root_item = self.invisibleRootItem() + for row_idx, status_item in enumerate(status_items): + name = status_item.name + if name in self._items_by_name: + is_new = False + item = self._items_by_name[name] + if check_all: + item.setCheckState(QtCore.Qt.Checked) + items_to_remove.discard(name) + else: + is_new = True + item = QtGui.QStandardItem() + item.setCheckState(QtCore.Qt.Checked) + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsUserCheckable + ) + self._items_by_name[name] = item + + icon = self._get_icon(status_item) + for role, value in ( + (STATUS_NAME_ROLE, status_item.name), + (STATUS_SHORT_ROLE, status_item.short), + (STATUS_COLOR_ROLE, status_item.color), + (STATUS_ICON_ROLE, icon), + ): + if item.data(role) != value: + item.setData(value, role) + + if is_new: + root_item.insertRow(row_idx, item) + + for name in items_to_remove: + item = self._items_by_name.pop(name) + root_item.removeRow(item.row()) + + def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: + name = status_item.name + color = status_item.color + unique_id = "|".join([name or "", color or ""]) + icon = self._icons_by_name_n_color.get(unique_id) + if icon is not None: + return icon + + icon: QtGui.QIcon = get_qt_icon({ + "type": "material-symbols", + "name": status_item.icon, + "color": status_item.color + }) + self._icons_by_name_n_color[unique_id] = icon + return icon + + +class StatusesCombobox(CustomPaintMultiselectComboBox): + def __init__(self, controller, parent): + self._controller = controller + model = StatusesQtModel(controller) + super().__init__( + STATUS_NAME_ROLE, + STATUS_SHORT_ROLE, + STATUS_COLOR_ROLE, + STATUS_ICON_ROLE, + model=model, + parent=parent + ) + self.set_placeholder_text("Statuses filter..") + self._model = model + self._last_project_name = None + + controller.register_event_callback( + "selection.project.changed", + self._on_project_change + ) + controller.register_event_callback( + "projects.refresh.finished", + self._on_projects_refresh + ) + self.setToolTip("Statuses filter") + self.value_changed.connect( + self._on_status_filter_change + ) + + def _on_status_filter_change(self): + tooltip = "Statuses filter" + all_enabled = True + all_disabled = True + lines = [] + for item in self.get_all_value_info(): + status_name, enabled = item + if enabled: + all_disabled = False + else: + all_enabled = False + + lines.append(f"{'✔' if enabled else '☐'} {status_name}") + + if all_disabled: + tooltip += "\n- All disabled" + elif all_enabled: + tooltip += "\n- All enabled" + else: + mod_names = "\n".join(lines) + tooltip += f"\n{mod_names}" + self.setToolTip(tooltip) + + def _on_project_change(self, event): + project_name = event["project_name"] + self._last_project_name = project_name + self._model.refresh(project_name) + self._on_status_filter_change() + + def _on_projects_refresh(self): + if self._last_project_name: + self._model.refresh(self._last_project_name) + self._on_status_filter_change() From 75e5c4a9fed83134ccab7ea7c313a892e8a3c6cc Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:28:40 +0200 Subject: [PATCH 038/163] products model fills statuses for filtering --- .../tools/loader/ui/products_model.py | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 6e36317da2..fb15507f1c 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -39,6 +39,8 @@ REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28 SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29 SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30 +STATUS_NAME_FILTER_ROLE = QtCore.Qt.UserRole + 31 + class ProductsModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() @@ -105,7 +107,7 @@ class ProductsModel(QtGui.QStandardItemModel): } def __init__(self, controller): - super(ProductsModel, self).__init__() + super().__init__() self.setColumnCount(len(self.column_labels)) for idx, label in enumerate(self.column_labels): self.setHeaderData(idx, QtCore.Qt.Horizontal, label) @@ -163,7 +165,7 @@ class ProductsModel(QtGui.QStandardItemModel): ) if index.column() != 0: index = self.index(index.row(), 0, index.parent()) - return super(ProductsModel, self).flags(index) + return super().flags(index) def data(self, index, role=None): if role is None: @@ -190,7 +192,7 @@ class ProductsModel(QtGui.QStandardItemModel): return self._get_status_icon(status_name) if col == 0: - return super(ProductsModel, self).data(index, role) + return super().data(index, role) if role == QtCore.Qt.DecorationRole: if col == 1: @@ -223,7 +225,7 @@ class ProductsModel(QtGui.QStandardItemModel): index = self.index(index.row(), 0, index.parent()) - return super(ProductsModel, self).data(index, role) + return super().data(index, role) def setData(self, index, value, role=None): if not index.isValid(): @@ -255,7 +257,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._set_version_data_to_product_item(item, final_version_item) self.version_changed.emit() return True - return super(ProductsModel, self).setData(index, value, role) + return super().setData(index, value, role) def _get_next_color(self): return next(self._color_iterator) @@ -401,6 +403,10 @@ class ProductsModel(QtGui.QStandardItemModel): versions = list(product_item.version_items.values()) versions.sort() last_version = versions[-1] + statuses = { + version_item.status + for version_item in product_item.version_items.values() + } if model_item is None: product_id = product_item.product_id model_item = QtGui.QStandardItem(product_item.product_name) @@ -418,6 +424,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._product_items_by_id[product_id] = product_item self._items_by_id[product_id] = model_item + model_item.setData("|".join(statuses), STATUS_NAME_FILTER_ROLE) model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE) in_scene = 1 if product_item.product_in_scene else 0 model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE) @@ -494,10 +501,7 @@ class ProductsModel(QtGui.QStandardItemModel): product_name = product_item.product_name group = product_name_matches_by_group[group_name] - if product_name not in group: - group[product_name] = [product_item] - continue - group[product_name].append(product_item) + group.setdefault(product_name, []).append(product_item) group_names = set(product_name_matches_by_group.keys()) @@ -513,8 +517,15 @@ class ProductsModel(QtGui.QStandardItemModel): merged_product_items = {} top_items = [] group_product_types = set() + group_status_names = set() for product_name, product_items in groups.items(): group_product_types |= {p.product_type for p in product_items} + for product_item in product_items: + group_product_types |= { + version_item.status + for version_item in product_item.version_items.values() + } + if len(product_items) == 1: top_items.append(product_items[0]) else: @@ -529,7 +540,13 @@ class ProductsModel(QtGui.QStandardItemModel): if group_name: parent_item = self._get_group_model_item(group_name) parent_item.setData( - "|".join(group_product_types), PRODUCT_TYPE_ROLE) + "|".join(group_product_types), + PRODUCT_TYPE_ROLE + ) + parent_item.setData( + "|".join(group_status_names), + STATUS_NAME_FILTER_ROLE + ) new_items = [] if parent_item is not None and parent_item.row() < 0: From bdceeeb62a4f94f125b9cfcdbb3a0aa59d7d3b85 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:29:02 +0200 Subject: [PATCH 039/163] proxy filter can filter by statuses --- .../tools/loader/ui/products_widget.py | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 61ddd690e9..be4a0698e8 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -22,6 +22,7 @@ from .products_model import ( VERSION_STATUS_COLOR_ROLE, VERSION_STATUS_ICON_ROLE, VERSION_THUMBNAIL_ID_ROLE, + STATUS_NAME_FILTER_ROLE, ) from .products_delegates import ( VersionDelegate, @@ -36,15 +37,23 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): super(ProductsProxyModel, self).__init__(parent) self._product_type_filters = {} + self._statuses_filter = None self._ascending_sort = True def set_product_type_filters(self, product_type_filters): self._product_type_filters = product_type_filters self.invalidateFilter() + def set_statuses_filter(self, statuses_filter): + if self._statuses_filter == statuses_filter: + return + self._statuses_filter = statuses_filter + self.invalidateFilter() + def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() index = source_model.index(source_row, 0, source_parent) + product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE) product_types = [] if product_types_s: @@ -53,8 +62,22 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): for product_type in product_types: if not self._product_type_filters.get(product_type, True): return False - return super(ProductsProxyModel, self).filterAcceptsRow( - source_row, source_parent) + + if not self._accept_row_by_statuses(index): + return False + return super().filterAcceptsRow(source_row, source_parent) + + def _accept_row_by_statuses(self, index): + if self._statuses_filter is None: + return True + if not self._statuses_filter: + return False + + status_s = index.data(STATUS_NAME_FILTER_ROLE) + for status in status_s.split("|"): + if status in self._statuses_filter: + return True + return False def lessThan(self, left, right): l_model = left.model() @@ -211,10 +234,20 @@ class ProductsWidget(QtWidgets.QWidget): Args: name (str): The string filter. - """ + """ self._products_proxy_model.setFilterFixedString(name) + def set_statuses_filter(self, status_names): + """Set filter of version statuses. + + Args: + status_names (list[str]): The list of status names. + + """ + self._products_proxy_model.set_statuses_filter(status_names) + self._version_delegate.set_statuses_filter(status_names) + def set_product_type_filter(self, product_type_filters): """ From dcc5ff957e3b13088c973b85bba4675c2c08d2c3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:29:23 +0200 Subject: [PATCH 040/163] Version combobox can filter by statuses --- .../tools/loader/ui/products_delegates.py | 116 ++++++++++++++---- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index cedac6199b..d54164c0f2 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -15,31 +15,21 @@ from .products_model import ( SYNC_REMOTE_SITE_AVAILABILITY, ) +STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 -class VersionComboBox(QtWidgets.QComboBox): - value_changed = QtCore.Signal(str) - def __init__(self, product_id, parent): - super(VersionComboBox, self).__init__(parent) - self._product_id = product_id +class VersionsModel(QtGui.QStandardItemModel): + def __init__(self): + super().__init__() self._items_by_id = {} - self._current_id = None - - self.currentIndexChanged.connect(self._on_index_change) - - def update_versions(self, version_items, current_version_id): - model = self.model() - root_item = model.invisibleRootItem() - version_items = list(reversed(version_items)) - version_ids = [ + def update_versions(self, version_items): + version_ids = { version_item.version_id for version_item in version_items - ] - if current_version_id not in version_ids and version_ids: - current_version_id = version_ids[0] - self._current_id = current_version_id + } + root_item = self.invisibleRootItem() to_remove = set(self._items_by_id.keys()) - set(version_ids) for item_id in to_remove: item = self._items_by_id.pop(item_id) @@ -54,13 +44,84 @@ class VersionComboBox(QtWidgets.QComboBox): item = QtGui.QStandardItem(label) item.setData(version_id, QtCore.Qt.UserRole) self._items_by_id[version_id] = item + item.setData(version_item.status, STATUS_NAME_ROLE) if item.row() != idx: root_item.insertRow(idx, item) + +class VersionsFilterModel(QtCore.QSortFilterProxyModel): + def __init__(self): + super().__init__() + self._status_filter = None + + def filterAcceptsRow(self, row, parent): + if self._status_filter is None: + return True + + if not self._status_filter: + return False + + index = self.sourceModel().index(row, 0, parent) + status = index.data(STATUS_NAME_ROLE) + return status in self._status_filter + + def set_statuses_filter(self, status_names): + if self._status_filter == status_names: + return + self._status_filter = status_names + self.invalidateFilter() + + +class VersionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal(str) + + def __init__(self, product_id, parent): + super().__init__(parent) + + versions_model = VersionsModel() + proxy_model = VersionsFilterModel() + proxy_model.setSourceModel(versions_model) + + self.setModel(proxy_model) + + self._product_id = product_id + self._items_by_id = {} + + self._current_id = None + + self._versions_model = versions_model + self._proxy_model = proxy_model + + self.currentIndexChanged.connect(self._on_index_change) + + def get_product_id(self): + return self._product_id + + def set_statuses_filter(self, status_names): + self._proxy_model.set_statuses_filter(status_names) + if self.count() == 0: + return + if self.currentIndex() != 0: + self.setCurrentIndex(0) + + def update_versions(self, version_items, current_version_id): + self.blockSignals(True) + version_items = list(version_items) + version_ids = [ + version_item.version_id + for version_item in version_items + ] + if current_version_id not in version_ids and version_ids: + current_version_id = version_ids[0] + self._current_id = current_version_id + + self._versions_model.update_versions(version_items) + index = version_ids.index(current_version_id) if self.currentIndex() != index: self.setCurrentIndex(index) + self.blockSignals(False) def _on_index_change(self): idx = self.currentIndex() @@ -77,14 +138,20 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): version_changed = QtCore.Signal() def __init__(self, *args, **kwargs): - super(VersionDelegate, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self._editor_by_product_id = {} + self._statuses_filter = None def displayText(self, value, locale): if not isinstance(value, numbers.Integral): return "N/A" return format_version(value) + def set_statuses_filter(self, status_names): + self._statuses_filter = set(status_names) + for widget in self._editor_by_product_id.values(): + widget.set_statuses_filter(status_names) + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -145,6 +212,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor = VersionComboBox(product_id, parent) self._editor_by_product_id[product_id] = editor editor.value_changed.connect(self._on_editor_change) + editor.set_statuses_filter(self._statuses_filter) + + def on_destroy(obj): + self._on_destroy(product_id) + + editor.destroyed.connect(on_destroy) return editor @@ -153,8 +226,9 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): # Update model data self.commitData.emit(editor) - # Display model data - self.version_changed.emit() + + def _on_destroy(self, product_id): + self._editor_by_product_id.pop(product_id, None) def setEditorData(self, editor, index): editor.clear() From 77c19510f7ed0ad6a39b7e2131bde272c7adab2e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:29:37 +0200 Subject: [PATCH 041/163] added statuses filter to loader tool --- client/ayon_core/tools/loader/ui/window.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/window.py b/client/ayon_core/tools/loader/ui/window.py index 8529a53b06..58af6f0b1f 100644 --- a/client/ayon_core/tools/loader/ui/window.py +++ b/client/ayon_core/tools/loader/ui/window.py @@ -19,6 +19,7 @@ from .product_types_widget import ProductTypesView from .product_group_dialog import ProductGroupDialog from .info_widget import InfoWidget from .repres_widget import RepresentationsWidget +from .statuses_combo import StatusesCombobox class LoadErrorMessageBox(ErrorMessageBox): @@ -183,6 +184,9 @@ class LoaderWindow(QtWidgets.QWidget): products_filter_input = PlaceholderLineEdit(products_inputs_widget) products_filter_input.setPlaceholderText("Product name filter...") + + product_status_filter_combo = StatusesCombobox(controller, self) + product_group_checkbox = QtWidgets.QCheckBox( "Enable grouping", products_inputs_widget) product_group_checkbox.setChecked(True) @@ -192,6 +196,7 @@ class LoaderWindow(QtWidgets.QWidget): products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget) products_inputs_layout.setContentsMargins(0, 0, 0, 0) products_inputs_layout.addWidget(products_filter_input, 1) + products_inputs_layout.addWidget(product_status_filter_combo, 1) products_inputs_layout.addWidget(product_group_checkbox, 0) products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget) @@ -245,6 +250,9 @@ class LoaderWindow(QtWidgets.QWidget): products_filter_input.textChanged.connect( self._on_product_filter_change ) + product_status_filter_combo.value_changed.connect( + self._on_status_filter_change + ) product_group_checkbox.stateChanged.connect( self._on_product_group_change ) @@ -299,6 +307,7 @@ class LoaderWindow(QtWidgets.QWidget): self._product_types_widget = product_types_widget self._products_filter_input = products_filter_input + self._product_status_filter_combo = product_status_filter_combo self._product_group_checkbox = product_group_checkbox self._products_widget = products_widget @@ -412,6 +421,10 @@ class LoaderWindow(QtWidgets.QWidget): def _on_product_filter_change(self, text): self._products_widget.set_name_filter(text) + def _on_status_filter_change(self): + status_names = self._product_status_filter_combo.get_value() + self._products_widget.set_statuses_filter(status_names) + def _on_product_type_filter_change(self): self._products_widget.set_product_type_filter( self._product_types_widget.get_filter_info() From 4bbc1e3c9a8de64ef22f98fbc84f2eadebf2fb00 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 11:29:53 +0200 Subject: [PATCH 042/163] fix checkstate enum conversion --- client/ayon_core/tools/utils/lib.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index f31bb82e59..676f853a32 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -17,7 +17,7 @@ from ayon_core.style import ( from ayon_core.resources import get_image_path from ayon_core.lib import Logger -from .constants import CHECKED_INT, UNCHECKED_INT +from .constants import CHECKED_INT, UNCHECKED_INT, PARTIALLY_CHECKED_INT log = Logger.get_logger(__name__) @@ -37,10 +37,10 @@ def checkstate_enum_to_int(state): if isinstance(state, int): return state if state == QtCore.Qt.Checked: - return 0 + return CHECKED_INT if state == QtCore.Qt.PartiallyChecked: - return 1 - return 2 + return PARTIALLY_CHECKED_INT + return UNCHECKED_INT def center_window(window): From 6f37ae40f6fe6864fe744331cc373b30779c7079 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:36:25 +0200 Subject: [PATCH 043/163] removed unused code --- .../tools/loader/ui/_multicombobox.py | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index c209993bdc..bb905d6238 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -183,80 +183,6 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): return icon -# class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): -# def paintEvent(self, event): -# painter = QtWidgets.QStylePainter(self) -# option = QtWidgets.QStyleOptionComboBox() -# self.initStyleOption(option) -# painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) -# idx = self.currentIndex() -# status_name = self.itemData(idx, self._name_role) -# if status_name is None: -# painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) -# return -# -# painter.save() -# -# status_icon = self.itemData(idx, self._icon_role) -# content_field_rect = self.style().subControlRect( -# QtWidgets.QStyle.CC_ComboBox, -# option, -# QtWidgets.QStyle.SC_ComboBoxEditField -# ).adjusted(1, 0, -1, 0) -# -# metrics = option.fontMetrics -# version_text_width = metrics.width(option.currentText) + 2 -# version_text_rect = QtCore.QRect(content_field_rect) -# version_text_rect.setWidth(version_text_width) -# -# painter.drawText( -# version_text_rect, -# QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, -# option.currentText -# ) -# -# status_text_rect = QtCore.QRect(content_field_rect) -# status_text_rect.setLeft(version_text_rect.right() + 2) -# if status_icon is not None and not status_icon.isNull(): -# icon_rect = QtCore.QRect(status_text_rect) -# diff = icon_rect.height() - metrics.height() -# if diff < 0: -# diff = 0 -# top_offset = diff // 2 -# bottom_offset = diff - top_offset -# icon_rect.adjust(0, top_offset, 0, -bottom_offset) -# icon_rect.setWidth(metrics.height()) -# status_icon.paint( -# painter, -# icon_rect, -# QtCore.Qt.AlignCenter, -# QtGui.QIcon.Normal, -# QtGui.QIcon.On -# ) -# status_text_rect.setLeft(icon_rect.right() + 2) -# -# if status_text_rect.width() <= 0: -# return -# -# if status_text_rect.width() < metrics.width(status_name): -# status_name = self.itemData(idx, self._short_name_role) -# if status_text_rect.width() < metrics.width(status_name): -# status_name = "" -# -# color = QtGui.QColor(self.itemData(idx, self._text_color_role)) -# -# pen = painter.pen() -# pen.setColor(color) -# painter.setPen(pen) -# painter.drawText( -# status_text_rect, -# QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, -# status_name -# ) -# painter.restore() -# painter.end() - - class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): value_changed = QtCore.Signal() focused_in = QtCore.Signal() From fa0bb796924f4c2d21bb89b357cc9911b5eb2b77 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:37:06 +0200 Subject: [PATCH 044/163] added better options for multiselection combobox --- .../tools/loader/ui/_multicombobox.py | 144 +++++++++++++----- 1 file changed, 104 insertions(+), 40 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index bb905d6238..18476fb2ec 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -10,6 +10,10 @@ from ayon_core.tools.utils.constants import ( ITEM_IS_USER_TRISTATE, ) +CUSTOM_ITEM_TYPE = 0 +STANDARD_ITEM_TYPE = 1 +SEPARATOR_ITEM_TYPE = 2 + class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): """Delegate showing status name and short name.""" @@ -22,6 +26,7 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): short_text_role, text_color_role, icon_role, + item_type_role=None, parent=None ): super().__init__(parent) @@ -29,8 +34,24 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): self._text_color_role = text_color_role self._short_text_role = short_text_role self._icon_role = icon_role + self._item_type_role = item_type_role def paint(self, painter, option, index): + item_type = None + if self._item_type_role is not None: + item_type = index.data(self._item_type_role) + + if item_type is None: + item_type = CUSTOM_ITEM_TYPE + + if item_type == STANDARD_ITEM_TYPE: + super().paint(painter, option, index) + return + + elif item_type == SEPARATOR_ITEM_TYPE: + self._paint_separator(painter, option, index) + return + if option.widget: style = option.widget.style() else: @@ -158,6 +179,29 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): painter.restore() + def _paint_separator(self, painter, option, index): + painter.save() + painter.setClipRect(option.rect) + + style = option.widget.style() + style.drawPrimitive( + QtWidgets.QCommonStyle.PE_PanelItemViewItem, + option, + painter, + option.widget + ) + + pen = painter.pen() + pen.setWidth(2) + painter.setPen(pen) + mid_y = (option.rect.top() + option.rect.bottom()) * 0.5 + painter.drawLine( + QtCore.QPointF(option.rect.left(), mid_y), + QtCore.QPointF(option.rect.right(), mid_y) + ) + + painter.restore() + def _get_index_name(self, index): return index.data(self._text_role) @@ -198,11 +242,12 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): def __init__( self, - name_role, - short_name_role, + text_role, + short_text_role, text_color_role, icon_role, value_role=None, + item_type_role=None, model=None, placeholder=None, parent=None, @@ -218,21 +263,23 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): self.setView(combo_view) item_delegate = CustomPaintDelegate( - name_role, - short_name_role, - text_color_role, - icon_role, + text_role=text_role, + short_text_role=short_text_role, + text_color_role=text_color_role, + icon_role=icon_role, + item_type_role=item_type_role, + parent=combo_view, ) combo_view.setItemDelegateForColumn(0, item_delegate) if value_role is None: - value_role = name_role + value_role = text_role self._combo_view = combo_view self._item_delegate = item_delegate self._value_role = value_role - self._name_role = name_role - self._short_name_role = short_name_role + self._text_role = text_role + self._short_text_role = short_text_role self._text_color_role = text_color_role self._icon_role = icon_role @@ -307,40 +354,18 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): state = checkstate_int_to_enum( current_index.data(QtCore.Qt.CheckStateRole) ) + new_state = None if event.type() == QtCore.QEvent.MouseButtonRelease: - if ( - self._block_mouse_release_timer.isActive() - or not current_index.isValid() - or not self.view().isVisible() - or not self.view().rect().contains(event.pos()) - or not index_flags & QtCore.Qt.ItemIsSelectable - or not index_flags & QtCore.Qt.ItemIsEnabled - or not index_flags & QtCore.Qt.ItemIsUserCheckable - ): - return - - if state == QtCore.Qt.Checked: - new_state = UNCHECKED_INT - else: - new_state = CHECKED_INT + new_state = self._mouse_released_event_handle( + event, current_index, index_flags, state + ) elif event.type() == QtCore.QEvent.KeyPress: - # TODO: handle QtCore.Qt.Key_Enter, Key_Return? - if event.key() == QtCore.Qt.Key_Space: - if ( - index_flags & QtCore.Qt.ItemIsUserCheckable - and index_flags & ITEM_IS_USER_TRISTATE - ): - new_state = (checkstate_enum_to_int(state) + 1) % 3 - - elif index_flags & QtCore.Qt.ItemIsUserCheckable: - # toggle the current items check state - if state != QtCore.Qt.Checked: - new_state = CHECKED_INT - else: - new_state = UNCHECKED_INT + new_state = self._key_press_event_handler( + event, current_index, index_flags, state + ) if new_state is not None: model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) @@ -421,7 +446,7 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): icon = index.data(self._icon_role) # TODO handle this case - if icon.isNull(): + if icon is None or icon.isNull(): continue icon_rect = QtCore.QRect(content_rect) @@ -519,4 +544,43 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): if event.key() in self.ignored_keys: return event.ignore() - return super().keyPressEvent(event) \ No newline at end of file + return super().keyPressEvent(event) + + def _mouse_released_event_handle( + self, event, current_index, index_flags, state + ): + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return None + + if state == QtCore.Qt.Checked: + return UNCHECKED_INT + return CHECKED_INT + + + def _key_press_event_handler( + self, event, current_index, index_flags, state + ): + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() != QtCore.Qt.Key_Space: + return None + + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & ITEM_IS_USER_TRISTATE + ): + return (checkstate_enum_to_int(state) + 1) % 3 + + if index_flags & QtCore.Qt.ItemIsUserCheckable: + # toggle the current items check state + if state != QtCore.Qt.Checked: + return CHECKED_INT + return UNCHECKED_INT + return None From 10810a301c2a3d04f2e0b629d0b4b38aaf674fa0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:37:27 +0200 Subject: [PATCH 045/163] added helper check action --- .../tools/loader/ui/statuses_combo.py | 193 +++++++++++++++++- 1 file changed, 192 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index dbd8a7d37c..0db02de081 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -5,14 +5,25 @@ from qtpy import QtWidgets, QtCore, QtGui from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.common_models import StatusItem -from ._multicombobox import CustomPaintMultiselectComboBox +from ._multicombobox import ( + CustomPaintMultiselectComboBox, + CUSTOM_ITEM_TYPE, + STANDARD_ITEM_TYPE, + SEPARATOR_ITEM_TYPE, +) +STATUS_ITEM_TYPE = 0 +SELECT_ALL_TYPE = 1 +DESELECT_ALL_TYPE = 2 +SWAP_STATE_TYPE = 3 STATUSES_FILTER_SENDER = "loader.statuses_filter" STATUS_NAME_ROLE = QtCore.Qt.UserRole + 1 STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 2 STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 3 STATUS_ICON_ROLE = QtCore.Qt.UserRole + 4 +ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 +ITEM_SUBTYPE_ROLE = QtCore.Qt.UserRole + 6 class StatusesQtModel(QtGui.QStandardItemModel): @@ -21,8 +32,18 @@ class StatusesQtModel(QtGui.QStandardItemModel): self._items_by_name: Dict[str, QtGui.QStandardItem] = {} self._icons_by_name_n_color: Dict[str, QtGui.QIcon] = {} self._last_project = None + + self._select_project_item = None + self._empty_statuses_item = None + + self._select_all_item = None + self._deselect_all_item = None + self._swap_states_item = None + super().__init__() + self.refresh(None) + def refresh(self, project_name): # New project was selected # status filter is reset to show all statuses @@ -31,11 +52,21 @@ class StatusesQtModel(QtGui.QStandardItemModel): self._last_project = project_name check_all = True + if project_name is None: + self._add_select_project_item() + return + status_items: List[StatusItem] = ( self._controller.get_project_status_items( project_name, sender=STATUSES_FILTER_SENDER ) ) + if not status_items: + self._add_empty_statuses_item() + return + + self._remove_empty_items() + items_to_remove = set(self._items_by_name) root_item = self.invisibleRootItem() for row_idx, status_item in enumerate(status_items): @@ -49,6 +80,7 @@ class StatusesQtModel(QtGui.QStandardItemModel): else: is_new = True item = QtGui.QStandardItem() + item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) item.setCheckState(QtCore.Qt.Checked) item.setFlags( QtCore.Qt.ItemIsEnabled @@ -74,6 +106,30 @@ class StatusesQtModel(QtGui.QStandardItemModel): item = self._items_by_name.pop(name) root_item.removeRow(item.row()) + self._add_selection_items() + + def setData(self, index, value, role): + if role == QtCore.Qt.CheckStateRole and index.isValid(): + item_type = index.data(ITEM_SUBTYPE_ROLE) + if item_type == SELECT_ALL_TYPE: + for item in self._items_by_name.values(): + item.setCheckState(QtCore.Qt.Checked) + return True + if item_type == DESELECT_ALL_TYPE: + for item in self._items_by_name.values(): + item.setCheckState(QtCore.Qt.Unchecked) + return True + if item_type == SWAP_STATE_TYPE: + for item in self._items_by_name.values(): + current_state = item.checkState() + item.setCheckState( + QtCore.Qt.Checked + if current_state == QtCore.Qt.Unchecked + else QtCore.Qt.Unchecked + ) + return True + return super().setData(index, value, role) + def _get_icon(self, status_item: StatusItem) -> QtGui.QIcon: name = status_item.name color = status_item.color @@ -90,6 +146,140 @@ class StatusesQtModel(QtGui.QStandardItemModel): self._icons_by_name_n_color[unique_id] = icon return icon + def _init_default_items(self): + if self._empty_statuses_item is not None: + return + + empty_statuses_item = QtGui.QStandardItem("No statuses..") + select_project_item = QtGui.QStandardItem("Select project..") + + select_all_item = QtGui.QStandardItem() + deselect_all_item = QtGui.QStandardItem() + swap_states_item = QtGui.QStandardItem() + + for item in ( + empty_statuses_item, + select_project_item, + ): + item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE) + + for item, label in ( + (select_all_item, "Select all"), + (deselect_all_item, "Deselect all"), + (swap_states_item, "Swap"), + ): + item.setData(label, STATUS_NAME_ROLE) + + select_all_item.setData(get_qt_icon({ + "type": "material-symbols", + "name": "done_all", + "color": "white" + }), STATUS_ICON_ROLE) + deselect_all_item.setData(get_qt_icon({ + "type": "material-symbols", + "name": "remove_done", + "color": "white" + }), STATUS_ICON_ROLE) + swap_states_item.setData(get_qt_icon({ + "type": "material-symbols", + "name": "swap_horiz", + "color": "white" + }), STATUS_ICON_ROLE) + + for item in ( + empty_statuses_item, + select_project_item, + ): + item.setFlags(QtCore.Qt.NoItemFlags) + + for item, item_type in ( + (select_all_item, SELECT_ALL_TYPE), + (deselect_all_item, DESELECT_ALL_TYPE), + (swap_states_item, SWAP_STATE_TYPE), + ): + item.setData(item_type, ITEM_SUBTYPE_ROLE) + + for item in ( + select_all_item, + deselect_all_item, + swap_states_item, + ): + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsUserCheckable + ) + + self._empty_statuses_item = empty_statuses_item + self._select_project_item = select_project_item + + self._select_all_item = select_all_item + self._deselect_all_item = deselect_all_item + self._swap_states_item = swap_states_item + + def _get_empty_statuses_item(self): + self._init_default_items() + return self._empty_statuses_item + + def _get_select_project_item(self): + self._init_default_items() + return self._select_project_item + + def _get_empty_items(self): + self._init_default_items() + return [ + self._empty_statuses_item, + self._select_project_item, + ] + + def _get_selection_items(self): + self._init_default_items() + return [ + self._select_all_item, + self._deselect_all_item, + self._swap_states_item, + ] + + def _get_default_items(self): + return self._get_empty_items() + self._get_selection_items() + + def _add_select_project_item(self): + item = self._get_select_project_item() + if item.row() < 0: + self._remove_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _add_empty_statuses_item(self): + item = self._get_empty_statuses_item() + if item.row() < 0: + self._remove_items() + root_item = self.invisibleRootItem() + root_item.appendRow(item) + + def _add_selection_items(self): + root_item = self.invisibleRootItem() + for item in self._get_selection_items(): + idx = root_item.rowCount() + root_item.insertRow(idx, item) + + def _remove_items(self): + root_item = self.invisibleRootItem() + for item in self._get_default_items(): + if item.row() < 0: + continue + root_item.takeRow(item.row()) + + root_item.removeRows(0, root_item.rowCount()) + self._items_by_name.clear() + + def _remove_empty_items(self): + root_item = self.invisibleRootItem() + for item in self._get_empty_items(): + if item.row() < 0: + continue + root_item.takeRow(item.row()) + class StatusesCombobox(CustomPaintMultiselectComboBox): def __init__(self, controller, parent): @@ -100,6 +290,7 @@ class StatusesCombobox(CustomPaintMultiselectComboBox): STATUS_SHORT_ROLE, STATUS_COLOR_ROLE, STATUS_ICON_ROLE, + item_type_role=ITEM_TYPE_ROLE, model=model, parent=parent ) From e4f750f3391ef974019f826909dab75d97e7da6b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:54:56 +0200 Subject: [PATCH 046/163] return only custom item values --- client/ayon_core/tools/loader/ui/_multicombobox.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index 18476fb2ec..66a6963775 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -282,6 +282,7 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): self._short_text_role = short_text_role self._text_color_role = text_color_role self._icon_role = icon_role + self._item_type_role = item_type_role self._popup_is_shown = False self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) @@ -498,6 +499,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): role = self._value_role items = [] for idx in range(self.count()): + item_type = self.itemData(idx, role=self._item_type_role) + if item_type is not None and item_type != CUSTOM_ITEM_TYPE: + continue + state = checkstate_int_to_enum( self.itemData(idx, role=QtCore.Qt.CheckStateRole) ) @@ -510,6 +515,10 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): role = self._value_role items = [] for idx in range(self.count()): + item_type = self.itemData(idx, role=self._item_type_role) + if item_type is not None and item_type != CUSTOM_ITEM_TYPE: + continue + state = checkstate_int_to_enum( self.itemData(idx, role=QtCore.Qt.CheckStateRole) ) From f6491bf08eee4735ab99c1a94408cd4c7c594584 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 13:55:08 +0200 Subject: [PATCH 047/163] selection items are standard items --- .../tools/loader/ui/statuses_combo.py | 54 +++++++------------ 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 0db02de081..b65507b52c 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -153,38 +153,34 @@ class StatusesQtModel(QtGui.QStandardItemModel): empty_statuses_item = QtGui.QStandardItem("No statuses..") select_project_item = QtGui.QStandardItem("Select project..") - select_all_item = QtGui.QStandardItem() - deselect_all_item = QtGui.QStandardItem() - swap_states_item = QtGui.QStandardItem() + select_all_item = QtGui.QStandardItem("Select all") + deselect_all_item = QtGui.QStandardItem("Deselect all") + swap_states_item = QtGui.QStandardItem("Swap") for item in ( empty_statuses_item, select_project_item, + select_all_item, + deselect_all_item, + swap_states_item, ): item.setData(STANDARD_ITEM_TYPE, ITEM_TYPE_ROLE) - for item, label in ( - (select_all_item, "Select all"), - (deselect_all_item, "Deselect all"), - (swap_states_item, "Swap"), - ): - item.setData(label, STATUS_NAME_ROLE) - - select_all_item.setData(get_qt_icon({ + select_all_item.setIcon(get_qt_icon({ "type": "material-symbols", "name": "done_all", "color": "white" - }), STATUS_ICON_ROLE) - deselect_all_item.setData(get_qt_icon({ + })) + deselect_all_item.setIcon(get_qt_icon({ "type": "material-symbols", "name": "remove_done", "color": "white" - }), STATUS_ICON_ROLE) - swap_states_item.setData(get_qt_icon({ + })) + swap_states_item.setIcon(get_qt_icon({ "type": "material-symbols", "name": "swap_horiz", "color": "white" - }), STATUS_ICON_ROLE) + })) for item in ( empty_statuses_item, @@ -259,9 +255,12 @@ class StatusesQtModel(QtGui.QStandardItemModel): def _add_selection_items(self): root_item = self.invisibleRootItem() + items = self._get_selection_items() for item in self._get_selection_items(): - idx = root_item.rowCount() - root_item.insertRow(idx, item) + row = item.row() + if row >= 0: + root_item.takeRow(row) + root_item.appendRows(items) def _remove_items(self): root_item = self.invisibleRootItem() @@ -312,27 +311,12 @@ class StatusesCombobox(CustomPaintMultiselectComboBox): ) def _on_status_filter_change(self): - tooltip = "Statuses filter" - all_enabled = True - all_disabled = True - lines = [] + lines = ["Statuses filter"] for item in self.get_all_value_info(): status_name, enabled = item - if enabled: - all_disabled = False - else: - all_enabled = False - lines.append(f"{'✔' if enabled else '☐'} {status_name}") - if all_disabled: - tooltip += "\n- All disabled" - elif all_enabled: - tooltip += "\n- All enabled" - else: - mod_names = "\n".join(lines) - tooltip += f"\n{mod_names}" - self.setToolTip(tooltip) + self.setToolTip("\n".join(lines)) def _on_project_change(self, event): project_name = event["project_name"] From a044aa53bd3208d8710fafdae169261808492eb0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 14:03:19 +0200 Subject: [PATCH 048/163] remove unused imports --- client/ayon_core/tools/loader/ui/statuses_combo.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index b65507b52c..4d029b5178 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -7,9 +7,7 @@ from ayon_core.tools.common_models import StatusItem from ._multicombobox import ( CustomPaintMultiselectComboBox, - CUSTOM_ITEM_TYPE, STANDARD_ITEM_TYPE, - SEPARATOR_ITEM_TYPE, ) STATUS_ITEM_TYPE = 0 From 89c55673ac07494245e29a0530ffe56c84bb38ee Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 5 Jul 2024 14:04:38 +0200 Subject: [PATCH 049/163] remove unused import --- client/ayon_core/tools/loader/ui/statuses_combo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 4d029b5178..881de94629 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -1,6 +1,6 @@ from typing import List, Dict -from qtpy import QtWidgets, QtCore, QtGui +from qtpy import QtCore, QtGui from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.common_models import StatusItem From dc43d25d31d966ada276666387afc4329be34b0c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Jul 2024 14:11:15 +0200 Subject: [PATCH 050/163] remove deprecated '-intra' --- client/ayon_core/lib/transcoding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/transcoding.py b/client/ayon_core/lib/transcoding.py index 4d778c2091..bff28614ea 100644 --- a/client/ayon_core/lib/transcoding.py +++ b/client/ayon_core/lib/transcoding.py @@ -978,7 +978,7 @@ def _ffmpeg_h264_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) - output.extend(["-intra", "-g", "1"]) + output.extend(["-g", "1"]) return output From 3dffcaff01534c3eceaf8306c6398a3d70cb85cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Jul 2024 15:34:36 +0200 Subject: [PATCH 051/163] create context does pass in project entity --- client/ayon_core/pipeline/create/context.py | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 0d8722dab1..066a147479 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -13,6 +13,7 @@ import pyblish.api 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, @@ -1404,6 +1405,7 @@ class CreateContext: self._current_workfile_path = None self._current_project_settings = None + self._current_project_entity = _NOT_SET self._current_folder_entity = _NOT_SET self._current_task_entity = _NOT_SET self._current_task_type = _NOT_SET @@ -1592,6 +1594,22 @@ class CreateContext: self._current_task_type = task_type return self._current_task_type + def get_current_project_entity(self): + """Project entity for current context project. + + Returns: + Union[dict[str, Any], None]: Folder entity. + + """ + if self._current_project_entity is not _NOT_SET: + return copy.deepcopy(self._current_project_entity) + project_entity = None + project_name = self.get_current_project_name() + if project_name: + project_entity = ayon_api.get_project(project_name) + self._current_project_entity = project_entity + return copy.deepcopy(self._current_project_entity) + def get_current_folder_entity(self): """Folder entity for current context folder. @@ -1788,6 +1806,7 @@ class CreateContext: self._current_task_name = task_name self._current_workfile_path = workfile_path + self._current_project_entity = _NOT_SET self._current_folder_entity = _NOT_SET self._current_task_entity = _NOT_SET self._current_task_type = _NOT_SET @@ -2083,13 +2102,21 @@ class CreateContext: # TODO validate types _pre_create_data.update(pre_create_data) - product_name = creator.get_product_name( + project_entity = self.get_current_project_entity() + args = ( project_name, folder_entity, task_entity, variant, self.host_name, ) + kwargs = {"project_entity": project_entity} + # Backwards compatibility for 'project_entity' argument + if not is_func_signature_supported( + creator.get_product_name, *args, **kwargs + ): + kwargs.pop("project_entity") + product_name = creator.get_product_name(*args, **kwargs) instance_data = { "folderPath": folder_entity["path"], From 2f0a6847ab4d72cf65329b0bcac5234d35db519d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Jul 2024 17:43:44 +0200 Subject: [PATCH 052/163] prefill project entity where we know it may be used --- client/ayon_core/pipeline/create/context.py | 1 + .../pipeline/create/creator_plugins.py | 10 ++++++---- .../ayon_core/pipeline/create/product_name.py | 8 +++++--- client/ayon_core/tools/publisher/abstract.py | 6 ++++++ client/ayon_core/tools/publisher/control.py | 3 +++ .../tools/publisher/models/create.py | 19 ++++++++++++++++--- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 066a147479..f97d34d305 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2112,6 +2112,7 @@ class CreateContext: ) kwargs = {"project_entity": project_entity} # Backwards compatibility for 'project_entity' argument + # - 'get_product_name' signature changed 24/07/08 if not is_func_signature_supported( creator.get_product_name, *args, **kwargs ): diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index e0b30763d0..8cacf7a1d0 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -297,7 +297,6 @@ class BaseCreator: )) setattr(self, key, value) - @property def identifier(self): """Identifier of creator (must be unique). @@ -493,7 +492,8 @@ class BaseCreator: task_entity, variant, host_name=None, - instance=None + instance=None, + project_entity=None, ): """Return product name for passed context. @@ -510,8 +510,9 @@ class BaseCreator: instance (Optional[CreatedInstance]): Object of 'CreatedInstance' for which is product name updated. Passed only on product name update. - """ + project_entity (Optional[dict[str, Any]]): Project entity. + """ if host_name is None: host_name = self.create_context.host_name @@ -537,7 +538,8 @@ class BaseCreator: self.product_type, variant, dynamic_data=dynamic_data, - project_settings=self.project_settings + project_settings=self.project_settings, + project_entity=project_entity, ) def get_instance_attr_defs(self): diff --git a/client/ayon_core/pipeline/create/product_name.py b/client/ayon_core/pipeline/create/product_name.py index 6f8a43cdbe..cd28a6eef0 100644 --- a/client/ayon_core/pipeline/create/product_name.py +++ b/client/ayon_core/pipeline/create/product_name.py @@ -126,11 +126,15 @@ def get_product_name( project_entity (Optional[Dict[str, Any]]): Project entity used when task short name is required by template. + Returns: + str: Product name. + Raises: + TaskNotSetError: If template requires task which is not provided. TemplateFillError: If filled template contains placeholder key which is not collected. - """ + """ if not product_type: return "" @@ -156,8 +160,6 @@ def get_product_name( task_value = task_name elif "{task[short]}" in template.lower(): - # NOTE this is very inefficient approach - # - project entity should be required if project_entity is None: project_entity = ayon_api.get_project(project_name) task_types_by_name = { diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index a9142396f5..768f4b052f 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -166,6 +166,12 @@ class AbstractPublisherBackend(AbstractPublisherCommon): ) -> Union[TaskItem, None]: pass + @abstractmethod + def get_project_entity( + self, project_name: str + ) -> Union[Dict[str, Any], None]: + pass + @abstractmethod def get_folder_entity( self, project_name: str, folder_id: str diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index f26f8fc524..257b45de08 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -193,6 +193,9 @@ class PublisherController( def get_convertor_items(self): return self._create_model.get_convertor_items() + def get_project_entity(self, project_name): + return self._projects_model.get_project_entity(project_name) + def get_folder_type_items(self, project_name, sender=None): return self._projects_model.get_folder_type_items( project_name, sender diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 6da3a51a31..ab2bf07614 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -9,6 +9,7 @@ from ayon_core.lib.attribute_definitions import ( ) from ayon_core.lib.profiles_filtering import filter_profiles from ayon_core.lib.attribute_definitions import UIDef +from ayon_core.lib import is_func_signature_supported from ayon_core.pipeline.create import ( BaseCreator, AutoCreator, @@ -26,6 +27,7 @@ from ayon_core.tools.publisher.abstract import ( AbstractPublisherBackend, CardMessageTypes, ) + CREATE_EVENT_SOURCE = "publisher.create.model" @@ -356,13 +358,24 @@ class CreateModel: project_name, task_item.task_id ) - return creator.get_product_name( + project_entity = self._controller.get_project_entity(project_name) + args = ( project_name, folder_entity, task_entity, - variant, - instance=instance + variant ) + kwargs = { + "instance": instance, + "project_entity": project_entity, + } + # Backwards compatibility for 'project_entity' argument + # - 'get_product_name' signature changed 24/07/08 + if not is_func_signature_supported( + creator.get_product_name, *args, **kwargs + ): + kwargs.pop("project_entity") + return creator.get_product_name(*args, **kwargs) def create( self, From 2beac025f72a55796d7880ec8deec9e8fd09f9d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:59:35 +0200 Subject: [PATCH 053/163] removed deadline addon --- .../deadline/client/ayon_deadline/__init__.py | 8 - .../ayon_deadline/abstract_submit_deadline.py | 617 ------------ .../deadline/client/ayon_deadline/addon.py | 81 -- .../deadline/client/ayon_deadline/lib.py | 10 - .../collect_deadline_server_from_instance.py | 115 --- .../collect_default_deadline_server.py | 48 - .../plugins/publish/collect_pools.py | 91 -- .../publish/collect_user_credentials.py | 98 -- .../help/validate_deadline_connection.xml | 17 - .../publish/help/validate_deadline_pools.xml | 31 - .../publish/submit_aftereffects_deadline.py | 143 --- .../publish/submit_blender_deadline.py | 225 ----- .../publish/submit_celaction_deadline.py | 271 ----- .../plugins/publish/submit_fusion_deadline.py | 253 ----- .../publish/submit_harmony_deadline.py | 420 -------- .../publish/submit_houdini_cache_deadline.py | 181 ---- .../publish/submit_houdini_render_deadline.py | 403 -------- .../plugins/publish/submit_max_deadline.py | 431 -------- .../plugins/publish/submit_maya_deadline.py | 935 ------------------ .../plugins/publish/submit_nuke_deadline.py | 558 ----------- .../publish/submit_publish_cache_job.py | 463 --------- .../plugins/publish/submit_publish_job.py | 585 ----------- .../publish/validate_deadline_connection.py | 52 - .../publish/validate_deadline_pools.py | 84 -- .../validate_expected_and_rendered_files.py | 256 ----- .../repository/custom/plugins/Ayon/Ayon.ico | Bin 7679 -> 0 bytes .../custom/plugins/Ayon/Ayon.options | 9 - .../repository/custom/plugins/Ayon/Ayon.param | 35 - .../repository/custom/plugins/Ayon/Ayon.py | 159 --- .../custom/plugins/CelAction/CelAction.ico | Bin 103192 -> 0 bytes .../custom/plugins/CelAction/CelAction.param | 38 - .../custom/plugins/CelAction/CelAction.py | 122 --- .../custom/plugins/GlobalJobPreLoad.py | 662 ------------- .../plugins/HarmonyAYON/HarmonyAYON.ico | Bin 1150 -> 0 bytes .../plugins/HarmonyAYON/HarmonyAYON.options | 532 ---------- .../plugins/HarmonyAYON/HarmonyAYON.param | 98 -- .../custom/plugins/HarmonyAYON/HarmonyAYON.py | 151 --- .../OpenPypeTileAssembler.ico | Bin 126987 -> 0 bytes .../OpenPypeTileAssembler.options | 35 - .../OpenPypeTileAssembler.param | 17 - .../OpenPypeTileAssembler.py | 457 --------- .../client/ayon_deadline/repository/readme.md | 29 - .../deadline/client/ayon_deadline/version.py | 3 - server_addon/deadline/package.py | 10 - server_addon/deadline/server/__init__.py | 15 - .../deadline/server/settings/__init__.py | 12 - server_addon/deadline/server/settings/main.py | 100 -- .../server/settings/publish_plugins.py | 578 ----------- .../deadline/server/settings/site_settings.py | 28 - 49 files changed, 9466 deletions(-) delete mode 100644 server_addon/deadline/client/ayon_deadline/__init__.py delete mode 100644 server_addon/deadline/client/ayon_deadline/abstract_submit_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/addon.py delete mode 100644 server_addon/deadline/client/ayon_deadline/lib.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/collect_deadline_server_from_instance.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/collect_default_deadline_server.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/collect_pools.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_connection.xml delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_pools.xml delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_aftereffects_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_blender_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_celaction_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_fusion_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_harmony_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_cache_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_render_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_max_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_maya_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_nuke_deadline.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_cache_job.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_job.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_connection.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_pools.py delete mode 100644 server_addon/deadline/client/ayon_deadline/plugins/publish/validate_expected_and_rendered_files.py delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/Ayon/Ayon.ico delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/Ayon/Ayon.options delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/Ayon/Ayon.param delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/Ayon/Ayon.py delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.ico delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.param delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.py delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/GlobalJobPreLoad.py delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.ico delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.options delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.param delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.py delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py delete mode 100644 server_addon/deadline/client/ayon_deadline/repository/readme.md delete mode 100644 server_addon/deadline/client/ayon_deadline/version.py delete mode 100644 server_addon/deadline/package.py delete mode 100644 server_addon/deadline/server/__init__.py delete mode 100644 server_addon/deadline/server/settings/__init__.py delete mode 100644 server_addon/deadline/server/settings/main.py delete mode 100644 server_addon/deadline/server/settings/publish_plugins.py delete mode 100644 server_addon/deadline/server/settings/site_settings.py diff --git a/server_addon/deadline/client/ayon_deadline/__init__.py b/server_addon/deadline/client/ayon_deadline/__init__.py deleted file mode 100644 index 6fec1006e6..0000000000 --- a/server_addon/deadline/client/ayon_deadline/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .addon import DeadlineAddon -from .version import __version__ - - -__all__ = ( - "DeadlineAddon", - "__version__" -) diff --git a/server_addon/deadline/client/ayon_deadline/abstract_submit_deadline.py b/server_addon/deadline/client/ayon_deadline/abstract_submit_deadline.py deleted file mode 100644 index ba50aaccf7..0000000000 --- a/server_addon/deadline/client/ayon_deadline/abstract_submit_deadline.py +++ /dev/null @@ -1,617 +0,0 @@ -# -*- coding: utf-8 -*- -"""Abstract package for submitting jobs to Deadline. - -It provides Deadline JobInfo data class. - -""" -import json.decoder -import os -from abc import abstractmethod -import platform -import getpass -from functools import partial -from collections import OrderedDict - -import six -import attr -import requests - -import pyblish.api -from ayon_core.pipeline.publish import ( - AbstractMetaInstancePlugin, - KnownPublishError, - AYONPyblishPluginMixin -) -from ayon_core.pipeline.publish.lib import ( - replace_with_published_scene_path -) - -JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) - - -def requests_post(*args, **kwargs): - """Wrap request post method. - - Disabling SSL certificate validation if ``verify`` kwarg is set to False. - This is useful when Deadline server is - running with self-signed certificates and its certificate is not - added to trusted certificates on client machines. - - Warning: - Disabling SSL certificate validation is defeating one line - of defense SSL is providing, and it is not recommended. - - """ - auth = kwargs.get("auth") - if auth: - kwargs["auth"] = tuple(auth) # explicit cast to tuple - # add 10sec timeout before bailing out - kwargs['timeout'] = 10 - return requests.post(*args, **kwargs) - - -def requests_get(*args, **kwargs): - """Wrap request get method. - - Disabling SSL certificate validation if ``verify`` kwarg is set to False. - This is useful when Deadline server is - running with self-signed certificates and its certificate is not - added to trusted certificates on client machines. - - Warning: - Disabling SSL certificate validation is defeating one line - of defense SSL is providing, and it is not recommended. - - """ - auth = kwargs.get("auth") - if auth: - kwargs["auth"] = tuple(auth) - # add 10sec timeout before bailing out - kwargs['timeout'] = 10 - return requests.get(*args, **kwargs) - - -class DeadlineKeyValueVar(dict): - """ - - Serializes dictionary key values as "{key}={value}" like Deadline uses - for EnvironmentKeyValue. - - As an example: - EnvironmentKeyValue0="A_KEY=VALUE_A" - EnvironmentKeyValue1="OTHER_KEY=VALUE_B" - - The keys are serialized in alphabetical order (sorted). - - Example: - >>> var = DeadlineKeyValueVar("EnvironmentKeyValue") - >>> var["my_var"] = "hello" - >>> var["my_other_var"] = "hello2" - >>> var.serialize() - - - """ - def __init__(self, key): - super(DeadlineKeyValueVar, self).__init__() - self.__key = key - - def serialize(self): - key = self.__key - - # Allow custom location for index in serialized string - if "{}" not in key: - key = key + "{}" - - return { - key.format(index): "{}={}".format(var_key, var_value) - for index, (var_key, var_value) in enumerate(sorted(self.items())) - } - - -class DeadlineIndexedVar(dict): - """ - - Allows to set and query values by integer indices: - Query: var[1] or var.get(1) - Set: var[1] = "my_value" - Append: var += "value" - - Note: Iterating the instance is not guarantueed to be the order of the - indices. To do so iterate with `sorted()` - - """ - def __init__(self, key): - super(DeadlineIndexedVar, self).__init__() - self.__key = key - - def serialize(self): - key = self.__key - - # Allow custom location for index in serialized string - if "{}" not in key: - key = key + "{}" - - return { - key.format(index): value for index, value in sorted(self.items()) - } - - def next_available_index(self): - # Add as first unused entry - i = 0 - while i in self.keys(): - i += 1 - return i - - def update(self, data): - # Force the integer key check - for key, value in data.items(): - self.__setitem__(key, value) - - def __iadd__(self, other): - index = self.next_available_index() - self[index] = other - return self - - def __setitem__(self, key, value): - if not isinstance(key, int): - raise TypeError("Key must be an integer: {}".format(key)) - - if key < 0: - raise ValueError("Negative index can't be set: {}".format(key)) - dict.__setitem__(self, key, value) - - -@attr.s -class DeadlineJobInfo(object): - """Mapping of all Deadline *JobInfo* attributes. - - This contains all JobInfo attributes plus their default values. - Those attributes set to `None` shouldn't be posted to Deadline as - the only required one is `Plugin`. Their default values used by Deadline - are stated in - comments. - - ..seealso: - https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/manual-submission.html - - """ - - # Required - # ---------------------------------------------- - Plugin = attr.ib() - - # General - Frames = attr.ib(default=None) # default: 0 - Name = attr.ib(default="Untitled") - Comment = attr.ib(default=None) # default: empty - Department = attr.ib(default=None) # default: empty - BatchName = attr.ib(default=None) # default: empty - UserName = attr.ib(default=getpass.getuser()) - MachineName = attr.ib(default=platform.node()) - Pool = attr.ib(default=None) # default: "none" - SecondaryPool = attr.ib(default=None) - Group = attr.ib(default=None) # default: "none" - Priority = attr.ib(default=50) - ChunkSize = attr.ib(default=1) - ConcurrentTasks = attr.ib(default=1) - LimitConcurrentTasksToNumberOfCpus = attr.ib( - default=None) # default: "true" - OnJobComplete = attr.ib(default="Nothing") - SynchronizeAllAuxiliaryFiles = attr.ib(default=None) # default: false - ForceReloadPlugin = attr.ib(default=None) # default: false - Sequential = attr.ib(default=None) # default: false - SuppressEvents = attr.ib(default=None) # default: false - Protected = attr.ib(default=None) # default: false - InitialStatus = attr.ib(default="Active") - NetworkRoot = attr.ib(default=None) - - # Timeouts - # ---------------------------------------------- - MinRenderTimeSeconds = attr.ib(default=None) # Default: 0 - MinRenderTimeMinutes = attr.ib(default=None) # Default: 0 - TaskTimeoutSeconds = attr.ib(default=None) # Default: 0 - TaskTimeoutMinutes = attr.ib(default=None) # Default: 0 - StartJobTimeoutSeconds = attr.ib(default=None) # Default: 0 - StartJobTimeoutMinutes = attr.ib(default=None) # Default: 0 - InitializePluginTimeoutSeconds = attr.ib(default=None) # Default: 0 - # can be one of - OnTaskTimeout = attr.ib(default=None) # Default: Error - EnableTimeoutsForScriptTasks = attr.ib(default=None) # Default: false - EnableFrameTimeouts = attr.ib(default=None) # Default: false - EnableAutoTimeout = attr.ib(default=None) # Default: false - - # Interruptible - # ---------------------------------------------- - Interruptible = attr.ib(default=None) # Default: false - InterruptiblePercentage = attr.ib(default=None) - RemTimeThreshold = attr.ib(default=None) - - # Notifications - # ---------------------------------------------- - # can be comma separated list of users - NotificationTargets = attr.ib(default=None) # Default: blank - ClearNotificationTargets = attr.ib(default=None) # Default: false - # A comma separated list of additional email addresses - NotificationEmails = attr.ib(default=None) # Default: blank - OverrideNotificationMethod = attr.ib(default=None) # Default: false - EmailNotification = attr.ib(default=None) # Default: false - PopupNotification = attr.ib(default=None) # Default: false - # String with `[EOL]` used for end of line - NotificationNote = attr.ib(default=None) # Default: blank - - # Machine Limit - # ---------------------------------------------- - MachineLimit = attr.ib(default=None) # Default: 0 - MachineLimitProgress = attr.ib(default=None) # Default: -1.0 - Whitelist = attr.ib(default=None) # Default: blank - Blacklist = attr.ib(default=None) # Default: blank - - # Limits - # ---------------------------------------------- - # comma separated list of limit groups - LimitGroups = attr.ib(default=None) # Default: blank - - # Dependencies - # ---------------------------------------------- - # comma separated list of job IDs - JobDependencies = attr.ib(default=None) # Default: blank - JobDependencyPercentage = attr.ib(default=None) # Default: -1 - IsFrameDependent = attr.ib(default=None) # Default: false - FrameDependencyOffsetStart = attr.ib(default=None) # Default: 0 - FrameDependencyOffsetEnd = attr.ib(default=None) # Default: 0 - ResumeOnCompleteDependencies = attr.ib(default=None) # Default: true - ResumeOnDeletedDependencies = attr.ib(default=None) # Default: false - ResumeOnFailedDependencies = attr.ib(default=None) # Default: false - # comma separated list of asset paths - RequiredAssets = attr.ib(default=None) # Default: blank - # comma separated list of script paths - ScriptDependencies = attr.ib(default=None) # Default: blank - - # Failure Detection - # ---------------------------------------------- - OverrideJobFailureDetection = attr.ib(default=None) # Default: false - FailureDetectionJobErrors = attr.ib(default=None) # 0..x - OverrideTaskFailureDetection = attr.ib(default=None) # Default: false - FailureDetectionTaskErrors = attr.ib(default=None) # 0..x - IgnoreBadJobDetection = attr.ib(default=None) # Default: false - SendJobErrorWarning = attr.ib(default=None) # Default: false - - # Cleanup - # ---------------------------------------------- - DeleteOnComplete = attr.ib(default=None) # Default: false - ArchiveOnComplete = attr.ib(default=None) # Default: false - OverrideAutoJobCleanup = attr.ib(default=None) # Default: false - OverrideJobCleanup = attr.ib(default=None) - JobCleanupDays = attr.ib(default=None) # Default: false - # - OverrideJobCleanupType = attr.ib(default=None) - - # Scheduling - # ---------------------------------------------- - # - ScheduledType = attr.ib(default=None) # Default: None - #
- ScheduledStartDateTime = attr.ib(default=None) - ScheduledDays = attr.ib(default=None) # Default: 1 - # - JobDelay = attr.ib(default=None) - # Time= - Scheduled = attr.ib(default=None) - - # Scripts - # ---------------------------------------------- - # all accept path to script - PreJobScript = attr.ib(default=None) # Default: blank - PostJobScript = attr.ib(default=None) # Default: blank - PreTaskScript = attr.ib(default=None) # Default: blank - PostTaskScript = attr.ib(default=None) # Default: blank - - # Event Opt-Ins - # ---------------------------------------------- - # comma separated list of plugins - EventOptIns = attr.ib(default=None) # Default: blank - - # Environment - # ---------------------------------------------- - EnvironmentKeyValue = attr.ib(factory=partial(DeadlineKeyValueVar, - "EnvironmentKeyValue")) - - IncludeEnvironment = attr.ib(default=None) # Default: false - UseJobEnvironmentOnly = attr.ib(default=None) # Default: false - CustomPluginDirectory = attr.ib(default=None) # Default: blank - - # Job Extra Info - # ---------------------------------------------- - ExtraInfo = attr.ib(factory=partial(DeadlineIndexedVar, "ExtraInfo")) - ExtraInfoKeyValue = attr.ib(factory=partial(DeadlineKeyValueVar, - "ExtraInfoKeyValue")) - - # Task Extra Info Names - # ---------------------------------------------- - OverrideTaskExtraInfoNames = attr.ib(default=None) # Default: false - TaskExtraInfoName = attr.ib(factory=partial(DeadlineIndexedVar, - "TaskExtraInfoName")) - - # Output - # ---------------------------------------------- - OutputFilename = attr.ib(factory=partial(DeadlineIndexedVar, - "OutputFilename")) - OutputFilenameTile = attr.ib(factory=partial(DeadlineIndexedVar, - "OutputFilename{}Tile")) - OutputDirectory = attr.ib(factory=partial(DeadlineIndexedVar, - "OutputDirectory")) - - # Asset Dependency - # ---------------------------------------------- - AssetDependency = attr.ib(factory=partial(DeadlineIndexedVar, - "AssetDependency")) - - # Tile Job - # ---------------------------------------------- - TileJob = attr.ib(default=None) # Default: false - TileJobFrame = attr.ib(default=None) # Default: 0 - TileJobTilesInX = attr.ib(default=None) # Default: 0 - TileJobTilesInY = attr.ib(default=None) # Default: 0 - TileJobTileCount = attr.ib(default=None) # Default: 0 - - # Maintenance Job - # ---------------------------------------------- - MaintenanceJob = attr.ib(default=None) # Default: false - MaintenanceJobStartFrame = attr.ib(default=None) # Default: 0 - MaintenanceJobEndFrame = attr.ib(default=None) # Default: 0 - - def serialize(self): - """Return all data serialized as dictionary. - - Returns: - OrderedDict: all serialized data. - - """ - def filter_data(a, v): - if isinstance(v, (DeadlineIndexedVar, DeadlineKeyValueVar)): - return False - if v is None: - return False - return True - - serialized = attr.asdict( - self, dict_factory=OrderedDict, filter=filter_data) - - # Custom serialize these attributes - for attribute in [ - self.EnvironmentKeyValue, - self.ExtraInfo, - self.ExtraInfoKeyValue, - self.TaskExtraInfoName, - self.OutputFilename, - self.OutputFilenameTile, - self.OutputDirectory, - self.AssetDependency - ]: - serialized.update(attribute.serialize()) - - return serialized - - def update(self, data): - """Update instance with data dict""" - for key, value in data.items(): - setattr(self, key, value) - - def add_render_job_env_var(self): - """Check if in OP or AYON mode and use appropriate env var.""" - self.EnvironmentKeyValue["AYON_RENDER_JOB"] = "1" - self.EnvironmentKeyValue["AYON_BUNDLE_NAME"] = ( - os.environ["AYON_BUNDLE_NAME"]) - - -@six.add_metaclass(AbstractMetaInstancePlugin) -class AbstractSubmitDeadline(pyblish.api.InstancePlugin, - AYONPyblishPluginMixin): - """Class abstracting access to Deadline.""" - - label = "Submit to Deadline" - order = pyblish.api.IntegratorOrder + 0.1 - - import_reference = False - use_published = True - asset_dependencies = False - default_priority = 50 - - def __init__(self, *args, **kwargs): - super(AbstractSubmitDeadline, self).__init__(*args, **kwargs) - self._instance = None - self._deadline_url = None - self.scene_path = None - self.job_info = None - self.plugin_info = None - self.aux_files = None - - def process(self, instance): - """Plugin entry point.""" - self._instance = instance - context = instance.context - self._deadline_url = instance.data["deadline"]["url"] - - assert self._deadline_url, "Requires Deadline Webservice URL" - - file_path = None - if self.use_published: - if not self.import_reference: - file_path = self.from_published_scene() - else: - self.log.info("use the scene with imported reference for rendering") # noqa - file_path = context.data["currentFile"] - - # fallback if nothing was set - if not file_path: - self.log.warning("Falling back to workfile") - file_path = context.data["currentFile"] - - self.scene_path = file_path - self.log.info("Using {} for render/export.".format(file_path)) - - self.job_info = self.get_job_info() - self.plugin_info = self.get_plugin_info() - self.aux_files = self.get_aux_files() - - job_id = self.process_submission() - self.log.info("Submitted job to Deadline: {}.".format(job_id)) - - # TODO: Find a way that's more generic and not render type specific - if instance.data.get("splitRender"): - self.log.info("Splitting export and render in two jobs") - self.log.info("Export job id: %s", job_id) - render_job_info = self.get_job_info(dependency_job_ids=[job_id]) - render_plugin_info = self.get_plugin_info(job_type="render") - payload = self.assemble_payload( - job_info=render_job_info, - plugin_info=render_plugin_info - ) - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - render_job_id = self.submit(payload, auth, verify) - self.log.info("Render job id: %s", render_job_id) - - def process_submission(self): - """Process data for submission. - - This takes Deadline JobInfo, PluginInfo, AuxFile, creates payload - from them and submit it do Deadline. - - Returns: - str: Deadline job ID - - """ - payload = self.assemble_payload() - auth = self._instance.data["deadline"]["auth"] - verify = self._instance.data["deadline"]["verify"] - return self.submit(payload, auth, verify) - - @abstractmethod - def get_job_info(self): - """Return filled Deadline JobInfo. - - This is host/plugin specific implementation of how to fill data in. - - See: - :class:`DeadlineJobInfo` - - Returns: - :class:`DeadlineJobInfo`: Filled Deadline JobInfo. - - """ - pass - - @abstractmethod - def get_plugin_info(self): - """Return filled Deadline PluginInfo. - - This is host/plugin specific implementation of how to fill data in. - - See: - :class:`DeadlineJobInfo` - - Returns: - dict: Filled Deadline JobInfo. - - """ - pass - - def get_aux_files(self): - """Return list of auxiliary files for Deadline job. - - If needed this should be overridden, otherwise return empty list as - that field even empty must be present on Deadline submission. - - Returns: - list: List of files. - - """ - return [] - - def from_published_scene(self, replace_in_path=True): - """Switch work scene for published scene. - - If rendering/exporting from published scenes is enabled, this will - replace paths from working scene to published scene. - - Args: - replace_in_path (bool): if True, it will try to find - old scene name in path of expected files and replace it - with name of published scene. - - Returns: - str: Published scene path. - None: if no published scene is found. - - Note: - Published scene path is actually determined from project Anatomy - as at the time this plugin is running scene can still no be - published. - - """ - return replace_with_published_scene_path( - self._instance, replace_in_path=replace_in_path) - - def assemble_payload( - self, job_info=None, plugin_info=None, aux_files=None): - """Assemble payload data from its various parts. - - Args: - job_info (DeadlineJobInfo): Deadline JobInfo. You can use - :class:`DeadlineJobInfo` for it. - plugin_info (dict): Deadline PluginInfo. Plugin specific options. - aux_files (list, optional): List of auxiliary file to submit with - the job. - - Returns: - dict: Deadline Payload. - - """ - job = job_info or self.job_info - return { - "JobInfo": job.serialize(), - "PluginInfo": plugin_info or self.plugin_info, - "AuxFiles": aux_files or self.aux_files - } - - def submit(self, payload, auth, verify): - """Submit payload to Deadline API end-point. - - This takes payload in the form of JSON file and POST it to - Deadline jobs end-point. - - Args: - payload (dict): dict to become json in deadline submission. - auth (tuple): (username, password) - verify (bool): verify SSL certificate if present - - Returns: - str: resulting Deadline job id. - - Throws: - KnownPublishError: if submission fails. - - """ - url = "{}/api/jobs".format(self._deadline_url) - response = requests_post( - url, json=payload, auth=auth, verify=verify) - if not response.ok: - self.log.error("Submission failed!") - self.log.error(response.status_code) - self.log.error(response.content) - self.log.debug(payload) - raise KnownPublishError(response.text) - - try: - result = response.json() - except JSONDecodeError: - msg = "Broken response {}. ".format(response) - msg += "Try restarting the Deadline Webservice." - self.log.warning(msg, exc_info=True) - raise KnownPublishError("Broken response from DL") - - # for submit publish job - self._instance.data["deadlineSubmissionJob"] = result - - return result["_id"] diff --git a/server_addon/deadline/client/ayon_deadline/addon.py b/server_addon/deadline/client/ayon_deadline/addon.py deleted file mode 100644 index 87fc2ad665..0000000000 --- a/server_addon/deadline/client/ayon_deadline/addon.py +++ /dev/null @@ -1,81 +0,0 @@ -import os -import sys - -import requests -import six - -from ayon_core.lib import Logger -from ayon_core.addon import AYONAddon, IPluginPaths - -from .version import __version__ - - -class DeadlineWebserviceError(Exception): - """ - Exception to throw when connection to Deadline server fails. - """ - - -class DeadlineAddon(AYONAddon, IPluginPaths): - name = "deadline" - version = __version__ - - def initialize(self, studio_settings): - deadline_settings = studio_settings[self.name] - deadline_servers_info = { - url_item["name"]: url_item - for url_item in deadline_settings["deadline_urls"] - } - - if not deadline_servers_info: - self.enabled = False - self.log.warning(( - "Deadline Webservice URLs are not specified. Disabling addon." - )) - - self.deadline_servers_info = deadline_servers_info - - def get_plugin_paths(self): - """Deadline plugin paths.""" - current_dir = os.path.dirname(os.path.abspath(__file__)) - return { - "publish": [os.path.join(current_dir, "plugins", "publish")] - } - - @staticmethod - def get_deadline_pools(webservice, auth=None, log=None): - """Get pools from Deadline. - Args: - webservice (str): Server url. - auth (Optional[Tuple[str, str]]): Tuple containing username, - password - log (Optional[Logger]): Logger to log errors to, if provided. - Returns: - List[str]: Pools. - Throws: - RuntimeError: If deadline webservice is unreachable. - - """ - from .abstract_submit_deadline import requests_get - - if not log: - log = Logger.get_logger(__name__) - - argument = "{}/api/pools?NamesOnly=true".format(webservice) - try: - kwargs = {} - if auth: - kwargs["auth"] = auth - response = requests_get(argument, **kwargs) - except requests.exceptions.ConnectionError as exc: - msg = 'Cannot connect to DL web service {}'.format(webservice) - log.error(msg) - six.reraise( - DeadlineWebserviceError, - DeadlineWebserviceError('{} - {}'.format(msg, exc)), - sys.exc_info()[2]) - if not response.ok: - log.warning("No pools retrieved") - return [] - - return response.json() diff --git a/server_addon/deadline/client/ayon_deadline/lib.py b/server_addon/deadline/client/ayon_deadline/lib.py deleted file mode 100644 index 7f07c350ec..0000000000 --- a/server_addon/deadline/client/ayon_deadline/lib.py +++ /dev/null @@ -1,10 +0,0 @@ -# describes list of product typed used for plugin filtering for farm publishing -FARM_FAMILIES = [ - "render", "render.farm", "render.frames_farm", - "prerender", "prerender.farm", "prerender.frames_farm", - "renderlayer", "imagesequence", "image", - "vrayscene", "maxrender", - "arnold_rop", "mantra_rop", - "karma_rop", "vray_rop", "redshift_rop", - "renderFarm", "usdrender", "publish.hou" -] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_deadline_server_from_instance.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_deadline_server_from_instance.py deleted file mode 100644 index 2c8cbd1620..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_deadline_server_from_instance.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect Deadline servers from instance. - -This is resolving index of server lists stored in `deadlineServers` instance -attribute or using default server if that attribute doesn't exists. - -""" -import pyblish.api -from ayon_core.pipeline.publish import KnownPublishError - -from ayon_deadline.lib import FARM_FAMILIES - - -class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): - """Collect Deadline Webservice URL from instance.""" - - # Run before collect_render. - order = pyblish.api.CollectorOrder + 0.225 - label = "Deadline Webservice from the Instance" - targets = ["local"] - - families = FARM_FAMILIES - - def process(self, instance): - if not instance.data.get("farm"): - self.log.debug("Should not be processed on farm, skipping.") - return - - if not instance.data.get("deadline"): - instance.data["deadline"] = {} - - # todo: separate logic should be removed, all hosts should have same - host_name = instance.context.data["hostName"] - if host_name == "maya": - deadline_url = self._collect_deadline_url(instance) - else: - deadline_url = (instance.data.get("deadlineUrl") or # backwards - instance.data.get("deadline", {}).get("url")) - if deadline_url: - instance.data["deadline"]["url"] = deadline_url.strip().rstrip("/") - else: - instance.data["deadline"]["url"] = instance.context.data["deadline"]["defaultUrl"] # noqa - self.log.debug( - "Using {} for submission".format(instance.data["deadline"]["url"])) - - def _collect_deadline_url(self, render_instance): - # type: (pyblish.api.Instance) -> str - """Get Deadline Webservice URL from render instance. - - This will get all configured Deadline Webservice URLs and create - subset of them based upon project configuration. It will then take - `deadlineServers` from render instance that is now basically `int` - index of that list. - - Args: - render_instance (pyblish.api.Instance): Render instance created - by Creator in Maya. - - Returns: - str: Selected Deadline Webservice URL. - - """ - # Not all hosts can import this module. - from maya import cmds - deadline_settings = ( - render_instance.context.data - ["project_settings"] - ["deadline"] - ) - default_server_url = (render_instance.context.data["deadline"] - ["defaultUrl"]) - # QUESTION How and where is this is set? Should be removed? - instance_server = render_instance.data.get("deadlineServers") - if not instance_server: - self.log.debug("Using default server.") - return default_server_url - - # Get instance server as sting. - if isinstance(instance_server, int): - instance_server = cmds.getAttr( - "{}.deadlineServers".format(render_instance.data["objset"]), - asString=True - ) - - default_servers = { - url_item["name"]: url_item["value"] - for url_item in deadline_settings["deadline_servers_info"] - } - project_servers = ( - render_instance.context.data - ["project_settings"] - ["deadline"] - ["deadline_servers"] - ) - if not project_servers: - self.log.debug("Not project servers found. Using default servers.") - return default_servers[instance_server] - - project_enabled_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers - } - - if instance_server not in project_enabled_servers: - msg = ( - "\"{}\" server on instance is not enabled in project settings." - " Enabled project servers:\n{}".format( - instance_server, project_enabled_servers - ) - ) - raise KnownPublishError(msg) - - self.log.debug("Using project approved server.") - return project_enabled_servers[instance_server] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_default_deadline_server.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_default_deadline_server.py deleted file mode 100644 index 77d03c713f..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_default_deadline_server.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect default Deadline server.""" -import pyblish.api - - -class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): - """Collect default Deadline Webservice URL. - - DL webservice addresses must be configured first in System Settings for - project settings enum to work. - - Default webservice could be overridden by - `project_settings/deadline/deadline_servers`. Currently only single url - is expected. - - This url could be overridden by some hosts directly on instances with - `CollectDeadlineServerFromInstance`. - """ - - # Run before collect_deadline_server_instance. - order = pyblish.api.CollectorOrder + 0.200 - label = "Default Deadline Webservice" - targets = ["local"] - - def process(self, context): - try: - deadline_addon = context.data["ayonAddonsManager"]["deadline"] - except AttributeError: - self.log.error("Cannot get AYON Deadline addon.") - raise AssertionError("AYON Deadline addon not found.") - - deadline_settings = context.data["project_settings"]["deadline"] - deadline_server_name = deadline_settings["deadline_server"] - - dl_server_info = None - if deadline_server_name: - dl_server_info = deadline_addon.deadline_servers_info.get( - deadline_server_name) - - if dl_server_info: - deadline_url = dl_server_info["value"] - else: - default_dl_server_info = deadline_addon.deadline_servers_info[0] - deadline_url = default_dl_server_info["value"] - - context.data["deadline"] = {} - context.data["deadline"]["defaultUrl"] = ( - deadline_url.strip().rstrip("/")) diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_pools.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_pools.py deleted file mode 100644 index b2b6bc60d4..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_pools.py +++ /dev/null @@ -1,91 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from ayon_core.lib import TextDef -from ayon_core.pipeline.publish import AYONPyblishPluginMixin - -from ayon_deadline.lib import FARM_FAMILIES - - -class CollectDeadlinePools(pyblish.api.InstancePlugin, - AYONPyblishPluginMixin): - """Collect pools from instance or Publisher attributes, from Setting - otherwise. - - Pools are used to control which DL workers could render the job. - - Pools might be set: - - directly on the instance (set directly in DCC) - - from Publisher attributes - - from defaults from Settings. - - Publisher attributes could be shown even for instances that should be - rendered locally as visibility is driven by product type of the instance - (which will be `render` most likely). - (Might be resolved in the future and class attribute 'families' should - be cleaned up.) - - """ - - order = pyblish.api.CollectorOrder + 0.420 - label = "Collect Deadline Pools" - hosts = [ - "aftereffects", - "fusion", - "harmony", - "maya", - "max", - "houdini", - "nuke", - ] - - families = FARM_FAMILIES - - primary_pool = None - secondary_pool = None - - @classmethod - def apply_settings(cls, project_settings): - # deadline.publish.CollectDeadlinePools - settings = project_settings["deadline"]["publish"]["CollectDeadlinePools"] # noqa - cls.primary_pool = settings.get("primary_pool", None) - cls.secondary_pool = settings.get("secondary_pool", None) - - def process(self, instance): - attr_values = self.get_attr_values_from_data(instance.data) - if not instance.data.get("primaryPool"): - instance.data["primaryPool"] = ( - attr_values.get("primaryPool") or self.primary_pool or "none" - ) - if instance.data["primaryPool"] == "-": - instance.data["primaryPool"] = None - - if not instance.data.get("secondaryPool"): - instance.data["secondaryPool"] = ( - attr_values.get("secondaryPool") or self.secondary_pool or "none" # noqa - ) - - if instance.data["secondaryPool"] == "-": - instance.data["secondaryPool"] = None - - @classmethod - def get_attribute_defs(cls): - # TODO: Preferably this would be an enum for the user - # but the Deadline server URL can be dynamic and - # can be set per render instance. Since get_attribute_defs - # can't be dynamic unfortunately EnumDef isn't possible (yet?) - # pool_names = self.deadline_addon.get_deadline_pools(deadline_url, - # self.log) - # secondary_pool_names = ["-"] + pool_names - - return [ - TextDef("primaryPool", - label="Primary Pool", - default=cls.primary_pool, - tooltip="Deadline primary pool, " - "applicable for farm rendering"), - TextDef("secondaryPool", - label="Secondary Pool", - default=cls.secondary_pool, - tooltip="Deadline secondary pool, " - "applicable for farm rendering") - ] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py deleted file mode 100644 index 1c59c178d3..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/collect_user_credentials.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect user credentials - -Requires: - context -> project_settings - instance.data["deadline"]["url"] - -Provides: - instance.data["deadline"] -> require_authentication (bool) - instance.data["deadline"] -> auth (tuple (str, str)) - - (username, password) or None -""" -import pyblish.api - -from ayon_api import get_server_api_connection - -from ayon_deadline.lib import FARM_FAMILIES - - -class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin): - """Collects user name and password for artist if DL requires authentication - - If Deadline server is marked to require authentication, it looks first for - default values in 'Studio Settings', which could be overriden by artist - dependent values from 'Site settings`. - """ - order = pyblish.api.CollectorOrder + 0.250 - label = "Collect Deadline User Credentials" - - targets = ["local"] - hosts = ["aftereffects", - "blender", - "fusion", - "harmony", - "nuke", - "maya", - "max", - "houdini"] - - families = FARM_FAMILIES - - def process(self, instance): - if not instance.data.get("farm"): - self.log.debug("Should not be processed on farm, skipping.") - return - - collected_deadline_url = instance.data["deadline"]["url"] - if not collected_deadline_url: - raise ValueError("Instance doesn't have '[deadline][url]'.") - context_data = instance.context.data - deadline_settings = context_data["project_settings"]["deadline"] - - deadline_server_name = None - # deadline url might be set directly from instance, need to find - # metadata for it - for deadline_info in deadline_settings["deadline_urls"]: - dl_settings_url = deadline_info["value"].strip().rstrip("/") - if dl_settings_url == collected_deadline_url: - deadline_server_name = deadline_info["name"] - break - - if not deadline_server_name: - raise ValueError(f"Collected {collected_deadline_url} doesn't " - "match any site configured in Studio Settings") - - instance.data["deadline"]["require_authentication"] = ( - deadline_info["require_authentication"] - ) - instance.data["deadline"]["auth"] = None - - instance.data["deadline"]["verify"] = ( - not deadline_info["not_verify_ssl"]) - - if not deadline_info["require_authentication"]: - return - - addons_manager = instance.context.data["ayonAddonsManager"] - deadline_addon = addons_manager["deadline"] - - default_username = deadline_info["default_username"] - default_password = deadline_info["default_password"] - if default_username and default_password: - self.log.debug("Setting credentials from defaults") - instance.data["deadline"]["auth"] = (default_username, - default_password) - - # TODO import 'get_addon_site_settings' when available - # in public 'ayon_api' - local_settings = get_server_api_connection().get_addon_site_settings( - deadline_addon.name, deadline_addon.version) - local_settings = local_settings["local_settings"] - for server_info in local_settings: - if deadline_server_name == server_info["server_name"]: - if server_info["username"] and server_info["password"]: - self.log.debug("Setting credentials from Site Settings") - instance.data["deadline"]["auth"] = \ - (server_info["username"], server_info["password"]) - break diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_connection.xml b/server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_connection.xml deleted file mode 100644 index eec05df08a..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_connection.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - Deadline Authentication - -## Deadline authentication is required - -This project has set in Settings that Deadline requires authentication. - -### How to repair? - -Please go to Ayon Server > Site Settings and provide your Deadline username and password. -In some cases the password may be empty if Deadline is configured to allow that. Ask your administrator. - - - - \ No newline at end of file diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_pools.xml b/server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_pools.xml deleted file mode 100644 index 879adcee97..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/help/validate_deadline_pools.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - Deadline Pools - -## Invalid Deadline pools found - -Configured pools don't match available pools in Deadline. - -### How to repair? - -If your instance had deadline pools set on creation, remove or -change them. - -In other cases inform admin to change them in Settings. - -Available deadline pools: - -{pools_str} - - - -### __Detailed Info__ - -This error is shown when a configured pool is not available on Deadline. It -can happen when publishing old workfiles which were created with previous -deadline pools, or someone changed the available pools in Deadline, -but didn't modify AYON Settings to match the changes. - - - \ No newline at end of file diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_aftereffects_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_aftereffects_deadline.py deleted file mode 100644 index 45d907cbba..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_aftereffects_deadline.py +++ /dev/null @@ -1,143 +0,0 @@ -import os -import attr -import getpass -import pyblish.api -from datetime import datetime - -from ayon_core.lib import ( - env_value_to_bool, - collect_frames, - is_in_tests, -) -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo - - -@attr.s -class DeadlinePluginInfo(): - Comp = attr.ib(default=None) - SceneFile = attr.ib(default=None) - OutputFilePath = attr.ib(default=None) - Output = attr.ib(default=None) - StartupDirectory = attr.ib(default=None) - Arguments = attr.ib(default=None) - ProjectPath = attr.ib(default=None) - AWSAssetFile0 = attr.ib(default=None) - Version = attr.ib(default=None) - MultiProcess = attr.ib(default=None) - - -class AfterEffectsSubmitDeadline( - abstract_submit_deadline.AbstractSubmitDeadline -): - - label = "Submit AE to Deadline" - order = pyblish.api.IntegratorOrder + 0.1 - hosts = ["aftereffects"] - families = ["render.farm"] # cannot be "render' as that is integrated - use_published = True - targets = ["local"] - - priority = 50 - chunk_size = 1000000 - group = None - department = None - multiprocess = True - - def get_job_info(self): - dln_job_info = DeadlineJobInfo(Plugin="AfterEffects") - - context = self._instance.context - - batch_name = os.path.basename(self._instance.data["source"]) - if is_in_tests(): - batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - dln_job_info.Name = self._instance.data["name"] - dln_job_info.BatchName = batch_name - dln_job_info.Plugin = "AfterEffects" - dln_job_info.UserName = context.data.get( - "deadlineUser", getpass.getuser()) - # Deadline requires integers in frame range - frame_range = "{}-{}".format( - int(round(self._instance.data["frameStart"])), - int(round(self._instance.data["frameEnd"]))) - dln_job_info.Frames = frame_range - - dln_job_info.Priority = self.priority - dln_job_info.Pool = self._instance.data.get("primaryPool") - dln_job_info.SecondaryPool = self._instance.data.get("secondaryPool") - dln_job_info.Group = self.group - dln_job_info.Department = self.department - dln_job_info.ChunkSize = self.chunk_size - dln_job_info.OutputFilename += \ - os.path.basename(self._instance.data["expectedFiles"][0]) - dln_job_info.OutputDirectory += \ - os.path.dirname(self._instance.data["expectedFiles"][0]) - dln_job_info.JobDelay = "00:00:00" - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_LOG_NO_COLORS", - "AYON_IN_TESTS" - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - for key in keys: - value = environment.get(key) - if value: - dln_job_info.EnvironmentKeyValue[key] = value - - # to recognize render jobs - dln_job_info.add_render_job_env_var() - - return dln_job_info - - def get_plugin_info(self): - deadline_plugin_info = DeadlinePluginInfo() - - render_path = self._instance.data["expectedFiles"][0] - - file_name, frame = list(collect_frames([render_path]).items())[0] - if frame: - # replace frame ('000001') with Deadline's required '[#######]' - # expects filename in format project_folder_product_version.FRAME.ext - render_dir = os.path.dirname(render_path) - file_name = os.path.basename(render_path) - hashed = '[{}]'.format(len(frame) * "#") - file_name = file_name.replace(frame, hashed) - render_path = os.path.join(render_dir, file_name) - - deadline_plugin_info.Comp = self._instance.data["comp_name"] - deadline_plugin_info.Version = self._instance.data["app_version"] - # must be here because of DL AE plugin - # added override of multiprocess by env var, if shouldn't be used for - # some app variant use MULTIPROCESS:false in Settings, default is True - env_multi = env_value_to_bool("MULTIPROCESS", default=True) - deadline_plugin_info.MultiProcess = env_multi and self.multiprocess - deadline_plugin_info.SceneFile = self.scene_path - deadline_plugin_info.Output = render_path.replace("\\", "/") - - return attr.asdict(deadline_plugin_info) - - def from_published_scene(self): - """ Do not overwrite expected files. - - Use published is set to True, so rendering will be triggered - from published scene (in 'publish' folder). Default implementation - of abstract class renames expected (eg. rendered) files accordingly - which is not needed here. - """ - return super().from_published_scene(False) diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_blender_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_blender_deadline.py deleted file mode 100644 index 073de909b4..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_blender_deadline.py +++ /dev/null @@ -1,225 +0,0 @@ -# -*- coding: utf-8 -*- -"""Submitting render job to Deadline.""" - -import os -import getpass -import attr -from datetime import datetime - -from ayon_core.lib import ( - BoolDef, - NumberDef, - TextDef, - is_in_tests, -) -from ayon_core.pipeline.publish import AYONPyblishPluginMixin -from ayon_core.pipeline.farm.tools import iter_expected_files - -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo - - -@attr.s -class BlenderPluginInfo(): - SceneFile = attr.ib(default=None) # Input - Version = attr.ib(default=None) # Mandatory for Deadline - SaveFile = attr.ib(default=True) - - -class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, - AYONPyblishPluginMixin): - label = "Submit Render to Deadline" - hosts = ["blender"] - families = ["render"] - settings_category = "deadline" - - use_published = True - priority = 50 - chunk_size = 1 - jobInfo = {} - pluginInfo = {} - group = None - job_delay = "00:00:00:00" - - def get_job_info(self): - job_info = DeadlineJobInfo(Plugin="Blender") - - job_info.update(self.jobInfo) - - instance = self._instance - context = instance.context - - # Always use the original work file name for the Job name even when - # rendering is done from the published Work File. The original work - # file name is clearer because it can also have subversion strings, - # etc. which are stripped for the published file. - src_filepath = context.data["currentFile"] - src_filename = os.path.basename(src_filepath) - - if is_in_tests(): - src_filename += datetime.now().strftime("%d%m%Y%H%M%S") - - job_info.Name = f"{src_filename} - {instance.name}" - job_info.BatchName = src_filename - instance.data.get("blenderRenderPlugin", "Blender") - job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) - - # Deadline requires integers in frame range - frames = "{start}-{end}x{step}".format( - start=int(instance.data["frameStartHandle"]), - end=int(instance.data["frameEndHandle"]), - step=int(instance.data["byFrameStep"]), - ) - job_info.Frames = frames - - job_info.Pool = instance.data.get("primaryPool") - job_info.SecondaryPool = instance.data.get("secondaryPool") - job_info.Comment = instance.data.get("comment") - - if self.group != "none" and self.group: - job_info.Group = self.group - - attr_values = self.get_attr_values_from_data(instance.data) - render_globals = instance.data.setdefault("renderGlobals", {}) - machine_list = attr_values.get("machineList", "") - if machine_list: - if attr_values.get("whitelist", True): - machine_list_key = "Whitelist" - else: - machine_list_key = "Blacklist" - render_globals[machine_list_key] = machine_list - - job_info.ChunkSize = attr_values.get("chunkSize", self.chunk_size) - job_info.Priority = attr_values.get("priority", self.priority) - job_info.ScheduledType = "Once" - job_info.JobDelay = attr_values.get("job_delay", self.job_delay) - - # Add options from RenderGlobals - render_globals = instance.data.get("renderGlobals", {}) - job_info.update(render_globals) - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "OPENPYPE_SG_USER", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_IN_TESTS" - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - for key in keys: - value = environment.get(key) - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - - # to recognize job from PYPE for turning Event On/Off - job_info.add_render_job_env_var() - job_info.EnvironmentKeyValue["AYON_LOG_NO_COLORS"] = "1" - - # Adding file dependencies. - if self.asset_dependencies: - dependencies = instance.context.data["fileDependencies"] - for dependency in dependencies: - job_info.AssetDependency += dependency - - # Add list of expected files to job - # --------------------------------- - exp = instance.data.get("expectedFiles") - for filepath in iter_expected_files(exp): - job_info.OutputDirectory += os.path.dirname(filepath) - job_info.OutputFilename += os.path.basename(filepath) - - return job_info - - def get_plugin_info(self): - # Not all hosts can import this module. - import bpy - - plugin_info = BlenderPluginInfo( - SceneFile=self.scene_path, - Version=bpy.app.version_string, - SaveFile=True, - ) - - plugin_payload = attr.asdict(plugin_info) - - # Patching with pluginInfo from settings - for key, value in self.pluginInfo.items(): - plugin_payload[key] = value - - return plugin_payload - - def process_submission(self, auth=None): - instance = self._instance - - expected_files = instance.data["expectedFiles"] - if not expected_files: - raise RuntimeError("No Render Elements found!") - - first_file = next(iter_expected_files(expected_files)) - output_dir = os.path.dirname(first_file) - instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" - - payload = self.assemble_payload() - auth = self._instance.data["deadline"]["auth"] - verify = self._instance.data["deadline"]["verify"] - return self.submit(payload, auth=auth, verify=verify) - - def from_published_scene(self): - """ - This is needed to set the correct path for the json metadata. Because - the rendering path is set in the blend file during the collection, - and the path is adjusted to use the published scene, this ensures that - the metadata and the rendered files are in the same location. - """ - return super().from_published_scene(False) - - @classmethod - def get_attribute_defs(cls): - defs = super(BlenderSubmitDeadline, cls).get_attribute_defs() - defs.extend([ - BoolDef("use_published", - default=cls.use_published, - label="Use Published Scene"), - - NumberDef("priority", - minimum=1, - maximum=250, - decimals=0, - default=cls.priority, - label="Priority"), - - NumberDef("chunkSize", - minimum=1, - maximum=50, - decimals=0, - default=cls.chunk_size, - label="Frame Per Task"), - - TextDef("group", - default=cls.group, - label="Group Name"), - - TextDef("job_delay", - default=cls.job_delay, - label="Job Delay", - placeholder="dd:hh:mm:ss", - tooltip="Delay the job by the specified amount of time. " - "Timecode: dd:hh:mm:ss."), - ]) - - return defs diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_celaction_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_celaction_deadline.py deleted file mode 100644 index e9313e3f2f..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_celaction_deadline.py +++ /dev/null @@ -1,271 +0,0 @@ -import os -import re -import json -import getpass -import pyblish.api - -from ayon_deadline.abstract_submit_deadline import requests_post - - -class CelactionSubmitDeadline(pyblish.api.InstancePlugin): - """Submit CelAction2D scene to Deadline - - Renders are submitted to a Deadline Web Service. - - """ - - label = "Submit CelAction to Deadline" - order = pyblish.api.IntegratorOrder + 0.1 - hosts = ["celaction"] - families = ["render.farm"] - settings_category = "deadline" - - deadline_department = "" - deadline_priority = 50 - deadline_pool = "" - deadline_pool_secondary = "" - deadline_group = "" - deadline_chunk_size = 1 - deadline_job_delay = "00:00:08:00" - - def process(self, instance): - - context = instance.context - - deadline_url = instance.data["deadline"]["url"] - assert deadline_url, "Requires Deadline Webservice URL" - - self.deadline_url = "{}/api/jobs".format(deadline_url) - self._comment = instance.data["comment"] - self._deadline_user = context.data.get( - "deadlineUser", getpass.getuser()) - self._frame_start = int(instance.data["frameStart"]) - self._frame_end = int(instance.data["frameEnd"]) - - # get output path - render_path = instance.data['path'] - script_path = context.data["currentFile"] - - response = self.payload_submit(instance, - script_path, - render_path - ) - # Store output dir for unified publisher (filesequence) - instance.data["deadlineSubmissionJob"] = response.json() - - instance.data["outputDir"] = os.path.dirname( - render_path).replace("\\", "/") - - instance.data["publishJobState"] = "Suspended" - - # adding 2d render specific family for version identification in Loader - instance.data["families"] = ["render2d"] - - def payload_submit(self, - instance, - script_path, - render_path - ): - resolution_width = instance.data["resolutionWidth"] - resolution_height = instance.data["resolutionHeight"] - render_dir = os.path.normpath(os.path.dirname(render_path)) - render_path = os.path.normpath(render_path) - script_name = os.path.basename(script_path) - - anatomy = instance.context.data["anatomy"] - publish_template = anatomy.get_template_item( - "publish", "default", "path" - ) - for item in instance.context: - if "workfile" in item.data["productType"]: - msg = "Workfile (scene) must be published along" - assert item.data["publish"] is True, msg - - template_data = item.data.get("anatomyData") - rep = item.data.get("representations")[0].get("name") - template_data["representation"] = rep - template_data["ext"] = rep - template_data["comment"] = None - template_filled = publish_template.format_strict( - template_data - ) - script_path = os.path.normpath(template_filled) - - self.log.info( - "Using published scene for render {}".format(script_path) - ) - - jobname = "%s - %s" % (script_name, instance.name) - - output_filename_0 = self.preview_fname(render_path) - - try: - # Ensure render folder exists - os.makedirs(render_dir) - except OSError: - pass - - # define chunk and priority - chunk_size = instance.context.data.get("chunk") - if not chunk_size: - chunk_size = self.deadline_chunk_size - - # search for %02d pattern in name, and padding number - search_results = re.search(r"(%0)(\d)(d)[._]", render_path).groups() - split_patern = "".join(search_results) - padding_number = int(search_results[1]) - - args = [ - f"{script_path}", - "-a", - "-16", - "-s ", - "-e ", - f"-d {render_dir}", - f"-x {resolution_width}", - f"-y {resolution_height}", - f"-r {render_path.replace(split_patern, '')}", - f"-= AbsoluteFrameNumber=on -= PadDigits={padding_number}", - "-= ClearAttachment=on", - ] - - payload = { - "JobInfo": { - # Job name, as seen in Monitor - "Name": jobname, - - # plugin definition - "Plugin": "CelAction", - - # Top-level group name - "BatchName": script_name, - - # Arbitrary username, for visualisation in Monitor - "UserName": self._deadline_user, - - "Department": self.deadline_department, - "Priority": self.deadline_priority, - - "Group": self.deadline_group, - "Pool": self.deadline_pool, - "SecondaryPool": self.deadline_pool_secondary, - "ChunkSize": chunk_size, - - "Frames": f"{self._frame_start}-{self._frame_end}", - "Comment": self._comment, - - # Optional, enable double-click to preview rendered - # frames from Deadline Monitor - "OutputFilename0": output_filename_0.replace("\\", "/"), - - # # Asset dependency to wait for at least - # the scene file to sync. - # "AssetDependency0": script_path - "ScheduledType": "Once", - "JobDelay": self.deadline_job_delay - }, - "PluginInfo": { - # Input - "SceneFile": script_path, - - # Output directory - "OutputFilePath": render_dir.replace("\\", "/"), - - # Plugin attributes - "StartupDirectory": "", - "Arguments": " ".join(args), - - # Resolve relative references - "ProjectPath": script_path, - "AWSAssetFile0": render_path, - }, - - # Mandatory for Deadline, may be empty - "AuxFiles": [] - } - - plugin = payload["JobInfo"]["Plugin"] - self.log.debug("using render plugin : {}".format(plugin)) - - self.log.debug("Submitting..") - self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) - - # adding expectied files to instance.data - self.expected_files(instance, render_path) - self.log.debug("__ expectedFiles: `{}`".format( - instance.data["expectedFiles"])) - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - response = requests_post(self.deadline_url, json=payload, - auth=auth, - verify=verify) - - if not response.ok: - self.log.error( - "Submission failed! [{}] {}".format( - response.status_code, response.content)) - self.log.debug(payload) - raise SystemExit(response.text) - - return response - - def preflight_check(self, instance): - """Ensure the startFrame, endFrame and byFrameStep are integers""" - - for key in ("frameStart", "frameEnd"): - value = instance.data[key] - - if int(value) == value: - continue - - self.log.warning( - "%f=%d was rounded off to nearest integer" - % (value, int(value)) - ) - - def preview_fname(self, path): - """Return output file path with #### for padding. - - Deadline requires the path to be formatted with # in place of numbers. - For example `/path/to/render.####.png` - - Args: - path (str): path to rendered images - - Returns: - str - - """ - self.log.debug("_ path: `{}`".format(path)) - if "%" in path: - search_results = re.search(r"[._](%0)(\d)(d)[._]", path).groups() - split_patern = "".join(search_results) - split_path = path.split(split_patern) - hashes = "#" * int(search_results[1]) - return "".join([split_path[0], hashes, split_path[-1]]) - - self.log.debug("_ path: `{}`".format(path)) - return path - - def expected_files(self, instance, filepath): - """ Create expected files in instance data - """ - if not instance.data.get("expectedFiles"): - instance.data["expectedFiles"] = [] - - dirpath = os.path.dirname(filepath) - filename = os.path.basename(filepath) - - if "#" in filename: - pparts = filename.split("#") - padding = "%0{}d".format(len(pparts) - 1) - filename = pparts[0] + padding + pparts[-1] - - if "%" not in filename: - instance.data["expectedFiles"].append(filepath) - return - - for i in range(self._frame_start, (self._frame_end + 1)): - instance.data["expectedFiles"].append( - os.path.join(dirpath, (filename % i)).replace("\\", "/") - ) diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_fusion_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_fusion_deadline.py deleted file mode 100644 index bf9df40edc..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_fusion_deadline.py +++ /dev/null @@ -1,253 +0,0 @@ -import os -import json -import getpass - -import pyblish.api - -from ayon_deadline.abstract_submit_deadline import requests_post -from ayon_core.pipeline.publish import ( - AYONPyblishPluginMixin -) -from ayon_core.lib import NumberDef - - -class FusionSubmitDeadline( - pyblish.api.InstancePlugin, - AYONPyblishPluginMixin -): - """Submit current Comp to Deadline - - Renders are submitted to a Deadline Web Service as - supplied via settings key "DEADLINE_REST_URL". - - """ - - label = "Submit Fusion to Deadline" - order = pyblish.api.IntegratorOrder - hosts = ["fusion"] - families = ["render"] - targets = ["local"] - settings_category = "deadline" - - # presets - plugin = None - - priority = 50 - chunk_size = 1 - concurrent_tasks = 1 - group = "" - - @classmethod - def get_attribute_defs(cls): - return [ - NumberDef( - "priority", - label="Priority", - default=cls.priority, - decimals=0 - ), - NumberDef( - "chunk", - label="Frames Per Task", - default=cls.chunk_size, - decimals=0, - minimum=1, - maximum=1000 - ), - NumberDef( - "concurrency", - label="Concurrency", - default=cls.concurrent_tasks, - decimals=0, - minimum=1, - maximum=10 - ) - ] - - def process(self, instance): - if not instance.data.get("farm"): - self.log.debug("Skipping local instance.") - return - - attribute_values = self.get_attr_values_from_data( - instance.data) - - context = instance.context - - key = "__hasRun{}".format(self.__class__.__name__) - if context.data.get(key, False): - return - else: - context.data[key] = True - - from ayon_fusion.api.lib import get_frame_path - - deadline_url = instance.data["deadline"]["url"] - assert deadline_url, "Requires Deadline Webservice URL" - - # Collect all saver instances in context that are to be rendered - saver_instances = [] - for inst in context: - if inst.data["productType"] != "render": - # Allow only saver family instances - continue - - if not inst.data.get("publish", True): - # Skip inactive instances - continue - - self.log.debug(inst.data["name"]) - saver_instances.append(inst) - - if not saver_instances: - raise RuntimeError("No instances found for Deadline submission") - - comment = instance.data.get("comment", "") - deadline_user = context.data.get("deadlineUser", getpass.getuser()) - - script_path = context.data["currentFile"] - - anatomy = instance.context.data["anatomy"] - publish_template = anatomy.get_template_item( - "publish", "default", "path" - ) - for item in context: - if "workfile" in item.data["families"]: - msg = "Workfile (scene) must be published along" - assert item.data["publish"] is True, msg - - template_data = item.data.get("anatomyData") - rep = item.data.get("representations")[0].get("name") - template_data["representation"] = rep - template_data["ext"] = rep - template_data["comment"] = None - template_filled = publish_template.format_strict( - template_data - ) - script_path = os.path.normpath(template_filled) - - self.log.info( - "Using published scene for render {}".format(script_path) - ) - - filename = os.path.basename(script_path) - - # Documentation for keys available at: - # https://docs.thinkboxsoftware.com - # /products/deadline/8.0/1_User%20Manual/manual - # /manual-submission.html#job-info-file-options - payload = { - "JobInfo": { - # Top-level group name - "BatchName": filename, - - # Asset dependency to wait for at least the scene file to sync. - "AssetDependency0": script_path, - - # Job name, as seen in Monitor - "Name": filename, - - "Priority": attribute_values.get( - "priority", self.priority), - "ChunkSize": attribute_values.get( - "chunk", self.chunk_size), - "ConcurrentTasks": attribute_values.get( - "concurrency", - self.concurrent_tasks - ), - - # User, as seen in Monitor - "UserName": deadline_user, - - "Pool": instance.data.get("primaryPool"), - "SecondaryPool": instance.data.get("secondaryPool"), - "Group": self.group, - - "Plugin": self.plugin, - "Frames": "{start}-{end}".format( - start=int(instance.data["frameStartHandle"]), - end=int(instance.data["frameEndHandle"]) - ), - - "Comment": comment, - }, - "PluginInfo": { - # Input - "FlowFile": script_path, - - # Mandatory for Deadline - "Version": str(instance.data["app_version"]), - - # Render in high quality - "HighQuality": True, - - # Whether saver output should be checked after rendering - # is complete - "CheckOutput": True, - - # Proxy: higher numbers smaller images for faster test renders - # 1 = no proxy quality - "Proxy": 1 - }, - - # Mandatory for Deadline, may be empty - "AuxFiles": [] - } - - # Enable going to rendered frames from Deadline Monitor - for index, instance in enumerate(saver_instances): - head, padding, tail = get_frame_path( - instance.data["expectedFiles"][0] - ) - path = "{}{}{}".format(head, "#" * padding, tail) - folder, filename = os.path.split(path) - payload["JobInfo"]["OutputDirectory%d" % index] = folder - payload["JobInfo"]["OutputFilename%d" % index] = filename - - # Include critical variables with submission - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_LOG_NO_COLORS", - "AYON_IN_TESTS", - "AYON_BUNDLE_NAME", - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - # to recognize render jobs - environment["AYON_RENDER_JOB"] = "1" - - payload["JobInfo"].update({ - "EnvironmentKeyValue%d" % index: "{key}={value}".format( - key=key, - value=environment[key] - ) for index, key in enumerate(environment) - }) - - self.log.debug("Submitting..") - self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) - - # E.g. http://192.168.0.1:8082/api/jobs - url = "{}/api/jobs".format(deadline_url) - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - response = requests_post(url, json=payload, auth=auth, verify=verify) - if not response.ok: - raise Exception(response.text) - - # Store the response for dependent job submission plug-ins - for instance in saver_instances: - instance.data["deadlineSubmissionJob"] = response.json() diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_harmony_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_harmony_deadline.py deleted file mode 100644 index bc91483c4f..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_harmony_deadline.py +++ /dev/null @@ -1,420 +0,0 @@ -# -*- coding: utf-8 -*- -"""Submitting render job to Deadline.""" -import os -from pathlib import Path -from collections import OrderedDict -from zipfile import ZipFile, is_zipfile -import re -from datetime import datetime - -import attr -import pyblish.api - -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo -from ayon_core.lib import is_in_tests - - -class _ZipFile(ZipFile): - """Extended check for windows invalid characters.""" - - # this is extending default zipfile table for few invalid characters - # that can come from Mac - _windows_illegal_characters = ":<>|\"?*\r\n\x00" - _windows_illegal_name_trans_table = str.maketrans( - _windows_illegal_characters, - "_" * len(_windows_illegal_characters) - ) - - -@attr.s -class PluginInfo(object): - """Plugin info structure for Harmony Deadline plugin.""" - - SceneFile = attr.ib() - # Harmony version - Version = attr.ib() - - Camera = attr.ib(default="") - FieldOfView = attr.ib(default=41.11) - IsDatabase = attr.ib(default=False) - ResolutionX = attr.ib(default=1920) - ResolutionY = attr.ib(default=1080) - - # Resolution name preset, default - UsingResPreset = attr.ib(default=False) - ResolutionName = attr.ib(default="HDTV_1080p24") - - PreRenderInlineScript = attr.ib(default=None) - - # -------------------------------------------------- - _outputNode = attr.ib(factory=list) - - @property - def OutputNode(self): # noqa: N802 - """Return all output nodes formatted for Deadline. - - Returns: - dict: as `{'Output0Node', 'Top/renderFarmDefault'}` - - """ - out = {} - for index, v in enumerate(self._outputNode): - out["Output{}Node".format(index)] = v - return out - - @OutputNode.setter - def OutputNode(self, val): # noqa: N802 - self._outputNode.append(val) - - # -------------------------------------------------- - _outputType = attr.ib(factory=list) - - @property - def OutputType(self): # noqa: N802 - """Return output nodes type formatted for Deadline. - - Returns: - dict: as `{'Output0Type', 'Image'}` - - """ - out = {} - for index, v in enumerate(self._outputType): - out["Output{}Type".format(index)] = v - return out - - @OutputType.setter - def OutputType(self, val): # noqa: N802 - self._outputType.append(val) - - # -------------------------------------------------- - _outputLeadingZero = attr.ib(factory=list) - - @property - def OutputLeadingZero(self): # noqa: N802 - """Return output nodes type formatted for Deadline. - - Returns: - dict: as `{'Output0LeadingZero', '3'}` - - """ - out = {} - for index, v in enumerate(self._outputLeadingZero): - out["Output{}LeadingZero".format(index)] = v - return out - - @OutputLeadingZero.setter - def OutputLeadingZero(self, val): # noqa: N802 - self._outputLeadingZero.append(val) - - # -------------------------------------------------- - _outputFormat = attr.ib(factory=list) - - @property - def OutputFormat(self): # noqa: N802 - """Return output nodes format formatted for Deadline. - - Returns: - dict: as `{'Output0Type', 'PNG4'}` - - """ - out = {} - for index, v in enumerate(self._outputFormat): - out["Output{}Format".format(index)] = v - return out - - @OutputFormat.setter - def OutputFormat(self, val): # noqa: N802 - self._outputFormat.append(val) - - # -------------------------------------------------- - _outputStartFrame = attr.ib(factory=list) - - @property - def OutputStartFrame(self): # noqa: N802 - """Return start frame for output nodes formatted for Deadline. - - Returns: - dict: as `{'Output0StartFrame', '1'}` - - """ - out = {} - for index, v in enumerate(self._outputStartFrame): - out["Output{}StartFrame".format(index)] = v - return out - - @OutputStartFrame.setter - def OutputStartFrame(self, val): # noqa: N802 - self._outputStartFrame.append(val) - - # -------------------------------------------------- - _outputPath = attr.ib(factory=list) - - @property - def OutputPath(self): # noqa: N802 - """Return output paths for nodes formatted for Deadline. - - Returns: - dict: as `{'Output0Path', '/output/path'}` - - """ - out = {} - for index, v in enumerate(self._outputPath): - out["Output{}Path".format(index)] = v - return out - - @OutputPath.setter - def OutputPath(self, val): # noqa: N802 - self._outputPath.append(val) - - def set_output(self, node, image_format, output, - output_type="Image", zeros=3, start_frame=1): - """Helper to set output. - - This should be used instead of setting properties individually - as so index remain consistent. - - Args: - node (str): harmony write node name - image_format (str): format of output (PNG4, TIF, ...) - output (str): output path - output_type (str, optional): "Image" or "Movie" (not supported). - zeros (int, optional): Leading zeros (for 0001 = 3) - start_frame (int, optional): Sequence offset. - - """ - - self.OutputNode = node - self.OutputFormat = image_format - self.OutputPath = output - self.OutputType = output_type - self.OutputLeadingZero = zeros - self.OutputStartFrame = start_frame - - def serialize(self): - """Return all data serialized as dictionary. - - Returns: - OrderedDict: all serialized data. - - """ - def filter_data(a, v): - if a.name.startswith("_"): - return False - if v is None: - return False - return True - - serialized = attr.asdict( - self, dict_factory=OrderedDict, filter=filter_data) - serialized.update(self.OutputNode) - serialized.update(self.OutputFormat) - serialized.update(self.OutputPath) - serialized.update(self.OutputType) - serialized.update(self.OutputLeadingZero) - serialized.update(self.OutputStartFrame) - - return serialized - - -class HarmonySubmitDeadline( - abstract_submit_deadline.AbstractSubmitDeadline -): - """Submit render write of Harmony scene to Deadline. - - Renders are submitted to a Deadline Web Service as - supplied via the environment variable ``DEADLINE_REST_URL``. - - Note: - If Deadline configuration is not detected, this plugin will - be disabled. - - Attributes: - use_published (bool): Use published scene to render instead of the - one in work area. - - """ - - label = "Submit to Deadline" - order = pyblish.api.IntegratorOrder + 0.1 - hosts = ["harmony"] - families = ["render.farm"] - targets = ["local"] - settings_category = "deadline" - - optional = True - use_published = False - priority = 50 - chunk_size = 1000000 - group = "none" - department = "" - - def get_job_info(self): - job_info = DeadlineJobInfo("Harmony") - job_info.Name = self._instance.data["name"] - job_info.Plugin = "HarmonyAYON" - job_info.Frames = "{}-{}".format( - self._instance.data["frameStartHandle"], - self._instance.data["frameEndHandle"] - ) - # for now, get those from presets. Later on it should be - # configurable in Harmony UI directly. - job_info.Priority = self.priority - job_info.Pool = self._instance.data.get("primaryPool") - job_info.SecondaryPool = self._instance.data.get("secondaryPool") - job_info.ChunkSize = self.chunk_size - batch_name = os.path.basename(self._instance.data["source"]) - if is_in_tests(): - batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - job_info.BatchName = batch_name - job_info.Department = self.department - job_info.Group = self.group - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_LOG_NO_COLORS" - "AYON_IN_TESTS" - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - for key in keys: - value = environment.get(key) - if value: - job_info.EnvironmentKeyValue[key] = value - - # to recognize render jobs - job_info.add_render_job_env_var() - - return job_info - - def _unzip_scene_file(self, published_scene: Path) -> Path: - """Unzip scene zip file to its directory. - - Unzip scene file (if it is zip file) to its current directory and - return path to xstage file there. Xstage file is determined by its - name. - - Args: - published_scene (Path): path to zip file. - - Returns: - Path: The path to unzipped xstage. - """ - # if not zip, bail out. - if "zip" not in published_scene.suffix or not is_zipfile( - published_scene.as_posix() - ): - self.log.error("Published scene is not in zip.") - self.log.error(published_scene) - raise AssertionError("invalid scene format") - - xstage_path = ( - published_scene.parent - / published_scene.stem - / f"{published_scene.stem}.xstage" - ) - unzip_dir = (published_scene.parent / published_scene.stem) - with _ZipFile(published_scene, "r") as zip_ref: - # UNC path (//?/) added to minimalize risk with extracting - # to large file paths - zip_ref.extractall("//?/" + str(unzip_dir.as_posix())) - - # find any xstage files in directory, prefer the one with the same name - # as directory (plus extension) - xstage_files = [] - for scene in unzip_dir.iterdir(): - if scene.suffix == ".xstage": - xstage_files.append(scene) - - # there must be at least one (but maybe not more?) xstage file - if not xstage_files: - self.log.error("No xstage files found in zip") - raise AssertionError("Invalid scene archive") - - ideal_scene = False - # find the one with the same name as zip. In case there can be more - # then one xtage file. - for scene in xstage_files: - # if /foo/bar/baz.zip == /foo/bar/baz/baz.xstage - # ^^^ ^^^ - if scene.stem == published_scene.stem: - xstage_path = scene - ideal_scene = True - - # but sometimes xstage file has different name then zip - in that case - # use that one. - if not ideal_scene: - xstage_path = xstage_files[0] - return xstage_path - - def get_plugin_info(self): - # this is path to published scene workfile _ZIP_. Before - # rendering, we need to unzip it. - published_scene = Path( - self.from_published_scene(False)) - self.log.debug(f"Processing {published_scene.as_posix()}") - xstage_path = self._unzip_scene_file(published_scene) - render_path = xstage_path.parent / "renders" - - # for submit_publish job to create .json file in - self._instance.data["outputDir"] = render_path - new_expected_files = [] - render_path_str = str(render_path.as_posix()) - for file in self._instance.data["expectedFiles"]: - _file = str(Path(file).as_posix()) - expected_dir_str = os.path.dirname(_file) - new_expected_files.append( - _file.replace(expected_dir_str, render_path_str) - ) - audio_file = self._instance.data.get("audioFile") - if audio_file: - abs_path = xstage_path.parent / audio_file - self._instance.context.data["audioFile"] = str(abs_path) - - self._instance.data["source"] = str(published_scene.as_posix()) - self._instance.data["expectedFiles"] = new_expected_files - harmony_plugin_info = PluginInfo( - SceneFile=xstage_path.as_posix(), - Version=( - self._instance.context.data["harmonyVersion"].split(".")[0]), - FieldOfView=self._instance.context.data["FOV"], - ResolutionX=self._instance.data["resolutionWidth"], - ResolutionY=self._instance.data["resolutionHeight"] - ) - - pattern = '[0]{' + str(self._instance.data["leadingZeros"]) + \ - '}1\.[a-zA-Z]{3}' - render_prefix = re.sub(pattern, '', - self._instance.data["expectedFiles"][0]) - harmony_plugin_info.set_output( - self._instance.data["setMembers"][0], - self._instance.data["outputFormat"], - render_prefix, - self._instance.data["outputType"], - self._instance.data["leadingZeros"], - self._instance.data["outputStartFrame"] - ) - - all_write_nodes = self._instance.context.data["all_write_nodes"] - disable_nodes = [] - for node in all_write_nodes: - # disable all other write nodes - if node != self._instance.data["setMembers"][0]: - disable_nodes.append("node.setEnable('{}', false)" - .format(node)) - harmony_plugin_info.PreRenderInlineScript = ';'.join(disable_nodes) - - return harmony_plugin_info.serialize() diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_cache_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_cache_deadline.py deleted file mode 100644 index ac9ad570c3..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_cache_deadline.py +++ /dev/null @@ -1,181 +0,0 @@ -import os -import getpass -from datetime import datetime - -import attr -import pyblish.api -from ayon_core.lib import ( - TextDef, - NumberDef, - is_in_tests, -) -from ayon_core.pipeline import ( - AYONPyblishPluginMixin -) -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo - - -@attr.s -class HoudiniPluginInfo(object): - Build = attr.ib(default=None) - IgnoreInputs = attr.ib(default=True) - ScriptJob = attr.ib(default=True) - SceneFile = attr.ib(default=None) # Input - SaveFile = attr.ib(default=True) - ScriptFilename = attr.ib(default=None) - OutputDriver = attr.ib(default=None) - Version = attr.ib(default=None) # Mandatory for Deadline - ProjectPath = attr.ib(default=None) - - -class HoudiniCacheSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, # noqa - AYONPyblishPluginMixin): - """Submit Houdini scene to perform a local publish in Deadline. - - Publishing in Deadline can be helpful for scenes that publish very slow. - This way it can process in the background on another machine without the - Artist having to wait for the publish to finish on their local machine. - """ - - label = "Submit Scene to Deadline" - order = pyblish.api.IntegratorOrder - hosts = ["houdini"] - families = ["publish.hou"] - targets = ["local"] - settings_category = "deadline" - - priority = 50 - chunk_size = 999999 - group = None - jobInfo = {} - pluginInfo = {} - - - def get_job_info(self): - job_info = DeadlineJobInfo(Plugin="Houdini") - - job_info.update(self.jobInfo) - instance = self._instance - context = instance.context - assert all( - result["success"] for result in context.data["results"] - ), "Errors found, aborting integration.." - - project_name = instance.context.data["projectName"] - filepath = context.data["currentFile"] - scenename = os.path.basename(filepath) - job_name = "{scene} - {instance} [PUBLISH]".format( - scene=scenename, instance=instance.name) - batch_name = "{code} - {scene}".format(code=project_name, - scene=scenename) - if is_in_tests(): - batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - - job_info.Name = job_name - job_info.BatchName = batch_name - job_info.Plugin = instance.data["plugin"] - job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) - rop_node = self.get_rop_node(instance) - if rop_node.type().name() != "alembic": - frames = "{start}-{end}x{step}".format( - start=int(instance.data["frameStart"]), - end=int(instance.data["frameEnd"]), - step=int(instance.data["byFrameStep"]), - ) - - job_info.Frames = frames - - job_info.Pool = instance.data.get("primaryPool") - job_info.SecondaryPool = instance.data.get("secondaryPool") - - attr_values = self.get_attr_values_from_data(instance.data) - - job_info.ChunkSize = instance.data.get("chunk_size", self.chunk_size) - job_info.Comment = context.data.get("comment") - job_info.Priority = attr_values.get("priority", self.priority) - job_info.Group = attr_values.get("group", self.group) - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "OPENPYPE_SG_USER", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_LOG_NO_COLORS", - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - for key in keys: - value = environment.get(key) - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - # to recognize render jobs - job_info.add_render_job_env_var() - - return job_info - - def get_plugin_info(self): - # Not all hosts can import this module. - import hou - - instance = self._instance - version = hou.applicationVersionString() - version = ".".join(version.split(".")[:2]) - rop = self.get_rop_node(instance) - plugin_info = HoudiniPluginInfo( - Build=None, - IgnoreInputs=True, - ScriptJob=True, - SceneFile=self.scene_path, - SaveFile=True, - OutputDriver=rop.path(), - Version=version, - ProjectPath=os.path.dirname(self.scene_path) - ) - - plugin_payload = attr.asdict(plugin_info) - - return plugin_payload - - def process(self, instance): - super(HoudiniCacheSubmitDeadline, self).process(instance) - output_dir = os.path.dirname(instance.data["files"][0]) - instance.data["outputDir"] = output_dir - instance.data["toBeRenderedOn"] = "deadline" - - def get_rop_node(self, instance): - # Not all hosts can import this module. - import hou - - rop = instance.data.get("instance_node") - rop_node = hou.node(rop) - - return rop_node - - @classmethod - def get_attribute_defs(cls): - defs = super(HoudiniCacheSubmitDeadline, cls).get_attribute_defs() - defs.extend([ - NumberDef("priority", - minimum=1, - maximum=250, - decimals=0, - default=cls.priority, - label="Priority"), - TextDef("group", - default=cls.group, - label="Group Name"), - ]) - - return defs diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_render_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_render_deadline.py deleted file mode 100644 index 7956108e77..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_houdini_render_deadline.py +++ /dev/null @@ -1,403 +0,0 @@ -import os -import attr -import getpass -from datetime import datetime - -import pyblish.api - -from ayon_core.pipeline import AYONPyblishPluginMixin -from ayon_core.lib import ( - is_in_tests, - TextDef, - NumberDef -) -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo - - -@attr.s -class DeadlinePluginInfo(): - SceneFile = attr.ib(default=None) - OutputDriver = attr.ib(default=None) - Version = attr.ib(default=None) - IgnoreInputs = attr.ib(default=True) - - -@attr.s -class ArnoldRenderDeadlinePluginInfo(): - InputFile = attr.ib(default=None) - Verbose = attr.ib(default=4) - - -@attr.s -class MantraRenderDeadlinePluginInfo(): - SceneFile = attr.ib(default=None) - Version = attr.ib(default=None) - - -@attr.s -class VrayRenderPluginInfo(): - InputFilename = attr.ib(default=None) - SeparateFilesPerFrame = attr.ib(default=True) - - -@attr.s -class RedshiftRenderPluginInfo(): - SceneFile = attr.ib(default=None) - # Use "1" as the default Redshift version just because it - # default fallback version in Deadline's Redshift plugin - # if no version was specified - Version = attr.ib(default="1") - - -@attr.s -class HuskStandalonePluginInfo(): - """Requires Deadline Husk Standalone Plugin. - See Deadline Plug-in: - https://github.com/BigRoy/HuskStandaloneSubmitter - Also see Husk options here: - https://www.sidefx.com/docs/houdini/ref/utils/husk.html - """ - SceneFile = attr.ib() - # TODO: Below parameters are only supported by custom version of the plugin - Renderer = attr.ib(default=None) - RenderSettings = attr.ib(default="/Render/rendersettings") - Purpose = attr.ib(default="geometry,render") - Complexity = attr.ib(default="veryhigh") - Snapshot = attr.ib(default=-1) - LogLevel = attr.ib(default="2") - PreRender = attr.ib(default="") - PreFrame = attr.ib(default="") - PostFrame = attr.ib(default="") - PostRender = attr.ib(default="") - RestartDelegate = attr.ib(default="") - Version = attr.ib(default="") - - -class HoudiniSubmitDeadline( - abstract_submit_deadline.AbstractSubmitDeadline, - AYONPyblishPluginMixin -): - """Submit Render ROPs to Deadline. - - Renders are submitted to a Deadline Web Service as - supplied via the environment variable AVALON_DEADLINE. - - Target "local": - Even though this does *not* render locally this is seen as - a 'local' submission as it is the regular way of submitting - a Houdini render locally. - - """ - - label = "Submit Render to Deadline" - order = pyblish.api.IntegratorOrder - hosts = ["houdini"] - families = ["redshift_rop", - "arnold_rop", - "mantra_rop", - "karma_rop", - "vray_rop"] - targets = ["local"] - settings_category = "deadline" - use_published = True - - # presets - export_priority = 50 - export_chunk_size = 10 - export_group = "" - priority = 50 - chunk_size = 1 - group = "" - - @classmethod - def get_attribute_defs(cls): - return [ - NumberDef( - "priority", - label="Priority", - default=cls.priority, - decimals=0 - ), - NumberDef( - "chunk", - label="Frames Per Task", - default=cls.chunk_size, - decimals=0, - minimum=1, - maximum=1000 - ), - TextDef( - "group", - default=cls.group, - label="Group Name" - ), - NumberDef( - "export_priority", - label="Export Priority", - default=cls.export_priority, - decimals=0 - ), - NumberDef( - "export_chunk", - label="Export Frames Per Task", - default=cls.export_chunk_size, - decimals=0, - minimum=1, - maximum=1000 - ), - TextDef( - "export_group", - default=cls.export_group, - label="Export Group Name" - ), - ] - - def get_job_info(self, dependency_job_ids=None): - - instance = self._instance - context = instance.context - - attribute_values = self.get_attr_values_from_data(instance.data) - - # Whether Deadline render submission is being split in two - # (extract + render) - split_render_job = instance.data.get("splitRender") - - # If there's some dependency job ids we can assume this is a render job - # and not an export job - is_export_job = True - if dependency_job_ids: - is_export_job = False - - job_type = "[RENDER]" - if split_render_job and not is_export_job: - product_type = instance.data["productType"] - plugin = { - "usdrender": "HuskStandalone", - }.get(product_type) - if not plugin: - # Convert from product type to Deadline plugin name - # i.e., arnold_rop -> Arnold - plugin = product_type.replace("_rop", "").capitalize() - else: - plugin = "Houdini" - if split_render_job: - job_type = "[EXPORT IFD]" - - job_info = DeadlineJobInfo(Plugin=plugin) - - filepath = context.data["currentFile"] - filename = os.path.basename(filepath) - job_info.Name = "{} - {} {}".format(filename, instance.name, job_type) - job_info.BatchName = filename - - job_info.UserName = context.data.get( - "deadlineUser", getpass.getuser()) - - if is_in_tests(): - job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") - - # Deadline requires integers in frame range - start = instance.data["frameStartHandle"] - end = instance.data["frameEndHandle"] - frames = "{start}-{end}x{step}".format( - start=int(start), - end=int(end), - step=int(instance.data["byFrameStep"]), - ) - job_info.Frames = frames - - # Make sure we make job frame dependent so render tasks pick up a soon - # as export tasks are done - if split_render_job and not is_export_job: - job_info.IsFrameDependent = bool(instance.data.get( - "splitRenderFrameDependent", True)) - - job_info.Pool = instance.data.get("primaryPool") - job_info.SecondaryPool = instance.data.get("secondaryPool") - - if split_render_job and is_export_job: - job_info.Priority = attribute_values.get( - "export_priority", self.export_priority - ) - job_info.ChunkSize = attribute_values.get( - "export_chunk", self.export_chunk_size - ) - job_info.Group = self.export_group - else: - job_info.Priority = attribute_values.get( - "priority", self.priority - ) - job_info.ChunkSize = attribute_values.get( - "chunk", self.chunk_size - ) - job_info.Group = self.group - - # Apply render globals, like e.g. data from collect machine list - render_globals = instance.data.get("renderGlobals", {}) - if render_globals: - self.log.debug("Applying 'renderGlobals' to job info: %s", - render_globals) - job_info.update(render_globals) - - job_info.Comment = context.data.get("comment") - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "OPENPYPE_SG_USER", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_LOG_NO_COLORS", - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - for key in keys: - value = environment.get(key) - if value: - job_info.EnvironmentKeyValue[key] = value - - # to recognize render jobs - job_info.add_render_job_env_var() - - for i, filepath in enumerate(instance.data["files"]): - dirname = os.path.dirname(filepath) - fname = os.path.basename(filepath) - job_info.OutputDirectory += dirname.replace("\\", "/") - job_info.OutputFilename += fname - - # Add dependencies if given - if dependency_job_ids: - job_info.JobDependencies = ",".join(dependency_job_ids) - - return job_info - - def get_plugin_info(self, job_type=None): - # Not all hosts can import this module. - import hou - - instance = self._instance - context = instance.context - - hou_major_minor = hou.applicationVersionString().rsplit(".", 1)[0] - - # Output driver to render - if job_type == "render": - product_type = instance.data.get("productType") - if product_type == "arnold_rop": - plugin_info = ArnoldRenderDeadlinePluginInfo( - InputFile=instance.data["ifdFile"] - ) - elif product_type == "mantra_rop": - plugin_info = MantraRenderDeadlinePluginInfo( - SceneFile=instance.data["ifdFile"], - Version=hou_major_minor, - ) - elif product_type == "vray_rop": - plugin_info = VrayRenderPluginInfo( - InputFilename=instance.data["ifdFile"], - ) - elif product_type == "redshift_rop": - plugin_info = RedshiftRenderPluginInfo( - SceneFile=instance.data["ifdFile"] - ) - # Note: To use different versions of Redshift on Deadline - # set the `REDSHIFT_VERSION` env variable in the Tools - # settings in the AYON Application plugin. You will also - # need to set that version in `Redshift.param` file - # of the Redshift Deadline plugin: - # [Redshift_Executable_*] - # where * is the version number. - if os.getenv("REDSHIFT_VERSION"): - plugin_info.Version = os.getenv("REDSHIFT_VERSION") - else: - self.log.warning(( - "REDSHIFT_VERSION env variable is not set" - " - using version configured in Deadline" - )) - - elif product_type == "usdrender": - plugin_info = self._get_husk_standalone_plugin_info( - instance, hou_major_minor) - - else: - self.log.error( - "Product type '%s' not supported yet to split render job", - product_type - ) - return - else: - driver = hou.node(instance.data["instance_node"]) - plugin_info = DeadlinePluginInfo( - SceneFile=context.data["currentFile"], - OutputDriver=driver.path(), - Version=hou_major_minor, - IgnoreInputs=True - ) - - return attr.asdict(plugin_info) - - def process(self, instance): - if not instance.data["farm"]: - self.log.debug("Render on farm is disabled. " - "Skipping deadline submission.") - return - - super(HoudiniSubmitDeadline, self).process(instance) - - # TODO: Avoid the need for this logic here, needed for submit publish - # Store output dir for unified publisher (filesequence) - output_dir = os.path.dirname(instance.data["files"][0]) - instance.data["outputDir"] = output_dir - - def _get_husk_standalone_plugin_info(self, instance, hou_major_minor): - # Not all hosts can import this module. - import hou - - # Supply additional parameters from the USD Render ROP - # to the Husk Standalone Render Plug-in - rop_node = hou.node(instance.data["instance_node"]) - snapshot_interval = -1 - if rop_node.evalParm("dosnapshot"): - snapshot_interval = rop_node.evalParm("snapshotinterval") - - restart_delegate = 0 - if rop_node.evalParm("husk_restartdelegate"): - restart_delegate = rop_node.evalParm("husk_restartdelegateframes") - - rendersettings = ( - rop_node.evalParm("rendersettings") - or "/Render/rendersettings" - ) - return HuskStandalonePluginInfo( - SceneFile=instance.data["ifdFile"], - Renderer=rop_node.evalParm("renderer"), - RenderSettings=rendersettings, - Purpose=rop_node.evalParm("husk_purpose"), - Complexity=rop_node.evalParm("husk_complexity"), - Snapshot=snapshot_interval, - PreRender=rop_node.evalParm("husk_prerender"), - PreFrame=rop_node.evalParm("husk_preframe"), - PostFrame=rop_node.evalParm("husk_postframe"), - PostRender=rop_node.evalParm("husk_postrender"), - RestartDelegate=restart_delegate, - Version=hou_major_minor - ) - - -class HoudiniSubmitDeadlineUsdRender(HoudiniSubmitDeadline): - # Do not use published workfile paths for USD Render ROP because the - # Export Job doesn't seem to occur using the published path either, so - # output paths then do not match the actual rendered paths - use_published = False - families = ["usdrender"] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_max_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_max_deadline.py deleted file mode 100644 index 6a369eb001..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_max_deadline.py +++ /dev/null @@ -1,431 +0,0 @@ -import os -import getpass -import copy -import attr - -from ayon_core.lib import ( - TextDef, - BoolDef, - NumberDef, -) -from ayon_core.pipeline import ( - AYONPyblishPluginMixin -) -from ayon_core.pipeline.publish.lib import ( - replace_with_published_scene_path -) -from ayon_core.pipeline.publish import KnownPublishError -from ayon_max.api.lib import ( - get_current_renderer, - get_multipass_setting -) -from ayon_max.api.lib_rendersettings import RenderSettings -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo - - -@attr.s -class MaxPluginInfo(object): - SceneFile = attr.ib(default=None) # Input - Version = attr.ib(default=None) # Mandatory for Deadline - SaveFile = attr.ib(default=True) - IgnoreInputs = attr.ib(default=True) - - -class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, - AYONPyblishPluginMixin): - - label = "Submit Render to Deadline" - hosts = ["max"] - families = ["maxrender"] - targets = ["local"] - settings_category = "deadline" - - use_published = True - priority = 50 - chunk_size = 1 - jobInfo = {} - pluginInfo = {} - group = None - - @classmethod - def apply_settings(cls, project_settings): - settings = project_settings["deadline"]["publish"]["MaxSubmitDeadline"] # noqa - - # Take some defaults from settings - cls.use_published = settings.get("use_published", - cls.use_published) - cls.priority = settings.get("priority", - cls.priority) - cls.chuck_size = settings.get("chunk_size", cls.chunk_size) - cls.group = settings.get("group", cls.group) - # TODO: multiple camera instance, separate job infos - def get_job_info(self): - job_info = DeadlineJobInfo(Plugin="3dsmax") - - # todo: test whether this works for existing production cases - # where custom jobInfo was stored in the project settings - job_info.update(self.jobInfo) - - instance = self._instance - context = instance.context - # Always use the original work file name for the Job name even when - # rendering is done from the published Work File. The original work - # file name is clearer because it can also have subversion strings, - # etc. which are stripped for the published file. - - src_filepath = context.data["currentFile"] - src_filename = os.path.basename(src_filepath) - job_info.Name = "%s - %s" % (src_filename, instance.name) - job_info.BatchName = src_filename - job_info.Plugin = instance.data["plugin"] - job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) - job_info.EnableAutoTimeout = True - # Deadline requires integers in frame range - frames = "{start}-{end}".format( - start=int(instance.data["frameStart"]), - end=int(instance.data["frameEnd"]) - ) - job_info.Frames = frames - - job_info.Pool = instance.data.get("primaryPool") - job_info.SecondaryPool = instance.data.get("secondaryPool") - - attr_values = self.get_attr_values_from_data(instance.data) - - job_info.ChunkSize = attr_values.get("chunkSize", 1) - job_info.Comment = context.data.get("comment") - job_info.Priority = attr_values.get("priority", self.priority) - job_info.Group = attr_values.get("group", self.group) - - # Add options from RenderGlobals - render_globals = instance.data.get("renderGlobals", {}) - job_info.update(render_globals) - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "OPENPYPE_SG_USER", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_IN_TESTS", - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - for key in keys: - value = environment.get(key) - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - - # to recognize render jobs - job_info.add_render_job_env_var() - job_info.EnvironmentKeyValue["AYON_LOG_NO_COLORS"] = "1" - - # Add list of expected files to job - # --------------------------------- - if not instance.data.get("multiCamera"): - exp = instance.data.get("expectedFiles") - for filepath in self._iter_expected_files(exp): - job_info.OutputDirectory += os.path.dirname(filepath) - job_info.OutputFilename += os.path.basename(filepath) - - return job_info - - def get_plugin_info(self): - instance = self._instance - - plugin_info = MaxPluginInfo( - SceneFile=self.scene_path, - Version=instance.data["maxversion"], - SaveFile=True, - IgnoreInputs=True - ) - - plugin_payload = attr.asdict(plugin_info) - - # Patching with pluginInfo from settings - for key, value in self.pluginInfo.items(): - plugin_payload[key] = value - - return plugin_payload - - def process_submission(self): - - instance = self._instance - filepath = instance.context.data["currentFile"] - - files = instance.data["expectedFiles"] - if not files: - raise KnownPublishError("No Render Elements found!") - first_file = next(self._iter_expected_files(files)) - output_dir = os.path.dirname(first_file) - instance.data["outputDir"] = output_dir - - filename = os.path.basename(filepath) - - payload_data = { - "filename": filename, - "dirname": output_dir - } - - self.log.debug("Submitting 3dsMax render..") - project_settings = instance.context.data["project_settings"] - auth = self._instance.data["deadline"]["auth"] - verify = self._instance.data["deadline"]["verify"] - if instance.data.get("multiCamera"): - self.log.debug("Submitting jobs for multiple cameras..") - payload = self._use_published_name_for_multiples( - payload_data, project_settings) - job_infos, plugin_infos = payload - for job_info, plugin_info in zip(job_infos, plugin_infos): - self.submit( - self.assemble_payload(job_info, plugin_info), - auth=auth, - verify=verify - ) - else: - payload = self._use_published_name(payload_data, project_settings) - job_info, plugin_info = payload - self.submit( - self.assemble_payload(job_info, plugin_info), - auth=auth, - verify=verify - ) - - def _use_published_name(self, data, project_settings): - # Not all hosts can import these modules. - from ayon_max.api.lib import ( - get_current_renderer, - get_multipass_setting - ) - from ayon_max.api.lib_rendersettings import RenderSettings - - instance = self._instance - job_info = copy.deepcopy(self.job_info) - plugin_info = copy.deepcopy(self.plugin_info) - plugin_data = {} - - multipass = get_multipass_setting(project_settings) - if multipass: - plugin_data["DisableMultipass"] = 0 - else: - plugin_data["DisableMultipass"] = 1 - - files = instance.data.get("expectedFiles") - if not files: - raise KnownPublishError("No render elements found") - first_file = next(self._iter_expected_files(files)) - old_output_dir = os.path.dirname(first_file) - output_beauty = RenderSettings().get_render_output(instance.name, - old_output_dir) - rgb_bname = os.path.basename(output_beauty) - dir = os.path.dirname(first_file) - beauty_name = f"{dir}/{rgb_bname}" - beauty_name = beauty_name.replace("\\", "/") - plugin_data["RenderOutput"] = beauty_name - # as 3dsmax has version with different languages - plugin_data["Language"] = "ENU" - - renderer_class = get_current_renderer() - - renderer = str(renderer_class).split(":")[0] - if renderer in [ - "ART_Renderer", - "Redshift_Renderer", - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3", - "Default_Scanline_Renderer", - "Quicksilver_Hardware_Renderer", - ]: - render_elem_list = RenderSettings().get_render_element() - for i, element in enumerate(render_elem_list): - elem_bname = os.path.basename(element) - new_elem = f"{dir}/{elem_bname}" - new_elem = new_elem.replace("/", "\\") - plugin_data["RenderElementOutputFilename%d" % i] = new_elem # noqa - - if renderer == "Redshift_Renderer": - plugin_data["redshift_SeparateAovFiles"] = instance.data.get( - "separateAovFiles") - if instance.data["cameras"]: - camera = instance.data["cameras"][0] - plugin_info["Camera0"] = camera - plugin_info["Camera"] = camera - plugin_info["Camera1"] = camera - self.log.debug("plugin data:{}".format(plugin_data)) - plugin_info.update(plugin_data) - - return job_info, plugin_info - - def get_job_info_through_camera(self, camera): - """Get the job parameters for deadline submission when - multi-camera is enabled. - Args: - infos(dict): a dictionary with job info. - """ - instance = self._instance - context = instance.context - job_info = copy.deepcopy(self.job_info) - exp = instance.data.get("expectedFiles") - - src_filepath = context.data["currentFile"] - src_filename = os.path.basename(src_filepath) - job_info.Name = "%s - %s - %s" % ( - src_filename, instance.name, camera) - for filepath in self._iter_expected_files(exp): - if camera not in filepath: - continue - job_info.OutputDirectory += os.path.dirname(filepath) - job_info.OutputFilename += os.path.basename(filepath) - - return job_info - # set the output filepath with the relative camera - - def get_plugin_info_through_camera(self, camera): - """Get the plugin parameters for deadline submission when - multi-camera is enabled. - Args: - infos(dict): a dictionary with plugin info. - """ - instance = self._instance - # set the target camera - plugin_info = copy.deepcopy(self.plugin_info) - - plugin_data = {} - # set the output filepath with the relative camera - if instance.data.get("multiCamera"): - scene_filepath = instance.context.data["currentFile"] - scene_filename = os.path.basename(scene_filepath) - scene_directory = os.path.dirname(scene_filepath) - current_filename, ext = os.path.splitext(scene_filename) - camera_scene_name = f"{current_filename}_{camera}{ext}" - camera_scene_filepath = os.path.join( - scene_directory, f"_{current_filename}", camera_scene_name) - plugin_data["SceneFile"] = camera_scene_filepath - - files = instance.data.get("expectedFiles") - if not files: - raise KnownPublishError("No render elements found") - first_file = next(self._iter_expected_files(files)) - old_output_dir = os.path.dirname(first_file) - rgb_output = RenderSettings().get_batch_render_output(camera) # noqa - rgb_bname = os.path.basename(rgb_output) - dir = os.path.dirname(first_file) - beauty_name = f"{dir}/{rgb_bname}" - beauty_name = beauty_name.replace("\\", "/") - plugin_info["RenderOutput"] = beauty_name - renderer_class = get_current_renderer() - - renderer = str(renderer_class).split(":")[0] - if renderer in [ - "ART_Renderer", - "Redshift_Renderer", - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3", - "Default_Scanline_Renderer", - "Quicksilver_Hardware_Renderer", - ]: - render_elem_list = RenderSettings().get_batch_render_elements( - instance.name, old_output_dir, camera - ) - for i, element in enumerate(render_elem_list): - if camera in element: - elem_bname = os.path.basename(element) - new_elem = f"{dir}/{elem_bname}" - new_elem = new_elem.replace("/", "\\") - plugin_info["RenderElementOutputFilename%d" % i] = new_elem # noqa - - if camera: - # set the default camera and target camera - # (weird parameters from max) - plugin_data["Camera"] = camera - plugin_data["Camera1"] = camera - plugin_data["Camera0"] = None - - plugin_info.update(plugin_data) - return plugin_info - - def _use_published_name_for_multiples(self, data, project_settings): - """Process the parameters submission for deadline when - user enables multi-cameras option. - Args: - job_info_list (list): A list of multiple job infos - plugin_info_list (list): A list of multiple plugin infos - """ - job_info_list = [] - plugin_info_list = [] - instance = self._instance - cameras = instance.data.get("cameras", []) - plugin_data = {} - multipass = get_multipass_setting(project_settings) - if multipass: - plugin_data["DisableMultipass"] = 0 - else: - plugin_data["DisableMultipass"] = 1 - for cam in cameras: - job_info = self.get_job_info_through_camera(cam) - plugin_info = self.get_plugin_info_through_camera(cam) - plugin_info.update(plugin_data) - job_info_list.append(job_info) - plugin_info_list.append(plugin_info) - - return job_info_list, plugin_info_list - - def from_published_scene(self, replace_in_path=True): - instance = self._instance - if instance.data["renderer"] == "Redshift_Renderer": - self.log.debug("Using Redshift...published scene wont be used..") - replace_in_path = False - return replace_with_published_scene_path( - instance, replace_in_path) - - @staticmethod - def _iter_expected_files(exp): - if isinstance(exp[0], dict): - for _aov, files in exp[0].items(): - for file in files: - yield file - else: - for file in exp: - yield file - - @classmethod - def get_attribute_defs(cls): - defs = super(MaxSubmitDeadline, cls).get_attribute_defs() - defs.extend([ - BoolDef("use_published", - default=cls.use_published, - label="Use Published Scene"), - - NumberDef("priority", - minimum=1, - maximum=250, - decimals=0, - default=cls.priority, - label="Priority"), - - NumberDef("chunkSize", - minimum=1, - maximum=50, - decimals=0, - default=cls.chunk_size, - label="Frame Per Task"), - - TextDef("group", - default=cls.group, - label="Group Name"), - ]) - - return defs diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_maya_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_maya_deadline.py deleted file mode 100644 index d50b0147d9..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_maya_deadline.py +++ /dev/null @@ -1,935 +0,0 @@ -# -*- coding: utf-8 -*- -"""Submitting render job to Deadline. - -This module is taking care of submitting job from Maya to Deadline. It -creates job and set correct environments. Its behavior is controlled by -``DEADLINE_REST_URL`` environment variable - pointing to Deadline Web Service -and :data:`MayaSubmitDeadline.use_published` property telling Deadline to -use published scene workfile or not. - -If ``vrscene`` or ``assscene`` are detected in families, it will first -submit job to export these files and then dependent job to render them. - -Attributes: - payload_skeleton (dict): Skeleton payload data sent as job to Deadline. - Default values are for ``MayaBatch`` plugin. - -""" - -from __future__ import print_function -import os -import json -import getpass -import copy -import re -import hashlib -from datetime import datetime -import itertools -from collections import OrderedDict - -import attr - -from ayon_core.pipeline import ( - AYONPyblishPluginMixin -) -from ayon_core.lib import ( - BoolDef, - NumberDef, - TextDef, - EnumDef, - is_in_tests, -) -from ayon_maya.api.lib_rendersettings import RenderSettings -from ayon_maya.api.lib import get_attr_in_layer - -from ayon_core.pipeline.farm.tools import iter_expected_files - -from ayon_deadline import abstract_submit_deadline -from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo - - -def _validate_deadline_bool_value(instance, attribute, value): - if not isinstance(value, (str, bool)): - raise TypeError( - "Attribute {} must be str or bool.".format(attribute)) - if value not in {"1", "0", True, False}: - raise ValueError( - ("Value of {} must be one of " - "'0', '1', True, False").format(attribute) - ) - - -@attr.s -class MayaPluginInfo(object): - SceneFile = attr.ib(default=None) # Input - OutputFilePath = attr.ib(default=None) # Output directory and filename - OutputFilePrefix = attr.ib(default=None) - Version = attr.ib(default=None) # Mandatory for Deadline - UsingRenderLayers = attr.ib(default=True) - RenderLayer = attr.ib(default=None) # Render only this layer - Renderer = attr.ib(default=None) - ProjectPath = attr.ib(default=None) # Resolve relative references - # Include all lights flag - RenderSetupIncludeLights = attr.ib( - default="1", validator=_validate_deadline_bool_value) - StrictErrorChecking = attr.ib(default=True) - - -@attr.s -class PythonPluginInfo(object): - ScriptFile = attr.ib() - Version = attr.ib(default="3.6") - Arguments = attr.ib(default=None) - SingleFrameOnly = attr.ib(default=None) - - -@attr.s -class VRayPluginInfo(object): - InputFilename = attr.ib(default=None) # Input - SeparateFilesPerFrame = attr.ib(default=None) - VRayEngine = attr.ib(default="V-Ray") - Width = attr.ib(default=None) - Height = attr.ib(default=None) # Mandatory for Deadline - OutputFilePath = attr.ib(default=True) - OutputFileName = attr.ib(default=None) # Render only this layer - - -@attr.s -class ArnoldPluginInfo(object): - ArnoldFile = attr.ib(default=None) - - -class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, - AYONPyblishPluginMixin): - - label = "Submit Render to Deadline" - hosts = ["maya"] - families = ["renderlayer"] - targets = ["local"] - settings_category = "deadline" - - tile_assembler_plugin = "OpenPypeTileAssembler" - priority = 50 - tile_priority = 50 - limit = [] # limit groups - jobInfo = {} - pluginInfo = {} - group = "none" - strict_error_checking = True - - @classmethod - def apply_settings(cls, project_settings): - settings = project_settings["deadline"]["publish"]["MayaSubmitDeadline"] # noqa - - # Take some defaults from settings - cls.asset_dependencies = settings.get("asset_dependencies", - cls.asset_dependencies) - cls.import_reference = settings.get("import_reference", - cls.import_reference) - cls.use_published = settings.get("use_published", cls.use_published) - cls.priority = settings.get("priority", cls.priority) - cls.tile_priority = settings.get("tile_priority", cls.tile_priority) - cls.limit = settings.get("limit", cls.limit) - cls.group = settings.get("group", cls.group) - cls.strict_error_checking = settings.get("strict_error_checking", - cls.strict_error_checking) - job_info = settings.get("jobInfo") - if job_info: - job_info = json.loads(job_info) - plugin_info = settings.get("pluginInfo") - if plugin_info: - plugin_info = json.loads(plugin_info) - - cls.jobInfo = job_info or cls.jobInfo - cls.pluginInfo = plugin_info or cls.pluginInfo - - def get_job_info(self): - job_info = DeadlineJobInfo(Plugin="MayaBatch") - - # todo: test whether this works for existing production cases - # where custom jobInfo was stored in the project settings - job_info.update(self.jobInfo) - - instance = self._instance - context = instance.context - - # Always use the original work file name for the Job name even when - # rendering is done from the published Work File. The original work - # file name is clearer because it can also have subversion strings, - # etc. which are stripped for the published file. - src_filepath = context.data["currentFile"] - src_filename = os.path.basename(src_filepath) - - if is_in_tests(): - src_filename += datetime.now().strftime("%d%m%Y%H%M%S") - - job_info.Name = "%s - %s" % (src_filename, instance.name) - job_info.BatchName = src_filename - job_info.Plugin = instance.data.get("mayaRenderPlugin", "MayaBatch") - job_info.UserName = context.data.get("deadlineUser", getpass.getuser()) - - # Deadline requires integers in frame range - frames = "{start}-{end}x{step}".format( - start=int(instance.data["frameStartHandle"]), - end=int(instance.data["frameEndHandle"]), - step=int(instance.data["byFrameStep"]), - ) - job_info.Frames = frames - - job_info.Pool = instance.data.get("primaryPool") - job_info.SecondaryPool = instance.data.get("secondaryPool") - job_info.Comment = context.data.get("comment") - job_info.Priority = instance.data.get("priority", self.priority) - - if self.group != "none" and self.group: - job_info.Group = self.group - - if self.limit: - job_info.LimitGroups = ",".join(self.limit) - - attr_values = self.get_attr_values_from_data(instance.data) - render_globals = instance.data.setdefault("renderGlobals", dict()) - machine_list = attr_values.get("machineList", "") - if machine_list: - if attr_values.get("whitelist", True): - machine_list_key = "Whitelist" - else: - machine_list_key = "Blacklist" - render_globals[machine_list_key] = machine_list - - job_info.Priority = attr_values.get("priority") - job_info.ChunkSize = attr_values.get("chunkSize") - - # Add options from RenderGlobals - render_globals = instance.data.get("renderGlobals", {}) - job_info.update(render_globals) - - keys = [ - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "OPENPYPE_SG_USER", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_WORKDIR", - "AYON_APP_NAME", - "AYON_IN_TESTS" - ] - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - for key in keys: - value = environment.get(key) - if not value: - continue - job_info.EnvironmentKeyValue[key] = value - - # to recognize render jobs - job_info.add_render_job_env_var() - job_info.EnvironmentKeyValue["AYON_LOG_NO_COLORS"] = "1" - - # Adding file dependencies. - if not is_in_tests() and self.asset_dependencies: - dependencies = instance.context.data["fileDependencies"] - for dependency in dependencies: - job_info.AssetDependency += dependency - - # Add list of expected files to job - # --------------------------------- - exp = instance.data.get("expectedFiles") - for filepath in iter_expected_files(exp): - job_info.OutputDirectory += os.path.dirname(filepath) - job_info.OutputFilename += os.path.basename(filepath) - - return job_info - - def get_plugin_info(self): - # Not all hosts can import this module. - from maya import cmds - - instance = self._instance - context = instance.context - - # Set it to default Maya behaviour if it cannot be determined - # from instance (but it should be, by the Collector). - - default_rs_include_lights = ( - instance.context.data['project_settings'] - ['maya'] - ['render_settings'] - ['enable_all_lights'] - ) - - rs_include_lights = instance.data.get( - "renderSetupIncludeLights", default_rs_include_lights) - if rs_include_lights not in {"1", "0", True, False}: - rs_include_lights = default_rs_include_lights - - attr_values = self.get_attr_values_from_data(instance.data) - strict_error_checking = attr_values.get("strict_error_checking", - self.strict_error_checking) - plugin_info = MayaPluginInfo( - SceneFile=self.scene_path, - Version=cmds.about(version=True), - RenderLayer=instance.data['setMembers'], - Renderer=instance.data["renderer"], - RenderSetupIncludeLights=rs_include_lights, # noqa - ProjectPath=context.data["workspaceDir"], - UsingRenderLayers=True, - StrictErrorChecking=strict_error_checking - ) - - plugin_payload = attr.asdict(plugin_info) - - # Patching with pluginInfo from settings - for key, value in self.pluginInfo.items(): - plugin_payload[key] = value - - return plugin_payload - - def process_submission(self): - from maya import cmds - instance = self._instance - - filepath = self.scene_path # publish if `use_publish` else workfile - - # TODO: Avoid the need for this logic here, needed for submit publish - # Store output dir for unified publisher (filesequence) - expected_files = instance.data["expectedFiles"] - first_file = next(iter_expected_files(expected_files)) - output_dir = os.path.dirname(first_file) - instance.data["outputDir"] = output_dir - - # Patch workfile (only when use_published is enabled) - if self.use_published: - self._patch_workfile() - - # Gather needed data ------------------------------------------------ - filename = os.path.basename(filepath) - dirname = os.path.join( - cmds.workspace(query=True, rootDirectory=True), - cmds.workspace(fileRuleEntry="images") - ) - - # Fill in common data to payload ------------------------------------ - # TODO: Replace these with collected data from CollectRender - payload_data = { - "filename": filename, - "dirname": dirname, - } - - # Submit preceding export jobs ------------------------------------- - export_job = None - assert not all(x in instance.data["families"] - for x in ['vrayscene', 'assscene']), ( - "Vray Scene and Ass Scene options are mutually exclusive") - - auth = self._instance.data["deadline"]["auth"] - verify = self._instance.data["deadline"]["verify"] - if "vrayscene" in instance.data["families"]: - self.log.debug("Submitting V-Ray scene render..") - vray_export_payload = self._get_vray_export_payload(payload_data) - export_job = self.submit(vray_export_payload, - auth=auth, - verify=verify) - - payload = self._get_vray_render_payload(payload_data) - - else: - self.log.debug("Submitting MayaBatch render..") - payload = self._get_maya_payload(payload_data) - - # Add export job as dependency -------------------------------------- - if export_job: - job_info, _ = payload - job_info.JobDependencies = export_job - - if instance.data.get("tileRendering"): - # Prepare tiles data - self._tile_render(payload) - else: - # Submit main render job - job_info, plugin_info = payload - self.submit(self.assemble_payload(job_info, plugin_info), - auth=auth, - verify=verify) - - def _tile_render(self, payload): - """Submit as tile render per frame with dependent assembly jobs.""" - - # As collected by super process() - instance = self._instance - - payload_job_info, payload_plugin_info = payload - job_info = copy.deepcopy(payload_job_info) - plugin_info = copy.deepcopy(payload_plugin_info) - - # Force plugin reload for vray cause the region does not get flushed - # between tile renders. - if plugin_info["Renderer"] == "vray": - job_info.ForceReloadPlugin = True - - # if we have sequence of files, we need to create tile job for - # every frame - job_info.TileJob = True - job_info.TileJobTilesInX = instance.data.get("tilesX") - job_info.TileJobTilesInY = instance.data.get("tilesY") - - tiles_count = job_info.TileJobTilesInX * job_info.TileJobTilesInY - - plugin_info["ImageHeight"] = instance.data.get("resolutionHeight") - plugin_info["ImageWidth"] = instance.data.get("resolutionWidth") - plugin_info["RegionRendering"] = True - - R_FRAME_NUMBER = re.compile( - r".+\.(?P[0-9]+)\..+") # noqa: N806, E501 - REPL_FRAME_NUMBER = re.compile( - r"(.+\.)([0-9]+)(\..+)") # noqa: N806, E501 - - exp = instance.data["expectedFiles"] - if isinstance(exp[0], dict): - # we have aovs and we need to iterate over them - # get files from `beauty` - files = exp[0].get("beauty") - # assembly files are used for assembly jobs as we need to put - # together all AOVs - assembly_files = list( - itertools.chain.from_iterable( - [f for _, f in exp[0].items()])) - if not files: - # if beauty doesn't exist, use first aov we found - files = exp[0].get(list(exp[0].keys())[0]) - else: - files = exp - assembly_files = files - - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - - # Define frame tile jobs - frame_file_hash = {} - frame_payloads = {} - file_index = 1 - for file in files: - frame = re.search(R_FRAME_NUMBER, file).group("frame") - - new_job_info = copy.deepcopy(job_info) - new_job_info.Name += " (Frame {} - {} tiles)".format(frame, - tiles_count) - new_job_info.TileJobFrame = frame - - new_plugin_info = copy.deepcopy(plugin_info) - - # Add tile data into job info and plugin info - tiles_data = _format_tiles( - file, 0, - instance.data.get("tilesX"), - instance.data.get("tilesY"), - instance.data.get("resolutionWidth"), - instance.data.get("resolutionHeight"), - payload_plugin_info["OutputFilePrefix"] - )[0] - - new_job_info.update(tiles_data["JobInfo"]) - new_plugin_info.update(tiles_data["PluginInfo"]) - - self.log.debug("hashing {} - {}".format(file_index, file)) - job_hash = hashlib.sha256( - ("{}_{}".format(file_index, file)).encode("utf-8")) - - file_hash = job_hash.hexdigest() - frame_file_hash[frame] = file_hash - - new_job_info.ExtraInfo[0] = file_hash - new_job_info.ExtraInfo[1] = file - - frame_payloads[frame] = self.assemble_payload( - job_info=new_job_info, - plugin_info=new_plugin_info - ) - file_index += 1 - - self.log.debug( - "Submitting tile job(s) [{}] ...".format(len(frame_payloads))) - - # Submit frame tile jobs - frame_tile_job_id = {} - for frame, tile_job_payload in frame_payloads.items(): - job_id = self.submit( - tile_job_payload, auth, verify) - frame_tile_job_id[frame] = job_id - - # Define assembly payloads - assembly_job_info = copy.deepcopy(job_info) - assembly_job_info.Plugin = self.tile_assembler_plugin - assembly_job_info.Name += " - Tile Assembly Job" - assembly_job_info.Frames = 1 - assembly_job_info.MachineLimit = 1 - - attr_values = self.get_attr_values_from_data(instance.data) - assembly_job_info.Priority = attr_values.get("tile_priority", - self.tile_priority) - assembly_job_info.TileJob = False - - # TODO: This should be a new publisher attribute definition - pool = instance.context.data["project_settings"]["deadline"] - pool = pool["publish"]["ProcessSubmittedJobOnFarm"]["deadline_pool"] - assembly_job_info.Pool = pool or instance.data.get("primaryPool", "") - - assembly_plugin_info = { - "CleanupTiles": 1, - "ErrorOnMissing": True, - "Renderer": self._instance.data["renderer"] - } - - assembly_payloads = [] - output_dir = self.job_info.OutputDirectory[0] - config_files = [] - for file in assembly_files: - frame = re.search(R_FRAME_NUMBER, file).group("frame") - - frame_assembly_job_info = copy.deepcopy(assembly_job_info) - frame_assembly_job_info.Name += " (Frame {})".format(frame) - frame_assembly_job_info.OutputFilename[0] = re.sub( - REPL_FRAME_NUMBER, - "\\1{}\\3".format("#" * len(frame)), file) - - file_hash = frame_file_hash[frame] - tile_job_id = frame_tile_job_id[frame] - - frame_assembly_job_info.ExtraInfo[0] = file_hash - frame_assembly_job_info.ExtraInfo[1] = file - frame_assembly_job_info.JobDependencies = tile_job_id - frame_assembly_job_info.Frames = frame - - # write assembly job config files - config_file = os.path.join( - output_dir, - "{}_config_{}.txt".format( - os.path.splitext(file)[0], - datetime.now().strftime("%Y_%m_%d_%H_%M_%S") - ) - ) - config_files.append(config_file) - try: - if not os.path.isdir(output_dir): - os.makedirs(output_dir) - except OSError: - # directory is not available - self.log.warning("Path is unreachable: " - "`{}`".format(output_dir)) - - with open(config_file, "w") as cf: - print("TileCount={}".format(tiles_count), file=cf) - print("ImageFileName={}".format(file), file=cf) - print("ImageWidth={}".format( - instance.data.get("resolutionWidth")), file=cf) - print("ImageHeight={}".format( - instance.data.get("resolutionHeight")), file=cf) - - reversed_y = False - if plugin_info["Renderer"] == "arnold": - reversed_y = True - - with open(config_file, "a") as cf: - # Need to reverse the order of the y tiles, because image - # coordinates are calculated from bottom left corner. - tiles = _format_tiles( - file, 0, - instance.data.get("tilesX"), - instance.data.get("tilesY"), - instance.data.get("resolutionWidth"), - instance.data.get("resolutionHeight"), - payload_plugin_info["OutputFilePrefix"], - reversed_y=reversed_y - )[1] - for k, v in sorted(tiles.items()): - print("{}={}".format(k, v), file=cf) - - assembly_payloads.append( - self.assemble_payload( - job_info=frame_assembly_job_info, - plugin_info=assembly_plugin_info.copy(), - # This would fail if the client machine and webserice are - # using different storage paths. - aux_files=[config_file] - ) - ) - - # Submit assembly jobs - assembly_job_ids = [] - num_assemblies = len(assembly_payloads) - for i, payload in enumerate(assembly_payloads): - self.log.debug( - "submitting assembly job {} of {}".format(i + 1, - num_assemblies) - ) - assembly_job_id = self.submit( - payload, - auth=auth, - verify=verify - ) - assembly_job_ids.append(assembly_job_id) - - instance.data["assemblySubmissionJobs"] = assembly_job_ids - - # Remove config files to avoid confusion about where data is coming - # from in Deadline. - for config_file in config_files: - os.remove(config_file) - - def _get_maya_payload(self, data): - - job_info = copy.deepcopy(self.job_info) - - if not is_in_tests() and self.asset_dependencies: - # Asset dependency to wait for at least the scene file to sync. - job_info.AssetDependency += self.scene_path - - # Get layer prefix - renderlayer = self._instance.data["setMembers"] - renderer = self._instance.data["renderer"] - layer_prefix_attr = RenderSettings.get_image_prefix_attr(renderer) - layer_prefix = get_attr_in_layer(layer_prefix_attr, layer=renderlayer) - - plugin_info = copy.deepcopy(self.plugin_info) - plugin_info.update({ - # Output directory and filename - "OutputFilePath": data["dirname"].replace("\\", "/"), - "OutputFilePrefix": layer_prefix, - }) - - # This hack is here because of how Deadline handles Renderman version. - # it considers everything with `renderman` set as version older than - # Renderman 22, and so if we are using renderman > 21 we need to set - # renderer string on the job to `renderman22`. We will have to change - # this when Deadline releases new version handling this. - renderer = self._instance.data["renderer"] - if renderer == "renderman": - try: - from rfm2.config import cfg # noqa - except ImportError: - raise Exception("Cannot determine renderman version") - - rman_version = cfg().build_info.version() # type: str - if int(rman_version.split(".")[0]) > 22: - renderer = "renderman22" - - plugin_info["Renderer"] = renderer - - # this is needed because renderman plugin in Deadline - # handles directory and file prefixes separately - plugin_info["OutputFilePath"] = job_info.OutputDirectory[0] - - return job_info, plugin_info - - def _get_vray_export_payload(self, data): - - job_info = copy.deepcopy(self.job_info) - job_info.Name = self._job_info_label("Export") - - # Get V-Ray settings info to compute output path - vray_scene = self.format_vray_output_filename() - - plugin_info = { - "Renderer": "vray", - "SkipExistingFrames": True, - "UseLegacyRenderLayers": True, - "OutputFilePath": os.path.dirname(vray_scene) - } - - return job_info, attr.asdict(plugin_info) - - def _get_vray_render_payload(self, data): - - # Job Info - job_info = copy.deepcopy(self.job_info) - job_info.Name = self._job_info_label("Render") - job_info.Plugin = "Vray" - job_info.OverrideTaskExtraInfoNames = False - - # Plugin Info - plugin_info = VRayPluginInfo( - InputFilename=self.format_vray_output_filename(), - SeparateFilesPerFrame=False, - VRayEngine="V-Ray", - Width=self._instance.data["resolutionWidth"], - Height=self._instance.data["resolutionHeight"], - OutputFilePath=job_info.OutputDirectory[0], - OutputFileName=job_info.OutputFilename[0] - ) - - return job_info, attr.asdict(plugin_info) - - def _get_arnold_render_payload(self, data): - # Job Info - job_info = copy.deepcopy(self.job_info) - job_info.Name = self._job_info_label("Render") - job_info.Plugin = "Arnold" - job_info.OverrideTaskExtraInfoNames = False - - # Plugin Info - ass_file, _ = os.path.splitext(data["output_filename_0"]) - ass_filepath = ass_file + ".ass" - - plugin_info = ArnoldPluginInfo( - ArnoldFile=ass_filepath - ) - - return job_info, attr.asdict(plugin_info) - - def format_vray_output_filename(self): - """Format the expected output file of the Export job. - - Example: - /_/ - "shot010_v006/shot010_v006_CHARS/CHARS_0001.vrscene" - Returns: - str - - """ - from maya import cmds - # "vrayscene//_/" - vray_settings = cmds.ls(type="VRaySettingsNode") - node = vray_settings[0] - template = cmds.getAttr("{}.vrscene_filename".format(node)) - scene, _ = os.path.splitext(self.scene_path) - - def smart_replace(string, key_values): - new_string = string - for key, value in key_values.items(): - new_string = new_string.replace(key, value) - return new_string - - # Get workfile scene path without extension to format vrscene_filename - scene_filename = os.path.basename(self.scene_path) - scene_filename_no_ext, _ = os.path.splitext(scene_filename) - - layer = self._instance.data['setMembers'] - - # Reformat without tokens - output_path = smart_replace( - template, - {"": scene_filename_no_ext, - "": layer}) - - start_frame = int(self._instance.data["frameStartHandle"]) - workspace = self._instance.context.data["workspace"] - filename_zero = "{}_{:04d}.vrscene".format(output_path, start_frame) - filepath_zero = os.path.join(workspace, filename_zero) - - return filepath_zero.replace("\\", "/") - - def _patch_workfile(self): - """Patch Maya scene. - - This will take list of patches (lines to add) and apply them to - *published* Maya scene file (that is used later for rendering). - - Patches are dict with following structure:: - { - "name": "Name of patch", - "regex": "regex of line before patch", - "line": "line to insert" - } - - """ - project_settings = self._instance.context.data["project_settings"] - patches = ( - project_settings.get( - "deadline", {}).get( - "publish", {}).get( - "MayaSubmitDeadline", {}).get( - "scene_patches", {}) - ) - if not patches: - return - - if not os.path.splitext(self.scene_path)[1].lower() != ".ma": - self.log.debug("Skipping workfile patch since workfile is not " - ".ma file") - return - - compiled_regex = [re.compile(p["regex"]) for p in patches] - with open(self.scene_path, "r+") as pf: - scene_data = pf.readlines() - for ln, line in enumerate(scene_data): - for i, r in enumerate(compiled_regex): - if re.match(r, line): - scene_data.insert(ln + 1, patches[i]["line"]) - pf.seek(0) - pf.writelines(scene_data) - pf.truncate() - self.log.info("Applied {} patch to scene.".format( - patches[i]["name"] - )) - - def _job_info_label(self, label): - return "{label} {job.Name} [{start}-{end}]".format( - label=label, - job=self.job_info, - start=int(self._instance.data["frameStartHandle"]), - end=int(self._instance.data["frameEndHandle"]), - ) - - @classmethod - def get_attribute_defs(cls): - defs = super(MayaSubmitDeadline, cls).get_attribute_defs() - - defs.extend([ - NumberDef("priority", - label="Priority", - default=cls.default_priority, - decimals=0), - NumberDef("chunkSize", - label="Frames Per Task", - default=1, - decimals=0, - minimum=1, - maximum=1000), - TextDef("machineList", - label="Machine List", - default="", - placeholder="machine1,machine2"), - EnumDef("whitelist", - label="Machine List (Allow/Deny)", - items={ - True: "Allow List", - False: "Deny List", - }, - default=False), - NumberDef("tile_priority", - label="Tile Assembler Priority", - decimals=0, - default=cls.tile_priority), - BoolDef("strict_error_checking", - label="Strict Error Checking", - default=cls.strict_error_checking), - - ]) - - return defs - -def _format_tiles( - filename, - index, - tiles_x, - tiles_y, - width, - height, - prefix, - reversed_y=False -): - """Generate tile entries for Deadline tile job. - - Returns two dictionaries - one that can be directly used in Deadline - job, second that can be used for Deadline Assembly job configuration - file. - - This will format tile names: - - Example:: - { - "OutputFilename0Tile0": "_tile_1x1_4x4_Main_beauty.1001.exr", - "OutputFilename0Tile1": "_tile_2x1_4x4_Main_beauty.1001.exr" - } - - And add tile prefixes like: - - Example:: - Image prefix is: - `//_` - - Result for tile 0 for 4x4 will be: - `//_tile_1x1_4x4__` - - Calculating coordinates is tricky as in Job they are defined as top, - left, bottom, right with zero being in top-left corner. But Assembler - configuration file takes tile coordinates as X, Y, Width and Height and - zero is bottom left corner. - - Args: - filename (str): Filename to process as tiles. - index (int): Index of that file if it is sequence. - tiles_x (int): Number of tiles in X. - tiles_y (int): Number of tiles in Y. - width (int): Width resolution of final image. - height (int): Height resolution of final image. - prefix (str): Image prefix. - reversed_y (bool): Reverses the order of the y tiles. - - Returns: - (dict, dict): Tuple of two dictionaries - first can be used to - extend JobInfo, second has tiles x, y, width and height - used for assembler configuration. - - """ - # Math used requires integers for correct output - as such - # we ensure our inputs are correct. - assert isinstance(tiles_x, int), "tiles_x must be an integer" - assert isinstance(tiles_y, int), "tiles_y must be an integer" - assert isinstance(width, int), "width must be an integer" - assert isinstance(height, int), "height must be an integer" - - out = {"JobInfo": {}, "PluginInfo": {}} - cfg = OrderedDict() - w_space = width // tiles_x - h_space = height // tiles_y - - cfg["TilesCropped"] = "False" - - tile = 0 - range_y = range(1, tiles_y + 1) - reversed_y_range = list(reversed(range_y)) - for tile_x in range(1, tiles_x + 1): - for i, tile_y in enumerate(range_y): - tile_y_index = tile_y - if reversed_y: - tile_y_index = reversed_y_range[i] - - tile_prefix = "_tile_{}x{}_{}x{}_".format( - tile_x, tile_y_index, tiles_x, tiles_y - ) - - new_filename = "{}/{}{}".format( - os.path.dirname(filename), - tile_prefix, - os.path.basename(filename) - ) - - top = height - (tile_y * h_space) - bottom = height - ((tile_y - 1) * h_space) - 1 - left = (tile_x - 1) * w_space - right = (tile_x * w_space) - 1 - - # Job info - key = "OutputFilename{}".format(index) - out["JobInfo"][key] = new_filename - - # Plugin Info - key = "RegionPrefix{}".format(str(tile)) - out["PluginInfo"][key] = "/{}".format( - tile_prefix - ).join(prefix.rsplit("/", 1)) - out["PluginInfo"]["RegionTop{}".format(tile)] = top - out["PluginInfo"]["RegionBottom{}".format(tile)] = bottom - out["PluginInfo"]["RegionLeft{}".format(tile)] = left - out["PluginInfo"]["RegionRight{}".format(tile)] = right - - # Tile config - cfg["Tile{}FileName".format(tile)] = new_filename - cfg["Tile{}X".format(tile)] = left - cfg["Tile{}Y".format(tile)] = top - cfg["Tile{}Width".format(tile)] = w_space - cfg["Tile{}Height".format(tile)] = h_space - - tile += 1 - - return out, cfg diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_nuke_deadline.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_nuke_deadline.py deleted file mode 100644 index 7ead5142cf..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_nuke_deadline.py +++ /dev/null @@ -1,558 +0,0 @@ -import os -import re -import json -import getpass -from datetime import datetime - -import pyblish.api - -from ayon_core.pipeline.publish import ( - AYONPyblishPluginMixin -) -from ayon_core.lib import ( - is_in_tests, - BoolDef, - NumberDef -) -from ayon_deadline.abstract_submit_deadline import requests_post - - -class NukeSubmitDeadline(pyblish.api.InstancePlugin, - AYONPyblishPluginMixin): - """Submit write to Deadline - - Renders are submitted to a Deadline Web Service as - supplied via settings key "DEADLINE_REST_URL". - - """ - - label = "Submit Nuke to Deadline" - order = pyblish.api.IntegratorOrder + 0.1 - hosts = ["nuke"] - families = ["render", "prerender"] - optional = True - targets = ["local"] - settings_category = "deadline" - - # presets - priority = 50 - chunk_size = 1 - concurrent_tasks = 1 - group = "" - department = "" - limit_groups = [] - use_gpu = False - env_allowed_keys = [] - env_search_replace_values = [] - workfile_dependency = True - use_published_workfile = True - - @classmethod - def get_attribute_defs(cls): - return [ - NumberDef( - "priority", - label="Priority", - default=cls.priority, - decimals=0 - ), - NumberDef( - "chunk", - label="Frames Per Task", - default=cls.chunk_size, - decimals=0, - minimum=1, - maximum=1000 - ), - NumberDef( - "concurrency", - label="Concurrency", - default=cls.concurrent_tasks, - decimals=0, - minimum=1, - maximum=10 - ), - BoolDef( - "use_gpu", - default=cls.use_gpu, - label="Use GPU" - ), - BoolDef( - "workfile_dependency", - default=cls.workfile_dependency, - label="Workfile Dependency" - ), - BoolDef( - "use_published_workfile", - default=cls.use_published_workfile, - label="Use Published Workfile" - ) - ] - - def process(self, instance): - if not instance.data.get("farm"): - self.log.debug("Skipping local instance.") - return - instance.data["attributeValues"] = self.get_attr_values_from_data( - instance.data) - - families = instance.data["families"] - - node = instance.data["transientData"]["node"] - context = instance.context - - deadline_url = instance.data["deadline"]["url"] - assert deadline_url, "Requires Deadline Webservice URL" - - self.deadline_url = "{}/api/jobs".format(deadline_url) - self._comment = context.data.get("comment", "") - self._ver = re.search(r"\d+\.\d+", context.data.get("hostVersion")) - self._deadline_user = context.data.get( - "deadlineUser", getpass.getuser()) - submit_frame_start = int(instance.data["frameStartHandle"]) - submit_frame_end = int(instance.data["frameEndHandle"]) - - # get output path - render_path = instance.data['path'] - script_path = context.data["currentFile"] - - use_published_workfile = instance.data["attributeValues"].get( - "use_published_workfile", self.use_published_workfile - ) - if use_published_workfile: - script_path = self._get_published_workfile_path(context) - - # only add main rendering job if target is not frames_farm - r_job_response_json = None - if instance.data["render_target"] != "frames_farm": - r_job_response = self.payload_submit( - instance, - script_path, - render_path, - node.name(), - submit_frame_start, - submit_frame_end - ) - r_job_response_json = r_job_response.json() - instance.data["deadlineSubmissionJob"] = r_job_response_json - - # Store output dir for unified publisher (filesequence) - instance.data["outputDir"] = os.path.dirname( - render_path).replace("\\", "/") - instance.data["publishJobState"] = "Suspended" - - if instance.data.get("bakingNukeScripts"): - for baking_script in instance.data["bakingNukeScripts"]: - render_path = baking_script["bakeRenderPath"] - script_path = baking_script["bakeScriptPath"] - exe_node_name = baking_script["bakeWriteNodeName"] - - b_job_response = self.payload_submit( - instance, - script_path, - render_path, - exe_node_name, - submit_frame_start, - submit_frame_end, - r_job_response_json, - baking_submission=True - ) - - # Store output dir for unified publisher (filesequence) - instance.data["deadlineSubmissionJob"] = b_job_response.json() - - instance.data["publishJobState"] = "Suspended" - - # add to list of job Id - if not instance.data.get("bakingSubmissionJobs"): - instance.data["bakingSubmissionJobs"] = [] - - instance.data["bakingSubmissionJobs"].append( - b_job_response.json()["_id"]) - - # redefinition of families - if "render" in instance.data["productType"]: - instance.data["family"] = "write" - instance.data["productType"] = "write" - families.insert(0, "render2d") - elif "prerender" in instance.data["productType"]: - instance.data["family"] = "write" - instance.data["productType"] = "write" - families.insert(0, "prerender") - instance.data["families"] = families - - def _get_published_workfile_path(self, context): - """This method is temporary while the class is not inherited from - AbstractSubmitDeadline""" - anatomy = context.data["anatomy"] - # WARNING Hardcoded template name 'default' > may not be used - publish_template = anatomy.get_template_item( - "publish", "default", "path" - ) - for instance in context: - if ( - instance.data["productType"] != "workfile" - # Disabled instances won't be integrated - or instance.data("publish") is False - ): - continue - template_data = instance.data["anatomyData"] - # Expect workfile instance has only one representation - representation = instance.data["representations"][0] - # Get workfile extension - repre_file = representation["files"] - self.log.info(repre_file) - ext = os.path.splitext(repre_file)[1].lstrip(".") - - # Fill template data - template_data["representation"] = representation["name"] - template_data["ext"] = ext - template_data["comment"] = None - - template_filled = publish_template.format(template_data) - script_path = os.path.normpath(template_filled) - self.log.info( - "Using published scene for render {}".format( - script_path - ) - ) - return script_path - - return None - - def payload_submit( - self, - instance, - script_path, - render_path, - exe_node_name, - start_frame, - end_frame, - response_data=None, - baking_submission=False, - ): - """Submit payload to Deadline - - Args: - instance (pyblish.api.Instance): pyblish instance - script_path (str): path to nuke script - render_path (str): path to rendered images - exe_node_name (str): name of the node to render - start_frame (int): start frame - end_frame (int): end frame - response_data Optional[dict]: response data from - previous submission - baking_submission Optional[bool]: if it's baking submission - - Returns: - requests.Response - """ - render_dir = os.path.normpath(os.path.dirname(render_path)) - - # batch name - src_filepath = instance.context.data["currentFile"] - batch_name = os.path.basename(src_filepath) - job_name = os.path.basename(render_path) - - if is_in_tests(): - batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - - output_filename_0 = self.preview_fname(render_path) - - if not response_data: - response_data = {} - - try: - # Ensure render folder exists - os.makedirs(render_dir) - except OSError: - pass - - # resolve any limit groups - limit_groups = self.get_limit_groups() - self.log.debug("Limit groups: `{}`".format(limit_groups)) - - payload = { - "JobInfo": { - # Top-level group name - "BatchName": batch_name, - - # Job name, as seen in Monitor - "Name": job_name, - - # Arbitrary username, for visualisation in Monitor - "UserName": self._deadline_user, - - "Priority": instance.data["attributeValues"].get( - "priority", self.priority), - "ChunkSize": instance.data["attributeValues"].get( - "chunk", self.chunk_size), - "ConcurrentTasks": instance.data["attributeValues"].get( - "concurrency", - self.concurrent_tasks - ), - - "Department": self.department, - - "Pool": instance.data.get("primaryPool"), - "SecondaryPool": instance.data.get("secondaryPool"), - "Group": self.group, - - "Plugin": "Nuke", - "Frames": "{start}-{end}".format( - start=start_frame, - end=end_frame - ), - "Comment": self._comment, - - # Optional, enable double-click to preview rendered - # frames from Deadline Monitor - "OutputFilename0": output_filename_0.replace("\\", "/"), - - # limiting groups - "LimitGroups": ",".join(limit_groups) - - }, - "PluginInfo": { - # Input - "SceneFile": script_path, - - # Output directory and filename - "OutputFilePath": render_dir.replace("\\", "/"), - # "OutputFilePrefix": render_variables["filename_prefix"], - - # Mandatory for Deadline - "Version": self._ver.group(), - - # Resolve relative references - "ProjectPath": script_path, - "AWSAssetFile0": render_path, - - # using GPU by default - "UseGpu": instance.data["attributeValues"].get( - "use_gpu", self.use_gpu), - - # Only the specific write node is rendered. - "WriteNode": exe_node_name - }, - - # Mandatory for Deadline, may be empty - "AuxFiles": [] - } - - # Add workfile dependency. - workfile_dependency = instance.data["attributeValues"].get( - "workfile_dependency", self.workfile_dependency - ) - if workfile_dependency: - payload["JobInfo"].update({"AssetDependency0": script_path}) - - # TODO: rewrite for baking with sequences - if baking_submission: - payload["JobInfo"].update({ - "JobType": "Normal", - "ChunkSize": 99999999 - }) - - if response_data.get("_id"): - payload["JobInfo"].update({ - "BatchName": response_data["Props"]["Batch"], - "JobDependency0": response_data["_id"], - }) - - # Include critical environment variables with submission - keys = [ - "PYTHONPATH", - "PATH", - "AYON_BUNDLE_NAME", - "AYON_DEFAULT_SETTINGS_VARIANT", - "AYON_PROJECT_NAME", - "AYON_FOLDER_PATH", - "AYON_TASK_NAME", - "AYON_APP_NAME", - "FTRACK_API_KEY", - "FTRACK_API_USER", - "FTRACK_SERVER", - "PYBLISHPLUGINPATH", - "NUKE_PATH", - "TOOL_ENV", - "FOUNDRY_LICENSE", - "OPENPYPE_SG_USER", - ] - - # add allowed keys from preset if any - if self.env_allowed_keys: - keys += self.env_allowed_keys - - environment = { - key: os.environ[key] - for key in keys - if key in os.environ - } - - # to recognize render jobs - environment["AYON_RENDER_JOB"] = "1" - - # finally search replace in values of any key - if self.env_search_replace_values: - for key, value in environment.items(): - for item in self.env_search_replace_values: - environment[key] = value.replace( - item["name"], item["value"] - ) - - payload["JobInfo"].update({ - "EnvironmentKeyValue%d" % index: "{key}={value}".format( - key=key, - value=environment[key] - ) for index, key in enumerate(environment) - }) - - plugin = payload["JobInfo"]["Plugin"] - self.log.debug("using render plugin : {}".format(plugin)) - - self.log.debug("Submitting..") - self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) - - # adding expected files to instance.data - self.expected_files( - instance, - render_path, - start_frame, - end_frame - ) - - self.log.debug("__ expectedFiles: `{}`".format( - instance.data["expectedFiles"])) - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - response = requests_post(self.deadline_url, - json=payload, - timeout=10, - auth=auth, - verify=verify) - - if not response.ok: - raise Exception(response.text) - - return response - - def preflight_check(self, instance): - """Ensure the startFrame, endFrame and byFrameStep are integers""" - - for key in ("frameStart", "frameEnd"): - value = instance.data[key] - - if int(value) == value: - continue - - self.log.warning( - "%f=%d was rounded off to nearest integer" - % (value, int(value)) - ) - - def preview_fname(self, path): - """Return output file path with #### for padding. - - Deadline requires the path to be formatted with # in place of numbers. - For example `/path/to/render.####.png` - - Args: - path (str): path to rendered images - - Returns: - str - - """ - self.log.debug("_ path: `{}`".format(path)) - if "%" in path: - search_results = re.search(r"(%0)(\d)(d.)", path).groups() - self.log.debug("_ search_results: `{}`".format(search_results)) - return int(search_results[1]) - if "#" in path: - self.log.debug("_ path: `{}`".format(path)) - return path - - def expected_files( - self, - instance, - filepath, - start_frame, - end_frame - ): - """ Create expected files in instance data - """ - if not instance.data.get("expectedFiles"): - instance.data["expectedFiles"] = [] - - dirname = os.path.dirname(filepath) - file = os.path.basename(filepath) - - # since some files might be already tagged as publish_on_farm - # we need to avoid adding them to expected files since those would be - # duplicated into metadata.json file - representations = instance.data.get("representations", []) - # check if file is not in representations with publish_on_farm tag - for repre in representations: - # Skip if 'publish_on_farm' not available - if "publish_on_farm" not in repre.get("tags", []): - continue - - # in case where single file (video, image) is already in - # representation file. Will be added to expected files via - # submit_publish_job.py - if file in repre.get("files", []): - self.log.debug( - "Skipping expected file: {}".format(filepath)) - return - - # in case path is hashed sequence expression - # (e.g. /path/to/file.####.png) - if "#" in file: - pparts = file.split("#") - padding = "%0{}d".format(len(pparts) - 1) - file = pparts[0] + padding + pparts[-1] - - # in case input path was single file (video or image) - if "%" not in file: - instance.data["expectedFiles"].append(filepath) - return - - # shift start frame by 1 if slate is present - if instance.data.get("slate"): - start_frame -= 1 - - # add sequence files to expected files - for i in range(start_frame, (end_frame + 1)): - instance.data["expectedFiles"].append( - os.path.join(dirname, (file % i)).replace("\\", "/")) - - def get_limit_groups(self): - """Search for limit group nodes and return group name. - Limit groups will be defined as pairs in Nuke deadline submitter - presents where the key will be name of limit group and value will be - a list of plugin's node class names. Thus, when a plugin uses more - than one node, these will be captured and the triggered process - will add the appropriate limit group to the payload jobinfo attributes. - Returning: - list: captured groups list - """ - # Not all hosts can import this module. - import nuke - - captured_groups = [] - for limit_group in self.limit_groups: - lg_name = limit_group["name"] - - for node_class in limit_group["value"]: - for node in nuke.allNodes(recurseGroups=True): - # ignore all nodes not member of defined class - if node.Class() not in node_class: - continue - # ignore all disabled nodes - if node["disable"].value(): - continue - # add group name if not already added - if lg_name not in captured_groups: - captured_groups.append(lg_name) - return captured_groups diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_cache_job.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_cache_job.py deleted file mode 100644 index d93592a6a3..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_cache_job.py +++ /dev/null @@ -1,463 +0,0 @@ -# -*- coding: utf-8 -*- -"""Submit publishing job to farm.""" -import os -import json -import re -from copy import deepcopy - -import ayon_api -import pyblish.api - -from ayon_core.pipeline import publish -from ayon_core.lib import EnumDef, is_in_tests -from ayon_core.pipeline.version_start import get_versioning_start -from ayon_core.pipeline.farm.pyblish_functions import ( - create_skeleton_instance_cache, - create_instances_for_cache, - attach_instances_to_product, - prepare_cache_representations, - create_metadata_path -) -from ayon_deadline.abstract_submit_deadline import requests_post - - -class ProcessSubmittedCacheJobOnFarm(pyblish.api.InstancePlugin, - publish.AYONPyblishPluginMixin, - publish.ColormanagedPyblishPluginMixin): - """Process Cache Job submitted on farm - This is replicated version of submit publish job - specifically for cache(s). - - These jobs are dependent on a deadline job - submission prior to this plug-in. - - - In case of Deadline, it creates dependent job on farm publishing - rendered image sequence. - - Options in instance.data: - - deadlineSubmissionJob (dict, Required): The returned .json - data from the job submission to deadline. - - - outputDir (str, Required): The output directory where the metadata - file should be generated. It's assumed that this will also be - final folder containing the output files. - - - ext (str, Optional): The extension (including `.`) that is required - in the output filename to be picked up for image sequence - publishing. - - - expectedFiles (list or dict): explained below - - """ - - label = "Submit cache jobs to Deadline" - order = pyblish.api.IntegratorOrder + 0.2 - icon = "tractor" - settings_category = "deadline" - - targets = ["local"] - - hosts = ["houdini"] - - families = ["publish.hou"] - - environ_keys = [ - "FTRACK_API_USER", - "FTRACK_API_KEY", - "FTRACK_SERVER", - "AYON_APP_NAME", - "AYON_USERNAME", - "AYON_SG_USERNAME", - "KITSU_LOGIN", - "KITSU_PWD" - ] - - # custom deadline attributes - deadline_department = "" - deadline_pool = "" - deadline_pool_secondary = "" - deadline_group = "" - deadline_chunk_size = 1 - deadline_priority = None - - # regex for finding frame number in string - R_FRAME_NUMBER = re.compile(r'.+\.(?P[0-9]+)\..+') - - plugin_pype_version = "3.0" - - # script path for publish_filesequence.py - publishing_script = None - - def _submit_deadline_post_job(self, instance, job): - """Submit publish job to Deadline. - - Returns: - (str): deadline_publish_job_id - """ - data = instance.data.copy() - product_name = data["productName"] - job_name = "Publish - {}".format(product_name) - - anatomy = instance.context.data['anatomy'] - - # instance.data.get("productName") != instances[0]["productName"] - # 'Main' vs 'renderMain' - override_version = None - instance_version = instance.data.get("version") # take this if exists - if instance_version != 1: - override_version = instance_version - - output_dir = self._get_publish_folder( - anatomy, - deepcopy(instance.data["anatomyData"]), - instance.data.get("folderEntity"), - instance.data["productName"], - instance.context, - instance.data["productType"], - override_version - ) - - # Transfer the environment from the original job to this dependent - # job so they use the same environment - metadata_path, rootless_metadata_path = \ - create_metadata_path(instance, anatomy) - - environment = { - "AYON_PROJECT_NAME": instance.context.data["projectName"], - "AYON_FOLDER_PATH": instance.context.data["folderPath"], - "AYON_TASK_NAME": instance.context.data["task"], - "AYON_USERNAME": instance.context.data["user"], - "AYON_LOG_NO_COLORS": "1", - "AYON_IN_TESTS": str(int(is_in_tests())), - "AYON_PUBLISH_JOB": "1", - "AYON_RENDER_JOB": "0", - "AYON_REMOTE_PUBLISH": "0", - "AYON_BUNDLE_NAME": os.environ["AYON_BUNDLE_NAME"], - "AYON_DEFAULT_SETTINGS_VARIANT": ( - os.environ["AYON_DEFAULT_SETTINGS_VARIANT"] - ), - } - - # add environments from self.environ_keys - for env_key in self.environ_keys: - if os.getenv(env_key): - environment[env_key] = os.environ[env_key] - - priority = self.deadline_priority or instance.data.get("priority", 50) - - instance_settings = self.get_attr_values_from_data(instance.data) - initial_status = instance_settings.get("publishJobState", "Active") - - args = [ - "--headless", - 'publish', - '"{}"'.format(rootless_metadata_path), - "--targets", "deadline", - "--targets", "farm" - ] - - # Generate the payload for Deadline submission - secondary_pool = ( - self.deadline_pool_secondary or instance.data.get("secondaryPool") - ) - payload = { - "JobInfo": { - "Plugin": "Ayon", - "BatchName": job["Props"]["Batch"], - "Name": job_name, - "UserName": job["Props"]["User"], - "Comment": instance.context.data.get("comment", ""), - - "Department": self.deadline_department, - "ChunkSize": self.deadline_chunk_size, - "Priority": priority, - "InitialStatus": initial_status, - - "Group": self.deadline_group, - "Pool": self.deadline_pool or instance.data.get("primaryPool"), - "SecondaryPool": secondary_pool, - # ensure the outputdirectory with correct slashes - "OutputDirectory0": output_dir.replace("\\", "/") - }, - "PluginInfo": { - "Version": self.plugin_pype_version, - "Arguments": " ".join(args), - "SingleFrameOnly": "True", - }, - # Mandatory for Deadline, may be empty - "AuxFiles": [], - } - - if job.get("_id"): - payload["JobInfo"]["JobDependency0"] = job["_id"] - - for index, (key_, value_) in enumerate(environment.items()): - payload["JobInfo"].update( - { - "EnvironmentKeyValue%d" - % index: "{key}={value}".format( - key=key_, value=value_ - ) - } - ) - # remove secondary pool - payload["JobInfo"].pop("SecondaryPool", None) - - self.log.debug("Submitting Deadline publish job ...") - - url = "{}/api/jobs".format(self.deadline_url) - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - response = requests_post( - url, json=payload, timeout=10, auth=auth, verify=verify) - if not response.ok: - raise Exception(response.text) - - deadline_publish_job_id = response.json()["_id"] - - return deadline_publish_job_id - - def process(self, instance): - # type: (pyblish.api.Instance) -> None - """Process plugin. - - Detect type of render farm submission and create and post dependent - job in case of Deadline. It creates json file with metadata needed for - publishing in directory of render. - - Args: - instance (pyblish.api.Instance): Instance data. - - """ - if not instance.data.get("farm"): - self.log.debug("Skipping local instance.") - return - - anatomy = instance.context.data["anatomy"] - - instance_skeleton_data = create_skeleton_instance_cache(instance) - """ - if content of `expectedFiles` list are dictionaries, we will handle - it as list of AOVs, creating instance for every one of them. - - Example: - -------- - - expectedFiles = [ - { - "beauty": [ - "foo_v01.0001.exr", - "foo_v01.0002.exr" - ], - - "Z": [ - "boo_v01.0001.exr", - "boo_v01.0002.exr" - ] - } - ] - - This will create instances for `beauty` and `Z` product - adding those files to their respective representations. - - If we have only list of files, we collect all file sequences. - More then one doesn't probably make sense, but we'll handle it - like creating one instance with multiple representations. - - Example: - -------- - - expectedFiles = [ - "foo_v01.0001.exr", - "foo_v01.0002.exr", - "xxx_v01.0001.exr", - "xxx_v01.0002.exr" - ] - - This will result in one instance with two representations: - `foo` and `xxx` - """ - - if isinstance(instance.data.get("expectedFiles")[0], dict): - instances = create_instances_for_cache( - instance, instance_skeleton_data) - else: - representations = prepare_cache_representations( - instance_skeleton_data, - instance.data.get("expectedFiles"), - anatomy - ) - - if "representations" not in instance_skeleton_data.keys(): - instance_skeleton_data["representations"] = [] - - # add representation - instance_skeleton_data["representations"] += representations - instances = [instance_skeleton_data] - - # attach instances to product - if instance.data.get("attachTo"): - instances = attach_instances_to_product( - instance.data.get("attachTo"), instances - ) - - r''' SUBMiT PUBLiSH JOB 2 D34DLiN3 - ____ - ' ' .---. .---. .--. .---. .--..--..--..--. .---. - | | --= \ | . \/ _|/ \| . \ || || \ |/ _| - | JOB | --= / | | || __| .. | | | |;_ || \ || __| - | | |____./ \.__|._||_.|___./|_____|||__|\__|\.___| - ._____. - - ''' - - render_job = None - submission_type = "" - if instance.data.get("toBeRenderedOn") == "deadline": - render_job = instance.data.pop("deadlineSubmissionJob", None) - submission_type = "deadline" - - if not render_job: - import getpass - - render_job = {} - self.log.debug("Faking job data ...") - render_job["Props"] = {} - # Render job doesn't exist because we do not have prior submission. - # We still use data from it so lets fake it. - # - # Batch name reflect original scene name - - if instance.data.get("assemblySubmissionJobs"): - render_job["Props"]["Batch"] = instance.data.get( - "jobBatchName") - else: - batch = os.path.splitext(os.path.basename( - instance.context.data.get("currentFile")))[0] - render_job["Props"]["Batch"] = batch - # User is deadline user - render_job["Props"]["User"] = instance.context.data.get( - "deadlineUser", getpass.getuser()) - - deadline_publish_job_id = None - if submission_type == "deadline": - self.deadline_url = instance.data["deadline"]["url"] - assert self.deadline_url, "Requires Deadline Webservice URL" - - deadline_publish_job_id = \ - self._submit_deadline_post_job(instance, render_job) - - # Inject deadline url to instances. - for inst in instances: - if "deadline" not in inst: - inst["deadline"] = {} - inst["deadline"] = instance.data["deadline"] - - # publish job file - publish_job = { - "folderPath": instance_skeleton_data["folderPath"], - "frameStart": instance_skeleton_data["frameStart"], - "frameEnd": instance_skeleton_data["frameEnd"], - "fps": instance_skeleton_data["fps"], - "source": instance_skeleton_data["source"], - "user": instance.context.data["user"], - "version": instance.context.data["version"], # workfile version - "intent": instance.context.data.get("intent"), - "comment": instance.context.data.get("comment"), - "job": render_job or None, - "instances": instances - } - - if deadline_publish_job_id: - publish_job["deadline_publish_job_id"] = deadline_publish_job_id - - metadata_path, rootless_metadata_path = \ - create_metadata_path(instance, anatomy) - - with open(metadata_path, "w") as f: - json.dump(publish_job, f, indent=4, sort_keys=True) - - def _get_publish_folder(self, anatomy, template_data, - folder_entity, product_name, context, - product_type, version=None): - """ - Extracted logic to pre-calculate real publish folder, which is - calculated in IntegrateNew inside of Deadline process. - This should match logic in: - 'collect_anatomy_instance_data' - to - get correct anatomy, family, version for product and - 'collect_resources_path' - get publish_path - - Args: - anatomy (ayon_core.pipeline.anatomy.Anatomy): - template_data (dict): pre-calculated collected data for process - folder_entity (dict[str, Any]): Folder entity. - product_name (str): Product name (actually group name of product). - product_type (str): for current deadline process it's always - 'render' - TODO - for generic use family needs to be dynamically - calculated like IntegrateNew does - version (int): override version from instance if exists - - Returns: - (string): publish folder where rendered and published files will - be stored - based on 'publish' template - """ - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - if not version: - version_entity = None - if folder_entity: - version_entity = ayon_api.get_last_version_by_product_name( - project_name, - product_name, - folder_entity["id"] - ) - - if version_entity: - version = int(version_entity["version"]) + 1 - else: - version = get_versioning_start( - project_name, - host_name, - task_name=template_data["task"]["name"], - task_type=template_data["task"]["type"], - product_type="render", - product_name=product_name, - project_settings=context.data["project_settings"] - ) - - task_info = template_data.get("task") or {} - - template_name = publish.get_publish_template_name( - project_name, - host_name, - product_type, - task_info.get("name"), - task_info.get("type"), - ) - - template_data["subset"] = product_name - template_data["family"] = product_type - template_data["version"] = version - template_data["product"] = { - "name": product_name, - "type": product_type, - } - - render_dir_template = anatomy.get_template_item( - "publish", template_name, "directory" - ) - return render_dir_template.format_strict(template_data) - - @classmethod - def get_attribute_defs(cls): - return [ - EnumDef("publishJobState", - label="Publish Job State", - items=["Active", "Suspended"], - default="Active") - ] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_job.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_job.py deleted file mode 100644 index 643dcc1c46..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/submit_publish_job.py +++ /dev/null @@ -1,585 +0,0 @@ -# -*- coding: utf-8 -*- -"""Submit publishing job to farm.""" -import os -import json -import re -from copy import deepcopy - -import clique -import ayon_api -import pyblish.api - -from ayon_core.pipeline import publish -from ayon_core.lib import EnumDef, is_in_tests -from ayon_core.pipeline.version_start import get_versioning_start - -from ayon_core.pipeline.farm.pyblish_functions import ( - create_skeleton_instance, - create_instances_for_aov, - attach_instances_to_product, - prepare_representations, - create_metadata_path -) -from ayon_deadline.abstract_submit_deadline import requests_post - - -def get_resource_files(resources, frame_range=None): - """Get resource files at given path. - - If `frame_range` is specified those outside will be removed. - - Arguments: - resources (list): List of resources - frame_range (list): Frame range to apply override - - Returns: - list of str: list of collected resources - - """ - res_collections, _ = clique.assemble(resources) - assert len(res_collections) == 1, "Multiple collections found" - res_collection = res_collections[0] - - # Remove any frames - if frame_range is not None: - for frame in frame_range: - if frame not in res_collection.indexes: - continue - res_collection.indexes.remove(frame) - - return list(res_collection) - - -class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, - publish.AYONPyblishPluginMixin, - publish.ColormanagedPyblishPluginMixin): - """Process Job submitted on farm. - - These jobs are dependent on a deadline job - submission prior to this plug-in. - - It creates dependent job on farm publishing rendered image sequence. - - Options in instance.data: - - deadlineSubmissionJob (dict, Required): The returned .json - data from the job submission to deadline. - - - outputDir (str, Required): The output directory where the metadata - file should be generated. It's assumed that this will also be - final folder containing the output files. - - - ext (str, Optional): The extension (including `.`) that is required - in the output filename to be picked up for image sequence - publishing. - - - publishJobState (str, Optional): "Active" or "Suspended" - This defaults to "Suspended" - - - expectedFiles (list or dict): explained below - - """ - - label = "Submit Image Publishing job to Deadline" - order = pyblish.api.IntegratorOrder + 0.2 - icon = "tractor" - - targets = ["local"] - - hosts = ["fusion", "max", "maya", "nuke", "houdini", - "celaction", "aftereffects", "harmony", "blender"] - - families = ["render", "render.farm", "render.frames_farm", - "prerender", "prerender.farm", "prerender.frames_farm", - "renderlayer", "imagesequence", "image", - "vrayscene", "maxrender", - "arnold_rop", "mantra_rop", - "karma_rop", "vray_rop", - "redshift_rop", "usdrender"] - settings_category = "deadline" - - aov_filter = [ - { - "name": "maya", - "value": [r".*([Bb]eauty).*"] - }, - { - "name": "blender", - "value": [r".*([Bb]eauty).*"] - }, - { - # for everything from AE - "name": "aftereffects", - "value": [r".*"] - }, - { - "name": "harmony", - "value": [r".*"] - }, - { - "name": "celaction", - "value": [r".*"] - }, - { - "name": "max", - "value": [r".*"] - }, - ] - - environ_keys = [ - "FTRACK_API_USER", - "FTRACK_API_KEY", - "FTRACK_SERVER", - "AYON_APP_NAME", - "AYON_USERNAME", - "AYON_SG_USERNAME", - "KITSU_LOGIN", - "KITSU_PWD" - ] - - # custom deadline attributes - deadline_department = "" - deadline_pool = "" - deadline_pool_secondary = "" - deadline_group = "" - deadline_chunk_size = 1 - deadline_priority = None - - # regex for finding frame number in string - R_FRAME_NUMBER = re.compile(r'.+\.(?P[0-9]+)\..+') - - # mapping of instance properties to be transferred to new instance - # for every specified family - instance_transfer = { - "slate": ["slateFrames", "slate"], - "review": ["lutPath"], - "render2d": ["bakingNukeScripts", "version"], - "renderlayer": ["convertToScanline"] - } - - # list of family names to transfer to new family if present - families_transfer = ["render3d", "render2d", "ftrack", "slate"] - plugin_pype_version = "3.0" - - # script path for publish_filesequence.py - publishing_script = None - - # poor man exclusion - skip_integration_repre_list = [] - - def _submit_deadline_post_job(self, instance, job, instances): - """Submit publish job to Deadline. - - Returns: - (str): deadline_publish_job_id - """ - data = instance.data.copy() - product_name = data["productName"] - job_name = "Publish - {}".format(product_name) - - anatomy = instance.context.data['anatomy'] - - # instance.data.get("productName") != instances[0]["productName"] - # 'Main' vs 'renderMain' - override_version = None - instance_version = instance.data.get("version") # take this if exists - if instance_version != 1: - override_version = instance_version - - output_dir = self._get_publish_folder( - anatomy, - deepcopy(instance.data["anatomyData"]), - instance.data.get("folderEntity"), - instances[0]["productName"], - instance.context, - instances[0]["productType"], - override_version - ) - - # Transfer the environment from the original job to this dependent - # job so they use the same environment - metadata_path, rootless_metadata_path = \ - create_metadata_path(instance, anatomy) - - environment = { - "AYON_PROJECT_NAME": instance.context.data["projectName"], - "AYON_FOLDER_PATH": instance.context.data["folderPath"], - "AYON_TASK_NAME": instance.context.data["task"], - "AYON_USERNAME": instance.context.data["user"], - "AYON_LOG_NO_COLORS": "1", - "AYON_IN_TESTS": str(int(is_in_tests())), - "AYON_PUBLISH_JOB": "1", - "AYON_RENDER_JOB": "0", - "AYON_REMOTE_PUBLISH": "0", - "AYON_BUNDLE_NAME": os.environ["AYON_BUNDLE_NAME"], - "AYON_DEFAULT_SETTINGS_VARIANT": ( - os.environ["AYON_DEFAULT_SETTINGS_VARIANT"] - ), - } - - # add environments from self.environ_keys - for env_key in self.environ_keys: - if os.getenv(env_key): - environment[env_key] = os.environ[env_key] - - priority = self.deadline_priority or instance.data.get("priority", 50) - - instance_settings = self.get_attr_values_from_data(instance.data) - initial_status = instance_settings.get("publishJobState", "Active") - - args = [ - "--headless", - 'publish', - '"{}"'.format(rootless_metadata_path), - "--targets", "deadline", - "--targets", "farm" - ] - - # Generate the payload for Deadline submission - secondary_pool = ( - self.deadline_pool_secondary or instance.data.get("secondaryPool") - ) - payload = { - "JobInfo": { - "Plugin": "Ayon", - "BatchName": job["Props"]["Batch"], - "Name": job_name, - "UserName": job["Props"]["User"], - "Comment": instance.context.data.get("comment", ""), - - "Department": self.deadline_department, - "ChunkSize": self.deadline_chunk_size, - "Priority": priority, - "InitialStatus": initial_status, - - "Group": self.deadline_group, - "Pool": self.deadline_pool or instance.data.get("primaryPool"), - "SecondaryPool": secondary_pool, - # ensure the outputdirectory with correct slashes - "OutputDirectory0": output_dir.replace("\\", "/") - }, - "PluginInfo": { - "Version": self.plugin_pype_version, - "Arguments": " ".join(args), - "SingleFrameOnly": "True", - }, - # Mandatory for Deadline, may be empty - "AuxFiles": [], - } - - # add assembly jobs as dependencies - if instance.data.get("tileRendering"): - self.log.info("Adding tile assembly jobs as dependencies...") - job_index = 0 - for assembly_id in instance.data.get("assemblySubmissionJobs"): - payload["JobInfo"]["JobDependency{}".format( - job_index)] = assembly_id # noqa: E501 - job_index += 1 - elif instance.data.get("bakingSubmissionJobs"): - self.log.info( - "Adding baking submission jobs as dependencies..." - ) - job_index = 0 - for assembly_id in instance.data["bakingSubmissionJobs"]: - payload["JobInfo"]["JobDependency{}".format( - job_index)] = assembly_id # noqa: E501 - job_index += 1 - elif job.get("_id"): - payload["JobInfo"]["JobDependency0"] = job["_id"] - - for index, (key_, value_) in enumerate(environment.items()): - payload["JobInfo"].update( - { - "EnvironmentKeyValue%d" - % index: "{key}={value}".format( - key=key_, value=value_ - ) - } - ) - # remove secondary pool - payload["JobInfo"].pop("SecondaryPool", None) - - self.log.debug("Submitting Deadline publish job ...") - - url = "{}/api/jobs".format(self.deadline_url) - auth = instance.data["deadline"]["auth"] - verify = instance.data["deadline"]["verify"] - response = requests_post( - url, json=payload, timeout=10, auth=auth, verify=verify) - if not response.ok: - raise Exception(response.text) - - deadline_publish_job_id = response.json()["_id"] - - return deadline_publish_job_id - - def process(self, instance): - # type: (pyblish.api.Instance) -> None - """Process plugin. - - Detect type of render farm submission and create and post dependent - job in case of Deadline. It creates json file with metadata needed for - publishing in directory of render. - - Args: - instance (pyblish.api.Instance): Instance data. - - """ - if not instance.data.get("farm"): - self.log.debug("Skipping local instance.") - return - - anatomy = instance.context.data["anatomy"] - - instance_skeleton_data = create_skeleton_instance( - instance, families_transfer=self.families_transfer, - instance_transfer=self.instance_transfer) - """ - if content of `expectedFiles` list are dictionaries, we will handle - it as list of AOVs, creating instance for every one of them. - - Example: - -------- - - expectedFiles = [ - { - "beauty": [ - "foo_v01.0001.exr", - "foo_v01.0002.exr" - ], - - "Z": [ - "boo_v01.0001.exr", - "boo_v01.0002.exr" - ] - } - ] - - This will create instances for `beauty` and `Z` product - adding those files to their respective representations. - - If we have only list of files, we collect all file sequences. - More then one doesn't probably make sense, but we'll handle it - like creating one instance with multiple representations. - - Example: - -------- - - expectedFiles = [ - "foo_v01.0001.exr", - "foo_v01.0002.exr", - "xxx_v01.0001.exr", - "xxx_v01.0002.exr" - ] - - This will result in one instance with two representations: - `foo` and `xxx` - """ - do_not_add_review = False - if instance.data.get("review") is False: - self.log.debug("Instance has review explicitly disabled.") - do_not_add_review = True - - aov_filter = { - item["name"]: item["value"] - for item in self.aov_filter - } - if isinstance(instance.data.get("expectedFiles")[0], dict): - instances = create_instances_for_aov( - instance, instance_skeleton_data, - aov_filter, - self.skip_integration_repre_list, - do_not_add_review - ) - else: - representations = prepare_representations( - instance_skeleton_data, - instance.data.get("expectedFiles"), - anatomy, - aov_filter, - self.skip_integration_repre_list, - do_not_add_review, - instance.context, - self - ) - - if "representations" not in instance_skeleton_data.keys(): - instance_skeleton_data["representations"] = [] - - # add representation - instance_skeleton_data["representations"] += representations - instances = [instance_skeleton_data] - - # attach instances to product - if instance.data.get("attachTo"): - instances = attach_instances_to_product( - instance.data.get("attachTo"), instances - ) - - r''' SUBMiT PUBLiSH JOB 2 D34DLiN3 - ____ - ' ' .---. .---. .--. .---. .--..--..--..--. .---. - | | --= \ | . \/ _|/ \| . \ || || \ |/ _| - | JOB | --= / | | || __| .. | | | |;_ || \ || __| - | | |____./ \.__|._||_.|___./|_____|||__|\__|\.___| - ._____. - - ''' - - render_job = instance.data.pop("deadlineSubmissionJob", None) - if not render_job and instance.data.get("tileRendering") is False: - raise AssertionError(("Cannot continue without valid " - "Deadline submission.")) - if not render_job: - import getpass - - render_job = {} - self.log.debug("Faking job data ...") - render_job["Props"] = {} - # Render job doesn't exist because we do not have prior submission. - # We still use data from it so lets fake it. - # - # Batch name reflect original scene name - - if instance.data.get("assemblySubmissionJobs"): - render_job["Props"]["Batch"] = instance.data.get( - "jobBatchName") - else: - batch = os.path.splitext(os.path.basename( - instance.context.data.get("currentFile")))[0] - render_job["Props"]["Batch"] = batch - # User is deadline user - render_job["Props"]["User"] = instance.context.data.get( - "deadlineUser", getpass.getuser()) - - render_job["Props"]["Env"] = { - "FTRACK_API_USER": os.environ.get("FTRACK_API_USER"), - "FTRACK_API_KEY": os.environ.get("FTRACK_API_KEY"), - "FTRACK_SERVER": os.environ.get("FTRACK_SERVER"), - } - - # get default deadline webservice url from deadline module - self.deadline_url = instance.data["deadline"]["url"] - assert self.deadline_url, "Requires Deadline Webservice URL" - - deadline_publish_job_id = \ - self._submit_deadline_post_job(instance, render_job, instances) - - # Inject deadline url to instances to query DL for job id for overrides - for inst in instances: - inst["deadline"] = instance.data["deadline"] - - # publish job file - publish_job = { - "folderPath": instance_skeleton_data["folderPath"], - "frameStart": instance_skeleton_data["frameStart"], - "frameEnd": instance_skeleton_data["frameEnd"], - "fps": instance_skeleton_data["fps"], - "source": instance_skeleton_data["source"], - "user": instance.context.data["user"], - "version": instance.context.data["version"], # workfile version - "intent": instance.context.data.get("intent"), - "comment": instance.context.data.get("comment"), - "job": render_job or None, - "instances": instances - } - - if deadline_publish_job_id: - publish_job["deadline_publish_job_id"] = deadline_publish_job_id - - # add audio to metadata file if available - audio_file = instance.context.data.get("audioFile") - if audio_file and os.path.isfile(audio_file): - publish_job.update({"audio": audio_file}) - - metadata_path, rootless_metadata_path = \ - create_metadata_path(instance, anatomy) - - with open(metadata_path, "w") as f: - json.dump(publish_job, f, indent=4, sort_keys=True) - - def _get_publish_folder(self, anatomy, template_data, - folder_entity, product_name, context, - product_type, version=None): - """ - Extracted logic to pre-calculate real publish folder, which is - calculated in IntegrateNew inside of Deadline process. - This should match logic in: - 'collect_anatomy_instance_data' - to - get correct anatomy, family, version for product name and - 'collect_resources_path' - get publish_path - - Args: - anatomy (ayon_core.pipeline.anatomy.Anatomy): - template_data (dict): pre-calculated collected data for process - folder_entity (dict[str, Any]): Folder entity. - product_name (string): Product name (actually group name - of product) - product_type (string): for current deadline process it's always - 'render' - TODO - for generic use family needs to be dynamically - calculated like IntegrateNew does - version (int): override version from instance if exists - - Returns: - (string): publish folder where rendered and published files will - be stored - based on 'publish' template - """ - - project_name = context.data["projectName"] - host_name = context.data["hostName"] - if not version: - version_entity = None - if folder_entity: - version_entity = ayon_api.get_last_version_by_product_name( - project_name, - product_name, - folder_entity["id"] - ) - - if version_entity: - version = int(version_entity["version"]) + 1 - else: - version = get_versioning_start( - project_name, - host_name, - task_name=template_data["task"]["name"], - task_type=template_data["task"]["type"], - product_type="render", - product_name=product_name, - project_settings=context.data["project_settings"] - ) - - host_name = context.data["hostName"] - task_info = template_data.get("task") or {} - - template_name = publish.get_publish_template_name( - project_name, - host_name, - product_type, - task_info.get("name"), - task_info.get("type"), - ) - - template_data["version"] = version - template_data["subset"] = product_name - template_data["family"] = product_type - template_data["product"] = { - "name": product_name, - "type": product_type, - } - - render_dir_template = anatomy.get_template_item( - "publish", template_name, "directory" - ) - return render_dir_template.format_strict(template_data) - - @classmethod - def get_attribute_defs(cls): - return [ - EnumDef("publishJobState", - label="Publish Job State", - items=["Active", "Suspended"], - default="Active") - ] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_connection.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_connection.py deleted file mode 100644 index fd89e3a2a7..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_connection.py +++ /dev/null @@ -1,52 +0,0 @@ -import pyblish.api - -from ayon_core.pipeline import PublishXmlValidationError - -from ayon_deadline.abstract_submit_deadline import requests_get - - -class ValidateDeadlineConnection(pyblish.api.InstancePlugin): - """Validate Deadline Web Service is running""" - - label = "Validate Deadline Web Service" - order = pyblish.api.ValidatorOrder - hosts = ["maya", "nuke", "aftereffects", "harmony", "fusion"] - families = ["renderlayer", "render", "render.farm"] - - # cache - responses = {} - - def process(self, instance): - if not instance.data.get("farm"): - self.log.debug("Should not be processed on farm, skipping.") - return - - deadline_url = instance.data["deadline"]["url"] - assert deadline_url, "Requires Deadline Webservice URL" - - kwargs = {} - if instance.data["deadline"]["require_authentication"]: - auth = instance.data["deadline"]["auth"] - kwargs["auth"] = auth - - if not auth[0]: - raise PublishXmlValidationError( - self, - "Deadline requires authentication. " - "At least username is required to be set in " - "Site Settings.") - - if deadline_url not in self.responses: - self.responses[deadline_url] = requests_get(deadline_url, **kwargs) - - response = self.responses[deadline_url] - if response.status_code == 401: - raise PublishXmlValidationError( - self, - "Deadline requires authentication. " - "Provided credentials are not working. " - "Please change them in Site Settings") - assert response.ok, "Response must be ok" - assert response.text.startswith("Deadline Web Service "), ( - "Web service did not respond with 'Deadline Web Service'" - ) diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_pools.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_pools.py deleted file mode 100644 index c7445465c4..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_deadline_pools.py +++ /dev/null @@ -1,84 +0,0 @@ -import pyblish.api - -from ayon_core.pipeline import ( - PublishXmlValidationError, - OptionalPyblishPluginMixin -) - - -class ValidateDeadlinePools(OptionalPyblishPluginMixin, - pyblish.api.InstancePlugin): - """Validate primaryPool and secondaryPool on instance. - - Values are on instance based on value insertion when Creating instance or - by Settings in CollectDeadlinePools. - """ - - label = "Validate Deadline Pools" - order = pyblish.api.ValidatorOrder - families = ["rendering", - "render.farm", - "render.frames_farm", - "renderFarm", - "renderlayer", - "maxrender", - "publish.hou"] - optional = True - - # cache - pools_per_url = {} - - def process(self, instance): - if not self.is_active(instance.data): - return - - if not instance.data.get("farm"): - self.log.debug("Skipping local instance.") - return - - deadline_url = instance.data["deadline"]["url"] - addons_manager = instance.context.data["ayonAddonsManager"] - deadline_addon = addons_manager["deadline"] - pools = self.get_pools( - deadline_addon, - deadline_url, - instance.data["deadline"].get("auth") - ) - - invalid_pools = {} - primary_pool = instance.data.get("primaryPool") - if primary_pool and primary_pool not in pools: - invalid_pools["primary"] = primary_pool - - secondary_pool = instance.data.get("secondaryPool") - if secondary_pool and secondary_pool not in pools: - invalid_pools["secondary"] = secondary_pool - - if invalid_pools: - message = "\n".join( - "{} pool '{}' not available on Deadline".format(key.title(), - pool) - for key, pool in invalid_pools.items() - ) - raise PublishXmlValidationError( - plugin=self, - message=message, - formatting_data={"pools_str": ", ".join(pools)} - ) - - def get_pools(self, deadline_addon, deadline_url, auth): - if deadline_url not in self.pools_per_url: - self.log.debug( - "Querying available pools for Deadline url: {}".format( - deadline_url) - ) - pools = deadline_addon.get_deadline_pools( - deadline_url, auth=auth, log=self.log - ) - # some DL return "none" as a pool name - if "none" not in pools: - pools.append("none") - self.log.info("Available pools: {}".format(pools)) - self.pools_per_url[deadline_url] = pools - - return self.pools_per_url[deadline_url] diff --git a/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_expected_and_rendered_files.py b/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_expected_and_rendered_files.py deleted file mode 100644 index 3fd13cfa10..0000000000 --- a/server_addon/deadline/client/ayon_deadline/plugins/publish/validate_expected_and_rendered_files.py +++ /dev/null @@ -1,256 +0,0 @@ -import os -import requests - -import pyblish.api - -from ayon_core.lib import collect_frames -from ayon_deadline.abstract_submit_deadline import requests_get - - -class ValidateExpectedFiles(pyblish.api.InstancePlugin): - """Compare rendered and expected files""" - - label = "Validate rendered files from Deadline" - order = pyblish.api.ValidatorOrder - families = ["render"] - targets = ["deadline"] - - # check if actual frame range on render job wasn't different - # case when artists wants to render only subset of frames - allow_user_override = True - - def process(self, instance): - """Process all the nodes in the instance""" - - # get dependency jobs ids for retrieving frame list - dependent_job_ids = self._get_dependent_job_ids(instance) - - if not dependent_job_ids: - self.log.warning("No dependent jobs found for instance: {}" - "".format(instance)) - return - - # get list of frames from dependent jobs - frame_list = self._get_dependent_jobs_frames( - instance, dependent_job_ids) - - for repre in instance.data["representations"]: - expected_files = self._get_expected_files(repre) - - staging_dir = repre["stagingDir"] - existing_files = self._get_existing_files(staging_dir) - - if self.allow_user_override: - # We always check for user override because the user might have - # also overridden the Job frame list to be longer than the - # originally submitted frame range - # todo: We should first check if Job frame range was overridden - # at all so we don't unnecessarily override anything - file_name_template, frame_placeholder = \ - self._get_file_name_template_and_placeholder( - expected_files) - - if not file_name_template: - raise RuntimeError("Unable to retrieve file_name template" - "from files: {}".format(expected_files)) - - job_expected_files = self._get_job_expected_files( - file_name_template, - frame_placeholder, - frame_list) - - job_files_diff = job_expected_files.difference(expected_files) - if job_files_diff: - self.log.debug( - "Detected difference in expected output files from " - "Deadline job. Assuming an updated frame list by the " - "user. Difference: {}".format(sorted(job_files_diff)) - ) - - # Update the representation expected files - self.log.info("Update range from actual job range " - "to frame list: {}".format(frame_list)) - # single item files must be string not list - repre["files"] = (sorted(job_expected_files) - if len(job_expected_files) > 1 else - list(job_expected_files)[0]) - - # Update the expected files - expected_files = job_expected_files - - # We don't use set.difference because we do allow other existing - # files to be in the folder that we might not want to use. - missing = expected_files - existing_files - if missing: - raise RuntimeError( - "Missing expected files: {}\n" - "Expected files: {}\n" - "Existing files: {}".format( - sorted(missing), - sorted(expected_files), - sorted(existing_files) - ) - ) - - def _get_dependent_job_ids(self, instance): - """Returns list of dependent job ids from instance metadata.json - - Args: - instance (pyblish.api.Instance): pyblish instance - - Returns: - (list): list of dependent job ids - - """ - dependent_job_ids = [] - - # job_id collected from metadata.json - original_job_id = instance.data["render_job_id"] - - dependent_job_ids_env = os.environ.get("RENDER_JOB_IDS") - if dependent_job_ids_env: - dependent_job_ids = dependent_job_ids_env.split(',') - elif original_job_id: - dependent_job_ids = [original_job_id] - - return dependent_job_ids - - def _get_dependent_jobs_frames(self, instance, dependent_job_ids): - """Returns list of frame ranges from all render job. - - Render job might be re-submitted so job_id in metadata.json could be - invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS. - - Args: - instance (pyblish.api.Instance): pyblish instance - dependent_job_ids (list): list of dependent job ids - Returns: - (list) - """ - all_frame_lists = [] - - for job_id in dependent_job_ids: - job_info = self._get_job_info(instance, job_id) - frame_list = job_info["Props"].get("Frames") - if frame_list: - all_frame_lists.extend(frame_list.split(',')) - - return all_frame_lists - - def _get_job_expected_files(self, - file_name_template, - frame_placeholder, - frame_list): - """Calculates list of names of expected rendered files. - - Might be different from expected files from submission if user - explicitly and manually changed the frame list on the Deadline job. - - """ - # no frames in file name at all, eg 'renderCompositingMain.withLut.mov' - if not frame_placeholder: - return {file_name_template} - - real_expected_rendered = set() - src_padding_exp = "%0{}d".format(len(frame_placeholder)) - for frames in frame_list: - if '-' not in frames: # single frame - frames = "{}-{}".format(frames, frames) - - start, end = frames.split('-') - for frame in range(int(start), int(end) + 1): - ren_name = file_name_template.replace( - frame_placeholder, src_padding_exp % frame) - real_expected_rendered.add(ren_name) - - return real_expected_rendered - - def _get_file_name_template_and_placeholder(self, files): - """Returns file name with frame replaced with # and this placeholder""" - sources_and_frames = collect_frames(files) - - file_name_template = frame_placeholder = None - for file_name, frame in sources_and_frames.items(): - - # There might be cases where clique was unable to collect - # collections in `collect_frames` - thus we capture that case - if frame is not None: - frame_placeholder = "#" * len(frame) - - file_name_template = os.path.basename( - file_name.replace(frame, frame_placeholder)) - else: - file_name_template = file_name - break - - return file_name_template, frame_placeholder - - def _get_job_info(self, instance, job_id): - """Calls DL for actual job info for 'job_id' - - Might be different than job info saved in metadata.json if user - manually changes job pre/during rendering. - - Args: - instance (pyblish.api.Instance): pyblish instance - job_id (str): Deadline job id - - Returns: - (dict): Job info from Deadline - - """ - deadline_url = instance.data["deadline"]["url"] - assert deadline_url, "Requires Deadline Webservice URL" - - url = "{}/api/jobs?JobID={}".format(deadline_url, job_id) - try: - kwargs = {} - auth = instance.data["deadline"]["auth"] - if auth: - kwargs["auth"] = auth - response = requests_get(url, **kwargs) - except requests.exceptions.ConnectionError: - self.log.error("Deadline is not accessible at " - "{}".format(deadline_url)) - return {} - - if not response.ok: - self.log.error("Submission failed!") - self.log.error(response.status_code) - self.log.error(response.content) - raise RuntimeError(response.text) - - json_content = response.json() - if json_content: - return json_content.pop() - return {} - - def _get_existing_files(self, staging_dir): - """Returns set of existing file names from 'staging_dir'""" - existing_files = set() - for file_name in os.listdir(staging_dir): - existing_files.add(file_name) - return existing_files - - def _get_expected_files(self, repre): - """Returns set of file names in representation['files'] - - The representations are collected from `CollectRenderedFiles` using - the metadata.json file submitted along with the render job. - - Args: - repre (dict): The representation containing 'files' - - Returns: - set: Set of expected file_names in the staging directory. - - """ - expected_files = set() - - files = repre["files"] - if not isinstance(files, list): - files = [files] - - for file_name in files: - expected_files.add(file_name) - return expected_files diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/Ayon/Ayon.ico b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/Ayon/Ayon.ico deleted file mode 100644 index aea977a1251232d0f3d78ea6cb124994f4d31eb4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7679 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 dg=}F#T++JjmkP-0+ukjp@", str(self.GetStartFrame()), - arguments) - arguments = re.sub(r"<(?i)ENDFRAME>", str(self.GetEndFrame()), - arguments) - arguments = re.sub(r"<(?i)QUOTE>", "\"", arguments) - - arguments = self.ReplacePaddedFrame(arguments, - "<(?i)STARTFRAME%([0-9]+)>", - self.GetStartFrame()) - arguments = self.ReplacePaddedFrame(arguments, - "<(?i)ENDFRAME%([0-9]+)>", - self.GetEndFrame()) - - count = 0 - for filename in self.GetAuxiliaryFilenames(): - localAuxFile = Path.Combine(self.GetJobsDataDirectory(), filename) - arguments = re.sub(r"<(?i)AUXFILE" + str(count) + r">", - localAuxFile.replace("\\", "/"), arguments) - count += 1 - - return arguments - - def ReplacePaddedFrame(self, arguments, pattern, frame): - frameRegex = Regex(pattern) - while True: - frameMatch = frameRegex.Match(arguments) - if not frameMatch.Success: - break - paddingSize = int(frameMatch.Groups[1].Value) - if paddingSize > 0: - padding = StringUtils.ToZeroPaddedString( - frame, paddingSize, False) - else: - padding = str(frame) - arguments = arguments.replace( - frameMatch.Groups[0].Value, padding) - - return arguments - - def HandleProgress(self): - progress = float(self.GetRegexMatch(1)) - self.SetProgress(progress) diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.ico b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.ico deleted file mode 100644 index 39d61592fe1addb07ed3ef93de362370485a23b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103192 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 JVj#NS{{bY%R3rcZ diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.param b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.param deleted file mode 100644 index 24c59d2005..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.param +++ /dev/null @@ -1,38 +0,0 @@ -[About] -Type=label -Label=About -Category=About Plugin -CategoryOrder=-1 -Index=0 -Default=Celaction Plugin for Deadline -Description=Not configurable - -[ConcurrentTasks] -Type=label -Label=ConcurrentTasks -Category=About Plugin -CategoryOrder=-1 -Index=0 -Default=True -Description=Not configurable - -[Executable] -Type=filename -Label=Executable -Category=Config -CategoryOrder=0 -CategoryIndex=0 -Description=The command executable to run -Required=false -DisableIfBlank=true - -[RenderNameSeparator] -Type=string -Label=RenderNameSeparator -Category=Config -CategoryOrder=0 -CategoryIndex=1 -Description=The separator to use for naming -Required=false -DisableIfBlank=true -Default=. diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.py b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.py deleted file mode 100644 index 2d0edd3dca..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/CelAction.py +++ /dev/null @@ -1,122 +0,0 @@ -from System.Text.RegularExpressions import * - -from Deadline.Plugins import * -from Deadline.Scripting import * - -import _winreg - -###################################################################### -# This is the function that Deadline calls to get an instance of the -# main DeadlinePlugin class. -###################################################################### - - -def GetDeadlinePlugin(): - return CelActionPlugin() - - -def CleanupDeadlinePlugin(deadlinePlugin): - deadlinePlugin.Cleanup() - -###################################################################### -# This is the main DeadlinePlugin class for the CelAction plugin. -###################################################################### - - -class CelActionPlugin(DeadlinePlugin): - - def __init__(self): - self.InitializeProcessCallback += self.InitializeProcess - self.RenderExecutableCallback += self.RenderExecutable - self.RenderArgumentCallback += self.RenderArgument - self.StartupDirectoryCallback += self.StartupDirectory - - def Cleanup(self): - for stdoutHandler in self.StdoutHandlers: - del stdoutHandler.HandleCallback - - del self.InitializeProcessCallback - del self.RenderExecutableCallback - del self.RenderArgumentCallback - del self.StartupDirectoryCallback - - def GetCelActionRegistryKey(self): - # Modify registry for frame separation - path = r'Software\CelAction\CelAction2D\User Settings' - _winreg.CreateKey(_winreg.HKEY_CURRENT_USER, path) - regKey = _winreg.OpenKey(_winreg.HKEY_CURRENT_USER, path, 0, - _winreg.KEY_ALL_ACCESS) - return regKey - - def GetSeparatorValue(self, regKey): - useSeparator, _ = _winreg.QueryValueEx( - regKey, 'RenderNameUseSeparator') - separator, _ = _winreg.QueryValueEx(regKey, 'RenderNameSeparator') - - return useSeparator, separator - - def SetSeparatorValue(self, regKey, useSeparator, separator): - _winreg.SetValueEx(regKey, 'RenderNameUseSeparator', - 0, _winreg.REG_DWORD, useSeparator) - _winreg.SetValueEx(regKey, 'RenderNameSeparator', - 0, _winreg.REG_SZ, separator) - - def InitializeProcess(self): - # Set the plugin specific settings. - self.SingleFramesOnly = False - - # Set the process specific settings. - self.StdoutHandling = True - self.PopupHandling = True - - # Ignore 'celaction' Pop-up dialog - self.AddPopupIgnorer(".*Rendering.*") - self.AddPopupIgnorer(".*AutoRender.*") - - # Ignore 'celaction' Pop-up dialog - self.AddPopupIgnorer(".*Wait.*") - - # Ignore 'celaction' Pop-up dialog - self.AddPopupIgnorer(".*Timeline Scrub.*") - - celActionRegKey = self.GetCelActionRegistryKey() - - self.SetSeparatorValue(celActionRegKey, 1, self.GetConfigEntryWithDefault( - "RenderNameSeparator", ".").strip()) - - def RenderExecutable(self): - return RepositoryUtils.CheckPathMapping(self.GetConfigEntry("Executable").strip()) - - def RenderArgument(self): - arguments = RepositoryUtils.CheckPathMapping( - self.GetPluginInfoEntry("Arguments").strip()) - arguments = arguments.replace( - "", str(self.GetStartFrame())) - arguments = arguments.replace("", str(self.GetEndFrame())) - arguments = self.ReplacePaddedFrame( - arguments, "", self.GetStartFrame()) - arguments = self.ReplacePaddedFrame( - arguments, "", self.GetEndFrame()) - arguments = arguments.replace("", "\"") - return arguments - - def StartupDirectory(self): - return self.GetPluginInfoEntryWithDefault("StartupDirectory", "").strip() - - def ReplacePaddedFrame(self, arguments, pattern, frame): - frameRegex = Regex(pattern) - while True: - frameMatch = frameRegex.Match(arguments) - if frameMatch.Success: - paddingSize = int(frameMatch.Groups[1].Value) - if paddingSize > 0: - padding = StringUtils.ToZeroPaddedString( - frame, paddingSize, False) - else: - padding = str(frame) - arguments = arguments.replace( - frameMatch.Groups[0].Value, padding) - else: - break - - return arguments diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/GlobalJobPreLoad.py b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/GlobalJobPreLoad.py deleted file mode 100644 index dbd1798608..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ /dev/null @@ -1,662 +0,0 @@ -# /usr/bin/env python3 -# -*- coding: utf-8 -*- -import os -import tempfile -from datetime import datetime -import subprocess -import json -import platform -import uuid -import re -from Deadline.Scripting import ( - RepositoryUtils, - FileUtils, - DirectoryUtils, -) -__version__ = "1.1.1" -VERSION_REGEX = re.compile( - r"(?P0|[1-9]\d*)" - r"\.(?P0|[1-9]\d*)" - r"\.(?P0|[1-9]\d*)" - r"(?:-(?P[a-zA-Z\d\-.]*))?" - r"(?:\+(?P[a-zA-Z\d\-.]*))?" -) - - -class OpenPypeVersion: - """Fake semver version class for OpenPype version purposes. - - The version - """ - def __init__(self, major, minor, patch, prerelease, origin=None): - self.major = major - self.minor = minor - self.patch = patch - self.prerelease = prerelease - - is_valid = True - if major is None or minor is None or patch is None: - is_valid = False - self.is_valid = is_valid - - if origin is None: - base = "{}.{}.{}".format(str(major), str(minor), str(patch)) - if not prerelease: - origin = base - else: - origin = "{}-{}".format(base, str(prerelease)) - - self.origin = origin - - @classmethod - def from_string(cls, version): - """Create an object of version from string. - - Args: - version (str): Version as a string. - - Returns: - Union[OpenPypeVersion, None]: Version object if input is nonempty - string otherwise None. - """ - - if not version: - return None - valid_parts = VERSION_REGEX.findall(version) - if len(valid_parts) != 1: - # Return invalid version with filled 'origin' attribute - return cls(None, None, None, None, origin=str(version)) - - # Unpack found version - major, minor, patch, pre, post = valid_parts[0] - prerelease = pre - # Post release is not important anymore and should be considered as - # part of prerelease - # - comparison is implemented to find suitable build and builds should - # never contain prerelease part so "not proper" parsing is - # acceptable for this use case. - if post: - prerelease = "{}+{}".format(pre, post) - - return cls( - int(major), int(minor), int(patch), prerelease, origin=version - ) - - def has_compatible_release(self, other): - """Version has compatible release as other version. - - Both major and minor versions must be exactly the same. In that case - a build can be considered as release compatible with any version. - - Args: - other (OpenPypeVersion): Other version. - - Returns: - bool: Version is release compatible with other version. - """ - - if self.is_valid and other.is_valid: - return self.major == other.major and self.minor == other.minor - return False - - def __bool__(self): - return self.is_valid - - def __repr__(self): - return "<{} {}>".format(self.__class__.__name__, self.origin) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return self.origin == other - return self.origin == other.origin - - def __lt__(self, other): - if not isinstance(other, self.__class__): - return None - - if not self.is_valid: - return True - - if not other.is_valid: - return False - - if self.origin == other.origin: - return None - - same_major = self.major == other.major - if not same_major: - return self.major < other.major - - same_minor = self.minor == other.minor - if not same_minor: - return self.minor < other.minor - - same_patch = self.patch == other.patch - if not same_patch: - return self.patch < other.patch - - if not self.prerelease: - return False - - if not other.prerelease: - return True - - pres = [self.prerelease, other.prerelease] - pres.sort() - return pres[0] == self.prerelease - - -def get_openpype_version_from_path(path, build=True): - """Get OpenPype version from provided path. - path (str): Path to scan. - build (bool, optional): Get only builds, not sources - - Returns: - Union[OpenPypeVersion, None]: version of OpenPype if found. - """ - - # fix path for application bundle on macos - if platform.system().lower() == "darwin": - path = os.path.join(path, "MacOS") - - version_file = os.path.join(path, "openpype", "version.py") - if not os.path.isfile(version_file): - return None - - # skip if the version is not build - exe = os.path.join(path, "openpype_console.exe") - if platform.system().lower() in ["linux", "darwin"]: - exe = os.path.join(path, "openpype_console") - - # if only builds are requested - if build and not os.path.isfile(exe): # noqa: E501 - print(" ! path is not a build: {}".format(path)) - return None - - version = {} - with open(version_file, "r") as vf: - exec(vf.read(), version) - - version_str = version.get("__version__") - if version_str: - return OpenPypeVersion.from_string(version_str) - return None - - -def get_openpype_executable(): - """Return OpenPype Executable from Event Plug-in Settings""" - config = RepositoryUtils.GetPluginConfig("OpenPype") - exe_list = config.GetConfigEntryWithDefault("OpenPypeExecutable", "") - dir_list = config.GetConfigEntryWithDefault( - "OpenPypeInstallationDirs", "") - - # clean '\ ' for MacOS pasting - if platform.system().lower() == "darwin": - exe_list = exe_list.replace("\\ ", " ") - dir_list = dir_list.replace("\\ ", " ") - return exe_list, dir_list - - -def get_openpype_versions(dir_list): - print(">>> Getting OpenPype executable ...") - openpype_versions = [] - - # special case of multiple install dirs - for dir_list in dir_list.split(","): - install_dir = DirectoryUtils.SearchDirectoryList(dir_list) - if install_dir: - print("--- Looking for OpenPype at: {}".format(install_dir)) - sub_dirs = [ - f.path for f in os.scandir(install_dir) - if f.is_dir() - ] - for subdir in sub_dirs: - version = get_openpype_version_from_path(subdir) - if not version: - continue - print(" - found: {} - {}".format(version, subdir)) - openpype_versions.append((version, subdir)) - return openpype_versions - - -def get_requested_openpype_executable( - exe, dir_list, requested_version -): - requested_version_obj = OpenPypeVersion.from_string(requested_version) - if not requested_version_obj: - print(( - ">>> Requested version '{}' does not match version regex '{}'" - ).format(requested_version, VERSION_REGEX)) - return None - - print(( - ">>> Scanning for compatible requested version {}" - ).format(requested_version)) - openpype_versions = get_openpype_versions(dir_list) - if not openpype_versions: - return None - - # if looking for requested compatible version, - # add the implicitly specified to the list too. - if exe: - exe_dir = os.path.dirname(exe) - print("Looking for OpenPype at: {}".format(exe_dir)) - version = get_openpype_version_from_path(exe_dir) - if version: - print(" - found: {} - {}".format(version, exe_dir)) - openpype_versions.append((version, exe_dir)) - - matching_item = None - compatible_versions = [] - for version_item in openpype_versions: - version, version_dir = version_item - if requested_version_obj.has_compatible_release(version): - compatible_versions.append(version_item) - if version == requested_version_obj: - # Store version item if version match exactly - # - break if is found matching version - matching_item = version_item - break - - if not compatible_versions: - return None - - compatible_versions.sort(key=lambda item: item[0]) - if matching_item: - version, version_dir = matching_item - print(( - "*** Found exact match build version {} in {}" - ).format(version_dir, version)) - - else: - version, version_dir = compatible_versions[-1] - - print(( - "*** Latest compatible version found is {} in {}" - ).format(version_dir, version)) - - # create list of executables for different platform and let - # Deadline decide. - exe_list = [ - os.path.join(version_dir, "openpype_console.exe"), - os.path.join(version_dir, "openpype_console"), - os.path.join(version_dir, "MacOS", "openpype_console") - ] - return FileUtils.SearchFileList(";".join(exe_list)) - - -def inject_openpype_environment(deadlinePlugin): - """ Pull env vars from OpenPype and push them to rendering process. - - Used for correct paths, configuration from OpenPype etc. - """ - job = deadlinePlugin.GetJob() - - print(">>> Injecting OpenPype environments ...") - try: - exe_list, dir_list = get_openpype_executable() - exe = FileUtils.SearchFileList(exe_list) - - requested_version = job.GetJobEnvironmentKeyValue("OPENPYPE_VERSION") - if requested_version: - exe = get_requested_openpype_executable( - exe, dir_list, requested_version - ) - if exe is None: - raise RuntimeError(( - "Cannot find compatible version available for version {}" - " requested by the job. Please add it through plugin" - " configuration in Deadline or install it to configured" - " directory." - ).format(requested_version)) - - if not exe: - raise RuntimeError(( - "OpenPype executable was not found in the semicolon " - "separated list \"{}\"." - "The path to the render executable can be configured" - " from the Plugin Configuration in the Deadline Monitor." - ).format(";".join(exe_list))) - - print("--- OpenPype executable: {}".format(exe)) - - # tempfile.TemporaryFile cannot be used because of locking - temp_file_name = "{}_{}.json".format( - datetime.utcnow().strftime("%Y%m%d%H%M%S%f"), - str(uuid.uuid1()) - ) - export_url = os.path.join(tempfile.gettempdir(), temp_file_name) - print(">>> Temporary path: {}".format(export_url)) - - args = [ - "--headless", - "extractenvironments", - export_url - ] - - add_kwargs = { - "project": job.GetJobEnvironmentKeyValue("AVALON_PROJECT"), - "asset": job.GetJobEnvironmentKeyValue("AVALON_ASSET"), - "task": job.GetJobEnvironmentKeyValue("AVALON_TASK"), - "app": job.GetJobEnvironmentKeyValue("AVALON_APP_NAME"), - "envgroup": "farm" - } - - # use legacy IS_TEST env var to mark automatic tests for OP - if job.GetJobEnvironmentKeyValue("IS_TEST"): - args.append("--automatic-tests") - - if all(add_kwargs.values()): - for key, value in add_kwargs.items(): - args.extend(["--{}".format(key), value]) - else: - raise RuntimeError(( - "Missing required env vars: AVALON_PROJECT, AVALON_ASSET," - " AVALON_TASK, AVALON_APP_NAME" - )) - - openpype_mongo = job.GetJobEnvironmentKeyValue("OPENPYPE_MONGO") - if openpype_mongo: - # inject env var for OP extractenvironments - # SetEnvironmentVariable is important, not SetProcessEnv... - deadlinePlugin.SetEnvironmentVariable("OPENPYPE_MONGO", - openpype_mongo) - - if not os.environ.get("OPENPYPE_MONGO"): - print(">>> Missing OPENPYPE_MONGO env var, process won't work") - - os.environ["AVALON_TIMEOUT"] = "5000" - - args_str = subprocess.list2cmdline(args) - print(">>> Executing: {} {}".format(exe, args_str)) - process_exitcode = deadlinePlugin.RunProcess( - exe, args_str, os.path.dirname(exe), -1 - ) - - if process_exitcode != 0: - raise RuntimeError( - "Failed to run OpenPype process to extract environments." - ) - - print(">>> Loading file ...") - with open(export_url) as fp: - contents = json.load(fp) - - for key, value in contents.items(): - deadlinePlugin.SetProcessEnvironmentVariable(key, value) - - if "PATH" in contents: - # Set os.environ[PATH] so studio settings' path entries - # can be used to define search path for executables. - print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") - os.environ["PATH"] = contents["PATH"] - - script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") - if script_url: - script_url = script_url.format(**contents).replace("\\", "/") - print(">>> Setting script path {}".format(script_url)) - job.SetJobPluginInfoKeyValue("ScriptFilename", script_url) - - print(">>> Removing temporary file") - os.remove(export_url) - - print(">> Injection end.") - except Exception as e: - if hasattr(e, "output"): - print(">>> Exception {}".format(e.output)) - import traceback - print(traceback.format_exc()) - print("!!! Injection failed.") - RepositoryUtils.FailJob(job) - raise - - -def inject_ayon_environment(deadlinePlugin): - """ Pull env vars from AYON and push them to rendering process. - - Used for correct paths, configuration from AYON etc. - """ - job = deadlinePlugin.GetJob() - - print(">>> Injecting AYON environments ...") - try: - exe_list = get_ayon_executable() - exe = FileUtils.SearchFileList(exe_list) - - if not exe: - raise RuntimeError(( - "Ayon executable was not found in the semicolon " - "separated list \"{}\"." - "The path to the render executable can be configured" - " from the Plugin Configuration in the Deadline Monitor." - ).format(exe_list)) - - print("--- Ayon executable: {}".format(exe)) - - ayon_bundle_name = job.GetJobEnvironmentKeyValue("AYON_BUNDLE_NAME") - if not ayon_bundle_name: - raise RuntimeError( - "Missing env var in job properties AYON_BUNDLE_NAME" - ) - - config = RepositoryUtils.GetPluginConfig("Ayon") - ayon_server_url = ( - job.GetJobEnvironmentKeyValue("AYON_SERVER_URL") or - config.GetConfigEntryWithDefault("AyonServerUrl", "") - ) - ayon_api_key = ( - job.GetJobEnvironmentKeyValue("AYON_API_KEY") or - config.GetConfigEntryWithDefault("AyonApiKey", "") - ) - - if not all([ayon_server_url, ayon_api_key]): - raise RuntimeError(( - "Missing required values for server url and api key. " - "Please fill in Ayon Deadline plugin or provide by " - "AYON_SERVER_URL and AYON_API_KEY" - )) - - # tempfile.TemporaryFile cannot be used because of locking - temp_file_name = "{}_{}.json".format( - datetime.utcnow().strftime("%Y%m%d%H%M%S%f"), - str(uuid.uuid1()) - ) - export_url = os.path.join(tempfile.gettempdir(), temp_file_name) - print(">>> Temporary path: {}".format(export_url)) - - add_kwargs = { - "envgroup": "farm", - } - # Support backwards compatible keys - for key, env_keys in ( - ("project", ["AYON_PROJECT_NAME", "AVALON_PROJECT"]), - ("folder", ["AYON_FOLDER_PATH", "AVALON_ASSET"]), - ("task", ["AYON_TASK_NAME", "AVALON_TASK"]), - ("app", ["AYON_APP_NAME", "AVALON_APP_NAME"]), - ): - value = "" - for env_key in env_keys: - value = job.GetJobEnvironmentKeyValue(env_key) - if value: - break - add_kwargs[key] = value - - if not all(add_kwargs.values()): - raise RuntimeError(( - "Missing required env vars: AYON_PROJECT_NAME," - " AYON_FOLDER_PATH, AYON_TASK_NAME, AYON_APP_NAME" - )) - - # Use applications addon arguments - # TODO validate if applications addon should be used - args = [ - "--headless", - "addon", - "applications", - "extractenvironments", - export_url - ] - # Backwards compatibility for older versions - legacy_args = [ - "--headless", - "extractenvironments", - export_url - ] - - for key, value in add_kwargs.items(): - args.extend(["--{}".format(key), value]) - # Legacy arguments expect '--asset' instead of '--folder' - if key == "folder": - key = "asset" - legacy_args.extend(["--{}".format(key), value]) - - environment = { - "AYON_SERVER_URL": ayon_server_url, - "AYON_API_KEY": ayon_api_key, - "AYON_BUNDLE_NAME": ayon_bundle_name, - } - - automatic_tests = job.GetJobEnvironmentKeyValue("AYON_IN_TESTS") - if automatic_tests: - environment["AYON_IN_TESTS"] = automatic_tests - for env, val in environment.items(): - # Add the env var for the Render Plugin that is about to render - deadlinePlugin.SetEnvironmentVariable(env, val) - # Add the env var for current calls to `DeadlinePlugin.RunProcess` - deadlinePlugin.SetProcessEnvironmentVariable(env, val) - - args_str = subprocess.list2cmdline(args) - print(">>> Executing: {} {}".format(exe, args_str)) - process_exitcode = deadlinePlugin.RunProcess( - exe, args_str, os.path.dirname(exe), -1 - ) - - if process_exitcode != 0: - print( - "Failed to run AYON process to extract environments. Trying" - " to use legacy arguments." - ) - legacy_args_str = subprocess.list2cmdline(legacy_args) - process_exitcode = deadlinePlugin.RunProcess( - exe, legacy_args_str, os.path.dirname(exe), -1 - ) - if process_exitcode != 0: - raise RuntimeError( - "Failed to run AYON process to extract environments." - ) - - print(">>> Loading file ...") - with open(export_url) as fp: - contents = json.load(fp) - - for key, value in contents.items(): - deadlinePlugin.SetProcessEnvironmentVariable(key, value) - - if "PATH" in contents: - # Set os.environ[PATH] so studio settings' path entries - # can be used to define search path for executables. - print(f">>> Setting 'PATH' Environment to: {contents['PATH']}") - os.environ["PATH"] = contents["PATH"] - - script_url = job.GetJobPluginInfoKeyValue("ScriptFilename") - if script_url: - script_url = script_url.format(**contents).replace("\\", "/") - print(">>> Setting script path {}".format(script_url)) - job.SetJobPluginInfoKeyValue("ScriptFilename", script_url) - - print(">>> Removing temporary file") - os.remove(export_url) - - print(">> Injection end.") - except Exception as e: - if hasattr(e, "output"): - print(">>> Exception {}".format(e.output)) - import traceback - print(traceback.format_exc()) - print("!!! Injection failed.") - RepositoryUtils.FailJob(job) - raise - - -def get_ayon_executable(): - """Return AYON Executable from Event Plug-in Settings - - Returns: - list[str]: AYON executable paths. - - Raises: - RuntimeError: When no path configured at all. - - """ - config = RepositoryUtils.GetPluginConfig("Ayon") - exe_list = config.GetConfigEntryWithDefault("AyonExecutable", "") - - if not exe_list: - raise RuntimeError( - "Path to AYON executable not configured." - "Please set it in Ayon Deadline Plugin." - ) - - # clean '\ ' for MacOS pasting - if platform.system().lower() == "darwin": - exe_list = exe_list.replace("\\ ", " ") - - # Expand user paths - expanded_paths = [] - for path in exe_list.split(";"): - if path.startswith("~"): - path = os.path.expanduser(path) - expanded_paths.append(path) - return ";".join(expanded_paths) - - -def inject_render_job_id(deadlinePlugin): - """Inject dependency ids to publish process as env var for validation.""" - print(">>> Injecting render job id ...") - job = deadlinePlugin.GetJob() - - dependency_ids = job.JobDependencyIDs - print(">>> Dependency IDs: {}".format(dependency_ids)) - render_job_ids = ",".join(dependency_ids) - - deadlinePlugin.SetProcessEnvironmentVariable( - "RENDER_JOB_IDS", render_job_ids - ) - print(">>> Injection end.") - - -def __main__(deadlinePlugin): - print("*** GlobalJobPreload {} start ...".format(__version__)) - print(">>> Getting job ...") - job = deadlinePlugin.GetJob() - - openpype_render_job = job.GetJobEnvironmentKeyValue( - "OPENPYPE_RENDER_JOB") - openpype_publish_job = job.GetJobEnvironmentKeyValue( - "OPENPYPE_PUBLISH_JOB") - openpype_remote_job = job.GetJobEnvironmentKeyValue( - "OPENPYPE_REMOTE_PUBLISH") - - if openpype_publish_job == "1" and openpype_render_job == "1": - raise RuntimeError( - "Misconfiguration. Job couldn't be both render and publish." - ) - - if openpype_publish_job == "1": - inject_render_job_id(deadlinePlugin) - if openpype_render_job == "1" or openpype_remote_job == "1": - inject_openpype_environment(deadlinePlugin) - - ayon_render_job = job.GetJobEnvironmentKeyValue("AYON_RENDER_JOB") - ayon_publish_job = job.GetJobEnvironmentKeyValue("AYON_PUBLISH_JOB") - ayon_remote_job = job.GetJobEnvironmentKeyValue("AYON_REMOTE_PUBLISH") - - if ayon_publish_job == "1" and ayon_render_job == "1": - raise RuntimeError( - "Misconfiguration. Job couldn't be both render and publish." - ) - - if ayon_publish_job == "1": - inject_render_job_id(deadlinePlugin) - if ayon_render_job == "1" or ayon_remote_job == "1": - inject_ayon_environment(deadlinePlugin) diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.ico b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.ico deleted file mode 100644 index cf6f6bfcfa7cb19da3d76a576814a1473afc0d7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1150 zcmbtUze@sP7=FaS;f8c5h?a(iT3Q++Y-k8U`U6@j=?A8Cb)lZ8|A7tFRzpK%KZc7k zm42wHK}56!&Q1-XjndR0*ZUzKd|jsy-tpY~zR&wSAMf{ZcYuv=@%+FN!N9I?M?6NgnA_@od)18#nCLsU~rTuPbmmz z6`Ykc7*;51XdW+PlFfb-!bv$!egO|LH1>TMFUIgC@;oQc5#RUjA_i%OKf3EJtY<@r za}STSW@B$21KiJIMuEorEEY7pFL)q%hvCRaQ5Ab>1*+&}xu|<2zb<2rYc6DXa}hk> zNAPqPMw)4PPmFpHv&>7nGCSAQCN@N5s18oSC3?*HZ5Y`DAKZl~KE#<>PBY}pdUNd= zgG6V$ZT8ZceIKr3)U3Cv$-iUvGtNqD#U=c%-hzr6UxW7YnV3PZm9`ysC1Z*EFJ-K> n)3xL2JG19iyyR58vb>$H6QA^B;(V?3j(5^^(=-1M{#*Y5EFN;S diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.options b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.options deleted file mode 100644 index efd44b4f94..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.options +++ /dev/null @@ -1,532 +0,0 @@ -[SceneFile] -Type=filename -Label=Scene Filename -Category=Global Settings -CategoryOrder=0 -Index=0 -Description=The scene filename as it exists on the network. -Required=false -DisableIfBlank=true - -[Environment] -Type=filename -Label=Scene Environment -Category=Global Settings -CategoryOrder=0 -Index=1 -Description=The Environment for the scene. -Required=false -DisableIfBlank=true - -[Job] -Type=filename -Label=Scene Job -Category=Global Settings -CategoryOrder=0 -Index=2 -Description=The Job that the scene belongs to. -Required=false -DisableIfBlank=true - -[SceneName] -Type=filename -Label=Scene Name -Category=Global Settings -CategoryOrder=0 -Index=3 -Description=The name of the scene to render -Required=false -DisableIfBlank=true - -[SceneVersion] -Type=filename -Label=Scene Version -Category=Global Settings -CategoryOrder=0 -Index=4 -Description=The version of the scene to render. -Required=false -DisableIfBlank=true - -[Version] -Type=enum -Values=10;11;12 -Label=Harmony Version -Category=Global Settings -CategoryOrder=0 -Index=5 -Description=The version of Harmony to use. -Required=false -DisableIfBlank=true - -[IsDatabase] -Type=Boolean -Label=Is Database Scene -Category=Global Settings -CategoryOrder=0 -Index=6 -Description=Whether or not the scene is in the database or not -Required=false -DisableIfBlank=true - -[Camera] -Type=string -Label=Camera -Category=Render Settings -CategoryOrder=1 -Index=0 -Description=Specifies the camera to use for rendering images. If Blank, the scene will be rendered with the current Camera. -Required=false -DisableIfBlank=true - -[UsingResPreset] -Type=Boolean -Label=Use Resolution Preset -Category=Render Settings -CategoryOrder=1 -Index=1 -Description=Whether or not you are using a resolution preset. -Required=false -DisableIfBlank=true - -[ResolutionName] -Type=enum -Values=HDTV_1080p24;HDTV_1080p25;HDTV_720p24;4K_UHD;8K_UHD;DCI_2K;DCI_4K;film-2K;film-4K;film-1.33_H;film-1.66_H;film-1.66_V;Cineon;NTSC;PAL;2160p;1440p;1080p;720p;480p;360p;240p;low;Web_Video;Game_512;Game_512_Ortho;WebCC_Preview;Custom -Label=Resolution Preset -Category=Render Settings -CategoryOrder=1 -Index=2 -Description=The resolution preset to use. -Required=true -Default=HDTV_1080p24 - -[PresetName] -Type=string -Label=Preset Name -Category=Render Settings -CategoryOrder=1 -Index=3 -Description=Specify the custom resolution name. -Required=true -Default= - -[ResolutionX] -Type=integer -Label=Resolution X -Minimum=0 -Maximum=1000000 -Category=Render Settings -CategoryOrder=1 -Index=4 -Description=Specifies the width of the rendered images. If 0, then the current resolution and Field of view will be used. -Required=true -Default=1920 - -[ResolutionY] -Type=integer -Label=Resolution Y -Minimum=0 -Maximum=1000000 -Category=Render Settings -CategoryOrder=1 -Index=5 -Description=Specifies the height of the rendered images. If 0, then the current resolution and Field of view will be used. -Required=true -Default=1080 - -[FieldOfView] -Type=float -Label=Field Of View -Minimum=0 -Maximum=89 -DecimalPlaces=2 -Category=Render Settings -CategoryOrder=1 -Index=6 -Description=Specifies the field of view of the rendered images. If 0, then the current resolution and Field of view will be used. -Required=true -Default=41.11 - -[Output0Node] -Type=string -Label=Render Node 0 Name -Category=Output Settings -CategoryOrder=2 -Index=0 -Description=The name of the render node. -Required=false -DisableIfBlank=true - -[Output0Type] -Type=enum -Values=Image;Movie -Label=Render Node 0 Type -Category=Output Settings -CategoryOrder=2 -Index=1 -Description=The type of output that the render node is producing. -Required=false -DisableIfBlank=true - -[Output0Path] -Type=string -Label=Render Node 0 Path -Category=Output Settings -CategoryOrder=2 -Index=2 -Description=The output path and file name of the output files. -Required=false -DisableIfBlank=true - -[Output0LeadingZero] -Type=integer -Label=Render Node 0 Leading Zeroes -Category=Output Settings -CategoryOrder=2 -Minimum=0 -Maximum=5 -Index=3 -Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) -Required=false -DisableIfBlank=true - -[Output0Format] -Type=string -Label=Render Node 0 Format -Category=Output Settings -CategoryOrder=2 -Index=4 -Description=The format for the rendered output images. -Required=false -DisableIfBlank=true - -[Output0StartFrame] -Type=integer -Label=Render Node 0 Start Frame -Category=Output Settings -CategoryOrder=2 -Minimum=1 -Index=5 -Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. -Required=false -DisableIfBlank=true - -[Output1Node] -Type=string -Label=Render Node 1 Name -Category=Output Settings -CategoryOrder=2 -Index=6 -Description=The name of the render node. -Required=false -DisableIfBlank=true - -[Output1Type] -Type=enum -Values=Image;Movie -Label=Render Node 1 Type -Category=Output Settings -CategoryOrder=2 -Index=7 -Description=The type of output that the render node is producing. -Required=false -DisableIfBlank=true - -[Output1Path] -Type=string -Label=Render Node 1 Path -Category=Output Settings -CategoryOrder=2 -Index=8 -Description=The output path and file name of the output files. -Required=false -DisableIfBlank=true - -[Output1LeadingZero] -Type=integer -Label=Render Node 1 Leading Zeroes -Category=Output Settings -CategoryOrder=2 -Minimum=0 -Maximum=5 -Index=9 -Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) -Required=false -DisableIfBlank=true - -[Output1Format] -Type=string -Label=Render Node 1 Format -Category=Output Settings -CategoryOrder=2 -Index=10 -Description=The format for the rendered output images. -Required=false -DisableIfBlank=true - -[Output1StartFrame] -Type=integer -Label=Render Node 1 Start Frame -Category=Output Settings -CategoryOrder=2 -Minimum=1 -Index=11 -Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. -Required=false -DisableIfBlank=true - -[Output2Node] -Type=string -Label=Render Node 2 Name -Category=Output Settings -CategoryOrder=2 -Index=12 -Description=The name of the render node. -Required=false -DisableIfBlank=true - -[Output2Type] -Type=enum -Values=Image;Movie -Label=Render Node 2 Type -Category=Output Settings -CategoryOrder=2 -Index=13 -Description=The type of output that the render node is producing. -Required=false -DisableIfBlank=true - -[Output2Path] -Type=string -Label=Render Node 2 Path -Category=Output Settings -CategoryOrder=2 -Index=14 -Description=The output path and file name of the output files. -Required=false -DisableIfBlank=true - -[Output2LeadingZero] -Type=integer -Label=Render Node 2 Leading Zeroes -Category=Output Settings -CategoryOrder=2 -Minimum=0 -Maximum=5 -Index=15 -Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) -Required=false -DisableIfBlank=true - -[Output2Format] -Type=string -Label=Render Node 2 Format -Category=Output Settings -CategoryOrder=2 -Index=16 -Description=The format for the rendered output images. -Required=false -DisableIfBlank=true - -[Output2StartFrame] -Type=integer -Label=Render Node 2 Start Frame -Category=Output Settings -CategoryOrder=2 -Minimum=1 -Index=17 -Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. -Required=false -DisableIfBlank=true - -[Output3Node] -Type=string -Label=Render Node 3 Name -Category=Output Settings -CategoryOrder=2 -Index=18 -Description=The name of the render node. -Required=false -DisableIfBlank=true - -[Output3Type] -Type=enum -Values=Image;Movie -Label=Render Node 3 Type -Category=Output Settings -CategoryOrder=2 -Index=19 -Description=The type of output that the render node is producing. -Required=false -DisableIfBlank=true - -[Output3Path] -Type=string -Label=Render Node 3 Path -Category=Output Settings -CategoryOrder=2 -Index=20 -Description=The output path and file name of the output files. -Required=false -DisableIfBlank=true - -[Output3LeadingZero] -Type=integer -Label=Render Node 3 Leading Zeroes -Category=Output Settings -CategoryOrder=2 -Minimum=0 -Maximum=5 -Index=21 -Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) -Required=false -DisableIfBlank=true - -[Output3Format] -Type=string -Label=Render Node 3 Format -Category=Output Settings -CategoryOrder=2 -Index=22 -Description=The format for the rendered output images. -Required=false -DisableIfBlank=true - -[Output3StartFrame] -Type=integer -Label=Render Node 3 Start Frame -Category=Output Settings -CategoryOrder=2 -Minimum=1 -Index=23 -Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. -Required=false -DisableIfBlank=true - -[Output4Node] -Type=string -Label=Render Node 4 Name -Category=Output Settings -CategoryOrder=2 -Index=24 -Description=The name of the render node. -Required=false -DisableIfBlank=true - -[Output4Type] -Type=enum -Values=Image;Movie -Label=Render Node 4 Type -Category=Output Settings -CategoryOrder=2 -Index=25 -Description=The type of output that the render node is producing. -Required=false -DisableIfBlank=true - -[Output4Path] -Type=string -Label=Render Node 4 Path -Category=Output Settings -CategoryOrder=2 -Index=26 -Description=The output path and file name of the output files. -Required=false -DisableIfBlank=true - -[Output4LeadingZero] -Type=integer -Label=Render Node 4 Leading Zeroes -Category=Output Settings -CategoryOrder=2 -Minimum=0 -Maximum=5 -Index=27 -Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) -Required=false -DisableIfBlank=true - -[Output4Format] -Type=string -Label=Render Node 4 Format -Category=Output Settings -CategoryOrder=2 -Index=28 -Description=The format for the rendered output images. -Required=false -DisableIfBlank=true - -[Output4StartFrame] -Type=integer -Label=Render Node 4 Start Frame -Category=Output Settings -CategoryOrder=2 -Minimum=1 -Index=29 -Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. -Required=false -DisableIfBlank=true - -[Output5Node] -Type=string -Label=Render Node 5 Name -Category=Output Settings -CategoryOrder=2 -Index=30 -Description=The name of the render node. -Required=false -DisableIfBlank=true - -[Output5Type] -Type=enum -Values=Image;Movie -Label=Render Node 5 Type -Category=Output Settings -CategoryOrder=2 -Index=31 -Description=The type of output that the render node is producing. -Required=false -DisableIfBlank=true - -[Output5Path] -Type=string -Label=Render Node 5 Path -Category=Output Settings -CategoryOrder=2 -Index=32 -Description=The output path and file name of the output files. -Required=false -DisableIfBlank=true - -[Output5LeadingZero] -Type=integer -Label=Render Node 5 Leading Zeroes -Category=Output Settings -CategoryOrder=2 -Minimum=0 -Maximum=5 -Index=33 -Description=The number of leading zeroes for a 1 digit frame number. (1 less then the full padded length) -Required=false -DisableIfBlank=true - -[Output5Format] -Type=string -Label=Render Node 5 Format -Category=Output Settings -CategoryOrder=2 -Index=34 -Description=The format for the rendered output images. -Required=false -DisableIfBlank=true - -[Output5StartFrame] -Type=integer -Label=Render Node 5 Start Frame -Category=Output Settings -CategoryOrder=2 -Minimum=1 -Index=35 -Description=The frame that will correspond to frame one when numbering. If this value is not 1 then the monitor's job output features will not work properly. -Required=false -DisableIfBlank=true \ No newline at end of file diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.param b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.param deleted file mode 100644 index 43a54a464e..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.param +++ /dev/null @@ -1,98 +0,0 @@ -[About] -Type=label -Label=About -Category=About Plugin -CategoryOrder=-1 -Index=0 -Default=Harmony Render Plugin for Deadline -Description=Not configurable - -[ConcurrentTasks] -Type=label -Label=ConcurrentTasks -Category=About Plugin -CategoryOrder=-1 -Index=0 -Default=True -Description=Not configurable - -[Harmony_RenderExecutable_10] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=0 -Label=Harmony 10 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 10.0\win64\bin\Stage.exe - -[Harmony_RenderExecutable_11] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=1 -Label=Harmony 11 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 11.0\win64\bin\Stage.exe - -[Harmony_RenderExecutable_12] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=2 -Label=Harmony 12 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 12.0 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 12.0 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_12/lnx86_64/bin/HarmonyPremium - -[Harmony_RenderExecutable_14] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=3 -Label=Harmony 14 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 14.0 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 14.0 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_14/lnx86_64/bin/HarmonyPremium - -[Harmony_RenderExecutable_15] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=4 -Label=Harmony 15 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=C:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 15.0 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 15.0 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_15.0/lnx86_64/bin/HarmonyPremium - -[Harmony_RenderExecutable_17] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=4 -Label=Harmony 17 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 17 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 17 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_17/lnx86_64/bin/HarmonyPremium - -[Harmony_RenderExecutable_20] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=4 -Label=Harmony 20 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 20 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 20 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_20/lnx86_64/bin/HarmonyPremium - -[Harmony_RenderExecutable_21] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=4 -Label=Harmony 21 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 21 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 21 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_21/lnx86_64/bin/HarmonyPremium - -[Harmony_RenderExecutable_22] -Type=multilinemultifilename -Category=Render Executables -CategoryOrder=0 -Index=4 -Label=Harmony 22 Render Executable -Description=The path to the Harmony Render executable file used for rendering. Enter alternative paths on separate lines. -Default=c:\Program Files (x86)\Toon Boom Animation\Toon Boom Harmony 22 Premium\win64\bin\HarmonyPremium.exe;/Applications/Toon Boom Harmony 22 Premium/Harmony Premium.app/Contents/MacOS/Harmony Premium;/usr/local/ToonBoomAnimation/harmonyPremium_22/lnx86_64/bin/HarmonyPremium diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.py b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.py deleted file mode 100644 index d9fd0b49ef..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/HarmonyAYON.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -from System import * -from System.Diagnostics import * -from System.IO import * -from System.Text import * - -from Deadline.Plugins import * -from Deadline.Scripting import * - -def GetDeadlinePlugin(): - return HarmonyAYONPlugin() - -def CleanupDeadlinePlugin(deadlinePlugin): - deadlinePlugin.Cleanup() - -class HarmonyAYONPlugin(DeadlinePlugin): - - def __init__( self ): - super().__init__() - self.InitializeProcessCallback += self.InitializeProcess - self.RenderExecutableCallback += self.RenderExecutable - self.RenderArgumentCallback += self.RenderArgument - self.CheckExitCodeCallback += self.CheckExitCode - - def Cleanup( self ): - print("Cleanup") - for stdoutHandler in self.StdoutHandlers: - del stdoutHandler.HandleCallback - - del self.InitializeProcessCallback - del self.RenderExecutableCallback - del self.RenderArgumentCallback - - def CheckExitCode( self, exitCode ): - print("check code") - if exitCode != 0: - if exitCode == 100: - self.LogInfo( "Renderer reported an error with error code 100. This will be ignored, since the option to ignore it is specified in the Job Properties." ) - else: - self.FailRender( "Renderer returned non-zero error code %d. Check the renderer's output." % exitCode ) - - def InitializeProcess( self ): - self.PluginType = PluginType.Simple - self.StdoutHandling = True - self.PopupHandling = True - - self.AddStdoutHandlerCallback( "Rendered frame ([0-9]+)" ).HandleCallback += self.HandleStdoutProgress - - def HandleStdoutProgress( self ): - startFrame = self.GetStartFrame() - endFrame = self.GetEndFrame() - if( endFrame - startFrame + 1 != 0 ): - self.SetProgress( 100 * ( int(self.GetRegexMatch(1)) - startFrame + 1 ) / ( endFrame - startFrame + 1 ) ) - - def RenderExecutable( self ): - version = int( self.GetPluginInfoEntry( "Version" ) ) - exe = "" - exeList = self.GetConfigEntry( "Harmony_RenderExecutable_" + str(version) ) - exe = FileUtils.SearchFileList( exeList ) - if( exe == "" ): - self.FailRender( "Harmony render executable was not found in the configured separated list \"" + exeList + "\". The path to the render executable can be configured from the Plugin Configuration in the Deadline Monitor." ) - return exe - - def RenderArgument( self ): - renderArguments = "-batch" - - if self.GetBooleanPluginInfoEntryWithDefault( "UsingResPreset", False ): - resName = self.GetPluginInfoEntryWithDefault( "ResolutionName", "HDTV_1080p24" ) - if resName == "Custom": - renderArguments += " -res " + self.GetPluginInfoEntryWithDefault( "PresetName", "HDTV_1080p24" ) - else: - renderArguments += " -res " + resName - else: - resolutionX = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionX", -1 ) - resolutionY = self.GetIntegerPluginInfoEntryWithDefault( "ResolutionY", -1 ) - fov = self.GetFloatPluginInfoEntryWithDefault( "FieldOfView", -1 ) - - if resolutionX > 0 and resolutionY > 0 and fov > 0: - renderArguments += " -res " + str( resolutionX ) + " " + str( resolutionY ) + " " + str( fov ) - - camera = self.GetPluginInfoEntryWithDefault( "Camera", "" ) - - if not camera == "": - renderArguments += " -camera " + camera - - startFrame = str( self.GetStartFrame() ) - endFrame = str( self.GetEndFrame() ) - - renderArguments += " -frames " + startFrame + " " + endFrame - - if not self.GetBooleanPluginInfoEntryWithDefault( "IsDatabase", False ): - sceneFilename = self.GetPluginInfoEntryWithDefault( "SceneFile", self.GetDataFilename() ) - sceneFilename = RepositoryUtils.CheckPathMapping( sceneFilename ) - renderArguments += " \"" + sceneFilename + "\"" - else: - environment = self.GetPluginInfoEntryWithDefault( "Environment", "" ) - renderArguments += " -env " + environment - job = self.GetPluginInfoEntryWithDefault( "Job", "" ) - renderArguments += " -job " + job - scene = self.GetPluginInfoEntryWithDefault( "SceneName", "" ) - renderArguments += " -scene " + scene - version = self.GetPluginInfoEntryWithDefault( "SceneVersion", "" ) - renderArguments += " -version " + version - - #tempSceneDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) - #preRenderScript = - rendernodeNum = 0 - scriptBuilder = StringBuilder() - - while True: - nodeName = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Node", "" ) - if nodeName == "": - break - nodeType = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Type", "Image" ) - if nodeType == "Image": - nodePath = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Path", "" ) - nodeLeadingZero = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "LeadingZero", "" ) - nodeFormat = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Format", "" ) - nodeStartFrame = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "StartFrame", "" ) - - if not nodePath == "": - scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingName\", 1, \"" + nodePath + "\" );") - - if not nodeLeadingZero == "": - scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"leadingZeros\", 1, \"" + nodeLeadingZero + "\" );") - - if not nodeFormat == "": - scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"drawingType\", 1, \"" + nodeFormat + "\" );") - - if not nodeStartFrame == "": - scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"start\", 1, \"" + nodeStartFrame + "\" );") - - if nodeType == "Movie": - nodePath = self.GetPluginInfoEntryWithDefault( "Output" + str( rendernodeNum ) + "Path", "" ) - if not nodePath == "": - scriptBuilder.AppendLine("node.setTextAttr( \"" + nodeName + "\", \"moviePath\", 1, \"" + nodePath + "\" );") - - rendernodeNum += 1 - - tempDirectory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber()) ) - preRenderScriptName = Path.Combine( tempDirectory, "preRenderScript.txt" ) - - File.WriteAllText( preRenderScriptName, scriptBuilder.ToString() ) - - preRenderInlineScript = self.GetPluginInfoEntryWithDefault( "PreRenderInlineScript", "" ) - if preRenderInlineScript: - renderArguments += " -preRenderInlineScript \"" + preRenderInlineScript +"\"" - - renderArguments += " -preRenderScript \"" + preRenderScriptName +"\"" - - return renderArguments diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.ico deleted file mode 100644 index de860673c4f25ed881e975ea4b17e8673cefb6c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 126987 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 CpbIwu diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options deleted file mode 100644 index 0b631ba66d..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.options +++ /dev/null @@ -1,35 +0,0 @@ -[OIIOToolPath] -Type=filename -Label=OIIO Tool location -Category=OIIO -Index=0 -Description=OIIO Tool executable to use. -Required=false -DisableIfBlank=true - -[OutputFile] -Type=filenamesave -Label=Output File -Category=Output -Index=0 -Description=The scene filename as it exists on the network -Required=false -DisableIfBlank=true - -[CleanupTiles] -Type=boolean -Category=Options -Index=0 -Label=Cleanup Tiles -Required=false -DisableIfBlank=true -Description=If enabled, the OpenPype Tile Assembler will cleanup all tiles after assembly. - -[Renderer] -Type=string -Label=Renderer -Category=Quicktime Info -Index=0 -Description=Renderer name -Required=false -DisableIfBlank=true diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param deleted file mode 100644 index 66a3342e38..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.param +++ /dev/null @@ -1,17 +0,0 @@ -[About] -Type=label -Label=About -Category=About Plugin -CategoryOrder=-1 -Index=0 -Default=OpenPype Tile Assembler Plugin for Deadline -Description=Not configurable - -[OIIOTool_RenderExecutable] -Type=multilinemultifilename -Label=OIIO Tool Executable -Category=Render Executables -CategoryOrder=0 -Default=C:\Program Files\OIIO\bin\oiiotool.exe;/usr/bin/oiiotool -Description=The path to the Open Image IO Tool executable file used for rendering. Enter alternative paths on separate lines. -W diff --git a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py deleted file mode 100644 index f146aef7b4..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ /dev/null @@ -1,457 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tile Assembler Plugin using Open Image IO tool. - -Todo: - Currently we support only EXRs with their data window set. -""" -import os -import re -import subprocess -import xml.etree.ElementTree - -from System.IO import Path - -from Deadline.Plugins import DeadlinePlugin -from Deadline.Scripting import ( - FileUtils, RepositoryUtils, SystemUtils) - - -version_major = 1 -version_minor = 0 -version_patch = 0 -version_string = "{}.{}.{}".format(version_major, version_minor, version_patch) -STRING_TAGS = { - "format" -} -INT_TAGS = { - "x", "y", "z", - "width", "height", "depth", - "full_x", "full_y", "full_z", - "full_width", "full_height", "full_depth", - "tile_width", "tile_height", "tile_depth", - "nchannels", - "alpha_channel", - "z_channel", - "deep", - "subimages", -} - - -XML_CHAR_REF_REGEX_HEX = re.compile(r"&#x?[0-9a-fA-F]+;") - -# Regex to parse array attributes -ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") - - -def convert_value_by_type_name(value_type, value): - """Convert value to proper type based on type name. - - In some cases value types have custom python class. - """ - - # Simple types - if value_type == "string": - return value - - if value_type == "int": - return int(value) - - if value_type == "float": - return float(value) - - # Vectors will probably have more types - if value_type in ("vec2f", "float2"): - return [float(item) for item in value.split(",")] - - # Matrix should be always have square size of element 3x3, 4x4 - # - are returned as list of lists - if value_type == "matrix": - output = [] - current_index = -1 - parts = value.split(",") - parts_len = len(parts) - if parts_len == 1: - divisor = 1 - elif parts_len == 4: - divisor = 2 - elif parts_len == 9: - divisor = 3 - elif parts_len == 16: - divisor = 4 - else: - print("Unknown matrix resolution {}. Value: \"{}\"".format( - parts_len, value - )) - for part in parts: - output.append(float(part)) - return output - - for idx, item in enumerate(parts): - list_index = idx % divisor - if list_index > current_index: - current_index = list_index - output.append([]) - output[list_index].append(float(item)) - return output - - if value_type == "rational2i": - parts = value.split("/") - top = float(parts[0]) - bottom = 1.0 - if len(parts) != 1: - bottom = float(parts[1]) - return float(top) / float(bottom) - - if value_type == "vector": - parts = [part.strip() for part in value.split(",")] - output = [] - for part in parts: - if part == "-nan": - output.append(None) - continue - try: - part = float(part) - except ValueError: - pass - output.append(part) - return output - - if value_type == "timecode": - return value - - # Array of other types is converted to list - re_result = ARRAY_TYPE_REGEX.findall(value_type) - if re_result: - array_type = re_result[0] - output = [] - for item in value.split(","): - output.append( - convert_value_by_type_name(array_type, item) - ) - return output - - print(( - "Dev note (missing implementation):" - " Unknown attrib type \"{}\". Value: {}" - ).format(value_type, value)) - return value - - -def parse_oiio_xml_output(xml_string): - """Parse xml output from OIIO info command.""" - output = {} - if not xml_string: - return output - - # Fix values with ampresand (lazy fix) - # - oiiotool exports invalid xml which ElementTree can't handle - # e.g. "" - # WARNING: this will affect even valid character entities. If you need - # those values correctly, this must take care of valid character ranges. - # See https://github.com/pypeclub/OpenPype/pull/2729 - matches = XML_CHAR_REF_REGEX_HEX.findall(xml_string) - for match in matches: - new_value = match.replace("&", "&") - xml_string = xml_string.replace(match, new_value) - - tree = xml.etree.ElementTree.fromstring(xml_string) - attribs = {} - output["attribs"] = attribs - for child in tree: - tag_name = child.tag - if tag_name == "attrib": - attrib_def = child.attrib - value = convert_value_by_type_name( - attrib_def["type"], child.text - ) - - attribs[attrib_def["name"]] = value - continue - - # Channels are stored as tex on each child - if tag_name == "channelnames": - value = [] - for channel in child: - value.append(channel.text) - - # Convert known integer type tags to int - elif tag_name in INT_TAGS: - value = int(child.text) - - # Keep value of known string tags - elif tag_name in STRING_TAGS: - value = child.text - - # Keep value as text for unknown tags - # - feel free to add more tags - else: - value = child.text - print(( - "Dev note (missing implementation):" - " Unknown tag \"{}\". Value \"{}\"" - ).format(tag_name, value)) - - output[child.tag] = value - - return output - - -def info_about_input(oiiotool_path, filepath): - args = [ - oiiotool_path, - "--info", - "-v", - "-i:infoformat=xml", - filepath - ] - popen = subprocess.Popen(args, stdout=subprocess.PIPE) - _stdout, _stderr = popen.communicate() - output = "" - if _stdout: - output += _stdout.decode("utf-8", errors="backslashreplace") - - if _stderr: - output += _stderr.decode("utf-8", errors="backslashreplace") - - output = output.replace("\r\n", "\n") - xml_started = False - lines = [] - for line in output.split("\n"): - if not xml_started: - if not line.startswith("<"): - continue - xml_started = True - if xml_started: - lines.append(line) - - if not xml_started: - raise ValueError( - "Failed to read input file \"{}\".\nOutput:\n{}".format( - filepath, output - ) - ) - xml_text = "\n".join(lines) - return parse_oiio_xml_output(xml_text) - - -def GetDeadlinePlugin(): # noqa: N802 - """Helper.""" - return OpenPypeTileAssembler() - - -def CleanupDeadlinePlugin(deadlinePlugin): # noqa: N802, N803 - """Helper.""" - deadlinePlugin.cleanup() - - -class OpenPypeTileAssembler(DeadlinePlugin): - """Deadline plugin for assembling tiles using OIIO.""" - - def __init__(self): - """Init.""" - super().__init__() - self.InitializeProcessCallback += self.initialize_process - self.RenderExecutableCallback += self.render_executable - self.RenderArgumentCallback += self.render_argument - self.PreRenderTasksCallback += self.pre_render_tasks - self.PostRenderTasksCallback += self.post_render_tasks - - def cleanup(self): - """Cleanup function.""" - for stdoutHandler in self.StdoutHandlers: - del stdoutHandler.HandleCallback - - del self.InitializeProcessCallback - del self.RenderExecutableCallback - del self.RenderArgumentCallback - del self.PreRenderTasksCallback - del self.PostRenderTasksCallback - - def initialize_process(self): - """Initialization.""" - self.LogInfo("Plugin version: {}".format(version_string)) - self.SingleFramesOnly = True - self.StdoutHandling = True - self.renderer = self.GetPluginInfoEntryWithDefault( - "Renderer", "undefined") - self.AddStdoutHandlerCallback( - ".*Error.*").HandleCallback += self.handle_stdout_error - - def render_executable(self): - """Get render executable name. - - Get paths from plugin configuration, find executable and return it. - - Returns: - (str): Render executable. - - """ - oiiotool_exe_list = self.GetConfigEntry("OIIOTool_RenderExecutable") - oiiotool_exe = FileUtils.SearchFileList(oiiotool_exe_list) - - if oiiotool_exe == "": - self.FailRender(("No file found in the semicolon separated " - "list \"{}\". The path to the render executable " - "can be configured from the Plugin Configuration " - "in the Deadline Monitor.").format( - oiiotool_exe_list)) - - return oiiotool_exe - - def render_argument(self): - """Generate command line arguments for render executable. - - Returns: - (str): arguments to add to render executable. - - """ - # Read tile config file. This file is in compatible format with - # Draft Tile Assembler - data = {} - with open(self.config_file, "rU") as f: - for text in f: - # Parsing key-value pair and removing white-space - # around the entries - info = [x.strip() for x in text.split("=", 1)] - - if len(info) > 1: - try: - data[str(info[0])] = info[1] - except Exception as e: - # should never be called - self.FailRender( - "Cannot parse config file: {}".format(e)) - - # Get output file. We support only EXRs now. - output_file = data["ImageFileName"] - output_file = RepositoryUtils.CheckPathMapping(output_file) - output_file = self.process_path(output_file) - - tile_info = [] - for tile in range(int(data["TileCount"])): - tile_info.append({ - "filepath": data["Tile{}".format(tile)], - "pos_x": int(data["Tile{}X".format(tile)]), - "pos_y": int(data["Tile{}Y".format(tile)]), - "height": int(data["Tile{}Height".format(tile)]), - "width": int(data["Tile{}Width".format(tile)]) - }) - - arguments = self.tile_oiio_args( - int(data["ImageWidth"]), int(data["ImageHeight"]), - tile_info, output_file) - self.LogInfo( - "Using arguments: {}".format(" ".join(arguments))) - self.tiles = tile_info - return " ".join(arguments) - - def process_path(self, filepath): - """Handle slashes in file paths.""" - if SystemUtils.IsRunningOnWindows(): - filepath = filepath.replace("/", "\\") - if filepath.startswith("\\") and not filepath.startswith("\\\\"): - filepath = "\\" + filepath - else: - filepath = filepath.replace("\\", "/") - return filepath - - def pre_render_tasks(self): - """Load config file and do remapping.""" - self.LogInfo("OpenPype Tile Assembler starting...") - config_file = self.GetPluginInfoEntry("ConfigFile") - - temp_scene_directory = self.CreateTempDirectory( - "thread" + str(self.GetThreadNumber())) - temp_scene_filename = Path.GetFileName(config_file) - self.config_file = Path.Combine( - temp_scene_directory, temp_scene_filename) - - if SystemUtils.IsRunningOnWindows(): - RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( - config_file, self.config_file, "/", "\\") - else: - RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( - config_file, self.config_file, "\\", "/") - os.chmod(self.config_file, os.stat(self.config_file).st_mode) - - def post_render_tasks(self): - """Cleanup tiles if required.""" - if self.GetBooleanPluginInfoEntryWithDefault("CleanupTiles", False): - self.LogInfo("Cleaning up Tiles...") - for tile in self.tiles: - try: - self.LogInfo("Deleting: {}".format(tile["filepath"])) - os.remove(tile["filepath"]) - # By this time we would have errored out - # if error on missing was enabled - except KeyError: - pass - except OSError: - self.LogInfo("Failed to delete: {}".format( - tile["filepath"])) - pass - - self.LogInfo("OpenPype Tile Assembler Job finished.") - - def handle_stdout_error(self): - """Handle errors in stdout.""" - self.FailRender(self.GetRegexMatch(0)) - - def tile_oiio_args( - self, output_width, output_height, tile_info, output_path): - """Generate oiio tool arguments for tile assembly. - - Args: - output_width (int): Width of output image. - output_height (int): Height of output image. - tile_info (list): List of tile items, each item must be - dictionary with `filepath`, `pos_x` and `pos_y` keys - representing path to file and x, y coordinates on output - image where top-left point of tile item should start. - output_path (str): Path to file where should be output stored. - - Returns: - (list): oiio tools arguments. - - """ - args = [] - - # Create new image with output resolution, and with same type and - # channels as input - oiiotool_path = self.render_executable() - first_tile_path = tile_info[0]["filepath"] - first_tile_info = info_about_input(oiiotool_path, first_tile_path) - create_arg_template = "--create{} {}x{} {}" - - image_type = "" - image_format = first_tile_info.get("format") - if image_format: - image_type = ":type={}".format(image_format) - - create_arg = create_arg_template.format( - image_type, output_width, - output_height, first_tile_info["nchannels"] - ) - args.append(create_arg) - - for tile in tile_info: - path = tile["filepath"] - pos_x = tile["pos_x"] - tile_height = info_about_input(oiiotool_path, path)["height"] - if self.renderer == "vray": - pos_y = tile["pos_y"] - else: - pos_y = output_height - tile["pos_y"] - tile_height - - # Add input path and make sure inputs origin is 0, 0 - args.append(path) - args.append("--origin +0+0") - # Swap to have input as foreground - args.append("--swap") - # Paste foreground to background - args.append("--paste {x:+d}{y:+d}".format(x=pos_x, y=pos_y)) - - args.append("-o") - args.append(output_path) - - return args diff --git a/server_addon/deadline/client/ayon_deadline/repository/readme.md b/server_addon/deadline/client/ayon_deadline/repository/readme.md deleted file mode 100644 index 31ffffd0b7..0000000000 --- a/server_addon/deadline/client/ayon_deadline/repository/readme.md +++ /dev/null @@ -1,29 +0,0 @@ -## OpenPype Deadline repository overlay - - This directory is an overlay for Deadline repository. - It means that you can copy the whole hierarchy to Deadline repository and it - should work. - - Logic: - ----- - GlobalJobPreLoad - ----- - -The `GlobalJobPreLoad` will retrieve the OpenPype executable path from the -`OpenPype` Deadline Plug-in's settings. Then it will call the executable to -retrieve the environment variables needed for the Deadline Job. -These environment variables are injected into rendering process. - -Deadline triggers the `GlobalJobPreLoad.py` for each Worker as it starts the -Job. - -*Note*: It also contains backward compatible logic to preserve functionality -for old Pype2 and non-OpenPype triggered jobs. - - Plugin - ------ - For each render and publishing job the `OpenPype` Deadline Plug-in is checked - for the configured location of the OpenPype executable (needs to be configured - in `Deadline's Configure Plugins > OpenPype`) through `GlobalJobPreLoad`. - - diff --git a/server_addon/deadline/client/ayon_deadline/version.py b/server_addon/deadline/client/ayon_deadline/version.py deleted file mode 100644 index 96262d7186..0000000000 --- a/server_addon/deadline/client/ayon_deadline/version.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- -"""Package declaring AYON addon 'deadline' version.""" -__version__ = "0.2.3" diff --git a/server_addon/deadline/package.py b/server_addon/deadline/package.py deleted file mode 100644 index 8fcc007850..0000000000 --- a/server_addon/deadline/package.py +++ /dev/null @@ -1,10 +0,0 @@ -name = "deadline" -title = "Deadline" -version = "0.2.3" - -client_dir = "ayon_deadline" - -ayon_required_addons = { - "core": ">0.3.2", -} -ayon_compatible_addons = {} diff --git a/server_addon/deadline/server/__init__.py b/server_addon/deadline/server/__init__.py deleted file mode 100644 index 8d2dc152cd..0000000000 --- a/server_addon/deadline/server/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Type - -from ayon_server.addons import BaseServerAddon - -from .settings import DeadlineSettings, DEFAULT_VALUES, DeadlineSiteSettings - - -class Deadline(BaseServerAddon): - settings_model: Type[DeadlineSettings] = DeadlineSettings - site_settings_model: Type[DeadlineSiteSettings] = DeadlineSiteSettings - - - async def get_default_settings(self): - settings_model_cls = self.get_settings_model() - return settings_model_cls(**DEFAULT_VALUES) diff --git a/server_addon/deadline/server/settings/__init__.py b/server_addon/deadline/server/settings/__init__.py deleted file mode 100644 index d25c0fb330..0000000000 --- a/server_addon/deadline/server/settings/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from .main import ( - DeadlineSettings, - DEFAULT_VALUES, -) -from .site_settings import DeadlineSiteSettings - - -__all__ = ( - "DeadlineSettings", - "DeadlineSiteSettings", - "DEFAULT_VALUES", -) diff --git a/server_addon/deadline/server/settings/main.py b/server_addon/deadline/server/settings/main.py deleted file mode 100644 index edb8a16e35..0000000000 --- a/server_addon/deadline/server/settings/main.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import TYPE_CHECKING -from pydantic import validator - -from ayon_server.settings import ( - BaseSettingsModel, - SettingsField, - ensure_unique_names, -) -if TYPE_CHECKING: - from ayon_server.addons import BaseServerAddon - -from .publish_plugins import ( - PublishPluginsModel, - DEFAULT_DEADLINE_PLUGINS_SETTINGS -) - - -async def defined_deadline_ws_name_enum_resolver( - addon: "BaseServerAddon", - settings_variant: str = "production", - project_name: str | None = None, -) -> list[str]: - """Provides list of names of configured Deadline webservice urls.""" - if addon is None: - return [] - - settings = await addon.get_studio_settings(variant=settings_variant) - - ws_server_name = [] - for deadline_url_item in settings.deadline_urls: - ws_server_name.append(deadline_url_item.name) - - return ws_server_name - -class ServerItemSubmodel(BaseSettingsModel): - """Connection info about configured DL servers.""" - _layout = "expanded" - name: str = SettingsField(title="Name") - value: str = SettingsField(title="Url") - require_authentication: bool = SettingsField( - False, title="Require authentication") - not_verify_ssl: bool = SettingsField( - False, title="Don't verify SSL") - default_username: str = SettingsField( - "", - title="Default user name", - description="Webservice username, 'Require authentication' must be " - "enabled." - ) - default_password: str = SettingsField( - "", - title="Default password", - description="Webservice password, 'Require authentication' must be " - "enabled." - ) - - -class DeadlineSettings(BaseSettingsModel): - # configured DL servers - deadline_urls: list[ServerItemSubmodel] = SettingsField( - default_factory=list, - title="System Deadline Webservice Info", - scope=["studio"], - ) - - # name(key) of selected server for project - deadline_server: str = SettingsField( - title="Project Deadline server name", - section="---", - scope=["project"], - enum_resolver=defined_deadline_ws_name_enum_resolver - ) - - publish: PublishPluginsModel = SettingsField( - default_factory=PublishPluginsModel, - title="Publish Plugins", - ) - - @validator("deadline_urls") - def validate_unique_names(cls, value): - ensure_unique_names(value) - return value - - - -DEFAULT_VALUES = { - "deadline_urls": [ - { - "name": "default", - "value": "http://127.0.0.1:8082", - "require_authentication": False, - "not_verify_ssl": False, - "default_username": "", - "default_password": "" - - } - ], - "deadline_server": "default", - "publish": DEFAULT_DEADLINE_PLUGINS_SETTINGS -} diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py deleted file mode 100644 index 1cf699db23..0000000000 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ /dev/null @@ -1,578 +0,0 @@ -from pydantic import validator - -from ayon_server.settings import ( - BaseSettingsModel, - SettingsField, - ensure_unique_names, -) - - -class CollectDeadlinePoolsModel(BaseSettingsModel): - """Settings Deadline default pools.""" - - primary_pool: str = SettingsField(title="Primary Pool") - - secondary_pool: str = SettingsField(title="Secondary Pool") - - -class ValidateExpectedFilesModel(BaseSettingsModel): - enabled: bool = SettingsField(True, title="Enabled") - active: bool = SettingsField(True, title="Active") - allow_user_override: bool = SettingsField( - True, title="Allow user change frame range" - ) - families: list[str] = SettingsField( - default_factory=list, title="Trigger on families" - ) - targets: list[str] = SettingsField( - default_factory=list, title="Trigger for plugins" - ) - - -def tile_assembler_enum(): - """Return a list of value/label dicts for the enumerator. - - Returning a list of dicts is used to allow for a custom label to be - displayed in the UI. - """ - return [ - { - "value": "DraftTileAssembler", - "label": "Draft Tile Assembler" - }, - { - "value": "OpenPypeTileAssembler", - "label": "Open Image IO" - } - ] - - -class ScenePatchesSubmodel(BaseSettingsModel): - _layout = "expanded" - name: str = SettingsField(title="Patch name") - regex: str = SettingsField(title="Patch regex") - line: str = SettingsField(title="Patch line") - - -class MayaSubmitDeadlineModel(BaseSettingsModel): - """Maya deadline submitter settings.""" - - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - use_published: bool = SettingsField(title="Use Published scene") - import_reference: bool = SettingsField( - title="Use Scene with Imported Reference" - ) - asset_dependencies: bool = SettingsField(title="Use Asset dependencies") - priority: int = SettingsField(title="Priority") - tile_priority: int = SettingsField(title="Tile Priority") - group: str = SettingsField(title="Group") - limit: list[str] = SettingsField( - default_factory=list, - title="Limit Groups" - ) - tile_assembler_plugin: str = SettingsField( - title="Tile Assembler Plugin", - enum_resolver=tile_assembler_enum, - ) - jobInfo: str = SettingsField( - title="Additional JobInfo data", - widget="textarea", - ) - pluginInfo: str = SettingsField( - title="Additional PluginInfo data", - widget="textarea", - ) - - scene_patches: list[ScenePatchesSubmodel] = SettingsField( - default_factory=list, - title="Scene patches", - ) - strict_error_checking: bool = SettingsField( - title="Disable Strict Error Check profiles" - ) - - @validator("scene_patches") - def validate_unique_names(cls, value): - ensure_unique_names(value) - return value - - -class MaxSubmitDeadlineModel(BaseSettingsModel): - enabled: bool = SettingsField(True) - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - use_published: bool = SettingsField(title="Use Published scene") - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Frame per Task") - group: str = SettingsField("", title="Group Name") - - -class EnvSearchReplaceSubmodel(BaseSettingsModel): - _layout = "compact" - name: str = SettingsField(title="Name") - value: str = SettingsField(title="Value") - - -class LimitGroupsSubmodel(BaseSettingsModel): - _layout = "expanded" - name: str = SettingsField(title="Name") - value: list[str] = SettingsField( - default_factory=list, - title="Limit Groups" - ) - - -def fusion_deadline_plugin_enum(): - """Return a list of value/label dicts for the enumerator. - - Returning a list of dicts is used to allow for a custom label to be - displayed in the UI. - """ - return [ - { - "value": "Fusion", - "label": "Fusion" - }, - { - "value": "FusionCmd", - "label": "FusionCmd" - } - ] - - -class FusionSubmitDeadlineModel(BaseSettingsModel): - enabled: bool = SettingsField(True, title="Enabled") - optional: bool = SettingsField(False, title="Optional") - active: bool = SettingsField(True, title="Active") - priority: int = SettingsField(50, title="Priority") - chunk_size: int = SettingsField(10, title="Frame per Task") - concurrent_tasks: int = SettingsField( - 1, title="Number of concurrent tasks" - ) - group: str = SettingsField("", title="Group Name") - plugin: str = SettingsField("Fusion", - enum_resolver=fusion_deadline_plugin_enum, - title="Deadline Plugin") - - -class NukeSubmitDeadlineModel(BaseSettingsModel): - """Nuke deadline submitter settings.""" - - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Chunk Size") - concurrent_tasks: int = SettingsField(title="Number of concurrent tasks") - group: str = SettingsField(title="Group") - department: str = SettingsField(title="Department") - use_gpu: bool = SettingsField(title="Use GPU") - workfile_dependency: bool = SettingsField(title="Workfile Dependency") - use_published_workfile: bool = SettingsField( - title="Use Published Workfile" - ) - - env_allowed_keys: list[str] = SettingsField( - default_factory=list, - title="Allowed environment keys" - ) - - env_search_replace_values: list[EnvSearchReplaceSubmodel] = SettingsField( - default_factory=list, - title="Search & replace in environment values", - ) - - limit_groups: list[LimitGroupsSubmodel] = SettingsField( - default_factory=list, - title="Limit Groups", - ) - - @validator( - "limit_groups", - "env_search_replace_values") - def validate_unique_names(cls, value): - ensure_unique_names(value) - return value - - -class HarmonySubmitDeadlineModel(BaseSettingsModel): - """Harmony deadline submitter settings.""" - - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - use_published: bool = SettingsField(title="Use Published scene") - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Chunk Size") - group: str = SettingsField(title="Group") - department: str = SettingsField(title="Department") - - -class HoudiniSubmitDeadlineModel(BaseSettingsModel): - """Houdini deadline render submitter settings.""" - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Chunk Size") - group: str = SettingsField(title="Group") - - export_priority: int = SettingsField(title="Export Priority") - export_chunk_size: int = SettingsField(title="Export Chunk Size") - export_group: str = SettingsField(title="Export Group") - - -class HoudiniCacheSubmitDeadlineModel(BaseSettingsModel): - """Houdini deadline cache submitter settings.""" - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Chunk Size") - group: str = SettingsField(title="Group") - - -class AfterEffectsSubmitDeadlineModel(BaseSettingsModel): - """After Effects deadline submitter settings.""" - - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - use_published: bool = SettingsField(title="Use Published scene") - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Chunk Size") - group: str = SettingsField(title="Group") - department: str = SettingsField(title="Department") - multiprocess: bool = SettingsField(title="Optional") - - -class CelactionSubmitDeadlineModel(BaseSettingsModel): - enabled: bool = SettingsField(True, title="Enabled") - deadline_department: str = SettingsField("", title="Deadline apartment") - deadline_priority: int = SettingsField(50, title="Deadline priority") - deadline_pool: str = SettingsField("", title="Deadline pool") - deadline_pool_secondary: str = SettingsField( - "", title="Deadline pool (secondary)" - ) - deadline_group: str = SettingsField("", title="Deadline Group") - deadline_chunk_size: int = SettingsField(10, title="Deadline Chunk size") - deadline_job_delay: str = SettingsField( - "", title="Delay job (timecode dd:hh:mm:ss)" - ) - - -class BlenderSubmitDeadlineModel(BaseSettingsModel): - enabled: bool = SettingsField(True) - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - use_published: bool = SettingsField(title="Use Published scene") - asset_dependencies: bool = SettingsField(title="Use Asset dependencies") - priority: int = SettingsField(title="Priority") - chunk_size: int = SettingsField(title="Frame per Task") - group: str = SettingsField("", title="Group Name") - job_delay: str = SettingsField( - "", title="Delay job (timecode dd:hh:mm:ss)" - ) - - -class AOVFilterSubmodel(BaseSettingsModel): - _layout = "expanded" - name: str = SettingsField(title="Host") - value: list[str] = SettingsField( - default_factory=list, - title="AOV regex" - ) - - -class ProcessCacheJobFarmModel(BaseSettingsModel): - """Process submitted job on farm.""" - - enabled: bool = SettingsField(title="Enabled") - deadline_department: str = SettingsField(title="Department") - deadline_pool: str = SettingsField(title="Pool") - deadline_group: str = SettingsField(title="Group") - deadline_chunk_size: int = SettingsField(title="Chunk Size") - deadline_priority: int = SettingsField(title="Priority") - - -class ProcessSubmittedJobOnFarmModel(BaseSettingsModel): - """Process submitted job on farm.""" - - enabled: bool = SettingsField(title="Enabled") - deadline_department: str = SettingsField(title="Department") - deadline_pool: str = SettingsField(title="Pool") - deadline_group: str = SettingsField(title="Group") - deadline_chunk_size: int = SettingsField(title="Chunk Size") - deadline_priority: int = SettingsField(title="Priority") - publishing_script: str = SettingsField(title="Publishing script path") - skip_integration_repre_list: list[str] = SettingsField( - default_factory=list, - title="Skip integration of representation with ext" - ) - families_transfer: list[str] = SettingsField( - default_factory=list, - title=( - "List of family names to transfer\n" - "to generated instances (AOVs for example)." - ) - ) - aov_filter: list[AOVFilterSubmodel] = SettingsField( - default_factory=list, - title="Reviewable products filter", - ) - - @validator("aov_filter") - def validate_unique_names(cls, value): - ensure_unique_names(value) - return value - - -class PublishPluginsModel(BaseSettingsModel): - CollectDeadlinePools: CollectDeadlinePoolsModel = SettingsField( - default_factory=CollectDeadlinePoolsModel, - title="Default Pools") - ValidateExpectedFiles: ValidateExpectedFilesModel = SettingsField( - default_factory=ValidateExpectedFilesModel, - title="Validate Expected Files" - ) - AfterEffectsSubmitDeadline: AfterEffectsSubmitDeadlineModel = ( - SettingsField( - default_factory=AfterEffectsSubmitDeadlineModel, - title="After Effects to deadline", - section="Hosts" - ) - ) - BlenderSubmitDeadline: BlenderSubmitDeadlineModel = SettingsField( - default_factory=BlenderSubmitDeadlineModel, - title="Blender Submit Deadline") - CelactionSubmitDeadline: CelactionSubmitDeadlineModel = SettingsField( - default_factory=CelactionSubmitDeadlineModel, - title="Celaction Submit Deadline") - FusionSubmitDeadline: FusionSubmitDeadlineModel = SettingsField( - default_factory=FusionSubmitDeadlineModel, - title="Fusion submit to Deadline") - HarmonySubmitDeadline: HarmonySubmitDeadlineModel = SettingsField( - default_factory=HarmonySubmitDeadlineModel, - title="Harmony Submit to deadline") - HoudiniCacheSubmitDeadline: HoudiniCacheSubmitDeadlineModel = SettingsField( - default_factory=HoudiniCacheSubmitDeadlineModel, - title="Houdini Submit cache to deadline") - HoudiniSubmitDeadline: HoudiniSubmitDeadlineModel = SettingsField( - default_factory=HoudiniSubmitDeadlineModel, - title="Houdini Submit render to deadline") - MaxSubmitDeadline: MaxSubmitDeadlineModel = SettingsField( - default_factory=MaxSubmitDeadlineModel, - title="Max Submit to deadline") - MayaSubmitDeadline: MayaSubmitDeadlineModel = SettingsField( - default_factory=MayaSubmitDeadlineModel, - title="Maya Submit to deadline") - NukeSubmitDeadline: NukeSubmitDeadlineModel = SettingsField( - default_factory=NukeSubmitDeadlineModel, - title="Nuke Submit to deadline") - ProcessSubmittedCacheJobOnFarm: ProcessCacheJobFarmModel = SettingsField( - default_factory=ProcessCacheJobFarmModel, - title="Process submitted cache Job on farm", - section="Publish Jobs") - ProcessSubmittedJobOnFarm: ProcessSubmittedJobOnFarmModel = SettingsField( - default_factory=ProcessSubmittedJobOnFarmModel, - title="Process submitted job on farm") - - -DEFAULT_DEADLINE_PLUGINS_SETTINGS = { - "CollectDeadlinePools": { - "primary_pool": "", - "secondary_pool": "" - }, - "ValidateExpectedFiles": { - "enabled": True, - "active": True, - "allow_user_override": True, - "families": [ - "render" - ], - "targets": [ - "deadline" - ] - }, - "AfterEffectsSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "use_published": True, - "priority": 50, - "chunk_size": 10000, - "group": "", - "department": "", - "multiprocess": True - }, - "BlenderSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "use_published": True, - "asset_dependencies": True, - "priority": 50, - "chunk_size": 10, - "group": "none", - "job_delay": "00:00:00:00" - }, - "CelactionSubmitDeadline": { - "enabled": True, - "deadline_department": "", - "deadline_priority": 50, - "deadline_pool": "", - "deadline_pool_secondary": "", - "deadline_group": "", - "deadline_chunk_size": 10, - "deadline_job_delay": "00:00:00:00" - }, - "FusionSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "priority": 50, - "chunk_size": 10, - "concurrent_tasks": 1, - "group": "" - }, - "HarmonySubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "use_published": True, - "priority": 50, - "chunk_size": 10000, - "group": "", - "department": "" - }, - "HoudiniCacheSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "priority": 50, - "chunk_size": 999999, - "group": "" - }, - "HoudiniSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "priority": 50, - "chunk_size": 1, - "group": "", - "export_priority": 50, - "export_chunk_size": 10, - "export_group": "" - }, - "MaxSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "use_published": True, - "priority": 50, - "chunk_size": 10, - "group": "none" - }, - "MayaSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "tile_assembler_plugin": "DraftTileAssembler", - "use_published": True, - "import_reference": False, - "asset_dependencies": True, - "strict_error_checking": True, - "priority": 50, - "tile_priority": 50, - "group": "none", - "limit": [], - # this used to be empty dict - "jobInfo": "", - # this used to be empty dict - "pluginInfo": "", - "scene_patches": [] - }, - "NukeSubmitDeadline": { - "enabled": True, - "optional": False, - "active": True, - "priority": 50, - "chunk_size": 10, - "concurrent_tasks": 1, - "group": "", - "department": "", - "use_gpu": True, - "workfile_dependency": True, - "use_published_workfile": True, - "env_allowed_keys": [], - "env_search_replace_values": [], - "limit_groups": [] - }, - "ProcessSubmittedCacheJobOnFarm": { - "enabled": True, - "deadline_department": "", - "deadline_pool": "", - "deadline_group": "", - "deadline_chunk_size": 1, - "deadline_priority": 50 - }, - "ProcessSubmittedJobOnFarm": { - "enabled": True, - "deadline_department": "", - "deadline_pool": "", - "deadline_group": "", - "deadline_chunk_size": 1, - "deadline_priority": 50, - "publishing_script": "", - "skip_integration_repre_list": [], - "families_transfer": ["render3d", "render2d", "ftrack", "slate"], - "aov_filter": [ - { - "name": "maya", - "value": [ - ".*([Bb]eauty).*" - ] - }, - { - "name": "blender", - "value": [ - ".*([Bb]eauty).*" - ] - }, - { - "name": "aftereffects", - "value": [ - ".*" - ] - }, - { - "name": "celaction", - "value": [ - ".*" - ] - }, - { - "name": "harmony", - "value": [ - ".*" - ] - }, - { - "name": "max", - "value": [ - ".*" - ] - }, - { - "name": "fusion", - "value": [ - ".*" - ] - } - ] - } -} diff --git a/server_addon/deadline/server/settings/site_settings.py b/server_addon/deadline/server/settings/site_settings.py deleted file mode 100644 index 92c092324e..0000000000 --- a/server_addon/deadline/server/settings/site_settings.py +++ /dev/null @@ -1,28 +0,0 @@ -from ayon_server.settings import ( - BaseSettingsModel, - SettingsField, -) - -from .main import defined_deadline_ws_name_enum_resolver - - -class CredentialPerServerModel(BaseSettingsModel): - """Provide credentials for configured DL servers""" - _layout = "expanded" - server_name: str = SettingsField( - "", - title="DL server name", - enum_resolver=defined_deadline_ws_name_enum_resolver - ) - username: str = SettingsField("", title="Username") - password: str = SettingsField("", title="Password") - - -class DeadlineSiteSettings(BaseSettingsModel): - local_settings: list[CredentialPerServerModel] = SettingsField( - default_factory=list, - title="Local setting", - description=( - "Please provide credentials for configured Deadline servers" - ), - ) From 285ad4cdb3d9282df909d71e55714341cd4146f6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 8 Jul 2024 19:59:33 +0200 Subject: [PATCH 054/163] Ignore invalid representation ids --- ...collect_input_representations_to_versions.py | 17 +++++++++++++++++ .../publish/collect_scene_loaded_versions.py | 17 ++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) 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 770f3470c6..009acba89c 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 @@ -1,7 +1,18 @@ +import uuid + import ayon_api import pyblish.api +def is_valid_uuid(value) -> bool: + """Return whether value is a valid UUID""" + try: + uuid.UUID(value) + except ValueError: + return False + return True + + class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): """Converts collected input representations to input versions. @@ -23,6 +34,12 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): if inst_repre: representations.update(inst_repre) + # Ignore representation ids that are not valid + representations = { + representation_id for representation_id in representations + if is_valid_uuid(representation_id) + } + repre_entities = ayon_api.get_representations( project_name=context.data["projectName"], representation_ids=representations, diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 1267c009e7..0a8fc93cf7 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -1,9 +1,20 @@ +import uuid + import ayon_api import pyblish.api from ayon_core.pipeline import registered_host +def is_valid_uuid(value) -> bool: + """Return whether value is a valid UUID""" + try: + uuid.UUID(value) + except ValueError: + return False + return True + + class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.0001 @@ -40,6 +51,10 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): container["representation"] for container in containers } + repre_ids = { + repre_id for repre_id in repre_ids + if is_valid_uuid(repre_id) + } project_name = context.data["projectName"] repre_entities = ayon_api.get_representations( @@ -65,7 +80,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): continue # NOTE: - # may have more then one representation that are same version + # may have more than one representation that are same version version = { "container_name": con["name"], "representation_id": repre_entity["id"], From a782ede959d3689e27d06e6cc991d5d31b1a2741 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 9 Jul 2024 10:09:57 +0200 Subject: [PATCH 055/163] Use `ayon_api.utils.convert_entity_id` to validate UUID --- ...llect_input_representations_to_versions.py | 14 ++------------ .../publish/collect_scene_loaded_versions.py | 19 +++++-------------- 2 files changed, 7 insertions(+), 26 deletions(-) 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 009acba89c..b9fe97b80b 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 @@ -1,18 +1,8 @@ -import uuid - import ayon_api +import ayon_api.utils import pyblish.api -def is_valid_uuid(value) -> bool: - """Return whether value is a valid UUID""" - try: - uuid.UUID(value) - except ValueError: - return False - return True - - class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): """Converts collected input representations to input versions. @@ -37,7 +27,7 @@ class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): # Ignore representation ids that are not valid representations = { representation_id for representation_id in representations - if is_valid_uuid(representation_id) + if ayon_api.utils.convert_entity_id(representation_id) } repre_entities = ayon_api.get_representations( diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 0a8fc93cf7..7e955302c6 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -1,20 +1,9 @@ -import uuid - import ayon_api -import pyblish.api +import ayon_api.utils from ayon_core.pipeline import registered_host -def is_valid_uuid(value) -> bool: - """Return whether value is a valid UUID""" - try: - uuid.UUID(value) - except ValueError: - return False - return True - - class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.0001 @@ -51,9 +40,11 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): container["representation"] for container in containers } + + # Ignore representation ids that are not valid repre_ids = { - repre_id for repre_id in repre_ids - if is_valid_uuid(repre_id) + representation_id for representation_id in repre_ids + if ayon_api.utils.convert_entity_id(representation_id) } project_name = context.data["projectName"] From b25e3d0db63f21d0172decb5cec580cf3fdf6d76 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 9 Jul 2024 10:12:56 +0200 Subject: [PATCH 056/163] Fix broken import --- .../ayon_core/plugins/publish/collect_scene_loaded_versions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py index 7e955302c6..1abb8e29d2 100644 --- a/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py +++ b/client/ayon_core/plugins/publish/collect_scene_loaded_versions.py @@ -2,6 +2,7 @@ import ayon_api import ayon_api.utils from ayon_core.pipeline import registered_host +import pyblish.api class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): From 4e7ebb233244f12210d3aa8abe08565c2ba1f39e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 9 Jul 2024 10:50:48 +0200 Subject: [PATCH 057/163] Change label to `Restart & Update` --- client/ayon_core/tools/tray/dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/tray/dialogs.py b/client/ayon_core/tools/tray/dialogs.py index 67348284a1..d37188a845 100644 --- a/client/ayon_core/tools/tray/dialogs.py +++ b/client/ayon_core/tools/tray/dialogs.py @@ -83,7 +83,7 @@ class UpdateDialog(QtWidgets.QDialog): top_layout.addWidget(label_widget, 1) ignore_btn = QtWidgets.QPushButton("Ignore", self) - restart_btn = QtWidgets.QPushButton("Restart && Change", self) + restart_btn = QtWidgets.QPushButton("Restart && Update", self) restart_btn.setObjectName("TrayRestartButton") btns_layout = QtWidgets.QHBoxLayout() From b69085f65daa6e7a3a98fe61b40e1ef8d4c27ab7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:46:30 +0200 Subject: [PATCH 058/163] removed max addon --- server_addon/max/client/ayon_max/__init__.py | 13 - server_addon/max/client/ayon_max/addon.py | 28 -- .../max/client/ayon_max/api/__init__.py | 20 - .../max/client/ayon_max/api/action.py | 42 --- .../max/client/ayon_max/api/colorspace.py | 50 --- .../client/ayon_max/api/lib_renderproducts.py | 275 -------------- .../client/ayon_max/api/lib_rendersettings.py | 227 ------------ server_addon/max/client/ayon_max/api/menu.py | 167 --------- .../max/client/ayon_max/api/pipeline.py | 297 --------------- .../client/ayon_max/api/preview_animation.py | 344 ------------------ .../ayon_max/hooks/force_startup_script.py | 27 -- .../client/ayon_max/hooks/inject_python.py | 20 - .../max/client/ayon_max/hooks/set_paths.py | 18 - .../max/client/ayon_max/plugins/__init__.py | 0 .../ayon_max/plugins/create/create_camera.py | 13 - .../plugins/create/create_maxScene.py | 13 - .../ayon_max/plugins/create/create_model.py | 13 - .../plugins/create/create_pointcache.py | 13 - .../plugins/create/create_pointcloud.py | 13 - .../plugins/create/create_redshift_proxy.py | 12 - .../ayon_max/plugins/create/create_render.py | 52 --- .../ayon_max/plugins/create/create_review.py | 122 ------- .../plugins/create/create_workfile.py | 119 ------ .../ayon_max/plugins/load/load_camera_fbx.py | 101 ----- .../ayon_max/plugins/load/load_max_scene.py | 178 --------- .../ayon_max/plugins/load/load_model.py | 123 ------- .../ayon_max/plugins/load/load_model_fbx.py | 98 ----- .../ayon_max/plugins/load/load_model_obj.py | 89 ----- .../ayon_max/plugins/load/load_model_usd.py | 120 ------ .../ayon_max/plugins/load/load_pointcache.py | 132 ------- .../plugins/load/load_pointcache_ornatrix.py | 111 ------ .../ayon_max/plugins/load/load_pointcloud.py | 69 ---- .../plugins/load/load_redshift_proxy.py | 78 ---- .../plugins/publish/collect_current_file.py | 23 -- .../plugins/publish/collect_render.py | 122 ------- .../plugins/publish/collect_review.py | 153 -------- .../plugins/publish/collect_workfile.py | 46 --- .../plugins/publish/extract_alembic.py | 139 ------- .../ayon_max/plugins/publish/extract_fbx.py | 83 ----- .../plugins/publish/extract_max_scene_raw.py | 49 --- .../plugins/publish/extract_model_obj.py | 59 --- .../plugins/publish/extract_model_usd.py | 94 ----- .../plugins/publish/extract_redshift_proxy.py | 61 ---- .../publish/extract_review_animation.py | 64 ---- .../plugins/publish/extract_thumbnail.py | 51 --- .../publish/help/validate_model_name.xml | 26 -- .../publish/increment_workfile_version.py | 19 - .../ayon_max/plugins/publish/save_scene.py | 25 -- .../publish/save_scenes_for_cameras.py | 105 ------ .../plugins/publish/validate_attributes.py | 143 -------- .../publish/validate_camera_attributes.py | 90 ----- .../publish/validate_camera_contents.py | 43 --- .../publish/validate_extended_viewport.py | 29 -- .../publish/validate_instance_has_members.py | 25 -- .../publish/validate_instance_in_context.py | 86 ----- .../plugins/publish/validate_loaded_plugin.py | 143 -------- .../plugins/publish/validate_mesh_has_uv.py | 62 ---- .../publish/validate_model_contents.py | 44 --- .../plugins/publish/validate_model_name.py | 123 ------- .../plugins/publish/validate_no_animation.py | 69 ---- .../plugins/publish/validate_pointcloud.py | 126 ------- .../publish/validate_renderable_camera.py | 46 --- .../validate_renderer_redshift_proxy.py | 54 --- .../plugins/publish/validate_renderpasses.py | 187 ---------- .../publish/validate_resolution_setting.py | 92 ----- .../plugins/publish/validate_scene_saved.py | 18 - .../max/client/ayon_max/startup/startup.ms | 15 - .../max/client/ayon_max/startup/startup.py | 13 - server_addon/max/client/ayon_max/version.py | 3 - server_addon/max/package.py | 9 - server_addon/max/server/__init__.py | 13 - server_addon/max/server/settings/__init__.py | 10 - .../server/settings/create_review_settings.py | 91 ----- server_addon/max/server/settings/imageio.py | 63 ---- server_addon/max/server/settings/main.py | 94 ----- .../max/server/settings/render_settings.py | 47 --- 76 files changed, 5854 deletions(-) delete mode 100644 server_addon/max/client/ayon_max/__init__.py delete mode 100644 server_addon/max/client/ayon_max/addon.py delete mode 100644 server_addon/max/client/ayon_max/api/__init__.py delete mode 100644 server_addon/max/client/ayon_max/api/action.py delete mode 100644 server_addon/max/client/ayon_max/api/colorspace.py delete mode 100644 server_addon/max/client/ayon_max/api/lib_renderproducts.py delete mode 100644 server_addon/max/client/ayon_max/api/lib_rendersettings.py delete mode 100644 server_addon/max/client/ayon_max/api/menu.py delete mode 100644 server_addon/max/client/ayon_max/api/pipeline.py delete mode 100644 server_addon/max/client/ayon_max/api/preview_animation.py delete mode 100644 server_addon/max/client/ayon_max/hooks/force_startup_script.py delete mode 100644 server_addon/max/client/ayon_max/hooks/inject_python.py delete mode 100644 server_addon/max/client/ayon_max/hooks/set_paths.py delete mode 100644 server_addon/max/client/ayon_max/plugins/__init__.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_camera.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_maxScene.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_model.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_pointcache.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_pointcloud.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_redshift_proxy.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_render.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_review.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_workfile.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_camera_fbx.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_max_scene.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_model.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_model_fbx.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_model_obj.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_model_usd.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_pointcache.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_pointcache_ornatrix.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_pointcloud.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_redshift_proxy.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_current_file.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_render.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_review.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_workfile.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_alembic.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_fbx.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_max_scene_raw.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_model_obj.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_model_usd.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_redshift_proxy.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_review_animation.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_thumbnail.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/help/validate_model_name.xml delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/increment_workfile_version.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/save_scene.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/save_scenes_for_cameras.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_attributes.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_camera_attributes.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_camera_contents.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_extended_viewport.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_instance_has_members.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_instance_in_context.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_loaded_plugin.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_mesh_has_uv.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_model_contents.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_model_name.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_no_animation.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_pointcloud.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_renderable_camera.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_renderer_redshift_proxy.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_renderpasses.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_resolution_setting.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_scene_saved.py delete mode 100644 server_addon/max/client/ayon_max/startup/startup.ms delete mode 100644 server_addon/max/client/ayon_max/startup/startup.py delete mode 100644 server_addon/max/client/ayon_max/version.py delete mode 100644 server_addon/max/package.py delete mode 100644 server_addon/max/server/__init__.py delete mode 100644 server_addon/max/server/settings/__init__.py delete mode 100644 server_addon/max/server/settings/create_review_settings.py delete mode 100644 server_addon/max/server/settings/imageio.py delete mode 100644 server_addon/max/server/settings/main.py delete mode 100644 server_addon/max/server/settings/render_settings.py diff --git a/server_addon/max/client/ayon_max/__init__.py b/server_addon/max/client/ayon_max/__init__.py deleted file mode 100644 index 77293f9aa9..0000000000 --- a/server_addon/max/client/ayon_max/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .version import __version__ -from .addon import ( - MaxAddon, - MAX_HOST_DIR, -) - - -__all__ = ( - "__version__", - - "MaxAddon", - "MAX_HOST_DIR", -) diff --git a/server_addon/max/client/ayon_max/addon.py b/server_addon/max/client/ayon_max/addon.py deleted file mode 100644 index 9cc0cda1ee..0000000000 --- a/server_addon/max/client/ayon_max/addon.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from ayon_core.addon import AYONAddon, IHostAddon - -from .version import __version__ - -MAX_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) - - -class MaxAddon(AYONAddon, IHostAddon): - name = "max" - version = __version__ - host_name = "max" - - def add_implementation_envs(self, env, _app): - # Remove auto screen scale factor for Qt - # - let 3dsmax decide it's value - env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) - - def get_workfile_extensions(self): - return [".max"] - - def get_launch_hook_paths(self, app): - if app.host_name != self.host_name: - return [] - return [ - os.path.join(MAX_HOST_DIR, "hooks") - ] diff --git a/server_addon/max/client/ayon_max/api/__init__.py b/server_addon/max/client/ayon_max/api/__init__.py deleted file mode 100644 index 92097cc98b..0000000000 --- a/server_addon/max/client/ayon_max/api/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -"""Public API for 3dsmax""" - -from .pipeline import ( - MaxHost, -) - - -from .lib import ( - maintained_selection, - lsattr, - get_all_children -) - -__all__ = [ - "MaxHost", - "maintained_selection", - "lsattr", - "get_all_children" -] diff --git a/server_addon/max/client/ayon_max/api/action.py b/server_addon/max/client/ayon_max/api/action.py deleted file mode 100644 index bed72bc493..0000000000 --- a/server_addon/max/client/ayon_max/api/action.py +++ /dev/null @@ -1,42 +0,0 @@ -from pymxs import runtime as rt - -import pyblish.api - -from ayon_core.pipeline.publish import get_errored_instances_from_context - - -class SelectInvalidAction(pyblish.api.Action): - """Select invalid objects in Blender when a publish plug-in failed.""" - label = "Select Invalid" - on = "failed" - icon = "search" - - def process(self, context, plugin): - errored_instances = get_errored_instances_from_context(context, - plugin=plugin) - - # Get the invalid nodes for the plug-ins - self.log.info("Finding invalid nodes...") - invalid = list() - for instance in errored_instances: - invalid_nodes = plugin.get_invalid(instance) - if invalid_nodes: - if isinstance(invalid_nodes, (list, tuple)): - invalid.extend(invalid_nodes) - else: - self.log.warning( - "Failed plug-in doesn't have any selectable objects." - ) - - if not invalid: - self.log.info("No invalid nodes found.") - return - invalid_names = [obj.name for obj in invalid if not isinstance(obj, tuple)] - if not invalid_names: - invalid_names = [obj.name for obj, _ in invalid] - invalid = [obj for obj, _ in invalid] - self.log.info( - "Selecting invalid objects: %s", ", ".join(invalid_names) - ) - - rt.Select(invalid) diff --git a/server_addon/max/client/ayon_max/api/colorspace.py b/server_addon/max/client/ayon_max/api/colorspace.py deleted file mode 100644 index fafee4ee04..0000000000 --- a/server_addon/max/client/ayon_max/api/colorspace.py +++ /dev/null @@ -1,50 +0,0 @@ -import attr -from pymxs import runtime as rt - - -@attr.s -class LayerMetadata(object): - """Data class for Render Layer metadata.""" - frameStart = attr.ib() - frameEnd = attr.ib() - - -@attr.s -class RenderProduct(object): - """Getting Colorspace as - Specific Render Product Parameter for submitting - publish job. - """ - colorspace = attr.ib() # colorspace - view = attr.ib() - productName = attr.ib(default=None) - - -class ARenderProduct(object): - - def __init__(self): - """Constructor.""" - # Initialize - self.layer_data = self._get_layer_data() - self.layer_data.products = self.get_colorspace_data() - - def _get_layer_data(self): - return LayerMetadata( - frameStart=int(rt.rendStart), - frameEnd=int(rt.rendEnd), - ) - - def get_colorspace_data(self): - """To be implemented by renderer class. - This should return a list of RenderProducts. - Returns: - list: List of RenderProduct - """ - colorspace_data = [ - RenderProduct( - colorspace="sRGB", - view="ACES 1.0", - productName="" - ) - ] - return colorspace_data diff --git a/server_addon/max/client/ayon_max/api/lib_renderproducts.py b/server_addon/max/client/ayon_max/api/lib_renderproducts.py deleted file mode 100644 index 82a6a0c20c..0000000000 --- a/server_addon/max/client/ayon_max/api/lib_renderproducts.py +++ /dev/null @@ -1,275 +0,0 @@ -# Render Element Example : For scanline render, VRay -# https://help.autodesk.com/view/MAXDEV/2022/ENU/?guid=GUID-E8F75D47-B998-4800-A3A5-610E22913CFC -# arnold -# https://help.autodesk.com/view/ARNOL/ENU/?guid=arnold_for_3ds_max_ax_maxscript_commands_ax_renderview_commands_html -import os - -from pymxs import runtime as rt - -from ayon_max.api.lib import get_current_renderer -from ayon_core.pipeline import get_current_project_name -from ayon_core.settings import get_project_settings - - -class RenderProducts(object): - - def __init__(self, project_settings=None): - self._project_settings = project_settings - if not self._project_settings: - self._project_settings = get_project_settings( - get_current_project_name() - ) - - def get_beauty(self, container): - render_dir = os.path.dirname(rt.rendOutputFilename) - - output_file = os.path.join(render_dir, container) - - setting = self._project_settings - img_fmt = setting["max"]["RenderSettings"]["image_format"] # noqa - - start_frame = int(rt.rendStart) - end_frame = int(rt.rendEnd) + 1 - - return { - "beauty": self.get_expected_beauty( - output_file, start_frame, end_frame, img_fmt - ) - } - - def get_multiple_beauty(self, outputs, cameras): - beauty_output_frames = dict() - for output, camera in zip(outputs, cameras): - filename, ext = os.path.splitext(output) - filename = filename.replace(".", "") - ext = ext.replace(".", "") - start_frame = int(rt.rendStart) - end_frame = int(rt.rendEnd) + 1 - new_beauty = self.get_expected_beauty( - filename, start_frame, end_frame, ext - ) - beauty_output = ({ - f"{camera}_beauty": new_beauty - }) - beauty_output_frames.update(beauty_output) - return beauty_output_frames - - def get_multiple_aovs(self, outputs, cameras): - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - aovs_frames = {} - for output, camera in zip(outputs, cameras): - filename, ext = os.path.splitext(output) - filename = filename.replace(".", "") - ext = ext.replace(".", "") - start_frame = int(rt.rendStart) - end_frame = int(rt.rendEnd) + 1 - - if renderer in [ - "ART_Renderer", - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3", - "Default_Scanline_Renderer", - "Quicksilver_Hardware_Renderer", - ]: - render_name = self.get_render_elements_name() - if render_name: - for name in render_name: - aovs_frames.update({ - f"{camera}_{name}": self.get_expected_aovs( - filename, name, start_frame, - end_frame, ext) - }) - elif renderer == "Redshift_Renderer": - render_name = self.get_render_elements_name() - if render_name: - rs_aov_files = rt.Execute("renderers.current.separateAovFiles") # noqa - # this doesn't work, always returns False - # rs_AovFiles = rt.RedShift_Renderer().separateAovFiles - if ext == "exr" and not rs_aov_files: - for name in render_name: - if name == "RsCryptomatte": - aovs_frames.update({ - f"{camera}_{name}": self.get_expected_aovs( - filename, name, start_frame, - end_frame, ext) - }) - else: - for name in render_name: - aovs_frames.update({ - f"{camera}_{name}": self.get_expected_aovs( - filename, name, start_frame, - end_frame, ext) - }) - elif renderer == "Arnold": - render_name = self.get_arnold_product_name() - if render_name: - for name in render_name: - aovs_frames.update({ - f"{camera}_{name}": self.get_expected_arnold_product( # noqa - filename, name, start_frame, - end_frame, ext) - }) - elif renderer in [ - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3" - ]: - if ext != "exr": - render_name = self.get_render_elements_name() - if render_name: - for name in render_name: - aovs_frames.update({ - f"{camera}_{name}": self.get_expected_aovs( - filename, name, start_frame, - end_frame, ext) - }) - - return aovs_frames - - def get_aovs(self, container): - render_dir = os.path.dirname(rt.rendOutputFilename) - - output_file = os.path.join(render_dir, - container) - - setting = self._project_settings - img_fmt = setting["max"]["RenderSettings"]["image_format"] # noqa - - start_frame = int(rt.rendStart) - end_frame = int(rt.rendEnd) + 1 - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - render_dict = {} - - if renderer in [ - "ART_Renderer", - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3", - "Default_Scanline_Renderer", - "Quicksilver_Hardware_Renderer", - ]: - render_name = self.get_render_elements_name() - if render_name: - for name in render_name: - render_dict.update({ - name: self.get_expected_aovs( - output_file, name, start_frame, - end_frame, img_fmt) - }) - elif renderer == "Redshift_Renderer": - render_name = self.get_render_elements_name() - if render_name: - rs_aov_files = rt.Execute("renderers.current.separateAovFiles") - # this doesn't work, always returns False - # rs_AovFiles = rt.RedShift_Renderer().separateAovFiles - if img_fmt == "exr" and not rs_aov_files: - for name in render_name: - if name == "RsCryptomatte": - render_dict.update({ - name: self.get_expected_aovs( - output_file, name, start_frame, - end_frame, img_fmt) - }) - else: - for name in render_name: - render_dict.update({ - name: self.get_expected_aovs( - output_file, name, start_frame, - end_frame, img_fmt) - }) - - elif renderer == "Arnold": - render_name = self.get_arnold_product_name() - if render_name: - for name in render_name: - render_dict.update({ - name: self.get_expected_arnold_product( - output_file, name, start_frame, - end_frame, img_fmt) - }) - elif renderer in [ - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3" - ]: - if img_fmt != "exr": - render_name = self.get_render_elements_name() - if render_name: - for name in render_name: - render_dict.update({ - name: self.get_expected_aovs( - output_file, name, start_frame, - end_frame, img_fmt) # noqa - }) - - return render_dict - - def get_expected_beauty(self, folder, start_frame, end_frame, fmt): - beauty_frame_range = [] - for f in range(start_frame, end_frame): - frame = "%04d" % f - beauty_output = f"{folder}.{frame}.{fmt}" - beauty_output = beauty_output.replace("\\", "/") - beauty_frame_range.append(beauty_output) - - return beauty_frame_range - - def get_arnold_product_name(self): - """Get all the Arnold AOVs name""" - aov_name = [] - - amw = rt.MaxToAOps.AOVsManagerWindow() - aov_mgr = rt.renderers.current.AOVManager - # Check if there is any aov group set in AOV manager - aov_group_num = len(aov_mgr.drivers) - if aov_group_num < 1: - return - for i in range(aov_group_num): - # get the specific AOV group - aov_name.extend(aov.name for aov in aov_mgr.drivers[i].aov_list) - # close the AOVs manager window - amw.close() - - return aov_name - - def get_expected_arnold_product(self, folder, name, - start_frame, end_frame, fmt): - """Get all the expected Arnold AOVs""" - aov_list = [] - for f in range(start_frame, end_frame): - frame = "%04d" % f - render_element = f"{folder}_{name}.{frame}.{fmt}" - render_element = render_element.replace("\\", "/") - aov_list.append(render_element) - - return aov_list - - def get_render_elements_name(self): - """Get all the render element names for general """ - render_name = [] - render_elem = rt.maxOps.GetCurRenderElementMgr() - render_elem_num = render_elem.NumRenderElements() - if render_elem_num < 1: - return - # get render elements from the renders - for i in range(render_elem_num): - renderlayer_name = render_elem.GetRenderElement(i) - if renderlayer_name.enabled: - target, renderpass = str(renderlayer_name).split(":") - render_name.append(renderpass) - - return render_name - - def get_expected_aovs(self, folder, name, - start_frame, end_frame, fmt): - """Get all the expected render element output files. """ - render_elements = [] - for f in range(start_frame, end_frame): - frame = "%04d" % f - render_element = f"{folder}_{name}.{frame}.{fmt}" - render_element = render_element.replace("\\", "/") - render_elements.append(render_element) - - return render_elements - - def image_format(self): - return self._project_settings["max"]["RenderSettings"]["image_format"] # noqa diff --git a/server_addon/max/client/ayon_max/api/lib_rendersettings.py b/server_addon/max/client/ayon_max/api/lib_rendersettings.py deleted file mode 100644 index 4b65e1397e..0000000000 --- a/server_addon/max/client/ayon_max/api/lib_rendersettings.py +++ /dev/null @@ -1,227 +0,0 @@ -import os -from pymxs import runtime as rt -from ayon_core.lib import Logger -from ayon_core.settings import get_project_settings -from ayon_core.pipeline import get_current_project_name -from ayon_core.pipeline.context_tools import get_current_folder_entity - -from ayon_max.api.lib import ( - set_render_frame_range, - get_current_renderer, - get_default_render_folder -) - - -class RenderSettings(object): - - log = Logger.get_logger("RenderSettings") - - _aov_chars = { - "dot": ".", - "dash": "-", - "underscore": "_" - } - - def __init__(self, project_settings=None): - """ - Set up the naming convention for the render - elements for the deadline submission - """ - - self._project_settings = project_settings - if not self._project_settings: - self._project_settings = get_project_settings( - get_current_project_name() - ) - - def set_render_camera(self, selection): - for sel in selection: - # to avoid Attribute Error from pymxs wrapper - if rt.classOf(sel) in rt.Camera.classes: - rt.viewport.setCamera(sel) - return - raise RuntimeError("Active Camera not found") - - def render_output(self, container): - folder = rt.maxFilePath - # hard-coded, should be customized in the setting - file = rt.maxFileName - folder = folder.replace("\\", "/") - # hard-coded, set the renderoutput path - setting = self._project_settings - render_folder = get_default_render_folder(setting) - filename, ext = os.path.splitext(file) - output_dir = os.path.join(folder, - render_folder, - filename) - if not os.path.exists(output_dir): - os.makedirs(output_dir) - # hard-coded, should be customized in the setting - folder_attributes = get_current_folder_entity()["attrib"] - - # get project resolution - width = folder_attributes.get("resolutionWidth") - height = folder_attributes.get("resolutionHeight") - # Set Frame Range - frame_start = folder_attributes.get("frame_start") - frame_end = folder_attributes.get("frame_end") - set_render_frame_range(frame_start, frame_end) - # get the production render - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - - img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - output = os.path.join(output_dir, container) - try: - aov_separator = self._aov_chars[( - self._project_settings["max"] - ["RenderSettings"] - ["aov_separator"] - )] - except KeyError: - aov_separator = "." - output_filename = f"{output}..{img_fmt}" - output_filename = output_filename.replace("{aov_separator}", - aov_separator) - rt.rendOutputFilename = output_filename - if renderer == "VUE_File_Renderer": - return - # TODO: Finish the arnold render setup - if renderer == "Arnold": - self.arnold_setup() - - if renderer in [ - "ART_Renderer", - "Redshift_Renderer", - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3", - "Default_Scanline_Renderer", - "Quicksilver_Hardware_Renderer", - ]: - self.render_element_layer(output, width, height, img_fmt) - - rt.rendSaveFile = True - - if rt.renderSceneDialog.isOpen(): - rt.renderSceneDialog.close() - - def arnold_setup(self): - # get Arnold RenderView run in the background - # for setting up renderable camera - arv = rt.MAXToAOps.ArnoldRenderView() - render_camera = rt.viewport.GetCamera() - if render_camera: - arv.setOption("Camera", str(render_camera)) - - # TODO: add AOVs and extension - img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - setup_cmd = ( - f""" - amw = MaxtoAOps.AOVsManagerWindow() - amw.close() - aovmgr = renderers.current.AOVManager - aovmgr.drivers = #() - img_fmt = "{img_fmt}" - if img_fmt == "png" then driver = ArnoldPNGDriver() - if img_fmt == "jpg" then driver = ArnoldJPEGDriver() - if img_fmt == "exr" then driver = ArnoldEXRDriver() - if img_fmt == "tif" then driver = ArnoldTIFFDriver() - if img_fmt == "tiff" then driver = ArnoldTIFFDriver() - append aovmgr.drivers driver - aovmgr.drivers[1].aov_list = #() - """) - - rt.execute(setup_cmd) - arv.close() - - def render_element_layer(self, dir, width, height, ext): - """For Renderers with render elements""" - rt.renderWidth = width - rt.renderHeight = height - render_elem = rt.maxOps.GetCurRenderElementMgr() - render_elem_num = render_elem.NumRenderElements() - if render_elem_num < 0: - return - - for i in range(render_elem_num): - renderlayer_name = render_elem.GetRenderElement(i) - target, renderpass = str(renderlayer_name).split(":") - aov_name = f"{dir}_{renderpass}..{ext}" - render_elem.SetRenderElementFileName(i, aov_name) - - def get_render_output(self, container, output_dir): - output = os.path.join(output_dir, container) - img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - output_filename = f"{output}..{img_fmt}" - return output_filename - - def get_render_element(self): - orig_render_elem = [] - render_elem = rt.maxOps.GetCurRenderElementMgr() - render_elem_num = render_elem.NumRenderElements() - if render_elem_num < 0: - return - - for i in range(render_elem_num): - render_element = render_elem.GetRenderElementFilename(i) - orig_render_elem.append(render_element) - - return orig_render_elem - - def get_batch_render_elements(self, container, - output_dir, camera): - render_element_list = list() - output = os.path.join(output_dir, container) - render_elem = rt.maxOps.GetCurRenderElementMgr() - render_elem_num = render_elem.NumRenderElements() - if render_elem_num < 0: - return - img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - - for i in range(render_elem_num): - renderlayer_name = render_elem.GetRenderElement(i) - target, renderpass = str(renderlayer_name).split(":") - aov_name = f"{output}_{camera}_{renderpass}..{img_fmt}" - render_element_list.append(aov_name) - return render_element_list - - def get_batch_render_output(self, camera): - target_layer_no = rt.batchRenderMgr.FindView(camera) - target_layer = rt.batchRenderMgr.GetView(target_layer_no) - return target_layer.outputFilename - - def batch_render_elements(self, camera): - target_layer_no = rt.batchRenderMgr.FindView(camera) - target_layer = rt.batchRenderMgr.GetView(target_layer_no) - outputfilename = target_layer.outputFilename - directory = os.path.dirname(outputfilename) - render_elem = rt.maxOps.GetCurRenderElementMgr() - render_elem_num = render_elem.NumRenderElements() - if render_elem_num < 0: - return - ext = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - - for i in range(render_elem_num): - renderlayer_name = render_elem.GetRenderElement(i) - target, renderpass = str(renderlayer_name).split(":") - aov_name = f"{directory}_{camera}_{renderpass}..{ext}" - render_elem.SetRenderElementFileName(i, aov_name) - - def batch_render_layer(self, container, - output_dir, cameras): - outputs = list() - output = os.path.join(output_dir, container) - img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] # noqa - for cam in cameras: - camera = rt.getNodeByName(cam) - layer_no = rt.batchRenderMgr.FindView(cam) - renderlayer = None - if layer_no == 0: - renderlayer = rt.batchRenderMgr.CreateView(camera) - else: - renderlayer = rt.batchRenderMgr.GetView(layer_no) - # use camera name as renderlayer name - renderlayer.name = cam - renderlayer.outputFilename = f"{output}_{cam}..{img_fmt}" - outputs.append(renderlayer.outputFilename) - return outputs diff --git a/server_addon/max/client/ayon_max/api/menu.py b/server_addon/max/client/ayon_max/api/menu.py deleted file mode 100644 index 25dd39fd84..0000000000 --- a/server_addon/max/client/ayon_max/api/menu.py +++ /dev/null @@ -1,167 +0,0 @@ -# -*- coding: utf-8 -*- -"""3dsmax menu definition of AYON.""" -import os -from qtpy import QtWidgets, QtCore -from pymxs import runtime as rt - -from ayon_core.tools.utils import host_tools -from ayon_max.api import lib - - -class AYONMenu(object): - """Object representing AYON menu. - - This is using "hack" to inject itself before "Help" menu of 3dsmax. - For some reason `postLoadingMenus` event doesn't fire, and main menu - if probably re-initialized by menu templates, se we wait for at least - 1 event Qt event loop before trying to insert. - - """ - - def __init__(self): - super().__init__() - self.main_widget = self.get_main_widget() - self.menu = None - - timer = QtCore.QTimer() - # set number of event loops to wait. - timer.setInterval(1) - timer.timeout.connect(self._on_timer) - timer.start() - - self._timer = timer - self._counter = 0 - - def _on_timer(self): - if self._counter < 1: - self._counter += 1 - return - - self._counter = 0 - self._timer.stop() - self._build_ayon_menu() - - @staticmethod - def get_main_widget(): - """Get 3dsmax main window.""" - return QtWidgets.QWidget.find(rt.windows.getMAXHWND()) - - def get_main_menubar(self) -> QtWidgets.QMenuBar: - """Get main Menubar by 3dsmax main window.""" - return list(self.main_widget.findChildren(QtWidgets.QMenuBar))[0] - - def _get_or_create_ayon_menu( - self, name: str = "&AYON", - before: str = "&Help") -> QtWidgets.QAction: - """Create AYON menu. - - Args: - name (str, Optional): AYON menu name. - before (str, Optional): Name of the 3dsmax main menu item to - add AYON menu before. - - Returns: - QtWidgets.QAction: AYON menu action. - - """ - if self.menu is not None: - return self.menu - - menu_bar = self.get_main_menubar() - menu_items = menu_bar.findChildren( - QtWidgets.QMenu, options=QtCore.Qt.FindDirectChildrenOnly) - help_action = None - for item in menu_items: - if name in item.title(): - # we already have AYON menu - return item - - if before in item.title(): - help_action = item.menuAction() - tab_menu_label = os.environ.get("AYON_MENU_LABEL") or "AYON" - op_menu = QtWidgets.QMenu("&{}".format(tab_menu_label)) - menu_bar.insertMenu(help_action, op_menu) - - self.menu = op_menu - return op_menu - - def _build_ayon_menu(self) -> QtWidgets.QAction: - """Build items in AYON menu.""" - ayon_menu = self._get_or_create_ayon_menu() - load_action = QtWidgets.QAction("Load...", ayon_menu) - load_action.triggered.connect(self.load_callback) - ayon_menu.addAction(load_action) - - publish_action = QtWidgets.QAction("Publish...", ayon_menu) - publish_action.triggered.connect(self.publish_callback) - ayon_menu.addAction(publish_action) - - manage_action = QtWidgets.QAction("Manage...", ayon_menu) - manage_action.triggered.connect(self.manage_callback) - ayon_menu.addAction(manage_action) - - library_action = QtWidgets.QAction("Library...", ayon_menu) - library_action.triggered.connect(self.library_callback) - ayon_menu.addAction(library_action) - - ayon_menu.addSeparator() - - workfiles_action = QtWidgets.QAction("Work Files...", ayon_menu) - workfiles_action.triggered.connect(self.workfiles_callback) - ayon_menu.addAction(workfiles_action) - - ayon_menu.addSeparator() - - res_action = QtWidgets.QAction("Set Resolution", ayon_menu) - res_action.triggered.connect(self.resolution_callback) - ayon_menu.addAction(res_action) - - frame_action = QtWidgets.QAction("Set Frame Range", ayon_menu) - frame_action.triggered.connect(self.frame_range_callback) - ayon_menu.addAction(frame_action) - - colorspace_action = QtWidgets.QAction("Set Colorspace", ayon_menu) - colorspace_action.triggered.connect(self.colorspace_callback) - ayon_menu.addAction(colorspace_action) - - unit_scale_action = QtWidgets.QAction("Set Unit Scale", ayon_menu) - unit_scale_action.triggered.connect(self.unit_scale_callback) - ayon_menu.addAction(unit_scale_action) - - return ayon_menu - - def load_callback(self): - """Callback to show Loader tool.""" - host_tools.show_loader(parent=self.main_widget) - - def publish_callback(self): - """Callback to show Publisher tool.""" - host_tools.show_publisher(parent=self.main_widget) - - def manage_callback(self): - """Callback to show Scene Manager/Inventory tool.""" - host_tools.show_scene_inventory(parent=self.main_widget) - - def library_callback(self): - """Callback to show Library Loader tool.""" - host_tools.show_library_loader(parent=self.main_widget) - - def workfiles_callback(self): - """Callback to show Workfiles tool.""" - host_tools.show_workfiles(parent=self.main_widget) - - def resolution_callback(self): - """Callback to reset scene resolution""" - return lib.reset_scene_resolution() - - def frame_range_callback(self): - """Callback to reset frame range""" - return lib.reset_frame_range() - - def colorspace_callback(self): - """Callback to reset colorspace""" - return lib.reset_colorspace() - - def unit_scale_callback(self): - """Callback to reset unit scale""" - return lib.reset_unit_scale() diff --git a/server_addon/max/client/ayon_max/api/pipeline.py b/server_addon/max/client/ayon_max/api/pipeline.py deleted file mode 100644 index a87cd657ce..0000000000 --- a/server_addon/max/client/ayon_max/api/pipeline.py +++ /dev/null @@ -1,297 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pipeline tools for AYON 3ds max integration.""" -import os -import logging -from operator import attrgetter - -import json - -from ayon_core.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost -import pyblish.api -from ayon_core.pipeline import ( - register_creator_plugin_path, - register_loader_plugin_path, - AVALON_CONTAINER_ID, - AYON_CONTAINER_ID, -) -from ayon_max.api.menu import AYONMenu -from ayon_max.api import lib -from ayon_max.api.plugin import MS_CUSTOM_ATTRIB -from ayon_max import MAX_HOST_DIR - -from pymxs import runtime as rt # noqa - -log = logging.getLogger("ayon_max") - -PLUGINS_DIR = os.path.join(MAX_HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") - - -class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): - - name = "max" - menu = None - - def __init__(self): - super(MaxHost, self).__init__() - self._op_events = {} - self._has_been_setup = False - - def install(self): - pyblish.api.register_host("max") - - pyblish.api.register_plugin_path(PUBLISH_PATH) - register_loader_plugin_path(LOAD_PATH) - register_creator_plugin_path(CREATE_PATH) - - # self._register_callbacks() - self.menu = AYONMenu() - - self._has_been_setup = True - - rt.callbacks.addScript(rt.Name('systemPostNew'), on_new) - - rt.callbacks.addScript(rt.Name('filePostOpen'), - lib.check_colorspace) - - rt.callbacks.addScript(rt.Name('postWorkspaceChange'), - self._deferred_menu_creation) - rt.NodeEventCallback( - nameChanged=lib.update_modifier_node_names) - - def workfile_has_unsaved_changes(self): - return rt.getSaveRequired() - - def get_workfile_extensions(self): - return [".max"] - - def save_workfile(self, dst_path=None): - rt.saveMaxFile(dst_path) - return dst_path - - def open_workfile(self, filepath): - rt.checkForSave() - rt.loadMaxFile(filepath) - return filepath - - def get_current_workfile(self): - return os.path.join(rt.maxFilePath, rt.maxFileName) - - def get_containers(self): - return ls() - - def _register_callbacks(self): - rt.callbacks.removeScripts(id=rt.name("OpenPypeCallbacks")) - - rt.callbacks.addScript( - rt.Name("postLoadingMenus"), - self._deferred_menu_creation, id=rt.Name('OpenPypeCallbacks')) - - def _deferred_menu_creation(self): - self.log.info("Building menu ...") - self.menu = AYONMenu() - - @staticmethod - def create_context_node(): - """Helper for creating context holding node.""" - - root_scene = rt.rootScene - - create_attr_script = (""" -attributes "OpenPypeContext" -( - parameters main rollout:params - ( - context type: #string - ) - - rollout params "OpenPype Parameters" - ( - editText editTextContext "Context" type: #string - ) -) - """) - - attr = rt.execute(create_attr_script) - rt.custAttributes.add(root_scene, attr) - - return root_scene.OpenPypeContext.context - - def update_context_data(self, data, changes): - try: - _ = rt.rootScene.OpenPypeContext.context - except AttributeError: - # context node doesn't exists - self.create_context_node() - - rt.rootScene.OpenPypeContext.context = json.dumps(data) - - def get_context_data(self): - try: - context = rt.rootScene.OpenPypeContext.context - except AttributeError: - # context node doesn't exists - context = self.create_context_node() - if not context: - context = "{}" - return json.loads(context) - - def save_file(self, dst_path=None): - # Force forwards slashes to avoid segfault - dst_path = dst_path.replace("\\", "/") - rt.saveMaxFile(dst_path) - - -def parse_container(container): - """Return the container node's full container data. - - Args: - container (str): A container node name. - - Returns: - dict: The container schema data for this container node. - - """ - data = lib.read(container) - - # Backwards compatibility pre-schemas for containers - data["schema"] = data.get("schema", "openpype:container-3.0") - - # Append transient data - data["objectName"] = container.Name - return data - - -def ls(): - """Get all AYON containers.""" - objs = rt.objects - containers = [ - obj for obj in objs - if rt.getUserProp(obj, "id") in { - AYON_CONTAINER_ID, AVALON_CONTAINER_ID - } - ] - - for container in sorted(containers, key=attrgetter("name")): - yield parse_container(container) - - -def on_new(): - lib.set_context_setting() - if rt.checkForSave(): - rt.resetMaxFile(rt.Name("noPrompt")) - rt.clearUndoBuffer() - rt.redrawViews() - - -def containerise(name: str, nodes: list, context, - namespace=None, loader=None, suffix="_CON"): - data = { - "schema": "openpype:container-2.0", - "id": AVALON_CONTAINER_ID, - "name": name, - "namespace": namespace or "", - "loader": loader, - "representation": context["representation"]["id"], - } - container_name = f"{namespace}:{name}{suffix}" - container = rt.container(name=container_name) - import_custom_attribute_data(container, nodes) - if not lib.imprint(container_name, data): - print(f"imprinting of {container_name} failed.") - return container - - -def load_custom_attribute_data(): - """Re-loading the AYON custom parameter built by the creator - - Returns: - attribute: re-loading the custom OP attributes set in Maxscript - """ - return rt.Execute(MS_CUSTOM_ATTRIB) - - -def import_custom_attribute_data(container: str, selections: list): - """Importing the Openpype/AYON custom parameter built by the creator - - Args: - container (str): target container which adds custom attributes - selections (list): nodes to be added into - group in custom attributes - """ - attrs = load_custom_attribute_data() - modifier = rt.EmptyModifier() - rt.addModifier(container, modifier) - container.modifiers[0].name = "OP Data" - rt.custAttributes.add(container.modifiers[0], attrs) - node_list = [] - sel_list = [] - for i in selections: - node_ref = rt.NodeTransformMonitor(node=i) - node_list.append(node_ref) - sel_list.append(str(i)) - - # Setting the property - rt.setProperty( - container.modifiers[0].openPypeData, - "all_handles", node_list) - rt.setProperty( - container.modifiers[0].openPypeData, - "sel_list", sel_list) - - -def update_custom_attribute_data(container: str, selections: list): - """Updating the AYON custom parameter built by the creator - - Args: - container (str): target container which adds custom attributes - selections (list): nodes to be added into - group in custom attributes - """ - if container.modifiers[0].name == "OP Data": - rt.deleteModifier(container, container.modifiers[0]) - import_custom_attribute_data(container, selections) - - -def get_previous_loaded_object(container: str): - """Get previous loaded_object through the OP data - - Args: - container (str): the container which stores the OP data - - Returns: - node_list(list): list of nodes which are previously loaded - """ - node_list = [] - node_transform_monitor_list = rt.getProperty( - container.modifiers[0].openPypeData, "all_handles") - for node_transform_monitor in node_transform_monitor_list: - node_list.append(node_transform_monitor.node) - return node_list - - -def remove_container_data(container_node: str): - """Function to remove container data after updating, switching or deleting it. - - Args: - container_node (str): container node - """ - if container_node.modifiers[0].name == "OP Data": - all_set_members_names = [ - member.node for member - in container_node.modifiers[0].openPypeData.all_handles] - # clean up the children of alembic dummy objects - for current_set_member in all_set_members_names: - shape_list = [members for members in current_set_member.Children - if rt.ClassOf(members) == rt.AlembicObject - or rt.isValidNode(members)] - if shape_list: # noqa - rt.Delete(shape_list) - rt.Delete(current_set_member) - rt.deleteModifier(container_node, container_node.modifiers[0]) - - rt.Delete(container_node) - rt.redrawViews() diff --git a/server_addon/max/client/ayon_max/api/preview_animation.py b/server_addon/max/client/ayon_max/api/preview_animation.py deleted file mode 100644 index acda5360a1..0000000000 --- a/server_addon/max/client/ayon_max/api/preview_animation.py +++ /dev/null @@ -1,344 +0,0 @@ -import logging -import contextlib -from pymxs import runtime as rt -from .lib import get_max_version, render_resolution - -log = logging.getLogger("ayon_max") - - -@contextlib.contextmanager -def play_preview_when_done(has_autoplay): - """Set preview playback option during context - - Args: - has_autoplay (bool): autoplay during creating - preview animation - """ - current_playback = rt.preferences.playPreviewWhenDone - try: - rt.preferences.playPreviewWhenDone = has_autoplay - yield - finally: - rt.preferences.playPreviewWhenDone = current_playback - - -@contextlib.contextmanager -def viewport_layout_and_camera(camera, layout="layout_1"): - """Set viewport layout and camera during context - ***For 3dsMax 2024+ - Args: - camera (str): viewport camera - layout (str): layout to use in viewport, defaults to `layout_1` - Use None to not change viewport layout during context. - """ - needs_maximise = 0 - # Set to first active non extended viewport - rt.viewport.activeViewportEx(1) - original_camera = rt.viewport.getCamera() - original_type = rt.viewport.getType() - review_camera = rt.getNodeByName(camera) - - try: - if rt.viewport.getLayout() != rt.name(layout): - rt.execute("max tool maximize") - needs_maximise = 1 - rt.viewport.setCamera(review_camera) - yield - finally: - if needs_maximise == 1: - rt.execute("max tool maximize") - if original_type == rt.Name("view_camera"): - rt.viewport.setCamera(original_camera) - else: - rt.viewport.setType(original_type) - - -@contextlib.contextmanager -def viewport_preference_setting(general_viewport, - nitrous_manager, - nitrous_viewport, - vp_button_mgr): - """Function to set viewport setting during context - ***For Max Version < 2024 - Args: - camera (str): Viewport camera for review render - general_viewport (dict): General viewport setting - nitrous_manager (dict): Nitrous graphic manager - nitrous_viewport (dict): Nitrous setting for - preview animation - vp_button_mgr (dict): Viewport button manager Setting - preview_preferences (dict): Preview Preferences Setting - """ - orig_vp_grid = rt.viewport.getGridVisibility(1) - orig_vp_bkg = rt.viewport.IsSolidBackgroundColorMode() - - nitrousGraphicMgr = rt.NitrousGraphicsManager - viewport_setting = nitrousGraphicMgr.GetActiveViewportSetting() - vp_button_mgr_original = { - key: getattr(rt.ViewportButtonMgr, key) for key in vp_button_mgr - } - nitrous_manager_original = { - key: getattr(nitrousGraphicMgr, key) for key in nitrous_manager - } - nitrous_viewport_original = { - key: getattr(viewport_setting, key) for key in nitrous_viewport - } - - try: - rt.viewport.setGridVisibility(1, general_viewport["dspGrid"]) - rt.viewport.EnableSolidBackgroundColorMode(general_viewport["dspBkg"]) - for key, value in vp_button_mgr.items(): - setattr(rt.ViewportButtonMgr, key, value) - for key, value in nitrous_manager.items(): - setattr(nitrousGraphicMgr, key, value) - for key, value in nitrous_viewport.items(): - if nitrous_viewport[key] != nitrous_viewport_original[key]: - setattr(viewport_setting, key, value) - yield - - finally: - rt.viewport.setGridVisibility(1, orig_vp_grid) - rt.viewport.EnableSolidBackgroundColorMode(orig_vp_bkg) - for key, value in vp_button_mgr_original.items(): - setattr(rt.ViewportButtonMgr, key, value) - for key, value in nitrous_manager_original.items(): - setattr(nitrousGraphicMgr, key, value) - for key, value in nitrous_viewport_original.items(): - setattr(viewport_setting, key, value) - - -def _render_preview_animation_max_2024( - filepath, start, end, percentSize, ext, viewport_options): - """Render viewport preview with MaxScript using `CreateAnimation`. - ****For 3dsMax 2024+ - Args: - filepath (str): filepath for render output without frame number and - extension, for example: /path/to/file - start (int): startFrame - end (int): endFrame - percentSize (float): render resolution multiplier by 100 - e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x - viewport_options (dict): viewport setting options, e.g. - {"vpStyle": "defaultshading", "vpPreset": "highquality"} - Returns: - list: Created files - """ - # the percentSize argument must be integer - percent = int(percentSize) - filepath = filepath.replace("\\", "/") - preview_output = f"{filepath}..{ext}" - frame_template = f"{filepath}.{{:04d}}.{ext}" - job_args = [] - for key, value in viewport_options.items(): - if isinstance(value, bool): - if value: - job_args.append(f"{key}:{value}") - elif isinstance(value, str): - if key == "vpStyle": - if value == "Realistic": - value = "defaultshading" - elif value == "Shaded": - log.warning( - "'Shaded' Mode not supported in " - "preview animation in Max 2024.\n" - "Using 'defaultshading' instead.") - value = "defaultshading" - elif value == "ConsistentColors": - value = "flatcolor" - else: - value = value.lower() - elif key == "vpPreset": - if value == "Quality": - value = "highquality" - elif value == "Customize": - value = "userdefined" - else: - value = value.lower() - job_args.append(f"{key}: #{value}") - - job_str = ( - f'CreatePreview filename:"{preview_output}" outputAVI:false ' - f"percentSize:{percent} start:{start} end:{end} " - f"{' '.join(job_args)} " - "autoPlay:false" - ) - rt.completeRedraw() - rt.execute(job_str) - # Return the created files - return [frame_template.format(frame) for frame in range(start, end + 1)] - - -def _render_preview_animation_max_pre_2024( - filepath, startFrame, endFrame, - width, height, percentSize, ext): - """Render viewport animation by creating bitmaps - ***For 3dsMax Version <2024 - Args: - filepath (str): filepath without frame numbers and extension - startFrame (int): start frame - endFrame (int): end frame - width (int): render resolution width - height (int): render resolution height - percentSize (float): render resolution multiplier by 100 - e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x - ext (str): image extension - Returns: - list: Created filepaths - """ - - # get the screenshot - percent = percentSize / 100.0 - res_width = width * percent - res_height = height * percent - frame_template = "{}.{{:04}}.{}".format(filepath, ext) - frame_template.replace("\\", "/") - files = [] - user_cancelled = False - for frame in range(startFrame, endFrame + 1): - rt.sliderTime = frame - filepath = frame_template.format(frame) - preview_res = rt.bitmap( - res_width, res_height, filename=filepath - ) - dib = rt.gw.getViewportDib() - dib_width = float(dib.width) - dib_height = float(dib.height) - # aspect ratio - viewportRatio = dib_width / dib_height - renderRatio = float(res_width / res_height) - if viewportRatio < renderRatio: - heightCrop = (dib_width / renderRatio) - topEdge = int((dib_height - heightCrop) / 2.0) - tempImage_bmp = rt.bitmap(dib_width, heightCrop) - src_box_value = rt.Box2(0, topEdge, dib_width, heightCrop) - rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) - rt.copy(tempImage_bmp, preview_res) - rt.close(tempImage_bmp) - elif viewportRatio > renderRatio: - widthCrop = dib_height * renderRatio - leftEdge = int((dib_width - widthCrop) / 2.0) - tempImage_bmp = rt.bitmap(widthCrop, dib_height) - src_box_value = rt.Box2(leftEdge, 0, widthCrop, dib_height) - rt.pasteBitmap(dib, tempImage_bmp, src_box_value, rt.Point2(0, 0)) - rt.copy(tempImage_bmp, preview_res) - rt.close(tempImage_bmp) - else: - rt.copy(dib, preview_res) - rt.save(preview_res) - rt.close(preview_res) - rt.close(dib) - files.append(filepath) - if rt.keyboard.escPressed: - user_cancelled = True - break - # clean up the cache - rt.gc(delayed=True) - if user_cancelled: - raise RuntimeError("User cancelled rendering of viewport animation.") - return files - - -def render_preview_animation( - filepath, - ext, - camera, - start_frame=None, - end_frame=None, - percentSize=100.0, - width=1920, - height=1080, - viewport_options=None): - """Render camera review animation - Args: - filepath (str): filepath to render to, without frame number and - extension - ext (str): output file extension - camera (str): viewport camera for preview render - start_frame (int): start frame - end_frame (int): end frame - percentSize (float): render resolution multiplier by 100 - e.g. 100.0 is 1x, 50.0 is 0.5x, 150.0 is 1.5x - width (int): render resolution width - height (int): render resolution height - viewport_options (dict): viewport setting options - Returns: - list: Rendered output files - """ - if start_frame is None: - start_frame = int(rt.animationRange.start) - if end_frame is None: - end_frame = int(rt.animationRange.end) - - if viewport_options is None: - viewport_options = viewport_options_for_preview_animation() - with play_preview_when_done(False): - with viewport_layout_and_camera(camera): - if int(get_max_version()) < 2024: - with viewport_preference_setting( - viewport_options["general_viewport"], - viewport_options["nitrous_manager"], - viewport_options["nitrous_viewport"], - viewport_options["vp_btn_mgr"] - ): - return _render_preview_animation_max_pre_2024( - filepath, - start_frame, - end_frame, - width, - height, - percentSize, - ext - ) - else: - with render_resolution(width, height): - return _render_preview_animation_max_2024( - filepath, - start_frame, - end_frame, - percentSize, - ext, - viewport_options - ) - - -def viewport_options_for_preview_animation(): - """Get default viewport options for `render_preview_animation`. - - Returns: - dict: viewport setting options - """ - # viewport_options should be the dictionary - if int(get_max_version()) < 2024: - return { - "visualStyleMode": "defaultshading", - "viewportPreset": "highquality", - "vpTexture": False, - "dspGeometry": True, - "dspShapes": False, - "dspLights": False, - "dspCameras": False, - "dspHelpers": False, - "dspParticles": True, - "dspBones": False, - "dspBkg": True, - "dspGrid": False, - "dspSafeFrame": False, - "dspFrameNums": False - } - else: - viewport_options = {} - viewport_options["general_viewport"] = { - "dspBkg": True, - "dspGrid": False - } - viewport_options["nitrous_manager"] = { - "AntialiasingQuality": "None" - } - viewport_options["nitrous_viewport"] = { - "VisualStyleMode": "defaultshading", - "ViewportPreset": "highquality", - "UseTextureEnabled": False - } - viewport_options["vp_btn_mgr"] = { - "EnableButtons": False} - return viewport_options diff --git a/server_addon/max/client/ayon_max/hooks/force_startup_script.py b/server_addon/max/client/ayon_max/hooks/force_startup_script.py deleted file mode 100644 index 1699ea300a..0000000000 --- a/server_addon/max/client/ayon_max/hooks/force_startup_script.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pre-launch to force 3ds max startup script.""" -import os -from ayon_max import MAX_HOST_DIR -from ayon_applications import PreLaunchHook, LaunchTypes - - -class ForceStartupScript(PreLaunchHook): - """Inject AYON environment to 3ds max. - - Note that this works in combination whit 3dsmax startup script that - is translating it back to PYTHONPATH for cases when 3dsmax drops PYTHONPATH - environment. - - Hook `GlobalHostDataHook` must be executed before this hook. - """ - app_groups = {"3dsmax", "adsk_3dsmax"} - order = 11 - launch_types = {LaunchTypes.local} - - def execute(self): - startup_args = [ - "-U", - "MAXScript", - os.path.join(MAX_HOST_DIR, "startup", "startup.ms"), - ] - self.launch_context.launch_args.append(startup_args) diff --git a/server_addon/max/client/ayon_max/hooks/inject_python.py b/server_addon/max/client/ayon_max/hooks/inject_python.py deleted file mode 100644 index fc9626ab87..0000000000 --- a/server_addon/max/client/ayon_max/hooks/inject_python.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pre-launch hook to inject python environment.""" -import os -from ayon_applications import PreLaunchHook, LaunchTypes - - -class InjectPythonPath(PreLaunchHook): - """Inject AYON environment to 3dsmax. - - Note that this works in combination whit 3dsmax startup script that - is translating it back to PYTHONPATH for cases when 3dsmax drops PYTHONPATH - environment. - - Hook `GlobalHostDataHook` must be executed before this hook. - """ - app_groups = {"3dsmax", "adsk_3dsmax"} - launch_types = {LaunchTypes.local} - - def execute(self): - self.launch_context.env["MAX_PYTHONPATH"] = os.environ["PYTHONPATH"] diff --git a/server_addon/max/client/ayon_max/hooks/set_paths.py b/server_addon/max/client/ayon_max/hooks/set_paths.py deleted file mode 100644 index f066de092e..0000000000 --- a/server_addon/max/client/ayon_max/hooks/set_paths.py +++ /dev/null @@ -1,18 +0,0 @@ -from ayon_applications import PreLaunchHook, LaunchTypes - - -class SetPath(PreLaunchHook): - """Set current dir to workdir. - - Hook `GlobalHostDataHook` must be executed before this hook. - """ - app_groups = {"max"} - launch_types = {LaunchTypes.local} - - def execute(self): - workdir = self.launch_context.env.get("AYON_WORKDIR", "") - if not workdir: - self.log.warning("BUG: Workdir is not filled.") - return - - self.launch_context.kwargs["cwd"] = workdir diff --git a/server_addon/max/client/ayon_max/plugins/__init__.py b/server_addon/max/client/ayon_max/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/server_addon/max/client/ayon_max/plugins/create/create_camera.py b/server_addon/max/client/ayon_max/plugins/create/create_camera.py deleted file mode 100644 index 451e178afc..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_camera.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating camera.""" -from ayon_max.api import plugin - - -class CreateCamera(plugin.MaxCreator): - """Creator plugin for Camera.""" - identifier = "io.openpype.creators.max.camera" - label = "Camera" - product_type = "camera" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/create/create_maxScene.py b/server_addon/max/client/ayon_max/plugins/create/create_maxScene.py deleted file mode 100644 index ee58ef663d..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_maxScene.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating raw max scene.""" -from ayon_max.api import plugin - - -class CreateMaxScene(plugin.MaxCreator): - """Creator plugin for 3ds max scenes.""" - identifier = "io.openpype.creators.max.maxScene" - label = "Max Scene" - product_type = "maxScene" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/create/create_model.py b/server_addon/max/client/ayon_max/plugins/create/create_model.py deleted file mode 100644 index f48182ecd7..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_model.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for model.""" -from ayon_max.api import plugin - - -class CreateModel(plugin.MaxCreator): - """Creator plugin for Model.""" - identifier = "io.openpype.creators.max.model" - label = "Model" - product_type = "model" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/create/create_pointcache.py b/server_addon/max/client/ayon_max/plugins/create/create_pointcache.py deleted file mode 100644 index 6d7aabe12c..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_pointcache.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating pointcache alembics.""" -from ayon_max.api import plugin - - -class CreatePointCache(plugin.MaxCreator): - """Creator plugin for Point caches.""" - identifier = "io.openpype.creators.max.pointcache" - label = "Point Cache" - product_type = "pointcache" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/create/create_pointcloud.py b/server_addon/max/client/ayon_max/plugins/create/create_pointcloud.py deleted file mode 100644 index 52014d77b2..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_pointcloud.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating point cloud.""" -from ayon_max.api import plugin - - -class CreatePointCloud(plugin.MaxCreator): - """Creator plugin for Point Clouds.""" - identifier = "io.openpype.creators.max.pointcloud" - label = "Point Cloud" - product_type = "pointcloud" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/create/create_redshift_proxy.py b/server_addon/max/client/ayon_max/plugins/create/create_redshift_proxy.py deleted file mode 100644 index bcc96c7efe..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_redshift_proxy.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating camera.""" -from ayon_max.api import plugin - - -class CreateRedshiftProxy(plugin.MaxCreator): - identifier = "io.openpype.creators.max.redshiftproxy" - label = "Redshift Proxy" - product_type = "redshiftproxy" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/create/create_render.py b/server_addon/max/client/ayon_max/plugins/create/create_render.py deleted file mode 100644 index d1e236f3ef..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_render.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating camera.""" -import os -from ayon_max.api import plugin -from ayon_core.lib import BoolDef -from ayon_max.api.lib_rendersettings import RenderSettings - - -class CreateRender(plugin.MaxCreator): - """Creator plugin for Renders.""" - identifier = "io.openpype.creators.max.render" - label = "Render" - product_type = "maxrender" - icon = "gear" - - settings_category = "max" - - def create(self, product_name, instance_data, pre_create_data): - from pymxs import runtime as rt - file = rt.maxFileName - filename, _ = os.path.splitext(file) - instance_data["AssetName"] = filename - instance_data["multiCamera"] = pre_create_data.get("multi_cam") - num_of_renderlayer = rt.batchRenderMgr.numViews - if num_of_renderlayer > 0: - rt.batchRenderMgr.DeleteView(num_of_renderlayer) - - instance = super(CreateRender, self).create( - product_name, - instance_data, - pre_create_data) - - container_name = instance.data.get("instance_node") - # set output paths for rendering(mandatory for deadline) - RenderSettings().render_output(container_name) - # TODO: create multiple camera options - if self.selected_nodes: - selected_nodes_name = [] - for sel in self.selected_nodes: - name = sel.name - selected_nodes_name.append(name) - RenderSettings().batch_render_layer( - container_name, filename, - selected_nodes_name) - - def get_pre_create_attr_defs(self): - attrs = super(CreateRender, self).get_pre_create_attr_defs() - return attrs + [ - BoolDef("multi_cam", - label="Multiple Cameras Submission", - default=False), - ] diff --git a/server_addon/max/client/ayon_max/plugins/create/create_review.py b/server_addon/max/client/ayon_max/plugins/create/create_review.py deleted file mode 100644 index a49490519a..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_review.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating review in Max.""" -from ayon_max.api import plugin -from ayon_core.lib import BoolDef, EnumDef, NumberDef - - -class CreateReview(plugin.MaxCreator): - """Review in 3dsMax""" - - identifier = "io.openpype.creators.max.review" - label = "Review" - product_type = "review" - icon = "video-camera" - - settings_category = "max" - - review_width = 1920 - review_height = 1080 - percentSize = 100 - keep_images = False - image_format = "png" - visual_style = "Realistic" - viewport_preset = "Quality" - vp_texture = True - anti_aliasing = "None" - - def apply_settings(self, project_settings): - settings = project_settings["max"]["CreateReview"] # noqa - - # Take some defaults from settings - self.review_width = settings.get("review_width", self.review_width) - self.review_height = settings.get("review_height", self.review_height) - self.percentSize = settings.get("percentSize", self.percentSize) - self.keep_images = settings.get("keep_images", self.keep_images) - self.image_format = settings.get("image_format", self.image_format) - self.visual_style = settings.get("visual_style", self.visual_style) - self.viewport_preset = settings.get( - "viewport_preset", self.viewport_preset) - self.anti_aliasing = settings.get( - "anti_aliasing", self.anti_aliasing) - self.vp_texture = settings.get("vp_texture", self.vp_texture) - - def create(self, product_name, instance_data, pre_create_data): - # Transfer settings from pre create to instance - creator_attributes = instance_data.setdefault( - "creator_attributes", dict()) - for key in ["imageFormat", - "keepImages", - "review_width", - "review_height", - "percentSize", - "visualStyleMode", - "viewportPreset", - "antialiasingQuality", - "vpTexture"]: - if key in pre_create_data: - creator_attributes[key] = pre_create_data[key] - - super(CreateReview, self).create( - product_name, - instance_data, - pre_create_data) - - def get_instance_attr_defs(self): - image_format_enum = ["exr", "jpg", "png", "tga"] - - visual_style_preset_enum = [ - "Realistic", "Shaded", "Facets", - "ConsistentColors", "HiddenLine", - "Wireframe", "BoundingBox", "Ink", - "ColorInk", "Acrylic", "Tech", "Graphite", - "ColorPencil", "Pastel", "Clay", "ModelAssist" - ] - preview_preset_enum = [ - "Quality", "Standard", "Performance", - "DXMode", "Customize"] - anti_aliasing_enum = ["None", "2X", "4X", "8X"] - - return [ - NumberDef("review_width", - label="Review width", - decimals=0, - minimum=0, - default=self.review_width), - NumberDef("review_height", - label="Review height", - decimals=0, - minimum=0, - default=self.review_height), - NumberDef("percentSize", - label="Percent of Output", - default=self.percentSize, - minimum=1, - decimals=0), - BoolDef("keepImages", - label="Keep Image Sequences", - default=self.keep_images), - EnumDef("imageFormat", - image_format_enum, - default=self.image_format, - label="Image Format Options"), - EnumDef("visualStyleMode", - visual_style_preset_enum, - default=self.visual_style, - label="Preference"), - EnumDef("viewportPreset", - preview_preset_enum, - default=self.viewport_preset, - label="Preview Preset"), - EnumDef("antialiasingQuality", - anti_aliasing_enum, - default=self.anti_aliasing, - label="Anti-aliasing Quality"), - BoolDef("vpTexture", - label="Viewport Texture", - default=self.vp_texture) - ] - - def get_pre_create_attr_defs(self): - # Use same attributes as for instance attributes - attrs = super().get_pre_create_attr_defs() - return attrs + self.get_instance_attr_defs() diff --git a/server_addon/max/client/ayon_max/plugins/create/create_workfile.py b/server_addon/max/client/ayon_max/plugins/create/create_workfile.py deleted file mode 100644 index 35c41f0fcc..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_workfile.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating workfiles.""" -import ayon_api - -from ayon_core.pipeline import CreatedInstance, AutoCreator -from ayon_max.api import plugin -from ayon_max.api.lib import read, imprint -from pymxs import runtime as rt - - -class CreateWorkfile(plugin.MaxCreatorBase, AutoCreator): - """Workfile auto-creator.""" - identifier = "io.ayon.creators.max.workfile" - label = "Workfile" - product_type = "workfile" - icon = "fa5.file" - - default_variant = "Main" - - settings_category = "max" - - def create(self): - variant = self.default_variant - current_instance = next( - ( - instance for instance in self.create_context.instances - if instance.creator_identifier == self.identifier - ), None) - project_name = self.project_name - folder_path = self.create_context.get_current_folder_path() - task_name = self.create_context.get_current_task_name() - host_name = self.create_context.host_name - - if current_instance is None: - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path - ) - task_entity = ayon_api.get_task_by_name( - project_name, folder_entity["id"], task_name - ) - product_name = self.get_product_name( - project_name, - folder_entity, - task_entity, - variant, - host_name, - ) - data = { - "folderPath": folder_path, - "task": task_name, - "variant": variant - } - - data.update( - self.get_dynamic_data( - project_name, - folder_entity, - task_entity, - variant, - host_name, - current_instance) - ) - self.log.info("Auto-creating workfile instance...") - instance_node = self.create_node(product_name) - data["instance_node"] = instance_node.name - current_instance = CreatedInstance( - self.product_type, product_name, data, self - ) - self._add_instance_to_context(current_instance) - imprint(instance_node.name, current_instance.data) - elif ( - current_instance["folderPath"] != folder_path - or current_instance["task"] != task_name - ): - # Update instance context if is not the same - folder_entity = ayon_api.get_folder_by_path( - project_name, folder_path - ) - task_entity = ayon_api.get_task_by_name( - project_name, folder_entity["id"], task_name - ) - product_name = self.get_product_name( - project_name, - folder_entity, - task_entity, - variant, - host_name, - ) - - current_instance["folderPath"] = folder_entity["path"] - current_instance["task"] = task_name - current_instance["productName"] = product_name - - def collect_instances(self): - self.cache_instance_data(self.collection_shared_data) - cached_instances = self.collection_shared_data["max_cached_instances"] - for instance in cached_instances.get(self.identifier, []): - if not rt.getNodeByName(instance): - continue - created_instance = CreatedInstance.from_existing( - read(rt.GetNodeByName(instance)), self - ) - self._add_instance_to_context(created_instance) - - def update_instances(self, update_list): - for created_inst, _ in update_list: - instance_node = created_inst.get("instance_node") - imprint( - instance_node, - created_inst.data_to_store() - ) - - def create_node(self, product_name): - if rt.getNodeByName(product_name): - node = rt.getNodeByName(product_name) - return node - node = rt.Container(name=product_name) - node.isHidden = True - return node diff --git a/server_addon/max/client/ayon_max/plugins/load/load_camera_fbx.py b/server_addon/max/client/ayon_max/plugins/load/load_camera_fbx.py deleted file mode 100644 index 81ea15d52a..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_camera_fbx.py +++ /dev/null @@ -1,101 +0,0 @@ -import os - -from ayon_max.api import lib -from ayon_max.api.lib import ( - unique_namespace, - get_namespace, - object_transform_set -) -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_core.pipeline import get_representation_path, load - - -class FbxLoader(load.LoaderPlugin): - """Fbx Loader.""" - - product_types = {"camera"} - representations = {"fbx"} - order = -9 - icon = "code-fork" - color = "white" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - filepath = self.filepath_from_context(context) - filepath = os.path.normpath(filepath) - rt.FBXImporterSetParam("Animation", True) - rt.FBXImporterSetParam("Camera", True) - rt.FBXImporterSetParam("AxisConversionMethod", True) - rt.FBXImporterSetParam("Mode", rt.Name("create")) - rt.FBXImporterSetParam("Preserveinstances", True) - rt.ImportFile( - filepath, - rt.name("noPrompt"), - using=rt.FBXIMP) - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - selections = rt.GetCurrentSelection() - - for selection in selections: - selection.name = f"{namespace}:{selection.name}" - - return containerise( - name, selections, context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node_name = container["instance_node"] - node = rt.getNodeByName(node_name) - namespace, _ = get_namespace(node_name) - - node_list = get_previous_loaded_object(node) - rt.Select(node_list) - prev_fbx_objects = rt.GetCurrentSelection() - transform_data = object_transform_set(prev_fbx_objects) - for prev_fbx_obj in prev_fbx_objects: - if rt.isValidNode(prev_fbx_obj): - rt.Delete(prev_fbx_obj) - - rt.FBXImporterSetParam("Animation", True) - rt.FBXImporterSetParam("Camera", True) - rt.FBXImporterSetParam("Mode", rt.Name("merge")) - rt.FBXImporterSetParam("AxisConversionMethod", True) - rt.FBXImporterSetParam("Preserveinstances", True) - rt.ImportFile( - path, rt.name("noPrompt"), using=rt.FBXIMP) - current_fbx_objects = rt.GetCurrentSelection() - fbx_objects = [] - for fbx_object in current_fbx_objects: - fbx_object.name = f"{namespace}:{fbx_object.name}" - fbx_objects.append(fbx_object) - fbx_transform = f"{fbx_object.name}.transform" - if fbx_transform in transform_data.keys(): - fbx_object.pos = transform_data[fbx_transform] or 0 - fbx_object.scale = transform_data[ - f"{fbx_object.name}.scale"] or 0 - - update_custom_attribute_data(node, fbx_objects) - lib.imprint(container["instance_node"], { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_max_scene.py b/server_addon/max/client/ayon_max/plugins/load/load_max_scene.py deleted file mode 100644 index 7fca69b193..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_max_scene.py +++ /dev/null @@ -1,178 +0,0 @@ -import os -from qtpy import QtWidgets, QtCore -from ayon_core.lib.attribute_definitions import EnumDef -from ayon_max.api import lib -from ayon_max.api.lib import ( - unique_namespace, - get_namespace, - object_transform_set, - is_headless -) -from ayon_max.api.pipeline import ( - containerise, get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_core.pipeline import get_representation_path, load - - -class MaterialDupOptionsWindow(QtWidgets.QDialog): - """The pop-up dialog allows users to choose material - duplicate options for importing Max objects when updating - or switching assets. - """ - def __init__(self, material_options): - super(MaterialDupOptionsWindow, self).__init__() - self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) - - self.material_option = None - self.material_options = material_options - - self.widgets = { - "label": QtWidgets.QLabel( - "Select material duplicate options before loading the max scene."), - "material_options_list": QtWidgets.QListWidget(), - "warning": QtWidgets.QLabel("No material options selected!"), - "buttons": QtWidgets.QWidget(), - "okButton": QtWidgets.QPushButton("Ok"), - "cancelButton": QtWidgets.QPushButton("Cancel") - } - for key, value in material_options.items(): - item = QtWidgets.QListWidgetItem(value) - self.widgets["material_options_list"].addItem(item) - item.setData(QtCore.Qt.UserRole, key) - # Build buttons. - layout = QtWidgets.QHBoxLayout(self.widgets["buttons"]) - layout.addWidget(self.widgets["okButton"]) - layout.addWidget(self.widgets["cancelButton"]) - # Build layout. - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(self.widgets["label"]) - layout.addWidget(self.widgets["material_options_list"]) - layout.addWidget(self.widgets["buttons"]) - - self.widgets["okButton"].pressed.connect(self.on_ok_pressed) - self.widgets["cancelButton"].pressed.connect(self.on_cancel_pressed) - self.widgets["material_options_list"].itemPressed.connect( - self.on_material_options_pressed) - - def on_material_options_pressed(self, item): - self.material_option = item.data(QtCore.Qt.UserRole) - - def on_ok_pressed(self): - if self.material_option is None: - self.widgets["warning"].setVisible(True) - return - self.close() - - def on_cancel_pressed(self): - self.material_option = "promptMtlDups" - self.close() - -class MaxSceneLoader(load.LoaderPlugin): - """Max Scene Loader.""" - - product_types = { - "camera", - "maxScene", - "model", - } - - representations = {"max"} - order = -8 - icon = "code-fork" - color = "green" - mtl_dup_default = "promptMtlDups" - mtl_dup_enum_dict = { - "promptMtlDups": "Prompt on Duplicate Materials", - "useMergedMtlDups": "Use Incoming Material", - "useSceneMtlDups": "Use Scene Material", - "renameMtlDups": "Merge and Rename Incoming Material" - } - @classmethod - def get_options(cls, contexts): - return [ - EnumDef("mtldup", - items=cls.mtl_dup_enum_dict, - default=cls.mtl_dup_default, - label="Material Duplicate Options") - ] - - def load(self, context, name=None, namespace=None, options=None): - from pymxs import runtime as rt - mat_dup_options = options.get("mtldup", self.mtl_dup_default) - path = self.filepath_from_context(context) - path = os.path.normpath(path) - # import the max scene by using "merge file" - path = path.replace('\\', '/') - rt.MergeMaxFile(path, rt.Name(mat_dup_options), - quiet=True, includeFullGroup=True) - max_objects = rt.getLastMergedNodes() - max_object_names = [obj.name for obj in max_objects] - # implement the OP/AYON custom attributes before load - max_container = [] - namespace = unique_namespace( - name + "_", - suffix="_", - ) - for max_obj, obj_name in zip(max_objects, max_object_names): - max_obj.name = f"{namespace}:{obj_name}" - max_container.append(max_obj) - return containerise( - name, max_container, context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node_name = container["instance_node"] - node = rt.getNodeByName(node_name) - namespace, _ = get_namespace(node_name) - # delete the old container with attribute - # delete old duplicate - # use the modifier OP data to delete the data - node_list = get_previous_loaded_object(node) - rt.select(node_list) - prev_max_objects = rt.GetCurrentSelection() - transform_data = object_transform_set(prev_max_objects) - - for prev_max_obj in prev_max_objects: - if rt.isValidNode(prev_max_obj): # noqa - rt.Delete(prev_max_obj) - material_option = self.mtl_dup_default - if not is_headless(): - window = MaterialDupOptionsWindow(self.mtl_dup_enum_dict) - window.exec_() - material_option = window.material_option - rt.MergeMaxFile(path, rt.Name(material_option), quiet=True) - - current_max_objects = rt.getLastMergedNodes() - - current_max_object_names = [obj.name for obj - in current_max_objects] - - max_objects = [] - for max_obj, obj_name in zip(current_max_objects, - current_max_object_names): - max_obj.name = f"{namespace}:{obj_name}" - max_objects.append(max_obj) - max_transform = f"{max_obj}.transform" - if max_transform in transform_data.keys(): - max_obj.pos = transform_data[max_transform] or 0 - max_obj.scale = transform_data[ - f"{max_obj}.scale"] or 0 - - update_custom_attribute_data(node, max_objects) - lib.imprint(container["instance_node"], { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_model.py b/server_addon/max/client/ayon_max/plugins/load/load_model.py deleted file mode 100644 index 2a6bc45c18..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_model.py +++ /dev/null @@ -1,123 +0,0 @@ -import os -from ayon_core.pipeline import load, get_representation_path -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - remove_container_data -) -from ayon_max.api import lib -from ayon_max.api.lib import ( - maintained_selection, unique_namespace -) - - -class ModelAbcLoader(load.LoaderPlugin): - """Loading model with the Alembic loader.""" - - product_types = {"model"} - label = "Load Model with Alembic" - representations = {"abc"} - order = -10 - icon = "code-fork" - color = "orange" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - - file_path = os.path.normpath(self.filepath_from_context(context)) - - abc_before = { - c - for c in rt.rootNode.Children - if rt.classOf(c) == rt.AlembicContainer - } - - rt.AlembicImport.ImportToRoot = False - rt.AlembicImport.CustomAttributes = True - rt.AlembicImport.UVs = True - rt.AlembicImport.VertexColors = True - rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) - - abc_after = { - c - for c in rt.rootNode.Children - if rt.classOf(c) == rt.AlembicContainer - } - - # This should yield new AlembicContainer node - abc_containers = abc_after.difference(abc_before) - - if len(abc_containers) != 1: - self.log.error("Something failed when loading.") - - abc_container = abc_containers.pop() - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - abc_objects = [] - for abc_object in abc_container.Children: - abc_object.name = f"{namespace}:{abc_object.name}" - abc_objects.append(abc_object) - # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}" - abc_container.name = abc_container_name - abc_objects.append(abc_container) - - return containerise( - name, abc_objects, context, - namespace, loader=self.__class__.__name__ - ) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node = rt.GetNodeByName(container["instance_node"]) - node_list = [n for n in get_previous_loaded_object(node) - if rt.ClassOf(n) == rt.AlembicContainer] - with maintained_selection(): - rt.Select(node_list) - - for alembic in rt.Selection: - abc = rt.GetNodeByName(alembic.name) - rt.Select(abc.Children) - for abc_con in abc.Children: - abc_con.source = path - rt.Select(abc_con.Children) - for abc_obj in abc_con.Children: - abc_obj.source = path - lib.imprint( - container["instance_node"], - {"representation": repre_entity["id"]}, - ) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) - - - @staticmethod - def get_container_children(parent, type_name): - from pymxs import runtime as rt - - def list_children(node): - children = [] - for c in node.Children: - children.append(c) - children += list_children(c) - return children - - filtered = [] - for child in list_children(parent): - class_type = str(rt.ClassOf(child.baseObject)) - if class_type == type_name: - filtered.append(child) - - return filtered diff --git a/server_addon/max/client/ayon_max/plugins/load/load_model_fbx.py b/server_addon/max/client/ayon_max/plugins/load/load_model_fbx.py deleted file mode 100644 index 2775e1b453..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_model_fbx.py +++ /dev/null @@ -1,98 +0,0 @@ -import os -from ayon_core.pipeline import load, get_representation_path -from ayon_max.api.pipeline import ( - containerise, get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_max.api import lib -from ayon_max.api.lib import ( - unique_namespace, - get_namespace, - object_transform_set -) -from ayon_max.api.lib import maintained_selection - - -class FbxModelLoader(load.LoaderPlugin): - """Fbx Model Loader.""" - - product_types = {"model"} - representations = {"fbx"} - order = -9 - icon = "code-fork" - color = "white" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - filepath = self.filepath_from_context(context) - filepath = os.path.normpath(filepath) - rt.FBXImporterSetParam("Animation", False) - rt.FBXImporterSetParam("Cameras", False) - rt.FBXImporterSetParam("Mode", rt.Name("create")) - rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile( - filepath, rt.name("noPrompt"), using=rt.FBXIMP) - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - selections = rt.GetCurrentSelection() - - for selection in selections: - selection.name = f"{namespace}:{selection.name}" - - return containerise( - name, selections, context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node_name = container["instance_node"] - node = rt.getNodeByName(node_name) - if not node: - rt.Container(name=node_name) - namespace, _ = get_namespace(node_name) - - node_list = get_previous_loaded_object(node) - rt.Select(node_list) - prev_fbx_objects = rt.GetCurrentSelection() - transform_data = object_transform_set(prev_fbx_objects) - for prev_fbx_obj in prev_fbx_objects: - if rt.isValidNode(prev_fbx_obj): - rt.Delete(prev_fbx_obj) - - rt.FBXImporterSetParam("Animation", False) - rt.FBXImporterSetParam("Cameras", False) - rt.FBXImporterSetParam("Mode", rt.Name("create")) - rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) - current_fbx_objects = rt.GetCurrentSelection() - fbx_objects = [] - for fbx_object in current_fbx_objects: - fbx_object.name = f"{namespace}:{fbx_object.name}" - fbx_objects.append(fbx_object) - fbx_transform = f"{fbx_object}.transform" - if fbx_transform in transform_data.keys(): - fbx_object.pos = transform_data[fbx_transform] or 0 - fbx_object.scale = transform_data[ - f"{fbx_object}.scale"] or 0 - - with maintained_selection(): - rt.Select(node) - update_custom_attribute_data(node, fbx_objects) - lib.imprint(container["instance_node"], { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_model_obj.py b/server_addon/max/client/ayon_max/plugins/load/load_model_obj.py deleted file mode 100644 index d38aadb5bc..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_model_obj.py +++ /dev/null @@ -1,89 +0,0 @@ -import os - -from ayon_max.api import lib -from ayon_max.api.lib import ( - unique_namespace, - get_namespace, - maintained_selection, - object_transform_set -) -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_core.pipeline import get_representation_path, load - - -class ObjLoader(load.LoaderPlugin): - """Obj Loader.""" - - product_types = {"model"} - representations = {"obj"} - order = -9 - icon = "code-fork" - color = "white" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - - filepath = os.path.normpath(self.filepath_from_context(context)) - self.log.debug("Executing command to import..") - - rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - # create "missing" container for obj import - selections = rt.GetCurrentSelection() - # get current selection - for selection in selections: - selection.name = f"{namespace}:{selection.name}" - return containerise( - name, selections, context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node_name = container["instance_node"] - node = rt.getNodeByName(node_name) - namespace, _ = get_namespace(node_name) - node_list = get_previous_loaded_object(node) - rt.Select(node_list) - previous_objects = rt.GetCurrentSelection() - transform_data = object_transform_set(previous_objects) - for prev_obj in previous_objects: - if rt.isValidNode(prev_obj): - rt.Delete(prev_obj) - - rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') - # get current selection - selections = rt.GetCurrentSelection() - for selection in selections: - selection.name = f"{namespace}:{selection.name}" - selection_transform = f"{selection}.transform" - if selection_transform in transform_data.keys(): - selection.pos = transform_data[selection_transform] or 0 - selection.scale = transform_data[ - f"{selection}.scale"] or 0 - update_custom_attribute_data(node, selections) - with maintained_selection(): - rt.Select(node) - - lib.imprint(node_name, { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_model_usd.py b/server_addon/max/client/ayon_max/plugins/load/load_model_usd.py deleted file mode 100644 index f4dd41d5db..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_model_usd.py +++ /dev/null @@ -1,120 +0,0 @@ -import os - -from pymxs import runtime as rt -from ayon_core.pipeline.load import LoadError -from ayon_max.api import lib -from ayon_max.api.lib import ( - unique_namespace, - get_namespace, - object_transform_set, - get_plugins -) -from ayon_max.api.lib import maintained_selection -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_core.pipeline import get_representation_path, load - - -class ModelUSDLoader(load.LoaderPlugin): - """Loading model with the USD loader.""" - - product_types = {"model"} - label = "Load Model(USD)" - representations = {"usda"} - order = -10 - icon = "code-fork" - color = "orange" - - def load(self, context, name=None, namespace=None, data=None): - # asset_filepath - plugin_info = get_plugins() - if "usdimport.dli" not in plugin_info: - raise LoadError("No USDImporter loaded/installed in Max..") - filepath = os.path.normpath(self.filepath_from_context(context)) - import_options = rt.USDImporter.CreateOptions() - base_filename = os.path.basename(filepath) - _, ext = os.path.splitext(base_filename) - log_filepath = filepath.replace(ext, "txt") - - rt.LogPath = log_filepath - rt.LogLevel = rt.Name("info") - rt.USDImporter.importFile(filepath, - importOptions=import_options) - namespace = unique_namespace( - name + "_", - suffix="_", - ) - asset = rt.GetNodeByName(name) - usd_objects = [] - - for usd_asset in asset.Children: - usd_asset.name = f"{namespace}:{usd_asset.name}" - usd_objects.append(usd_asset) - - asset_name = f"{namespace}:{name}" - asset.name = asset_name - # need to get the correct container after renamed - asset = rt.GetNodeByName(asset_name) - usd_objects.append(asset) - - return containerise( - name, usd_objects, context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node_name = container["instance_node"] - node = rt.GetNodeByName(node_name) - namespace, name = get_namespace(node_name) - node_list = get_previous_loaded_object(node) - rt.Select(node_list) - prev_objects = [sel for sel in rt.GetCurrentSelection() - if sel != rt.Container - and sel.name != node_name] - transform_data = object_transform_set(prev_objects) - for n in prev_objects: - rt.Delete(n) - - import_options = rt.USDImporter.CreateOptions() - base_filename = os.path.basename(path) - _, ext = os.path.splitext(base_filename) - log_filepath = path.replace(ext, "txt") - - rt.LogPath = log_filepath - rt.LogLevel = rt.Name("info") - rt.USDImporter.importFile( - path, importOptions=import_options) - - asset = rt.GetNodeByName(name) - usd_objects = [] - for children in asset.Children: - children.name = f"{namespace}:{children.name}" - usd_objects.append(children) - children_transform = f"{children}.transform" - if children_transform in transform_data.keys(): - children.pos = transform_data[children_transform] or 0 - children.scale = transform_data[ - f"{children}.scale"] or 0 - - asset.name = f"{namespace}:{asset.name}" - usd_objects.append(asset) - update_custom_attribute_data(node, usd_objects) - with maintained_selection(): - rt.Select(node) - - lib.imprint(node_name, { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_pointcache.py b/server_addon/max/client/ayon_max/plugins/load/load_pointcache.py deleted file mode 100644 index 87ea5c75bc..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_pointcache.py +++ /dev/null @@ -1,132 +0,0 @@ -# -*- coding: utf-8 -*- -"""Simple alembic loader for 3dsmax. - -Because of limited api, alembics can be only loaded, but not easily updated. - -""" -import os -from ayon_core.pipeline import load, get_representation_path -from ayon_max.api import lib, maintained_selection -from ayon_max.api.lib import unique_namespace, reset_frame_range -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - remove_container_data -) - - -class AbcLoader(load.LoaderPlugin): - """Alembic loader.""" - - product_types = {"camera", "animation", "pointcache"} - label = "Load Alembic" - representations = {"abc"} - order = -10 - icon = "code-fork" - color = "orange" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - - file_path = self.filepath_from_context(context) - file_path = os.path.normpath(file_path) - - abc_before = { - c - for c in rt.rootNode.Children - if rt.classOf(c) == rt.AlembicContainer - } - - rt.AlembicImport.ImportToRoot = False - # TODO: it will be removed after the improvement - # on the post-system setup - reset_frame_range() - rt.importFile(file_path, rt.name("noPrompt"), using=rt.AlembicImport) - - abc_after = { - c - for c in rt.rootNode.Children - if rt.classOf(c) == rt.AlembicContainer - } - - # This should yield new AlembicContainer node - abc_containers = abc_after.difference(abc_before) - - if len(abc_containers) != 1: - self.log.error("Something failed when loading.") - - abc_container = abc_containers.pop() - selections = rt.GetCurrentSelection() - for abc in selections: - for cam_shape in abc.Children: - cam_shape.playbackType = 0 - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - abc_objects = [] - for abc_object in abc_container.Children: - abc_object.name = f"{namespace}:{abc_object.name}" - abc_objects.append(abc_object) - # rename the abc container with namespace - abc_container_name = f"{namespace}:{name}" - abc_container.name = abc_container_name - abc_objects.append(abc_container) - - return containerise( - name, abc_objects, context, - namespace, loader=self.__class__.__name__ - ) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node = rt.GetNodeByName(container["instance_node"]) - abc_container = [n for n in get_previous_loaded_object(node) - if rt.ClassOf(n) == rt.AlembicContainer] - with maintained_selection(): - rt.Select(abc_container) - - for alembic in rt.Selection: - abc = rt.GetNodeByName(alembic.name) - rt.Select(abc.Children) - for abc_con in abc.Children: - abc_con.source = path - rt.Select(abc_con.Children) - for abc_obj in abc_con.Children: - abc_obj.source = path - lib.imprint( - container["instance_node"], - {"representation": repre_entity["id"]}, - ) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) - - - @staticmethod - def get_container_children(parent, type_name): - from pymxs import runtime as rt - - def list_children(node): - children = [] - for c in node.Children: - children.append(c) - children += list_children(c) - return children - - filtered = [] - for child in list_children(parent): - class_type = str(rt.classOf(child.baseObject)) - if class_type == type_name: - filtered.append(child) - - return filtered diff --git a/server_addon/max/client/ayon_max/plugins/load/load_pointcache_ornatrix.py b/server_addon/max/client/ayon_max/plugins/load/load_pointcache_ornatrix.py deleted file mode 100644 index bc997951c1..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_pointcache_ornatrix.py +++ /dev/null @@ -1,111 +0,0 @@ -import os -from ayon_core.pipeline import load, get_representation_path -from ayon_core.pipeline.load import LoadError -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) - -from ayon_max.api.lib import ( - unique_namespace, - get_namespace, - object_transform_set, - get_plugins -) -from ayon_max.api import lib -from pymxs import runtime as rt - - -class OxAbcLoader(load.LoaderPlugin): - """Ornatrix Alembic loader.""" - - product_types = {"camera", "animation", "pointcache"} - label = "Load Alembic with Ornatrix" - representations = {"abc"} - order = -10 - icon = "code-fork" - color = "orange" - postfix = "param" - - def load(self, context, name=None, namespace=None, data=None): - plugin_list = get_plugins() - if "ephere.plugins.autodesk.max.ornatrix.dlo" not in plugin_list: - raise LoadError("Ornatrix plugin not " - "found/installed in Max yet..") - - file_path = os.path.normpath(self.filepath_from_context(context)) - rt.AlembicImport.ImportToRoot = True - rt.AlembicImport.CustomAttributes = True - rt.importFile( - file_path, rt.name("noPrompt"), - using=rt.Ornatrix_Alembic_Importer) - - scene_object = [] - for obj in rt.rootNode.Children: - obj_type = rt.ClassOf(obj) - if str(obj_type).startswith("Ox_"): - scene_object.append(obj) - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - abc_container = [] - for abc in scene_object: - abc.name = f"{namespace}:{abc.name}" - abc_container.append(abc) - - return containerise( - name, abc_container, context, - namespace, loader=self.__class__.__name__ - ) - - def update(self, container, context): - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node_name = container["instance_node"] - namespace, name = get_namespace(node_name) - node = rt.getNodeByName(node_name) - node_list = get_previous_loaded_object(node) - rt.Select(node_list) - selections = rt.getCurrentSelection() - transform_data = object_transform_set(selections) - for prev_obj in selections: - if rt.isValidNode(prev_obj): - rt.Delete(prev_obj) - - rt.AlembicImport.ImportToRoot = False - rt.AlembicImport.CustomAttributes = True - rt.importFile( - path, rt.name("noPrompt"), - using=rt.Ornatrix_Alembic_Importer) - - scene_object = [] - for obj in rt.rootNode.Children: - obj_type = rt.ClassOf(obj) - if str(obj_type).startswith("Ox_"): - scene_object.append(obj) - ox_abc_objects = [] - for abc in scene_object: - abc.Parent = container - abc.name = f"{namespace}:{abc.name}" - ox_abc_objects.append(abc) - ox_transform = f"{abc}.transform" - if ox_transform in transform_data.keys(): - abc.pos = transform_data[ox_transform] or 0 - abc.scale = transform_data[f"{abc}.scale"] or 0 - update_custom_attribute_data(node, ox_abc_objects) - lib.imprint( - container["instance_node"], - {"representation": repre_entity["id"]}, - ) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_pointcloud.py b/server_addon/max/client/ayon_max/plugins/load/load_pointcloud.py deleted file mode 100644 index 0fb506d5bd..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_pointcloud.py +++ /dev/null @@ -1,69 +0,0 @@ -import os - -from ayon_max.api import lib, maintained_selection -from ayon_max.api.lib import ( - unique_namespace, - -) -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_core.pipeline import get_representation_path, load - - -class PointCloudLoader(load.LoaderPlugin): - """Point Cloud Loader.""" - - product_types = {"pointcloud"} - representations = {"prt"} - order = -8 - icon = "code-fork" - color = "green" - postfix = "param" - - def load(self, context, name=None, namespace=None, data=None): - """load point cloud by tyCache""" - from pymxs import runtime as rt - filepath = os.path.normpath(self.filepath_from_context(context)) - obj = rt.tyCache() - obj.filename = filepath - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - obj.name = f"{namespace}:{obj.name}" - - return containerise( - name, [obj], context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - """update the container""" - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node = rt.GetNodeByName(container["instance_node"]) - node_list = get_previous_loaded_object(node) - update_custom_attribute_data( - node, node_list) - with maintained_selection(): - rt.Select(node_list) - for prt in rt.Selection: - prt.filename = path - lib.imprint(container["instance_node"], { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - """remove the container""" - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/load/load_redshift_proxy.py b/server_addon/max/client/ayon_max/plugins/load/load_redshift_proxy.py deleted file mode 100644 index 3fd84b7538..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_redshift_proxy.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -import clique - -from ayon_core.pipeline import ( - load, - get_representation_path -) -from ayon_core.pipeline.load import LoadError -from ayon_max.api.pipeline import ( - containerise, - update_custom_attribute_data, - get_previous_loaded_object, - remove_container_data -) -from ayon_max.api import lib -from ayon_max.api.lib import ( - unique_namespace, - get_plugins -) - - -class RedshiftProxyLoader(load.LoaderPlugin): - """Load rs files with Redshift Proxy""" - - label = "Load Redshift Proxy" - product_types = {"redshiftproxy"} - representations = {"rs"} - order = -9 - icon = "code-fork" - color = "white" - - def load(self, context, name=None, namespace=None, data=None): - from pymxs import runtime as rt - plugin_info = get_plugins() - if "redshift4max.dlr" not in plugin_info: - raise LoadError("Redshift not loaded/installed in Max..") - filepath = self.filepath_from_context(context) - rs_proxy = rt.RedshiftProxy() - rs_proxy.file = filepath - files_in_folder = os.listdir(os.path.dirname(filepath)) - collections, remainder = clique.assemble(files_in_folder) - if collections: - rs_proxy.is_sequence = True - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - rs_proxy.name = f"{namespace}:{rs_proxy.name}" - - return containerise( - name, [rs_proxy], context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node = rt.getNodeByName(container["instance_node"]) - node_list = get_previous_loaded_object(node) - rt.Select(node_list) - update_custom_attribute_data( - node, rt.Selection) - for proxy in rt.Selection: - proxy.file = path - - lib.imprint(container["instance_node"], { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_current_file.py b/server_addon/max/client/ayon_max/plugins/publish/collect_current_file.py deleted file mode 100644 index 6f8b8dda4b..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_current_file.py +++ /dev/null @@ -1,23 +0,0 @@ -import os -import pyblish.api - -from pymxs import runtime as rt - - -class CollectCurrentFile(pyblish.api.ContextPlugin): - """Inject the current working file.""" - - order = pyblish.api.CollectorOrder - 0.5 - label = "Max Current File" - hosts = ['max'] - - def process(self, context): - """Inject the current working file""" - folder = rt.maxFilePath - file = rt.maxFileName - if not folder or not file: - self.log.error("Scene is not saved.") - current_file = os.path.join(folder, file) - - context.data["currentFile"] = current_file - self.log.debug("Scene path: {}".format(current_file)) diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_render.py b/server_addon/max/client/ayon_max/plugins/publish/collect_render.py deleted file mode 100644 index a5e8d65df2..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_render.py +++ /dev/null @@ -1,122 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect Render""" -import os -import pyblish.api - -from pymxs import runtime as rt -from ayon_core.pipeline.publish import KnownPublishError -from ayon_max.api import colorspace -from ayon_max.api.lib import get_max_version, get_current_renderer -from ayon_max.api.lib_rendersettings import RenderSettings -from ayon_max.api.lib_renderproducts import RenderProducts - - -class CollectRender(pyblish.api.InstancePlugin): - """Collect Render for Deadline""" - - order = pyblish.api.CollectorOrder + 0.02 - label = "Collect 3dsmax Render Layers" - hosts = ['max'] - families = ["maxrender"] - - def process(self, instance): - context = instance.context - folder = rt.maxFilePath - file = rt.maxFileName - current_file = os.path.join(folder, file) - filepath = current_file.replace("\\", "/") - context.data['currentFile'] = current_file - - files_by_aov = RenderProducts().get_beauty(instance.name) - aovs = RenderProducts().get_aovs(instance.name) - files_by_aov.update(aovs) - - camera = rt.viewport.GetCamera() - if instance.data.get("members"): - camera_list = [member for member in instance.data["members"] - if rt.ClassOf(member) == rt.Camera.Classes] - if camera_list: - camera = camera_list[-1] - - instance.data["cameras"] = [camera.name] if camera else None # noqa - - if instance.data.get("multiCamera"): - cameras = instance.data.get("members") - if not cameras: - raise KnownPublishError("There should be at least" - " one renderable camera in container") - sel_cam = [ - c.name for c in cameras - if rt.classOf(c) in rt.Camera.classes] - container_name = instance.data.get("instance_node") - render_dir = os.path.dirname(rt.rendOutputFilename) - outputs = RenderSettings().batch_render_layer( - container_name, render_dir, sel_cam - ) - - instance.data["cameras"] = sel_cam - - files_by_aov = RenderProducts().get_multiple_beauty( - outputs, sel_cam) - aovs = RenderProducts().get_multiple_aovs( - outputs, sel_cam) - files_by_aov.update(aovs) - - if "expectedFiles" not in instance.data: - instance.data["expectedFiles"] = list() - instance.data["files"] = list() - instance.data["expectedFiles"].append(files_by_aov) - instance.data["files"].append(files_by_aov) - - img_format = RenderProducts().image_format() - # OCIO config not support in - # most of the 3dsmax renderers - # so this is currently hard coded - # TODO: add options for redshift/vray ocio config - instance.data["colorspaceConfig"] = "" - instance.data["colorspaceDisplay"] = "sRGB" - instance.data["colorspaceView"] = "ACES 1.0 SDR-video" - - if int(get_max_version()) >= 2024: - colorspace_mgr = rt.ColorPipelineMgr # noqa - display = next( - (display for display in colorspace_mgr.GetDisplayList())) - view_transform = next( - (view for view in colorspace_mgr.GetViewList(display))) - instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath - instance.data["colorspaceDisplay"] = display - instance.data["colorspaceView"] = view_transform - - instance.data["renderProducts"] = colorspace.ARenderProduct() - instance.data["publishJobState"] = "Suspended" - instance.data["attachTo"] = [] - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - product_type = "maxrender" - # also need to get the render dir for conversion - data = { - "folderPath": instance.data["folderPath"], - "productName": str(instance.name), - "publish": True, - "maxversion": str(get_max_version()), - "imageFormat": img_format, - "productType": product_type, - "family": product_type, - "families": [product_type], - "renderer": renderer, - "source": filepath, - "plugin": "3dsmax", - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - "farm": True - } - instance.data.update(data) - - # TODO: this should be unified with maya and its "multipart" flag - # on instance. - if renderer == "Redshift_Renderer": - instance.data.update( - {"separateAovFiles": rt.Execute( - "renderers.current.separateAovFiles")}) - - self.log.info("data: {0}".format(data)) diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_review.py b/server_addon/max/client/ayon_max/plugins/publish/collect_review.py deleted file mode 100644 index 321aa7439c..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_review.py +++ /dev/null @@ -1,153 +0,0 @@ -# dont forget getting the focal length for burnin -"""Collect Review""" -import pyblish.api - -from pymxs import runtime as rt -from ayon_core.lib import BoolDef -from ayon_max.api.lib import get_max_version -from ayon_core.pipeline.publish import ( - AYONPyblishPluginMixin, - KnownPublishError -) - - -class CollectReview(pyblish.api.InstancePlugin, - AYONPyblishPluginMixin): - """Collect Review Data for Preview Animation""" - - order = pyblish.api.CollectorOrder + 0.02 - label = "Collect Review Data" - hosts = ['max'] - families = ["review"] - - def process(self, instance): - nodes = instance.data["members"] - - def is_camera(node): - is_camera_class = rt.classOf(node) in rt.Camera.classes - return is_camera_class and rt.isProperty(node, "fov") - - # Use first camera in instance - cameras = [node for node in nodes if is_camera(node)] - if cameras: - if len(cameras) > 1: - self.log.warning( - "Found more than one camera in instance, using first " - f"one found: {cameras[0]}" - ) - camera = cameras[0] - camera_name = camera.name - focal_length = camera.fov - else: - raise KnownPublishError( - "Unable to find a valid camera in 'Review' container." - " Only native max Camera supported. " - f"Found objects: {nodes}" - ) - creator_attrs = instance.data["creator_attributes"] - attr_values = self.get_attr_values_from_data(instance.data) - - general_preview_data = { - "review_camera": camera_name, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - "percentSize": creator_attrs["percentSize"], - "imageFormat": creator_attrs["imageFormat"], - "keepImages": creator_attrs["keepImages"], - "fps": instance.context.data["fps"], - "review_width": creator_attrs["review_width"], - "review_height": creator_attrs["review_height"], - } - - if int(get_max_version()) >= 2024: - colorspace_mgr = rt.ColorPipelineMgr # noqa - display = next( - (display for display in colorspace_mgr.GetDisplayList())) - view_transform = next( - (view for view in colorspace_mgr.GetViewList(display))) - instance.data["colorspaceConfig"] = colorspace_mgr.OCIOConfigPath - instance.data["colorspaceDisplay"] = display - instance.data["colorspaceView"] = view_transform - - preview_data = { - "vpStyle": creator_attrs["visualStyleMode"], - "vpPreset": creator_attrs["viewportPreset"], - "vpTextures": creator_attrs["vpTexture"], - "dspGeometry": attr_values.get("dspGeometry"), - "dspShapes": attr_values.get("dspShapes"), - "dspLights": attr_values.get("dspLights"), - "dspCameras": attr_values.get("dspCameras"), - "dspHelpers": attr_values.get("dspHelpers"), - "dspParticles": attr_values.get("dspParticles"), - "dspBones": attr_values.get("dspBones"), - "dspBkg": attr_values.get("dspBkg"), - "dspGrid": attr_values.get("dspGrid"), - "dspSafeFrame": attr_values.get("dspSafeFrame"), - "dspFrameNums": attr_values.get("dspFrameNums") - } - else: - general_viewport = { - "dspBkg": attr_values.get("dspBkg"), - "dspGrid": attr_values.get("dspGrid") - } - nitrous_manager = { - "AntialiasingQuality": creator_attrs["antialiasingQuality"], - } - nitrous_viewport = { - "VisualStyleMode": creator_attrs["visualStyleMode"], - "ViewportPreset": creator_attrs["viewportPreset"], - "UseTextureEnabled": creator_attrs["vpTexture"] - } - preview_data = { - "general_viewport": general_viewport, - "nitrous_manager": nitrous_manager, - "nitrous_viewport": nitrous_viewport, - "vp_btn_mgr": {"EnableButtons": False} - } - - # Enable ftrack functionality - instance.data.setdefault("families", []).append('ftrack') - - burnin_members = instance.data.setdefault("burninDataMembers", {}) - burnin_members["focalLength"] = focal_length - - instance.data.update(general_preview_data) - instance.data["viewport_options"] = preview_data - - @classmethod - def get_attribute_defs(cls): - return [ - BoolDef("dspGeometry", - label="Geometry", - default=True), - BoolDef("dspShapes", - label="Shapes", - default=False), - BoolDef("dspLights", - label="Lights", - default=False), - BoolDef("dspCameras", - label="Cameras", - default=False), - BoolDef("dspHelpers", - label="Helpers", - default=False), - BoolDef("dspParticles", - label="Particle Systems", - default=True), - BoolDef("dspBones", - label="Bone Objects", - default=False), - BoolDef("dspBkg", - label="Background", - default=True), - BoolDef("dspGrid", - label="Active Grid", - default=False), - BoolDef("dspSafeFrame", - label="Safe Frames", - default=False), - BoolDef("dspFrameNums", - label="Frame Numbers", - default=False) - ] diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_workfile.py b/server_addon/max/client/ayon_max/plugins/publish/collect_workfile.py deleted file mode 100644 index 6eec0f7292..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_workfile.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect current work file.""" -import os -import pyblish.api - -from pymxs import runtime as rt - - -class CollectWorkfile(pyblish.api.InstancePlugin): - """Inject the current working file into context""" - - order = pyblish.api.CollectorOrder - 0.01 - label = "Collect 3dsmax Workfile" - hosts = ['max'] - families = ["workfile"] - - def process(self, instance): - """Inject the current working file.""" - context = instance.context - folder = rt.maxFilePath - file = rt.maxFileName - if not folder or not file: - self.log.error("Scene is not saved.") - ext = os.path.splitext(file)[-1].lstrip(".") - - data = {} - - data.update({ - "setMembers": context.data["currentFile"], - "frameStart": context.data["frameStart"], - "frameEnd": context.data["frameEnd"], - "handleStart": context.data["handleStart"], - "handleEnd": context.data["handleEnd"] - }) - - data["representations"] = [{ - "name": ext, - "ext": ext, - "files": file, - "stagingDir": folder, - }] - - instance.data.update(data) - self.log.debug("Collected data: {}".format(data)) - self.log.debug("Collected instance: {}".format(file)) - self.log.debug("staging Dir: {}".format(folder)) diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_alembic.py b/server_addon/max/client/ayon_max/plugins/publish/extract_alembic.py deleted file mode 100644 index b0999e5a78..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_alembic.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Export alembic file. - -Note: - Parameters on AlembicExport (AlembicExport.Parameter): - - ParticleAsMesh (bool): Sets whether particle shapes are exported - as meshes. - AnimTimeRange (enum): How animation is saved: - #CurrentFrame: saves current frame - #TimeSlider: saves the active time segments on time slider (default) - #StartEnd: saves a range specified by the Step - StartFrame (int) - EnFrame (int) - ShapeSuffix (bool): When set to true, appends the string "Shape" to the - name of each exported mesh. This property is set to false by default. - SamplesPerFrame (int): Sets the number of animation samples per frame. - Hidden (bool): When true, export hidden geometry. - UVs (bool): When true, export the mesh UV map channel. - Normals (bool): When true, export the mesh normals. - VertexColors (bool): When true, export the mesh vertex color map 0 and the - current vertex color display data when it differs - ExtraChannels (bool): When true, export the mesh extra map channels - (map channels greater than channel 1) - Velocity (bool): When true, export the meh vertex and particle velocity - data. - MaterialIDs (bool): When true, export the mesh material ID as - Alembic face sets. - Visibility (bool): When true, export the node visibility data. - LayerName (bool): When true, export the node layer name as an Alembic - object property. - MaterialName (bool): When true, export the geometry node material name as - an Alembic object property - ObjectID (bool): When true, export the geometry node g-buffer object ID as - an Alembic object property. - CustomAttributes (bool): When true, export the node and its modifiers - custom attributes into an Alembic object compound property. -""" -import os -import pyblish.api -from ayon_core.pipeline import publish, OptionalPyblishPluginMixin -from pymxs import runtime as rt -from ayon_max.api import maintained_selection -from ayon_max.api.lib import suspended_refresh -from ayon_core.lib import BoolDef - - -class ExtractAlembic(publish.Extractor, - OptionalPyblishPluginMixin): - order = pyblish.api.ExtractorOrder - label = "Extract Pointcache" - hosts = ["max"] - families = ["pointcache"] - optional = True - active = True - - def process(self, instance): - if not self.is_active(instance.data): - return - - parent_dir = self.staging_dir(instance) - file_name = "{name}.abc".format(**instance.data) - path = os.path.join(parent_dir, file_name) - - with suspended_refresh(): - self._set_abc_attributes(instance) - with maintained_selection(): - # select and export - node_list = instance.data["members"] - rt.Select(node_list) - rt.exportFile( - path, - rt.name("noPrompt"), - selectedOnly=True, - using=rt.AlembicExport, - ) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "abc", - "ext": "abc", - "files": file_name, - "stagingDir": parent_dir, - } - instance.data["representations"].append(representation) - - def _set_abc_attributes(self, instance): - start = instance.data["frameStartHandle"] - end = instance.data["frameEndHandle"] - attr_values = self.get_attr_values_from_data(instance.data) - custom_attrs = attr_values.get("custom_attrs", False) - if not custom_attrs: - self.log.debug( - "No Custom Attributes included in this abc export...") - rt.AlembicExport.ArchiveType = rt.Name("ogawa") - rt.AlembicExport.CoordinateSystem = rt.Name("maya") - rt.AlembicExport.StartFrame = start - rt.AlembicExport.EndFrame = end - rt.AlembicExport.CustomAttributes = custom_attrs - - @classmethod - def get_attribute_defs(cls): - defs = super(ExtractAlembic, cls).get_attribute_defs() - defs.extend([ - BoolDef("custom_attrs", - label="Custom Attributes", - default=False), - ]) - return defs - - -class ExtractCameraAlembic(ExtractAlembic): - """Extract Camera with AlembicExport.""" - label = "Extract Alembic Camera" - families = ["camera"] - optional = True - - -class ExtractModelAlembic(ExtractAlembic): - """Extract Geometry in Alembic Format""" - label = "Extract Geometry (Alembic)" - families = ["model"] - optional = True - - def _set_abc_attributes(self, instance): - attr_values = self.get_attr_values_from_data(instance.data) - custom_attrs = attr_values.get("custom_attrs", False) - if not custom_attrs: - self.log.debug( - "No Custom Attributes included in this abc export...") - rt.AlembicExport.ArchiveType = rt.name("ogawa") - rt.AlembicExport.CoordinateSystem = rt.name("maya") - rt.AlembicExport.CustomAttributes = custom_attrs - rt.AlembicExport.UVs = True - rt.AlembicExport.VertexColors = True - rt.AlembicExport.PreserveInstances = True diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_fbx.py b/server_addon/max/client/ayon_max/plugins/publish/extract_fbx.py deleted file mode 100644 index bdfc1d0d78..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_fbx.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import pyblish.api -from ayon_core.pipeline import publish, OptionalPyblishPluginMixin -from pymxs import runtime as rt -from ayon_max.api import maintained_selection -from ayon_max.api.lib import convert_unit_scale - - -class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): - """ - Extract Geometry in FBX Format - """ - - order = pyblish.api.ExtractorOrder - 0.05 - label = "Extract FBX" - hosts = ["max"] - families = ["model"] - optional = True - - def process(self, instance): - if not self.is_active(instance.data): - return - - stagingdir = self.staging_dir(instance) - filename = "{name}.fbx".format(**instance.data) - filepath = os.path.join(stagingdir, filename) - self._set_fbx_attributes() - - with maintained_selection(): - # select and export - node_list = instance.data["members"] - rt.Select(node_list) - rt.exportFile( - filepath, - rt.name("noPrompt"), - selectedOnly=True, - using=rt.FBXEXP, - ) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "fbx", - "ext": "fbx", - "files": filename, - "stagingDir": stagingdir, - } - instance.data["representations"].append(representation) - self.log.info( - "Extracted instance '%s' to: %s" % (instance.name, filepath) - ) - - def _set_fbx_attributes(self): - unit_scale = convert_unit_scale() - rt.FBXExporterSetParam("Animation", False) - rt.FBXExporterSetParam("Cameras", False) - rt.FBXExporterSetParam("Lights", False) - rt.FBXExporterSetParam("PointCache", False) - rt.FBXExporterSetParam("AxisConversionMethod", "Animation") - rt.FBXExporterSetParam("UpAxis", "Y") - rt.FBXExporterSetParam("Preserveinstances", True) - if unit_scale: - rt.FBXExporterSetParam("ConvertUnit", unit_scale) - - -class ExtractCameraFbx(ExtractModelFbx): - """Extract Camera with FbxExporter.""" - - order = pyblish.api.ExtractorOrder - 0.2 - label = "Extract Fbx Camera" - families = ["camera"] - optional = True - - def _set_fbx_attributes(self): - unit_scale = convert_unit_scale() - rt.FBXExporterSetParam("Animation", True) - rt.FBXExporterSetParam("Cameras", True) - rt.FBXExporterSetParam("AxisConversionMethod", "Animation") - rt.FBXExporterSetParam("UpAxis", "Y") - rt.FBXExporterSetParam("Preserveinstances", True) - if unit_scale: - rt.FBXExporterSetParam("ConvertUnit", unit_scale) diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_max_scene_raw.py b/server_addon/max/client/ayon_max/plugins/publish/extract_max_scene_raw.py deleted file mode 100644 index ecde6d2ce9..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_max_scene_raw.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -import pyblish.api -from ayon_core.pipeline import publish, OptionalPyblishPluginMixin -from pymxs import runtime as rt - - -class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): - """ - Extract Raw Max Scene with SaveSelected - """ - - order = pyblish.api.ExtractorOrder - 0.2 - label = "Extract Max Scene (Raw)" - hosts = ["max"] - families = ["camera", "maxScene", "model"] - optional = True - - settings_category = "max" - - def process(self, instance): - if not self.is_active(instance.data): - return - - # publish the raw scene for camera - self.log.debug("Extracting Raw Max Scene ...") - - stagingdir = self.staging_dir(instance) - filename = "{name}.max".format(**instance.data) - - max_path = os.path.join(stagingdir, filename) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - nodes = instance.data["members"] - rt.saveNodes(nodes, max_path, quiet=True) - - self.log.info("Performing Extraction ...") - - representation = { - "name": "max", - "ext": "max", - "files": filename, - "stagingDir": stagingdir, - } - instance.data["representations"].append(representation) - self.log.info( - "Extracted instance '%s' to: %s" % (instance.name, max_path) - ) diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_model_obj.py b/server_addon/max/client/ayon_max/plugins/publish/extract_model_obj.py deleted file mode 100644 index 6556bd7809..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_model_obj.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -import pyblish.api -from ayon_core.pipeline import publish, OptionalPyblishPluginMixin -from pymxs import runtime as rt -from ayon_max.api import maintained_selection -from ayon_max.api.lib import suspended_refresh -from ayon_core.pipeline.publish import KnownPublishError - - -class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): - """ - Extract Geometry in OBJ Format - """ - - order = pyblish.api.ExtractorOrder - 0.05 - label = "Extract OBJ" - hosts = ["max"] - families = ["model"] - optional = True - - settings_category = "max" - - def process(self, instance): - if not self.is_active(instance.data): - return - - stagingdir = self.staging_dir(instance) - filename = "{name}.obj".format(**instance.data) - filepath = os.path.join(stagingdir, filename) - - with suspended_refresh(): - with maintained_selection(): - # select and export - node_list = instance.data["members"] - rt.Select(node_list) - rt.exportFile( - filepath, - rt.name("noPrompt"), - selectedOnly=True, - using=rt.ObjExp, - ) - if not os.path.exists(filepath): - raise KnownPublishError( - "File {} wasn't produced by 3ds max, please check the logs.") - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "obj", - "ext": "obj", - "files": filename, - "stagingDir": stagingdir, - } - - instance.data["representations"].append(representation) - self.log.info( - "Extracted instance '%s' to: %s" % (instance.name, filepath) - ) diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_model_usd.py b/server_addon/max/client/ayon_max/plugins/publish/extract_model_usd.py deleted file mode 100644 index a48126c6e5..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_model_usd.py +++ /dev/null @@ -1,94 +0,0 @@ -import os - -import pyblish.api -from pymxs import runtime as rt - -from ayon_max.api import maintained_selection -from ayon_core.pipeline import OptionalPyblishPluginMixin, publish - - -class ExtractModelUSD(publish.Extractor, - OptionalPyblishPluginMixin): - """Extract Geometry in USDA Format.""" - - order = pyblish.api.ExtractorOrder - 0.05 - label = "Extract Geometry (USD)" - hosts = ["max"] - families = ["model"] - optional = True - - settings_category = "max" - - def process(self, instance): - if not self.is_active(instance.data): - return - - self.log.info("Extracting Geometry ...") - - stagingdir = self.staging_dir(instance) - asset_filename = "{name}.usda".format(**instance.data) - asset_filepath = os.path.join(stagingdir, - asset_filename) - self.log.info(f"Writing USD '{asset_filepath}' to '{stagingdir}'") - - log_filename = "{name}.txt".format(**instance.data) - log_filepath = os.path.join(stagingdir, - log_filename) - self.log.info(f"Writing log '{log_filepath}' to '{stagingdir}'") - - # get the nodes which need to be exported - export_options = self.get_export_options(log_filepath) - with maintained_selection(): - # select and export - node_list = instance.data["members"] - rt.Select(node_list) - rt.USDExporter.ExportFile(asset_filepath, - exportOptions=export_options, - contentSource=rt.Name("selected"), - nodeList=node_list) - - self.log.info("Performing Extraction ...") - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'usda', - 'ext': 'usda', - 'files': asset_filename, - "stagingDir": stagingdir, - } - instance.data["representations"].append(representation) - - log_representation = { - 'name': 'txt', - 'ext': 'txt', - 'files': log_filename, - "stagingDir": stagingdir, - } - instance.data["representations"].append(log_representation) - - self.log.info( - f"Extracted instance '{instance.name}' to: {asset_filepath}") - - @staticmethod - def get_export_options(log_path): - """Set Export Options for USD Exporter""" - - export_options = rt.USDExporter.createOptions() - - export_options.Meshes = True - export_options.Shapes = False - export_options.Lights = False - export_options.Cameras = False - export_options.Materials = False - export_options.MeshFormat = rt.Name('fromScene') - export_options.FileFormat = rt.Name('ascii') - export_options.UpAxis = rt.Name('y') - export_options.LogLevel = rt.Name('info') - export_options.LogPath = log_path - export_options.PreserveEdgeOrientation = True - export_options.TimeMode = rt.Name('current') - - rt.USDexporter.UIOptions = export_options - - return export_options diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_redshift_proxy.py b/server_addon/max/client/ayon_max/plugins/publish/extract_redshift_proxy.py deleted file mode 100644 index dfb3527be1..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_redshift_proxy.py +++ /dev/null @@ -1,61 +0,0 @@ -import os -import pyblish.api -from ayon_core.pipeline import publish -from pymxs import runtime as rt -from ayon_max.api import maintained_selection - - -class ExtractRedshiftProxy(publish.Extractor): - """ - Extract Redshift Proxy with rsProxy - """ - - order = pyblish.api.ExtractorOrder - 0.1 - label = "Extract RedShift Proxy" - hosts = ["max"] - families = ["redshiftproxy"] - - def process(self, instance): - start = instance.data["frameStartHandle"] - end = instance.data["frameEndHandle"] - - self.log.debug("Extracting Redshift Proxy...") - stagingdir = self.staging_dir(instance) - rs_filename = "{name}.rs".format(**instance.data) - rs_filepath = os.path.join(stagingdir, rs_filename) - rs_filepath = rs_filepath.replace("\\", "/") - - rs_filenames = self.get_rsfiles(instance, start, end) - - with maintained_selection(): - # select and export - node_list = instance.data["members"] - rt.Select(node_list) - # Redshift rsProxy command - # rsProxy fp selected compress connectivity startFrame endFrame - # camera warnExisting transformPivotToOrigin - rt.rsProxy(rs_filepath, 1, 0, 0, start, end, 0, 1, 1) - - self.log.info("Performing Extraction ...") - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': 'rs', - 'ext': 'rs', - 'files': rs_filenames if len(rs_filenames) > 1 else rs_filenames[0], # noqa - "stagingDir": stagingdir, - } - instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - stagingdir)) - - def get_rsfiles(self, instance, startFrame, endFrame): - rs_filenames = [] - rs_name = instance.data["name"] - for frame in range(startFrame, endFrame + 1): - rs_filename = "%s.%04d.rs" % (rs_name, frame) - rs_filenames.append(rs_filename) - - return rs_filenames diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_review_animation.py b/server_addon/max/client/ayon_max/plugins/publish/extract_review_animation.py deleted file mode 100644 index b6397d404e..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_review_animation.py +++ /dev/null @@ -1,64 +0,0 @@ -import os -import pyblish.api -from ayon_core.pipeline import publish -from ayon_max.api.preview_animation import ( - render_preview_animation -) - - -class ExtractReviewAnimation(publish.Extractor): - """ - Extract Review by Review Animation - """ - - order = pyblish.api.ExtractorOrder + 0.001 - label = "Extract Review Animation" - hosts = ["max"] - families = ["review"] - - def process(self, instance): - staging_dir = self.staging_dir(instance) - ext = instance.data.get("imageFormat") - start = int(instance.data["frameStart"]) - end = int(instance.data["frameEnd"]) - filepath = os.path.join(staging_dir, instance.name) - self.log.debug( - "Writing Review Animation to '{}'".format(filepath)) - - review_camera = instance.data["review_camera"] - viewport_options = instance.data.get("viewport_options", {}) - files = render_preview_animation( - filepath, - ext, - review_camera, - start, - end, - percentSize=instance.data["percentSize"], - width=instance.data["review_width"], - height=instance.data["review_height"], - viewport_options=viewport_options) - - filenames = [os.path.basename(path) for path in files] - - tags = ["review"] - if not instance.data.get("keepImages"): - tags.append("delete") - - self.log.debug("Performing Extraction ...") - - representation = { - "name": instance.data["imageFormat"], - "ext": instance.data["imageFormat"], - "files": filenames, - "stagingDir": staging_dir, - "frameStart": instance.data["frameStartHandle"], - "frameEnd": instance.data["frameEndHandle"], - "tags": tags, - "preview": True, - "camera_name": review_camera - } - self.log.debug(f"{representation}") - - if "representations" not in instance.data: - instance.data["representations"] = [] - instance.data["representations"].append(representation) diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_thumbnail.py b/server_addon/max/client/ayon_max/plugins/publish/extract_thumbnail.py deleted file mode 100644 index 183e381be2..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_thumbnail.py +++ /dev/null @@ -1,51 +0,0 @@ -import os -import pyblish.api -from ayon_core.pipeline import publish -from ayon_max.api.preview_animation import render_preview_animation - - -class ExtractThumbnail(publish.Extractor): - """Extract Thumbnail for Review - """ - - order = pyblish.api.ExtractorOrder - label = "Extract Thumbnail" - hosts = ["max"] - families = ["review"] - - def process(self, instance): - ext = instance.data.get("imageFormat") - frame = int(instance.data["frameStart"]) - staging_dir = self.staging_dir(instance) - filepath = os.path.join( - staging_dir, f"{instance.name}_thumbnail") - self.log.debug("Writing Thumbnail to '{}'".format(filepath)) - - review_camera = instance.data["review_camera"] - viewport_options = instance.data.get("viewport_options", {}) - files = render_preview_animation( - filepath, - ext, - review_camera, - start_frame=frame, - end_frame=frame, - percentSize=instance.data["percentSize"], - width=instance.data["review_width"], - height=instance.data["review_height"], - viewport_options=viewport_options) - - thumbnail = next(os.path.basename(path) for path in files) - - representation = { - "name": "thumbnail", - "ext": ext, - "files": thumbnail, - "stagingDir": staging_dir, - "thumbnail": True - } - - self.log.debug(f"{representation}") - - if "representations" not in instance.data: - instance.data["representations"] = [] - instance.data["representations"].append(representation) diff --git a/server_addon/max/client/ayon_max/plugins/publish/help/validate_model_name.xml b/server_addon/max/client/ayon_max/plugins/publish/help/validate_model_name.xml deleted file mode 100644 index e41146910a..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/help/validate_model_name.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - -Invalid Model Name -## Nodes found with Invalid Model Name - -Nodes were detected in your scene which have invalid model name which does not -match the regex you preset in AYON setting. -### How to repair? -Make sure the model name aligns with validation regex in your AYON setting. - - - -### Invalid nodes - -{nodes} - - -### How could this happen? - -This often happens if you have mesh with the model naming does not match -with regex in the setting. - - - - \ No newline at end of file diff --git a/server_addon/max/client/ayon_max/plugins/publish/increment_workfile_version.py b/server_addon/max/client/ayon_max/plugins/publish/increment_workfile_version.py deleted file mode 100644 index c7c3f49626..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/increment_workfile_version.py +++ /dev/null @@ -1,19 +0,0 @@ -import pyblish.api -from ayon_core.lib import version_up -from pymxs import runtime as rt - - -class IncrementWorkfileVersion(pyblish.api.ContextPlugin): - """Increment current workfile version.""" - - order = pyblish.api.IntegratorOrder + 0.9 - label = "Increment Workfile Version" - hosts = ["max"] - families = ["maxrender", "workfile"] - - def process(self, context): - path = context.data["currentFile"] - filepath = version_up(path) - - rt.saveMaxFile(filepath) - self.log.info("Incrementing file version") diff --git a/server_addon/max/client/ayon_max/plugins/publish/save_scene.py b/server_addon/max/client/ayon_max/plugins/publish/save_scene.py deleted file mode 100644 index fe2c7f50f4..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/save_scene.py +++ /dev/null @@ -1,25 +0,0 @@ -import pyblish.api -from ayon_core.pipeline import registered_host - - -class SaveCurrentScene(pyblish.api.InstancePlugin): - """Save current scene""" - - label = "Save current file" - order = pyblish.api.ExtractorOrder - 0.49 - hosts = ["max"] - families = ["maxrender", "workfile"] - - def process(self, instance): - host = registered_host() - current_file = host.get_current_workfile() - - assert instance.context.data["currentFile"] == current_file - if instance.data["productType"] == "maxrender": - host.save_workfile(current_file) - - elif host.workfile_has_unsaved_changes(): - self.log.info(f"Saving current file: {current_file}") - host.save_workfile(current_file) - else: - self.log.debug("No unsaved changes, skipping file save..") \ No newline at end of file diff --git a/server_addon/max/client/ayon_max/plugins/publish/save_scenes_for_cameras.py b/server_addon/max/client/ayon_max/plugins/publish/save_scenes_for_cameras.py deleted file mode 100644 index a211210550..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/save_scenes_for_cameras.py +++ /dev/null @@ -1,105 +0,0 @@ -import pyblish.api -import os -import sys -import tempfile - -from pymxs import runtime as rt -from ayon_core.lib import run_subprocess -from ayon_max.api.lib_rendersettings import RenderSettings -from ayon_max.api.lib_renderproducts import RenderProducts - - -class SaveScenesForCamera(pyblish.api.InstancePlugin): - """Save scene files for multiple cameras without - editing the original scene before deadline submission - - """ - - label = "Save Scene files for cameras" - order = pyblish.api.ExtractorOrder - 0.48 - hosts = ["max"] - families = ["maxrender"] - - def process(self, instance): - if not instance.data.get("multiCamera"): - self.log.debug( - "Multi Camera disabled. " - "Skipping to save scene files for cameras") - return - current_folder = rt.maxFilePath - current_filename = rt.maxFileName - current_filepath = os.path.join(current_folder, current_filename) - camera_scene_files = [] - scripts = [] - filename, ext = os.path.splitext(current_filename) - fmt = RenderProducts().image_format() - cameras = instance.data.get("cameras") - if not cameras: - return - new_folder = f"{current_folder}_{filename}" - os.makedirs(new_folder, exist_ok=True) - for camera in cameras: - new_output = RenderSettings().get_batch_render_output(camera) # noqa - new_output = new_output.replace("\\", "/") - new_filename = f"{filename}_{camera}{ext}" - new_filepath = os.path.join(new_folder, new_filename) - new_filepath = new_filepath.replace("\\", "/") - camera_scene_files.append(new_filepath) - RenderSettings().batch_render_elements(camera) - rt.rendOutputFilename = new_output - rt.saveMaxFile(current_filepath) - script = (""" -from pymxs import runtime as rt -import os -filename = "{filename}" -new_filepath = "{new_filepath}" -new_output = "{new_output}" -camera = "{camera}" -rt.rendOutputFilename = new_output -directory = os.path.dirname(rt.rendOutputFilename) -directory = os.path.join(directory, filename) -render_elem = rt.maxOps.GetCurRenderElementMgr() -render_elem_num = render_elem.NumRenderElements() -if render_elem_num > 0: - ext = "{ext}" - for i in range(render_elem_num): - renderlayer_name = render_elem.GetRenderElement(i) - target, renderpass = str(renderlayer_name).split(":") - aov_name = f"{{directory}}_{camera}_{{renderpass}}..{ext}" - render_elem.SetRenderElementFileName(i, aov_name) -rt.saveMaxFile(new_filepath) - """).format(filename=instance.name, - new_filepath=new_filepath, - new_output=new_output, - camera=camera, - ext=fmt) - scripts.append(script) - - maxbatch_exe = os.path.join( - os.path.dirname(sys.executable), "3dsmaxbatch") - maxbatch_exe = maxbatch_exe.replace("\\", "/") - if sys.platform == "windows": - maxbatch_exe += ".exe" - maxbatch_exe = os.path.normpath(maxbatch_exe) - with tempfile.TemporaryDirectory() as tmp_dir_name: - tmp_script_path = os.path.join( - tmp_dir_name, "extract_scene_files.py") - self.log.info("Using script file: {}".format(tmp_script_path)) - - with open(tmp_script_path, "wt") as tmp: - for script in scripts: - tmp.write(script + "\n") - - try: - current_filepath = current_filepath.replace("\\", "/") - tmp_script_path = tmp_script_path.replace("\\", "/") - run_subprocess([maxbatch_exe, tmp_script_path, - "-sceneFile", current_filepath]) - except RuntimeError: - self.log.debug("Checking the scene files existing") - - for camera_scene in camera_scene_files: - if not os.path.exists(camera_scene): - self.log.error("Camera scene files not existed yet!") - raise RuntimeError("MaxBatch.exe doesn't run as expected") - self.log.debug(f"Found Camera scene:{camera_scene}") diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_attributes.py b/server_addon/max/client/ayon_max/plugins/publish/validate_attributes.py deleted file mode 100644 index a489533b2c..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_attributes.py +++ /dev/null @@ -1,143 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator for Attributes.""" -import json - -from pyblish.api import ContextPlugin, ValidatorOrder -from pymxs import runtime as rt - -from ayon_core.pipeline.publish import ( - OptionalPyblishPluginMixin, - PublishValidationError, - RepairContextAction -) - - -def has_property(object_name, property_name): - """Return whether an object has a property with given name""" - return rt.Execute(f'isProperty {object_name} "{property_name}"') - - -def is_matching_value(object_name, property_name, value): - """Return whether an existing property matches value `value""" - property_value = rt.Execute(f"{object_name}.{property_name}") - - # Wrap property value if value is a string valued attributes - # starting with a `#` - if ( - isinstance(value, str) and - value.startswith("#") and - not value.endswith(")") - ): - # prefix value with `#` - # not applicable for #() array value type - # and only applicable for enum i.e. #bob, #sally - property_value = f"#{property_value}" - - return property_value == value - - -class ValidateAttributes(OptionalPyblishPluginMixin, - ContextPlugin): - """Validates attributes in the project setting are consistent - with the nodes from MaxWrapper Class in 3ds max. - E.g. "renderers.current.separateAovFiles", - "renderers.production.PrimaryGIEngine" - Admin(s) need to put the dict below and enable this validator for a check: - { - "renderers.current":{ - "separateAovFiles" : True - }, - "renderers.production":{ - "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" - } - .... - } - - """ - - order = ValidatorOrder - hosts = ["max"] - label = "Attributes" - actions = [RepairContextAction] - optional = True - - settings_category = "max" - - @classmethod - def get_invalid(cls, context): - attributes = json.loads( - context.data - ["project_settings"] - ["max"] - ["publish"] - ["ValidateAttributes"] - ["attributes"] - ) - if not attributes: - return - invalid = [] - for object_name, required_properties in attributes.items(): - if not rt.Execute(f"isValidValue {object_name}"): - # Skip checking if the node does not - # exist in MaxWrapper Class - cls.log.debug(f"Unable to find '{object_name}'." - " Skipping validation of attributes.") - continue - - for property_name, value in required_properties.items(): - if not has_property(object_name, property_name): - cls.log.error( - "Non-existing property: " - f"{object_name}.{property_name}") - invalid.append((object_name, property_name)) - - if not is_matching_value(object_name, property_name, value): - cls.log.error( - f"Invalid value for: {object_name}.{property_name}" - f" should be: {value}") - invalid.append((object_name, property_name)) - - return invalid - - def process(self, context): - if not self.is_active(context.data): - self.log.debug("Skipping Validate Attributes...") - return - invalid_attributes = self.get_invalid(context) - if invalid_attributes: - bullet_point_invalid_statement = "\n".join( - "- {}".format(invalid) for invalid - in invalid_attributes - ) - report = ( - "Required Attribute(s) have invalid value(s).\n\n" - f"{bullet_point_invalid_statement}\n\n" - "You can use repair action to fix them if they are not\n" - "unknown property value(s)." - ) - raise PublishValidationError( - report, title="Invalid Value(s) for Required Attribute(s)") - - @classmethod - def repair(cls, context): - attributes = json.loads( - context.data - ["project_settings"] - ["max"] - ["publish"] - ["ValidateAttributes"] - ["attributes"] - ) - invalid_attributes = cls.get_invalid(context) - for attrs in invalid_attributes: - prop, attr = attrs - value = attributes[prop][attr] - if isinstance(value, str) and not value.startswith("#"): - attribute_fix = '{}.{}="{}"'.format( - prop, attr, value - ) - else: - attribute_fix = "{}.{}={}".format( - prop, attr, value - ) - rt.Execute(attribute_fix) diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_camera_attributes.py b/server_addon/max/client/ayon_max/plugins/publish/validate_camera_attributes.py deleted file mode 100644 index 63a2ef39a7..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_camera_attributes.py +++ /dev/null @@ -1,90 +0,0 @@ -import pyblish.api -from pymxs import runtime as rt - -from ayon_core.pipeline.publish import ( - RepairAction, - OptionalPyblishPluginMixin, - PublishValidationError -) -from ayon_max.api.action import SelectInvalidAction - - -class ValidateCameraAttributes(OptionalPyblishPluginMixin, - pyblish.api.InstancePlugin): - """Validates Camera has no invalid attribute properties - or values.(For 3dsMax Cameras only) - - """ - - order = pyblish.api.ValidatorOrder - families = ['camera'] - hosts = ['max'] - label = 'Validate Camera Attributes' - actions = [SelectInvalidAction, RepairAction] - optional = True - - settings_category = "max" - - DEFAULTS = ["fov", "nearrange", "farrange", - "nearclip", "farclip"] - CAM_TYPE = ["Freecamera", "Targetcamera", - "Physical"] - - @classmethod - def get_invalid(cls, instance): - invalid = [] - if rt.units.DisplayType != rt.Name("Generic"): - cls.log.warning( - "Generic Type is not used as a scene unit\n\n" - "sure you tweak the settings with your own values\n\n" - "before validation.") - cameras = instance.data["members"] - project_settings = instance.context.data["project_settings"].get("max") - cam_attr_settings = ( - project_settings["publish"]["ValidateCameraAttributes"] - ) - for camera in cameras: - if str(rt.ClassOf(camera)) not in cls.CAM_TYPE: - cls.log.debug( - "Skipping camera created from external plugin..") - continue - for attr in cls.DEFAULTS: - default_value = cam_attr_settings.get(attr) - if default_value == float(0): - cls.log.debug( - f"the value of {attr} in setting set to" - " zero. Skipping the check.") - continue - if round(rt.getProperty(camera, attr), 1) != default_value: - cls.log.error( - f"Invalid attribute value for {camera.name}:{attr} " - f"(should be: {default_value}))") - invalid.append(camera) - - return invalid - - def process(self, instance): - if not self.is_active(instance.data): - self.log.debug("Skipping Validate Camera Attributes.") - return - invalid = self.get_invalid(instance) - - if invalid: - raise PublishValidationError( - "Invalid camera attributes found. See log.") - - @classmethod - def repair(cls, instance): - invalid_cameras = cls.get_invalid(instance) - project_settings = instance.context.data["project_settings"].get("max") - cam_attr_settings = ( - project_settings["publish"]["ValidateCameraAttributes"] - ) - for camera in invalid_cameras: - for attr in cls.DEFAULTS: - expected_value = cam_attr_settings.get(attr) - if expected_value == float(0): - cls.log.debug( - f"the value of {attr} in setting set to zero.") - continue - rt.setProperty(camera, attr, expected_value) diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_camera_contents.py b/server_addon/max/client/ayon_max/plugins/publish/validate_camera_contents.py deleted file mode 100644 index 334e7dcec9..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_camera_contents.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api - -from ayon_core.pipeline import PublishValidationError - - -class ValidateCameraContent(pyblish.api.InstancePlugin): - """Validates Camera instance contents. - - A Camera instance may only hold a SINGLE camera's transform - """ - - order = pyblish.api.ValidatorOrder - families = ["camera", "review"] - hosts = ["max"] - label = "Camera Contents" - camera_type = ["$Free_Camera", "$Target_Camera", - "$Physical_Camera", "$Target"] - - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError(("Camera instance must only include" - "camera (and camera target). " - f"Invalid content {invalid}")) - - def get_invalid(self, instance): - """ - Get invalid nodes if the instance is not camera - """ - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating camera content for {container}") - - selection_list = instance.data["members"] - for sel in selection_list: - # to avoid Attribute Error from pymxs wrapper - sel_tmp = str(sel) - found = any(sel_tmp.startswith(cam) for cam in self.camera_type) - if not found: - self.log.error("Camera not found") - invalid.append(sel) - return invalid diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_extended_viewport.py b/server_addon/max/client/ayon_max/plugins/publish/validate_extended_viewport.py deleted file mode 100644 index ed476ec874..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_extended_viewport.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from ayon_core.pipeline import PublishValidationError -from pymxs import runtime as rt - - -class ValidateExtendedViewport(pyblish.api.ContextPlugin): - """Validate if the first viewport is an extended viewport.""" - - order = pyblish.api.ValidatorOrder - families = ["review"] - hosts = ["max"] - label = "Validate Extended Viewport" - - def process(self, context): - try: - rt.viewport.activeViewportEx(1) - except RuntimeError: - raise PublishValidationError( - "Please make sure one viewport is not an extended viewport", - description = ( - "Please make sure at least one viewport is not an " - "extended viewport but a 3dsmax supported viewport " - "i.e camera/persp/orthographic view.\n\n" - "To rectify it, please go to view in the top menubar, " - "go to Views -> Viewports Configuration -> Layout and " - "right click on one of the panels to change it." - )) - diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_instance_has_members.py b/server_addon/max/client/ayon_max/plugins/publish/validate_instance_has_members.py deleted file mode 100644 index 552e9ea0e2..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_instance_has_members.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from ayon_core.pipeline import PublishValidationError - - -class ValidateInstanceHasMembers(pyblish.api.InstancePlugin): - """Validates Instance has members. - - Check if MaxScene containers includes any contents underneath. - """ - - order = pyblish.api.ValidatorOrder - families = ["camera", - "model", - "maxScene", - "review", - "pointcache", - "pointcloud", - "redshiftproxy"] - hosts = ["max"] - label = "Container Contents" - - def process(self, instance): - if not instance.data["members"]: - raise PublishValidationError("No content found in the container") diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_instance_in_context.py b/server_addon/max/client/ayon_max/plugins/publish/validate_instance_in_context.py deleted file mode 100644 index d5bdfe4eb0..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_instance_in_context.py +++ /dev/null @@ -1,86 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate if instance context is the same as current context.""" -import pyblish.api -from ayon_core.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError, - OptionalPyblishPluginMixin -) -from ayon_max.api.action import SelectInvalidAction -from pymxs import runtime as rt - - -class ValidateInstanceInContext(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validator to check if instance context match current context. - - When working in per-shot style you always publish data in context of - current context (shot). This validator checks if this is so. It is optional - so it can be disabled when needed. - - Action on this validator will select invalid instances. - """ - order = ValidateContentsOrder - label = "Instance in same Context" - optional = True - hosts = ["max"] - actions = [SelectInvalidAction, RepairAction] - - settings_category = "max" - - def process(self, instance): - if not self.is_active(instance.data): - return - - folderPath = instance.data.get("folderPath") - task = instance.data.get("task") - context = self.get_context(instance) - if (folderPath, task) != context: - context_label = "{} > {}".format(*context) - instance_label = "{} > {}".format(folderPath, task) - message = ( - "Instance '{}' publishes to different context(folder or task) " - "than current context: {}. Current context: {}".format( - instance.name, instance_label, context_label - ) - ) - raise PublishValidationError( - message=message, - description=( - "## Publishing to a different context data(folder or task)\n" - "There are publish instances present which are publishing " - "into a different folder path or task than your current context.\n\n" - "Usually this is not what you want but there can be cases " - "where you might want to publish into another context or " - "shot. If that's the case you can disable the validation " - "on the instance to ignore it." - ) - ) - - @classmethod - def get_invalid(cls, instance): - invalid = [] - folderPath = instance.data.get("folderPath") - task = instance.data.get("task") - context = cls.get_context(instance) - if (folderPath, task) != context: - invalid.append(rt.getNodeByName(instance.name)) - return invalid - - @classmethod - def repair(cls, instance): - context_asset = instance.context.data["folderPath"] - context_task = instance.context.data["task"] - instance_node = rt.getNodeByName(instance.data.get( - "instance_node", "")) - if not instance_node: - return - rt.SetUserProp(instance_node, "folderPath", context_asset) - rt.SetUserProp(instance_node, "task", context_task) - - @staticmethod - def get_context(instance): - """Return asset, task from publishing context data""" - context = instance.context - return context.data["folderPath"], context.data["task"] diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_loaded_plugin.py b/server_addon/max/client/ayon_max/plugins/publish/validate_loaded_plugin.py deleted file mode 100644 index 1fddc7998d..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_loaded_plugin.py +++ /dev/null @@ -1,143 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validator for Loaded Plugin.""" -import os -import pyblish.api -from pymxs import runtime as rt - -from ayon_core.pipeline.publish import ( - RepairAction, - OptionalPyblishPluginMixin, - PublishValidationError -) -from ayon_max.api.lib import get_plugins - - -class ValidateLoadedPlugin(OptionalPyblishPluginMixin, - pyblish.api.InstancePlugin): - """Validates if the specific plugin is loaded in 3ds max. - Studio Admin(s) can add the plugins they want to check in validation - via studio defined project settings - """ - - order = pyblish.api.ValidatorOrder - hosts = ["max"] - label = "Validate Loaded Plugins" - optional = True - actions = [RepairAction] - - settings_category = "max" - - family_plugins_mapping = [] - - @classmethod - def get_invalid(cls, instance): - """Plugin entry point.""" - family_plugins_mapping = cls.family_plugins_mapping - if not family_plugins_mapping: - return - - # Backward compatibility - settings did have 'product_types' - if "product_types" in family_plugins_mapping: - family_plugins_mapping["families"] = family_plugins_mapping.pop( - "product_types" - ) - - invalid = [] - # Find all plug-in requirements for current instance - instance_families = {instance.data["productType"]} - instance_families.update(instance.data.get("families", [])) - cls.log.debug("Checking plug-in validation " - f"for instance families: {instance_families}") - all_required_plugins = set() - - for mapping in family_plugins_mapping: - # Check for matching families - if not mapping: - return - - match_families = { - fam.strip() for fam in mapping["families"] - } - has_match = "*" in match_families or match_families.intersection( - instance_families) - - if not has_match: - continue - - cls.log.debug( - f"Found plug-in family requirements: {match_families}") - required_plugins = [ - # match lowercase and format with os.environ to allow - # plugin names defined by max version, e.g. {3DSMAX_VERSION} - plugin.format(**os.environ).lower() - for plugin in mapping["plugins"] - # ignore empty fields in settings - if plugin.strip() - ] - - all_required_plugins.update(required_plugins) - - if not all_required_plugins: - # Instance has no plug-in requirements - return - - # get all DLL loaded plugins in Max and their plugin index - available_plugins = { - plugin_name.lower(): index for index, plugin_name in enumerate( - get_plugins()) - } - # validate the required plug-ins - for plugin in sorted(all_required_plugins): - plugin_index = available_plugins.get(plugin) - if plugin_index is None: - debug_msg = ( - f"Plugin {plugin} does not exist" - " in 3dsMax Plugin List." - ) - invalid.append((plugin, debug_msg)) - continue - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - debug_msg = f"Plugin {plugin} not loaded." - invalid.append((plugin, debug_msg)) - return invalid - - def process(self, instance): - if not self.is_active(instance.data): - self.log.debug("Skipping Validate Loaded Plugin...") - return - invalid = self.get_invalid(instance) - if invalid: - bullet_point_invalid_statement = "\n".join( - "- {}".format(message) for _, message in invalid - ) - report = ( - "Required plugins are not loaded.\n\n" - f"{bullet_point_invalid_statement}\n\n" - "You can use repair action to load the plugin." - ) - raise PublishValidationError( - report, title="Missing Required Plugins") - - @classmethod - def repair(cls, instance): - # get all DLL loaded plugins in Max and their plugin index - invalid = cls.get_invalid(instance) - if not invalid: - return - - # get all DLL loaded plugins in Max and their plugin index - available_plugins = { - plugin_name.lower(): index for index, plugin_name in enumerate( - get_plugins()) - } - - for invalid_plugin, _ in invalid: - plugin_index = available_plugins.get(invalid_plugin) - - if plugin_index is None: - cls.log.warning( - f"Can't enable missing plugin: {invalid_plugin}") - continue - - if not rt.pluginManager.isPluginDllLoaded(plugin_index): - rt.pluginManager.loadPluginDll(plugin_index) diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_mesh_has_uv.py b/server_addon/max/client/ayon_max/plugins/publish/validate_mesh_has_uv.py deleted file mode 100644 index 31143a60c0..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_mesh_has_uv.py +++ /dev/null @@ -1,62 +0,0 @@ - -import pyblish.api -from ayon_max.api.action import SelectInvalidAction -from ayon_core.pipeline.publish import ( - ValidateMeshOrder, - OptionalPyblishPluginMixin, - PublishValidationError -) -from pymxs import runtime as rt - - -class ValidateMeshHasUVs(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - - """Validate the current mesh has UVs. - - This validator only checks if the mesh has UVs but not - whether all the individual faces of the mesh have UVs. - - It validates whether the current mesh has texture vertices. - If the mesh does not have texture vertices, it does not - have UVs in Max. - - """ - - order = ValidateMeshOrder - hosts = ['max'] - families = ['model'] - label = 'Validate Mesh Has UVs' - actions = [SelectInvalidAction] - optional = True - - settings_category = "max" - - @classmethod - def get_invalid(cls, instance): - meshes = [member for member in instance.data["members"] - if rt.isProperty(member, "mesh")] - invalid = [member for member in meshes - if member.mesh.numTVerts == 0] - return invalid - - def process(self, instance): - if not self.is_active(instance.data): - return - invalid = self.get_invalid(instance) - if invalid: - bullet_point_invalid_statement = "\n".join( - "- {}".format(invalid.name) for invalid - in invalid - ) - report = ( - "Model meshes are required to have UVs.\n\n" - "Meshes detected with invalid or missing UVs:\n" - f"{bullet_point_invalid_statement}\n" - ) - raise PublishValidationError( - report, - description=( - "Model meshes are required to have UVs.\n\n" - "Meshes detected with no texture vertice or missing UVs"), - title="Non-mesh objects found or mesh has missing UVs") diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_model_contents.py b/server_addon/max/client/ayon_max/plugins/publish/validate_model_contents.py deleted file mode 100644 index 9a4d988aa4..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_model_contents.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from pymxs import runtime as rt - -from ayon_core.pipeline import PublishValidationError - - -class ValidateModelContent(pyblish.api.InstancePlugin): - """Validates Model instance contents. - - A model instance may only hold either geometry-related - object(excluding Shapes) or editable meshes. - """ - - order = pyblish.api.ValidatorOrder - families = ["model"] - hosts = ["max"] - label = "Model Contents" - - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError(("Model instance must only include" - "Geometry and Editable Mesh. " - f"Invalid types on: {invalid}")) - - def get_invalid(self, instance): - """ - Get invalid nodes if the instance is not camera - """ - invalid = [] - container = instance.data["instance_node"] - self.log.info(f"Validating model content for {container}") - - selection_list = instance.data["members"] - for sel in selection_list: - if rt.ClassOf(sel) in rt.Camera.classes: - invalid.append(sel) - if rt.ClassOf(sel) in rt.Light.classes: - invalid.append(sel) - if rt.ClassOf(sel) in rt.Shape.classes: - invalid.append(sel) - - return invalid diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_model_name.py b/server_addon/max/client/ayon_max/plugins/publish/validate_model_name.py deleted file mode 100644 index d691b739b7..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_model_name.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate model nodes names.""" -import re - -import pyblish.api - -from ayon_max.api.action import SelectInvalidAction - -from ayon_core.pipeline.publish import ( - OptionalPyblishPluginMixin, - PublishXmlValidationError, - ValidateContentsOrder -) - -class ValidateModelName(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validate Model Name. - - Validation regex is `(.*)_(?P.*)_(GEO)` by default. - The setting supports the following regex group name: - - project - - asset - - subset - - Examples: - `{SOME_RANDOM_NAME}_{YOUR_SUBSET_NAME}_GEO` should be your - default model name. - The regex of `(?P.*)` can be replaced by `(?P.*)` - and `(?P.*)`. - `(.*)_(?P.*)_(GEO)` check if your model name is - `{SOME_RANDOM_NAME}_{CURRENT_ASSET_NAME}_GEO` - `(.*)_(?P.*)_(GEO)` check if your model name is - `{SOME_RANDOM_NAME}_{CURRENT_PROJECT_NAME}_GEO` - - """ - optional = True - order = ValidateContentsOrder - hosts = ["max"] - families = ["model"] - label = "Validate Model Name" - actions = [SelectInvalidAction] - - settings_category = "max" - - # defined by settings - regex = r"(.*)_(?P.*)_(GEO)" - # cache - regex_compiled = None - - def process(self, instance): - if not self.is_active(instance.data): - return - - invalid = self.get_invalid(instance) - if invalid: - names = "\n".join( - "- {}".format(node.name) for node in invalid - ) - raise PublishXmlValidationError( - plugin=self, - message="Nodes found with invalid model names: {}".format(invalid), - formatting_data={"nodes": names} - ) - - @classmethod - def get_invalid(cls, instance): - if not cls.regex: - cls.log.warning("No regex pattern set. Nothing to validate.") - return - - members = instance.data.get("members") - if not members: - cls.log.error("No members found in the instance.") - return - - cls.regex_compiled = re.compile(cls.regex) - - invalid = [] - for obj in members: - if cls.invalid_name(instance, obj): - invalid.append(obj) - return invalid - - @classmethod - def invalid_name(cls, instance, obj): - """Function to check the object has invalid name - regarding to the validation regex in the AYON setttings - - Args: - instance (pyblish.api.instance): Instance - obj (str): object name - - Returns: - str: invalid object - """ - regex = cls.regex_compiled - name = obj.name - match = regex.match(name) - - if match is None: - cls.log.error("Invalid model name on: %s", name) - cls.log.error("Name doesn't match regex {}".format(regex.pattern)) - return obj - - # Validate regex groups - invalid = False - compare = { - "project": instance.context.data["projectName"], - "asset": instance.data["folderPath"], - "subset": instance.data["productName"] - } - for key, required_value in compare.items(): - if key in regex.groupindex: - if match.group(key) != required_value: - cls.log.error( - "Invalid %s name for the model %s, " - "required name is %s", - key, name, required_value - ) - invalid = True - - if invalid: - return obj diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_no_animation.py b/server_addon/max/client/ayon_max/plugins/publish/validate_no_animation.py deleted file mode 100644 index 26384954ca..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_no_animation.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from pymxs import runtime as rt -from ayon_core.pipeline import ( - PublishValidationError, - OptionalPyblishPluginMixin -) -from ayon_max.api.action import SelectInvalidAction - - -def get_invalid_keys(obj): - """function to check on whether there is keyframe in - - Args: - obj (str): object needed to check if there is a keyframe - - Returns: - bool: whether invalid object(s) exist - """ - for transform in ["Position", "Rotation", "Scale"]: - num_of_key = rt.NumKeys(rt.getPropertyController( - obj.controller, transform)) - if num_of_key > 0: - return True - return False - - -class ValidateNoAnimation(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validates No Animation - - Ensure no keyframes on nodes in the Instance - """ - - order = pyblish.api.ValidatorOrder - families = ["model"] - hosts = ["max"] - optional = True - label = "Validate No Animation" - actions = [SelectInvalidAction] - - settings_category = "max" - - def process(self, instance): - if not self.is_active(instance.data): - return - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError( - "Keyframes found on:\n\n{0}".format(invalid) - , - title="Keyframes on model" - ) - - @staticmethod - def get_invalid(instance): - """Get invalid object(s) which have keyframe(s) - - - Args: - instance (pyblish.api.instance): Instance - - Returns: - list: list of invalid objects - """ - invalid = [invalid for invalid in instance.data["members"] - if invalid.isAnimated or get_invalid_keys(invalid)] - - return invalid diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_pointcloud.py b/server_addon/max/client/ayon_max/plugins/publish/validate_pointcloud.py deleted file mode 100644 index 73b18984ed..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_pointcloud.py +++ /dev/null @@ -1,126 +0,0 @@ -import pyblish.api -from ayon_core.pipeline import PublishValidationError -from pymxs import runtime as rt - - -class ValidatePointCloud(pyblish.api.InstancePlugin): - """Validate that work file was saved.""" - - order = pyblish.api.ValidatorOrder - families = ["pointcloud"] - hosts = ["max"] - label = "Validate Point Cloud" - - def process(self, instance): - """ - Notes: - 1. Validate if the export mode of Export Particle is at PRT format - 2. Validate the partition count and range set as default value - Partition Count : 100 - Partition Range : 1 to 1 - 3. Validate if the custom attribute(s) exist as parameter(s) - of export_particle operator - - """ - report = [] - - if self.validate_export_mode(instance): - report.append("The export mode is not at PRT") - - if self.validate_partition_value(instance): - report.append(("tyFlow Partition setting is " - "not at the default value")) - - invalid_attribute = self.validate_custom_attribute(instance) - if invalid_attribute: - report.append(("Custom Attribute not found " - f":{invalid_attribute}")) - - if report: - raise PublishValidationError(f"{report}") - - def validate_custom_attribute(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info( - f"Validating tyFlow custom attributes for {container}") - - selection_list = instance.data["members"] - - project_settings = instance.context.data["project_settings"] - attr_settings = project_settings["max"]["PointCloud"]["attribute"] - for sel in selection_list: - obj = sel.baseobject - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get all the names of the related tyFlow nodes - sub_anim = rt.GetSubAnim(obj, anim_name) - if rt.IsProperty(sub_anim, "Export_Particles"): - event_name = sub_anim.name - opt = "${0}.{1}.export_particles".format(sel.name, - event_name) - for attr in attr_settings: - key = attr["name"] - value = attr["value"] - custom_attr = "{0}.PRTChannels_{1}".format(opt, - value) - try: - rt.Execute(custom_attr) - except RuntimeError: - invalid.append(key) - - return invalid - - def validate_partition_value(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info( - f"Validating tyFlow partition value for {container}") - - selection_list = instance.data["members"] - for sel in selection_list: - obj = sel.baseobject - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get all the names of the related tyFlow nodes - sub_anim = rt.GetSubAnim(obj, anim_name) - if rt.IsProperty(sub_anim, "Export_Particles"): - event_name = sub_anim.name - opt = "${0}.{1}.export_particles".format(sel.name, - event_name) - count = rt.Execute(f'{opt}.PRTPartitionsCount') - if count != 100: - invalid.append(count) - start = rt.Execute(f'{opt}.PRTPartitionsFrom') - if start != 1: - invalid.append(start) - end = rt.Execute(f'{opt}.PRTPartitionsTo') - if end != 1: - invalid.append(end) - - return invalid - - def validate_export_mode(self, instance): - invalid = [] - container = instance.data["instance_node"] - self.log.info( - f"Validating tyFlow export mode for {container}") - - con = rt.GetNodeByName(container) - selection_list = list(con.Children) - for sel in selection_list: - obj = sel.baseobject - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get all the names of the related tyFlow nodes - sub_anim = rt.GetSubAnim(obj, anim_name) - # check if there is export particle operator - boolean = rt.IsProperty(sub_anim, "Export_Particles") - event_name = sub_anim.name - if boolean: - opt = f"${sel.name}.{event_name}.export_particles" - export_mode = rt.Execute(f'{opt}.exportMode') - if export_mode != 1: - invalid.append(export_mode) - - return invalid diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_renderable_camera.py b/server_addon/max/client/ayon_max/plugins/publish/validate_renderable_camera.py deleted file mode 100644 index dc05771e1b..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_renderable_camera.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from ayon_core.pipeline import ( - PublishValidationError, - OptionalPyblishPluginMixin) -from ayon_core.pipeline.publish import RepairAction -from ayon_max.api.lib import get_current_renderer - -from pymxs import runtime as rt - - -class ValidateRenderableCamera(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validates Renderable Camera - - Check if the renderable camera used for rendering - """ - - order = pyblish.api.ValidatorOrder - families = ["maxrender"] - hosts = ["max"] - label = "Renderable Camera" - optional = True - actions = [RepairAction] - - def process(self, instance): - if not self.is_active(instance.data): - return - if not instance.data["cameras"]: - raise PublishValidationError( - "No renderable Camera found in scene." - ) - - @classmethod - def repair(cls, instance): - - rt.viewport.setType(rt.Name("view_camera")) - camera = rt.viewport.GetCamera() - cls.log.info(f"Camera {camera} set as renderable camera") - renderer_class = get_current_renderer() - renderer = str(renderer_class).split(":")[0] - if renderer == "Arnold": - arv = rt.MAXToAOps.ArnoldRenderView() - arv.setOption("Camera", str(camera)) - arv.close() - instance.data["cameras"] = [camera.name] diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_renderer_redshift_proxy.py b/server_addon/max/client/ayon_max/plugins/publish/validate_renderer_redshift_proxy.py deleted file mode 100644 index 66c69bc100..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_renderer_redshift_proxy.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from ayon_core.pipeline import PublishValidationError -from pymxs import runtime as rt -from ayon_core.pipeline.publish import RepairAction -from ayon_max.api.lib import get_current_renderer - - -class ValidateRendererRedshiftProxy(pyblish.api.InstancePlugin): - """ - Validates Redshift as the current renderer for creating - Redshift Proxy - """ - - order = pyblish.api.ValidatorOrder - families = ["redshiftproxy"] - hosts = ["max"] - label = "Redshift Renderer" - actions = [RepairAction] - - def process(self, instance): - invalid = self.get_redshift_renderer(instance) - if invalid: - raise PublishValidationError("Please install Redshift for 3dsMax" - " before using the Redshift proxy instance") # noqa - invalid = self.get_current_renderer(instance) - if invalid: - raise PublishValidationError("The Redshift proxy extraction" - "discontinued since the current renderer is not Redshift") # noqa - - def get_redshift_renderer(self, instance): - invalid = list() - max_renderers_list = str(rt.RendererClass.classes) - if "Redshift_Renderer" not in max_renderers_list: - invalid.append(max_renderers_list) - - return invalid - - def get_current_renderer(self, instance): - invalid = list() - renderer_class = get_current_renderer() - current_renderer = str(renderer_class).split(":")[0] - if current_renderer != "Redshift_Renderer": - invalid.append(current_renderer) - - return invalid - - @classmethod - def repair(cls, instance): - for Renderer in rt.RendererClass.classes: - renderer = Renderer() - if "Redshift_Renderer" in str(renderer): - rt.renderers.production = renderer - break diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_renderpasses.py b/server_addon/max/client/ayon_max/plugins/publish/validate_renderpasses.py deleted file mode 100644 index d0d47c6340..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_renderpasses.py +++ /dev/null @@ -1,187 +0,0 @@ -import os -import pyblish.api -from pymxs import runtime as rt -from ayon_core.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError, - OptionalPyblishPluginMixin -) -from ayon_max.api.lib_rendersettings import RenderSettings - - -class ValidateRenderPasses(OptionalPyblishPluginMixin, - pyblish.api.InstancePlugin): - """Validates Render Passes before farm submission - """ - - order = ValidateContentsOrder - families = ["maxrender"] - hosts = ["max"] - label = "Validate Render Passes" - actions = [RepairAction] - - settings_category = "max" - - def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - bullet_point_invalid_statement = "\n".join( - f"- {err_type}: {filepath}" for err_type, filepath - in invalid - ) - report = ( - "Invalid render passes found.\n\n" - f"{bullet_point_invalid_statement}\n\n" - "You can use repair action to fix the invalid filepath." - ) - raise PublishValidationError( - report, title="Invalid Render Passes") - - @classmethod - def get_invalid(cls, instance): - """Function to get invalid beauty render outputs and - render elements. - - 1. Check Render Output Folder matches the name of - the current Max Scene, e.g. - The name of the current Max scene: - John_Doe.max - The expected render output directory: - {root[work]}/{project[name]}/{hierarchy}/{asset}/ - work/{task[name]}/render/3dsmax/John_Doe/ - - 2. Check image extension(s) of the render output(s) - matches the image format in OP/AYON setting, e.g. - The current image format in settings: png - The expected render outputs: John_Doe.png - - 3. Check filename of render element ends with the name of - render element from the 3dsMax Render Element Manager. - e.g. The name of render element: RsCryptomatte - The expected filename: {InstanceName}_RsCryptomatte.png - - Args: - instance (pyblish.api.Instance): instance - workfile_name (str): filename of the Max scene - - Returns: - list: list of invalid filename which doesn't match - with the project name - """ - invalid = [] - file = rt.maxFileName - workfile_name, ext = os.path.splitext(file) - if workfile_name not in rt.rendOutputFilename: - cls.log.error( - "Render output folder must include" - f" the max scene name {workfile_name} " - ) - invalid_folder_name = os.path.dirname( - rt.rendOutputFilename).replace( - "\\", "/").split("/")[-1] - invalid.append(("Invalid Render Output Folder", - invalid_folder_name)) - beauty_fname = os.path.basename(rt.rendOutputFilename) - beauty_name, ext = os.path.splitext(beauty_fname) - invalid_filenames = cls.get_invalid_filenames( - instance, beauty_name) - invalid.extend(invalid_filenames) - invalid_image_format = cls.get_invalid_image_format( - instance, ext.lstrip(".")) - invalid.extend(invalid_image_format) - renderer = instance.data["renderer"] - if renderer in [ - "ART_Renderer", - "Redshift_Renderer", - "V_Ray_6_Hotfix_3", - "V_Ray_GPU_6_Hotfix_3", - "Default_Scanline_Renderer", - "Quicksilver_Hardware_Renderer", - ]: - render_elem = rt.maxOps.GetCurRenderElementMgr() - render_elem_num = render_elem.NumRenderElements() - for i in range(render_elem_num): - renderlayer_name = render_elem.GetRenderElement(i) - renderpass = str(renderlayer_name).rsplit(":", 1)[-1] - rend_file = render_elem.GetRenderElementFilename(i) - if not rend_file: - continue - - rend_fname, ext = os.path.splitext( - os.path.basename(rend_file)) - invalid_filenames = cls.get_invalid_filenames( - instance, rend_fname, renderpass=renderpass) - invalid.extend(invalid_filenames) - invalid_image_format = cls.get_invalid_image_format( - instance, ext) - invalid.extend(invalid_image_format) - elif renderer == "Arnold": - cls.log.debug( - "Renderpass validation does not support Arnold yet," - " validation skipped...") - else: - cls.log.debug( - "Skipping render element validation " - f"for renderer: {renderer}") - return invalid - - @classmethod - def get_invalid_filenames(cls, instance, file_name, renderpass=None): - """Function to get invalid filenames from render outputs. - - Args: - instance (pyblish.api.Instance): instance - file_name (str): name of the file - renderpass (str, optional): name of the renderpass. - Defaults to None. - - Returns: - list: invalid filenames - """ - invalid = [] - if instance.name not in file_name: - cls.log.error("The renderpass filename should contain the instance name.") - invalid.append(("Invalid instance name", - file_name)) - if renderpass is not None: - if not file_name.rstrip(".").endswith(renderpass): - cls.log.error( - f"Filename for {renderpass} should " - f"end with {renderpass}: {file_name}" - ) - invalid.append((f"Invalid {renderpass}", - os.path.basename(file_name))) - return invalid - - @classmethod - def get_invalid_image_format(cls, instance, ext): - """Function to check if the image format of the render outputs - aligns with that in the setting. - - Args: - instance (pyblish.api.Instance): instance - ext (str): image extension - - Returns: - list: list of files with invalid image format - """ - invalid = [] - settings = instance.context.data["project_settings"].get("max") - image_format = settings["RenderSettings"]["image_format"] - ext = ext.lstrip(".") - if ext != image_format: - msg = ( - f"Invalid image format {ext} for render outputs.\n" - f"Should be: {image_format}") - cls.log.error(msg) - invalid.append((msg, ext)) - return invalid - - @classmethod - def repair(cls, instance): - container = instance.data.get("instance_node") - # TODO: need to rename the function of render_output - RenderSettings().render_output(container) - cls.log.debug("Finished repairing the render output " - "folder and filenames.") diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_resolution_setting.py b/server_addon/max/client/ayon_max/plugins/publish/validate_resolution_setting.py deleted file mode 100644 index 9f7ec17dd9..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_resolution_setting.py +++ /dev/null @@ -1,92 +0,0 @@ -import pyblish.api -from pymxs import runtime as rt -from ayon_core.pipeline import ( - OptionalPyblishPluginMixin -) -from ayon_core.pipeline.publish import ( - RepairAction, - PublishValidationError -) -from ayon_max.api.lib import ( - reset_scene_resolution, - imprint -) - - -class ValidateResolutionSetting(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validate the resolution setting aligned with DB""" - - order = pyblish.api.ValidatorOrder - 0.01 - families = ["maxrender"] - hosts = ["max"] - label = "Validate Resolution Setting" - optional = True - actions = [RepairAction] - - def process(self, instance): - if not self.is_active(instance.data): - return - width, height = self.get_folder_resolution(instance) - current_width, current_height = ( - self.get_current_resolution(instance) - ) - - if current_width != width and current_height != height: - raise PublishValidationError("Resolution Setting " - "not matching resolution " - "set on asset or shot.") - if current_width != width: - raise PublishValidationError("Width in Resolution Setting " - "not matching resolution set " - "on asset or shot.") - - if current_height != height: - raise PublishValidationError("Height in Resolution Setting " - "not matching resolution set " - "on asset or shot.") - - def get_current_resolution(self, instance): - return rt.renderWidth, rt.renderHeight - - @classmethod - def get_folder_resolution(cls, instance): - task_entity = instance.data.get("taskEntity") - if task_entity: - task_attributes = task_entity["attrib"] - width = task_attributes["resolutionWidth"] - height = task_attributes["resolutionHeight"] - return int(width), int(height) - - # Defaults if not found in folder entity - return 1920, 1080 - - @classmethod - def repair(cls, instance): - reset_scene_resolution() - - -class ValidateReviewResolutionSetting(ValidateResolutionSetting): - families = ["review"] - optional = True - actions = [RepairAction] - - def get_current_resolution(self, instance): - current_width = instance.data["review_width"] - current_height = instance.data["review_height"] - return current_width, current_height - - @classmethod - def repair(cls, instance): - context_width, context_height = ( - cls.get_folder_resolution(instance) - ) - creator_attrs = instance.data["creator_attributes"] - creator_attrs["review_width"] = context_width - creator_attrs["review_height"] = context_height - creator_attrs_data = { - "creator_attributes": creator_attrs - } - # update the width and height of review - # data in creator_attributes - imprint(instance.data["instance_node"], creator_attrs_data) diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_scene_saved.py b/server_addon/max/client/ayon_max/plugins/publish/validate_scene_saved.py deleted file mode 100644 index 3028a55337..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_scene_saved.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from ayon_core.pipeline import PublishValidationError -from pymxs import runtime as rt - - -class ValidateSceneSaved(pyblish.api.InstancePlugin): - """Validate that workfile was saved.""" - - order = pyblish.api.ValidatorOrder - families = ["workfile"] - hosts = ["max"] - label = "Validate Workfile is saved" - - def process(self, instance): - if not rt.maxFilePath or not rt.maxFileName: - raise PublishValidationError( - "Workfile is not saved", title=self.label) diff --git a/server_addon/max/client/ayon_max/startup/startup.ms b/server_addon/max/client/ayon_max/startup/startup.ms deleted file mode 100644 index c5b4f0e526..0000000000 --- a/server_addon/max/client/ayon_max/startup/startup.ms +++ /dev/null @@ -1,15 +0,0 @@ --- AYON Init Script -( - local sysPath = dotNetClass "System.IO.Path" - local sysDir = dotNetClass "System.IO.Directory" - local localScript = getThisScriptFilename() - local startup = sysPath.Combine (sysPath.GetDirectoryName localScript) "startup.py" - - local pythonpath = systemTools.getEnvVariable "MAX_PYTHONPATH" - systemTools.setEnvVariable "PYTHONPATH" pythonpath - - /*opens the create menu on startup to ensure users are presented with a useful default view.*/ - max create mode - - python.ExecuteFile startup -) diff --git a/server_addon/max/client/ayon_max/startup/startup.py b/server_addon/max/client/ayon_max/startup/startup.py deleted file mode 100644 index 1462cc93b7..0000000000 --- a/server_addon/max/client/ayon_max/startup/startup.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import sys -from ayon_max.api import MaxHost -from ayon_core.pipeline import install_host -# this might happen in some 3dsmax version where PYTHONPATH isn't added -# to sys.path automatically -for path in os.environ["PYTHONPATH"].split(os.pathsep): - if path and path not in sys.path: - sys.path.append(path) - -host = MaxHost() -install_host(host) diff --git a/server_addon/max/client/ayon_max/version.py b/server_addon/max/client/ayon_max/version.py deleted file mode 100644 index acb68bbdfc..0000000000 --- a/server_addon/max/client/ayon_max/version.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- -"""Package declaring AYON addon 'max' version.""" -__version__ = "0.2.1" diff --git a/server_addon/max/package.py b/server_addon/max/package.py deleted file mode 100644 index 09e86f8d50..0000000000 --- a/server_addon/max/package.py +++ /dev/null @@ -1,9 +0,0 @@ -name = "max" -title = "Max" -version = "0.2.1" -client_dir = "ayon_max" - -ayon_required_addons = { - "core": ">0.3.2", -} -ayon_compatible_addons = {} diff --git a/server_addon/max/server/__init__.py b/server_addon/max/server/__init__.py deleted file mode 100644 index d03b29d249..0000000000 --- a/server_addon/max/server/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Type - -from ayon_server.addons import BaseServerAddon - -from .settings import MaxSettings, DEFAULT_VALUES - - -class MaxAddon(BaseServerAddon): - settings_model: Type[MaxSettings] = MaxSettings - - async def get_default_settings(self): - settings_model_cls = self.get_settings_model() - return settings_model_cls(**DEFAULT_VALUES) diff --git a/server_addon/max/server/settings/__init__.py b/server_addon/max/server/settings/__init__.py deleted file mode 100644 index 986b1903a5..0000000000 --- a/server_addon/max/server/settings/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .main import ( - MaxSettings, - DEFAULT_VALUES, -) - - -__all__ = ( - "MaxSettings", - "DEFAULT_VALUES", -) diff --git a/server_addon/max/server/settings/create_review_settings.py b/server_addon/max/server/settings/create_review_settings.py deleted file mode 100644 index 807976a391..0000000000 --- a/server_addon/max/server/settings/create_review_settings.py +++ /dev/null @@ -1,91 +0,0 @@ -from ayon_server.settings import BaseSettingsModel, SettingsField - - -def image_format_enum(): - """Return enumerator for image output formats.""" - return [ - {"label": "exr", "value": "exr"}, - {"label": "jpg", "value": "jpg"}, - {"label": "png", "value": "png"}, - {"label": "tga", "value": "tga"} - ] - - -def visual_style_enum(): - """Return enumerator for viewport visual style.""" - return [ - {"label": "Realistic", "value": "Realistic"}, - {"label": "Shaded", "value": "Shaded"}, - {"label": "Facets", "value": "Facets"}, - {"label": "ConsistentColors", - "value": "ConsistentColors"}, - {"label": "Wireframe", "value": "Wireframe"}, - {"label": "BoundingBox", "value": "BoundingBox"}, - {"label": "Ink", "value": "Ink"}, - {"label": "ColorInk", "value": "ColorInk"}, - {"label": "Acrylic", "value": "Acrylic"}, - {"label": "Tech", "value": "Tech"}, - {"label": "Graphite", "value": "Graphite"}, - {"label": "ColorPencil", "value": "ColorPencil"}, - {"label": "Pastel", "value": "Pastel"}, - {"label": "Clay", "value": "Clay"}, - {"label": "ModelAssist", "value": "ModelAssist"} - ] - - -def preview_preset_enum(): - """Return enumerator for viewport visual preset.""" - return [ - {"label": "Quality", "value": "Quality"}, - {"label": "Standard", "value": "Standard"}, - {"label": "Performance", "value": "Performance"}, - {"label": "DXMode", "value": "DXMode"}, - {"label": "Customize", "value": "Customize"}, - ] - - -def anti_aliasing_enum(): - """Return enumerator for viewport anti-aliasing.""" - return [ - {"label": "None", "value": "None"}, - {"label": "2X", "value": "2X"}, - {"label": "4X", "value": "4X"}, - {"label": "8X", "value": "8X"} - ] - - -class CreateReviewModel(BaseSettingsModel): - review_width: int = SettingsField(1920, title="Review Width") - review_height: int = SettingsField(1080, title="Review Height") - percentSize: float = SettingsField(100.0, title="Percent of Output") - keep_images: bool = SettingsField(False, title="Keep Image Sequences") - image_format: str = SettingsField( - enum_resolver=image_format_enum, - title="Image Format Options" - ) - visual_style: str = SettingsField( - enum_resolver=visual_style_enum, - title="Preference" - ) - viewport_preset: str = SettingsField( - enum_resolver=preview_preset_enum, - title="Preview Preset" - ) - anti_aliasing: str = SettingsField( - enum_resolver=anti_aliasing_enum, - title="Anti-aliasing Quality" - ) - vp_texture: bool = SettingsField(True, title="Viewport Texture") - - -DEFAULT_CREATE_REVIEW_SETTINGS = { - "review_width": 1920, - "review_height": 1080, - "percentSize": 100.0, - "keep_images": False, - "image_format": "png", - "visual_style": "Realistic", - "viewport_preset": "Quality", - "anti_aliasing": "None", - "vp_texture": True -} diff --git a/server_addon/max/server/settings/imageio.py b/server_addon/max/server/settings/imageio.py deleted file mode 100644 index 9c6f1b6409..0000000000 --- a/server_addon/max/server/settings/imageio.py +++ /dev/null @@ -1,63 +0,0 @@ -from pydantic import validator -from ayon_server.settings import BaseSettingsModel, SettingsField -from ayon_server.settings.validators import ensure_unique_names - - -class ImageIOConfigModel(BaseSettingsModel): - """[DEPRECATED] Addon OCIO config settings. Please set the OCIO config - path in the Core addon profiles here - (ayon+settings://core/imageio/ocio_config_profiles). - """ - - override_global_config: bool = SettingsField( - False, - title="Override global OCIO config", - description=( - "DEPRECATED functionality. Please set the OCIO config path in the " - "Core addon profiles here (ayon+settings://core/imageio/" - "ocio_config_profiles)." - ), - ) - filepath: list[str] = SettingsField( - default_factory=list, - title="Config path", - description=( - "DEPRECATED functionality. Please set the OCIO config path in the " - "Core addon profiles here (ayon+settings://core/imageio/" - "ocio_config_profiles)." - ), - ) - - -class ImageIOFileRuleModel(BaseSettingsModel): - name: str = SettingsField("", title="Rule name") - pattern: str = SettingsField("", title="Regex pattern") - colorspace: str = SettingsField("", title="Colorspace name") - ext: str = SettingsField("", title="File extension") - - -class ImageIOFileRulesModel(BaseSettingsModel): - activate_host_rules: bool = SettingsField(False) - rules: list[ImageIOFileRuleModel] = SettingsField( - default_factory=list, - title="Rules" - ) - - @validator("rules") - def validate_unique_outputs(cls, value): - ensure_unique_names(value) - return value - - -class ImageIOSettings(BaseSettingsModel): - activate_host_color_management: bool = SettingsField( - True, title="Enable Color Management" - ) - ocio_config: ImageIOConfigModel = SettingsField( - default_factory=ImageIOConfigModel, - title="OCIO config" - ) - file_rules: ImageIOFileRulesModel = SettingsField( - default_factory=ImageIOFileRulesModel, - title="File Rules" - ) diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py deleted file mode 100644 index 7b0bfc6421..0000000000 --- a/server_addon/max/server/settings/main.py +++ /dev/null @@ -1,94 +0,0 @@ -from ayon_server.settings import BaseSettingsModel, SettingsField -from .imageio import ImageIOSettings -from .render_settings import ( - RenderSettingsModel, DEFAULT_RENDER_SETTINGS -) -from .create_review_settings import ( - CreateReviewModel, DEFAULT_CREATE_REVIEW_SETTINGS -) -from .publishers import ( - PublishersModel, DEFAULT_PUBLISH_SETTINGS -) - - -def unit_scale_enum(): - """Return enumerator for scene unit scale.""" - return [ - {"label": "mm", "value": "Millimeters"}, - {"label": "cm", "value": "Centimeters"}, - {"label": "m", "value": "Meters"}, - {"label": "km", "value": "Kilometers"} - ] - - -class UnitScaleSettings(BaseSettingsModel): - enabled: bool = SettingsField(True, title="Enabled") - scene_unit_scale: str = SettingsField( - "Centimeters", - title="Scene Unit Scale", - enum_resolver=unit_scale_enum - ) - - -class PRTAttributesModel(BaseSettingsModel): - _layout = "compact" - name: str = SettingsField(title="Name") - value: str = SettingsField(title="Attribute") - - -class PointCloudSettings(BaseSettingsModel): - attribute: list[PRTAttributesModel] = SettingsField( - default_factory=list, title="Channel Attribute") - - -class MaxSettings(BaseSettingsModel): - unit_scale_settings: UnitScaleSettings = SettingsField( - default_factory=UnitScaleSettings, - title="Set Unit Scale" - ) - imageio: ImageIOSettings = SettingsField( - default_factory=ImageIOSettings, - title="Color Management (ImageIO)" - ) - RenderSettings: RenderSettingsModel = SettingsField( - default_factory=RenderSettingsModel, - title="Render Settings" - ) - CreateReview: CreateReviewModel = SettingsField( - default_factory=CreateReviewModel, - title="Create Review" - ) - PointCloud: PointCloudSettings = SettingsField( - default_factory=PointCloudSettings, - title="Point Cloud" - ) - publish: PublishersModel = SettingsField( - default_factory=PublishersModel, - title="Publish Plugins") - - -DEFAULT_VALUES = { - "unit_scale_settings": { - "enabled": True, - "scene_unit_scale": "Centimeters" - }, - "RenderSettings": DEFAULT_RENDER_SETTINGS, - "CreateReview": DEFAULT_CREATE_REVIEW_SETTINGS, - "PointCloud": { - "attribute": [ - {"name": "Age", "value": "age"}, - {"name": "Radius", "value": "radius"}, - {"name": "Position", "value": "position"}, - {"name": "Rotation", "value": "rotation"}, - {"name": "Scale", "value": "scale"}, - {"name": "Velocity", "value": "velocity"}, - {"name": "Color", "value": "color"}, - {"name": "TextureCoordinate", "value": "texcoord"}, - {"name": "MaterialID", "value": "matid"}, - {"name": "custFloats", "value": "custFloats"}, - {"name": "custVecs", "value": "custVecs"}, - ] - }, - "publish": DEFAULT_PUBLISH_SETTINGS - -} diff --git a/server_addon/max/server/settings/render_settings.py b/server_addon/max/server/settings/render_settings.py deleted file mode 100644 index 19d36dd0f8..0000000000 --- a/server_addon/max/server/settings/render_settings.py +++ /dev/null @@ -1,47 +0,0 @@ -from ayon_server.settings import BaseSettingsModel, SettingsField - - -def aov_separators_enum(): - return [ - {"value": "dash", "label": "- (dash)"}, - {"value": "underscore", "label": "_ (underscore)"}, - {"value": "dot", "label": ". (dot)"} - ] - - -def image_format_enum(): - """Return enumerator for image output formats.""" - return [ - {"label": "bmp", "value": "bmp"}, - {"label": "exr", "value": "exr"}, - {"label": "tif", "value": "tif"}, - {"label": "tiff", "value": "tiff"}, - {"label": "jpg", "value": "jpg"}, - {"label": "png", "value": "png"}, - {"label": "tga", "value": "tga"}, - {"label": "dds", "value": "dds"} - ] - - -class RenderSettingsModel(BaseSettingsModel): - default_render_image_folder: str = SettingsField( - title="Default render image folder" - ) - aov_separator: str = SettingsField( - "underscore", - title="AOV Separator character", - enum_resolver=aov_separators_enum - ) - image_format: str = SettingsField( - enum_resolver=image_format_enum, - title="Output Image Format" - ) - multipass: bool = SettingsField(title="multipass") - - -DEFAULT_RENDER_SETTINGS = { - "default_render_image_folder": "renders/3dsmax", - "aov_separator": "underscore", - "image_format": "exr", - "multipass": True -} From 54b73e2ad4c131fe6228bd434bd5ed83906d7b46 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:46:38 +0200 Subject: [PATCH 059/163] removed server addon --- server_addon/README.md | 34 --- server_addon/create_ayon_addons.py | 376 ----------------------------- 2 files changed, 410 deletions(-) delete mode 100644 server_addon/README.md delete mode 100644 server_addon/create_ayon_addons.py diff --git a/server_addon/README.md b/server_addon/README.md deleted file mode 100644 index c6d467adaa..0000000000 --- a/server_addon/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Addons for AYON server -Preparation of AYON addons based on OpenPype codebase. The output is a bunch of zip files in `./packages` directory that can be uploaded to AYON server. One of the packages is `openpype` which is OpenPype code converted to AYON addon. The addon is must have requirement to be able to use `ayon-launcher`. The versioning of `openpype` addon is following versioning of OpenPype. The other addons contain only settings models. - -## Intro -OpenPype is transitioning to AYON, a dedicated server with its own database, moving away from MongoDB. During this transition period, OpenPype will remain compatible with both MongoDB and AYON. However, we will gradually update the codebase to align with AYON's data structure and separate individual components into addons. - -Currently, OpenPype has an AYON mode, which means it utilizes the AYON server instead of MongoDB through conversion utilities. Initially, we added the AYON executable alongside the OpenPype executables to enable AYON mode. While this approach worked, updating to new code versions would require a complete reinstallation. To address this, we have decided to create a new repository specifically for the base desktop application logic, which we currently refer to as the AYON Launcher. This Launcher will replace the executables generated by the OpenPype build and convert the OpenPype code into a server addon, resulting in smaller updates. - -Since the implementation of the AYON Launcher is not yet fully completed, we will maintain both methods of starting AYON mode for now. Once the AYON Launcher is finished, we will remove the AYON executables from the OpenPype codebase entirely. - -During this transitional period, the AYON Launcher addon will be a requirement as the entry point for using the AYON Launcher. - -## How to start -There is a `create_ayon_addons.py` python file which contains logic how to create server addon from OpenPype codebase. Just run the code. -```shell -./.poetry/bin/poetry run python ./server_addon/create_ayon_addons.py -``` - -It will create directory `./packages/.zip` files for AYON server. You can then copy upload the zip files to AYON server. Restart server to update addons information, add the addon version to server bundle and set the bundle for production or staging usage. - -Once addon is on server and is enabled, you can just run AYON launcher. Content will be downloaded and used automatically. - -### Additional arguments -Additional arguments are useful for development purposes. - -To skip zip creation to keep only server ready folder structure, pass `--skip-zip` argument. -```shell -./.poetry/bin/poetry run python ./server_addon/create_ayon_addons.py --skip-zip -``` - -To create both zips and keep folder structure, pass `--keep-sources` argument. -```shell -./.poetry/bin/poetry run python ./server_addon/create_ayon_addons.py --keep-sources -``` diff --git a/server_addon/create_ayon_addons.py b/server_addon/create_ayon_addons.py deleted file mode 100644 index 73d0b54770..0000000000 --- a/server_addon/create_ayon_addons.py +++ /dev/null @@ -1,376 +0,0 @@ -import io -import os -import sys -import re -import shutil -import argparse -import zipfile -import types -import importlib.machinery -import platform -import collections -from pathlib import Path -from typing import Optional, Iterable, Pattern, List, Tuple - -# Patterns of directories to be skipped for server part of addon -IGNORE_DIR_PATTERNS: List[Pattern] = [ - re.compile(pattern) - for pattern in { - # Skip directories starting with '.' - r"^\.", - # Skip any pycache folders - "^__pycache__$" - } -] - -# Patterns of files to be skipped for server part of addon -IGNORE_FILE_PATTERNS: List[Pattern] = [ - re.compile(pattern) - for pattern in { - # Skip files starting with '.' - # NOTE this could be an issue in some cases - r"^\.", - # Skip '.pyc' files - r"\.pyc$" - } -] - -IGNORED_HOSTS = [ - "flame", - "harmony", -] - -IGNORED_MODULES = [] - -PACKAGE_PY_TEMPLATE = """name = "{addon_name}" -version = "{addon_version}" -plugin_for = ["ayon_server"] -""" - -CLIENT_VERSION_CONTENT = '''# -*- coding: utf-8 -*- -"""Package declaring AYON addon '{}' version.""" -__version__ = "{}" -''' - - -class ZipFileLongPaths(zipfile.ZipFile): - """Allows longer paths in zip files. - - Regular DOS paths are limited to MAX_PATH (260) characters, including - the string's terminating NUL character. - That limit can be exceeded by using an extended-length path that - starts with the '\\?\' prefix. - """ - _is_windows = platform.system().lower() == "windows" - - def _extract_member(self, member, tpath, pwd): - if self._is_windows: - tpath = os.path.abspath(tpath) - if tpath.startswith("\\\\"): - tpath = "\\\\?\\UNC\\" + tpath[2:] - else: - tpath = "\\\\?\\" + tpath - - return super()._extract_member(member, tpath, pwd) - - -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: str, - ignore_file_patterns: Optional[List[Pattern]] = None, - ignore_dir_patterns: Optional[List[Pattern]] = None, - include_empty_dirs: bool = True -): - """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. - include_empty_dirs (Optional[bool]): Do not skip empty directories. - - Returns: - List[Tuple[str, str]]: List of tuples with path to file and parent - directories relative to 'src_path'. - """ - if not os.path.exists(src_path): - return [] - - 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: List[Tuple[str, str]] = [] - - hierarchy_queue = collections.deque() - hierarchy_queue.append((src_path, [])) - while hierarchy_queue: - item: Tuple[str, List[str]] = hierarchy_queue.popleft() - dirpath, parents = item - subnames = list(os.listdir(dirpath)) - if not subnames and include_empty_dirs: - output.append((dirpath, os.path.sep.join(parents))) - - for name in subnames: - path = os.path.join(dirpath, name) - if os.path.isfile(path): - if not _value_match_regexes(name, ignore_file_patterns): - items = 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.append(name) - hierarchy_queue.append((path, items)) - - return output - - -def create_addon_zip( - output_dir: Path, - addon_name: str, - addon_version: str, - files_mapping: List[Tuple[str, str]], - client_zip_content: io.BytesIO -): - zip_filepath = output_dir / f"{addon_name}-{addon_version}.zip" - - with ZipFileLongPaths(zip_filepath, "w", zipfile.ZIP_DEFLATED) as zipf: - for src_path, dst_subpath in files_mapping: - zipf.write(src_path, dst_subpath) - - if client_zip_content is not None: - zipf.writestr("private/client.zip", client_zip_content.getvalue()) - - -def prepare_client_zip( - addon_dir: Path, - addon_name: str, - addon_version: str, - client_dir: str -): - if not client_dir: - return None - client_dir_obj = addon_dir / "client" / client_dir - if not client_dir_obj.exists(): - return None - - # Update version.py with server version if 'version.py' is available - version_path = client_dir_obj / "version.py" - if version_path.exists(): - with open(version_path, "w") as stream: - stream.write( - CLIENT_VERSION_CONTENT.format(addon_name, addon_version) - ) - - zip_content = io.BytesIO() - with ZipFileLongPaths(zip_content, "a", zipfile.ZIP_DEFLATED) as zipf: - # Add client code content to zip - for path, sub_path in find_files_in_subdir( - str(client_dir_obj), include_empty_dirs=False - ): - sub_path = os.path.join(client_dir, sub_path) - zipf.write(path, sub_path) - - zip_content.seek(0) - return zip_content - - -def import_filepath(path: Path, module_name: Optional[str] = None): - if not module_name: - module_name = os.path.splitext(path.name)[0] - - # Convert to string - path = str(path) - module = types.ModuleType(module_name) - module.__file__ = path - - # Use loader so module has full specs - module_loader = importlib.machinery.SourceFileLoader( - module_name, path - ) - module_loader.exec_module(module) - return module - - -def _get_server_mapping( - addon_dir: Path, addon_version: str -) -> List[Tuple[str, str]]: - server_dir = addon_dir / "server" - public_dir = addon_dir / "public" - src_package_py = addon_dir / "package.py" - pyproject_toml = addon_dir / "client" / "pyproject.toml" - - mapping: List[Tuple[str, str]] = [ - (src_path, f"server/{sub_path}") - for src_path, sub_path in find_files_in_subdir(str(server_dir)) - ] - mapping.extend([ - (src_path, f"public/{sub_path}") - for src_path, sub_path in find_files_in_subdir(str(public_dir)) - ]) - mapping.append((src_package_py.as_posix(), "package.py")) - if pyproject_toml.exists(): - mapping.append((pyproject_toml.as_posix(), "private/pyproject.toml")) - - return mapping - - -def create_addon_package( - addon_dir: Path, - output_dir: Path, - create_zip: bool, -): - src_package_py = addon_dir / "package.py" - - package = import_filepath(src_package_py) - addon_name = package.name - addon_version = package.version - - files_mapping = _get_server_mapping(addon_dir, addon_version) - - client_dir = getattr(package, "client_dir", None) - client_zip_content = prepare_client_zip( - addon_dir, addon_name, addon_version, client_dir - ) - - if create_zip: - create_addon_zip( - output_dir, - addon_name, - addon_version, - files_mapping, - client_zip_content - ) - - else: - addon_output_dir = output_dir / addon_dir.name / addon_version - if addon_output_dir.exists(): - shutil.rmtree(str(addon_output_dir)) - - addon_output_dir.mkdir(parents=True, exist_ok=True) - - for src_path, dst_subpath in files_mapping: - dst_path = addon_output_dir / dst_subpath - dst_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(src_path, dst_path) - - if client_zip_content is not None: - private_dir = addon_output_dir / "private" - private_dir.mkdir(parents=True, exist_ok=True) - with open(private_dir / "client.zip", "wb") as stream: - stream.write(client_zip_content.read()) - - -def main( - output_dir=None, - skip_zip=True, - clear_output_dir=False, - addons=None, -): - current_dir = Path(os.path.dirname(os.path.abspath(__file__))) - create_zip = not skip_zip - - if output_dir: - output_dir = Path(output_dir) - else: - output_dir = current_dir / "packages" - - if output_dir.exists() and clear_output_dir: - shutil.rmtree(str(output_dir)) - - print("Package creation started...") - print(f"Output directory: {output_dir}") - - # Make sure output dir is created - output_dir.mkdir(parents=True, exist_ok=True) - ignored_addons = set(IGNORED_HOSTS) | set(IGNORED_MODULES) - for addon_dir in current_dir.iterdir(): - if not addon_dir.is_dir(): - continue - - if addons and addon_dir.name not in addons: - continue - - if addon_dir.name in ignored_addons: - continue - - server_dir = addon_dir / "server" - if not server_dir.exists(): - continue - - create_addon_package(addon_dir, output_dir, create_zip) - - print(f"- package '{addon_dir.name}' created") - print(f"Package creation finished. Output directory: {output_dir}") - - -if __name__ == "__main__": - parser = argparse.ArgumentParser() - parser.add_argument( - "--skip-zip", - dest="skip_zip", - action="store_true", - help=( - "Skip zipping server package and create only" - " 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( - "-o", "--output", - dest="output_dir", - default=None, - help=( - "Directory path where package will be created" - " (Will be purged if already exists!)" - ) - ) - parser.add_argument( - "-c", "--clear-output-dir", - dest="clear_output_dir", - action="store_true", - help=( - "Clear output directory before package creation." - ) - ) - parser.add_argument( - "-a", - "--addon", - dest="addons", - action="append", - help="Limit addon creation to given addon name", - ) - - args = parser.parse_args(sys.argv[1:]) - if args.keep_sources: - print("Keeping sources is not supported anymore!") - - main( - args.output_dir, - args.skip_zip, - args.clear_output_dir, - args.addons, - ) From d96cf7ff6b343470482f8b47209ac4608f6fbb48 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:47:58 +0200 Subject: [PATCH 060/163] removed server addons from ruff ignore --- pyproject.toml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 82f0fc364e..eef5575f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,11 +79,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" exclude = [ "client/ayon_core/modules/click_wrap.py", - "client/ayon_core/scripts/slates/__init__.py", - "server_addon/deadline/client/ayon_deadline/repository/custom/plugins/CelAction/*", - "server_addon/deadline/client/ayon_deadline/repository/custom/plugins/HarmonyAYON/*", - "server_addon/hiero/client/ayon_hiero/api/startup/*", - "server_addon/aftereffects/client/ayon_aftereffects/api/extension/js/libs/*" + "client/ayon_core/scripts/slates/__init__.py" ] [tool.ruff.lint.per-file-ignores] From af0b775cbf71deeb85bf5bf4f733759b755d848f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:48:08 +0200 Subject: [PATCH 061/163] remove server addon from gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index acbc3e2572..41389755f1 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,6 @@ Temporary Items # Package dirs ########### -/server_addon/packages/* /package/* /.venv From 2b57f89b209472f3aca69c354fcdceafd1186e29 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 9 Jul 2024 18:45:04 +0800 Subject: [PATCH 062/163] use different file output template for textures from substance painter --- server/settings/tools.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/settings/tools.py b/server/settings/tools.py index 3ed12d3d0a..4f03f554f0 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -572,6 +572,17 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "task_names": [], "template_name": "tycache" + }, + { + "product_types": [ + "image" + ], + "hosts": [ + "substancepainter" + ], + "task_types": [], + "task_names": [], + "template_name": "substanceImage" } ], "hero_template_name_profiles": [ From 01fa1a8c254ca649f8bfd0c87fa8639a733e2eb0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 9 Jul 2024 14:34:23 +0200 Subject: [PATCH 063/163] all deselected statuses works as all statuses are selected --- .../tools/loader/ui/_multicombobox.py | 175 ++++++++++++------ .../tools/loader/ui/statuses_combo.py | 23 ++- 2 files changed, 132 insertions(+), 66 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/_multicombobox.py b/client/ayon_core/tools/loader/ui/_multicombobox.py index 66a6963775..c026952418 100644 --- a/client/ayon_core/tools/loader/ui/_multicombobox.py +++ b/client/ayon_core/tools/loader/ui/_multicombobox.py @@ -1,3 +1,5 @@ +from typing import List, Tuple, Optional, Iterable, Any + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.tools.utils.lib import ( @@ -10,7 +12,7 @@ from ayon_core.tools.utils.constants import ( ITEM_IS_USER_TRISTATE, ) -CUSTOM_ITEM_TYPE = 0 +VALUE_ITEM_TYPE = 0 STANDARD_ITEM_TYPE = 1 SEPARATOR_ITEM_TYPE = 2 @@ -22,11 +24,11 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): def __init__( self, - text_role, - short_text_role, - text_color_role, - icon_role, - item_type_role=None, + text_role: int, + short_text_role: int, + text_color_role: int, + icon_role: int, + item_type_role: Optional[int] = None, parent=None ): super().__init__(parent) @@ -42,7 +44,7 @@ class CustomPaintDelegate(QtWidgets.QStyledItemDelegate): item_type = index.data(self._item_type_role) if item_type is None: - item_type = CUSTOM_ITEM_TYPE + item_type = VALUE_ITEM_TYPE if item_type == STANDARD_ITEM_TYPE: super().paint(painter, option, index) @@ -290,15 +292,49 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): self._placeholder_text = placeholder self._custom_text = None + self._all_unchecked_as_checked = True - def get_placeholder_text(self): + def all_unchecked_as_checked(self) -> bool: + return self._all_unchecked_as_checked + + def set_all_unchecked_as_checked(self, value: bool): + """Set if all unchecked items should be treated as checked. + + Args: + value (bool): If True, all unchecked items will be treated + as checked. + + """ + self._all_unchecked_as_checked = value + + def get_placeholder_text(self) -> Optional[str]: return self._placeholder_text - def set_placeholder_text(self, text): + def set_placeholder_text(self, text: Optional[str]): + """Set the placeholder text. + + Text shown when nothing is selected. + + Args: + text (str | None): The placeholder text. + + """ + if text == self._placeholder_text: + return self._placeholder_text = text self.repaint() - def set_custom_text(self, text): + def set_custom_text(self, text: Optional[str]): + """Set the placeholder text. + + Text always shown in combobox field. + + Args: + text (str | None): The text. Use 'None' to reset to default. + + """ + if text == self._custom_text: + return self._custom_text = text self.repaint() @@ -481,64 +517,78 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): def setItemCheckState(self, index, state): self.setItemData(index, state, QtCore.Qt.CheckStateRole) - def set_value(self, values, role=None): + def set_value(self, values: Optional[Iterable[Any]], role: Optional[int] = None): if role is None: role = self._value_role for idx in range(self.count()): value = self.itemData(idx, role=role) - if value in values: - check_state = CHECKED_INT - else: + check_state = CHECKED_INT + if values is None or value not in values: check_state = UNCHECKED_INT self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) self.repaint() + def get_value_info( + self, + role: Optional[int] = None, + propagate_all_unchecked_as_checked: bool = None + ) -> List[Tuple[Any, bool]]: + """Get the values and their checked state. + + Args: + role (int | None): The role to get the values from. + If None, the default value role is used. + propagate_all_unchecked_as_checked (bool | None): If True, + all unchecked items will be treated as checked. + If None, the current value of + 'propagate_all_unchecked_as_checked' is used. + + Returns: + List[Tuple[Any, bool]]: The values and their checked state. + + """ + if role is None: + role = self._value_role + + if propagate_all_unchecked_as_checked is None: + propagate_all_unchecked_as_checked = ( + self._all_unchecked_as_checked + ) + + items = [] + all_unchecked = True + for idx in range(self.count()): + item_type = self.itemData(idx, role=self._item_type_role) + if item_type is not None and item_type != VALUE_ITEM_TYPE: + continue + + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + checked = state == QtCore.Qt.Checked + if checked: + all_unchecked = False + items.append( + (self.itemData(idx, role=role), checked) + ) + + if propagate_all_unchecked_as_checked and all_unchecked: + items = [ + (value, True) + for value, checked in items + ] + return items + def get_value(self, role=None): if role is None: role = self._value_role - items = [] - for idx in range(self.count()): - item_type = self.itemData(idx, role=self._item_type_role) - if item_type is not None and item_type != CUSTOM_ITEM_TYPE: - continue - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - if state == QtCore.Qt.Checked: - items.append(self.itemData(idx, role=role)) - return items - - def get_all_value_info(self, role=None): - if role is None: - role = self._value_role - items = [] - for idx in range(self.count()): - item_type = self.itemData(idx, role=self._item_type_role) - if item_type is not None and item_type != CUSTOM_ITEM_TYPE: - continue - - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - items.append( - ( - self.itemData(idx, role=role), - state == QtCore.Qt.Checked - ) - ) - return items - - def _get_checked_idx(self): - indexes = [] - for idx in range(self.count()): - state = checkstate_int_to_enum( - self.itemData(idx, role=QtCore.Qt.CheckStateRole) - ) - if state == QtCore.Qt.Checked: - indexes.append(idx) - return indexes + return [ + value + for value, checked in self.get_value_info(role) + if checked + ] def wheelEvent(self, event): event.ignore() @@ -555,6 +605,20 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): return super().keyPressEvent(event) + def _get_checked_idx(self) -> List[int]: + checked_indexes = [] + for idx in range(self.count()): + item_type = self.itemData(idx, role=self._item_type_role) + if item_type is not None and item_type != VALUE_ITEM_TYPE: + continue + + state = checkstate_int_to_enum( + self.itemData(idx, role=QtCore.Qt.CheckStateRole) + ) + if state == QtCore.Qt.Checked: + checked_indexes.append(idx) + return checked_indexes + def _mouse_released_event_handle( self, event, current_index, index_flags, state ): @@ -573,7 +637,6 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox): return UNCHECKED_INT return CHECKED_INT - def _key_press_event_handler( self, event, current_index, index_flags, state ): diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 881de94629..5587853940 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -42,13 +42,16 @@ class StatusesQtModel(QtGui.QStandardItemModel): self.refresh(None) + def get_placeholder_text(self): + return self._placeholder + def refresh(self, project_name): # New project was selected # status filter is reset to show all statuses - check_all = False + uncheck_all = False if project_name != self._last_project: self._last_project = project_name - check_all = True + uncheck_all = True if project_name is None: self._add_select_project_item() @@ -72,14 +75,14 @@ class StatusesQtModel(QtGui.QStandardItemModel): if name in self._items_by_name: is_new = False item = self._items_by_name[name] - if check_all: - item.setCheckState(QtCore.Qt.Checked) + if uncheck_all: + item.setCheckState(QtCore.Qt.Unchecked) items_to_remove.discard(name) else: is_new = True item = QtGui.QStandardItem() item.setData(ITEM_SUBTYPE_ROLE, STATUS_ITEM_TYPE) - item.setCheckState(QtCore.Qt.Checked) + item.setCheckState(QtCore.Qt.Unchecked) item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable @@ -148,8 +151,8 @@ class StatusesQtModel(QtGui.QStandardItemModel): if self._empty_statuses_item is not None: return - empty_statuses_item = QtGui.QStandardItem("No statuses..") - select_project_item = QtGui.QStandardItem("Select project..") + empty_statuses_item = QtGui.QStandardItem("No statuses...") + select_project_item = QtGui.QStandardItem("Select project...") select_all_item = QtGui.QStandardItem("Select all") deselect_all_item = QtGui.QStandardItem("Deselect all") @@ -291,9 +294,10 @@ class StatusesCombobox(CustomPaintMultiselectComboBox): model=model, parent=parent ) - self.set_placeholder_text("Statuses filter..") + self.set_placeholder_text("Statuses...") self._model = model self._last_project_name = None + self._fully_disabled_filter = False controller.register_event_callback( "selection.project.changed", @@ -310,7 +314,7 @@ class StatusesCombobox(CustomPaintMultiselectComboBox): def _on_status_filter_change(self): lines = ["Statuses filter"] - for item in self.get_all_value_info(): + for item in self.get_value_info(): status_name, enabled = item lines.append(f"{'✔' if enabled else '☐'} {status_name}") @@ -320,7 +324,6 @@ class StatusesCombobox(CustomPaintMultiselectComboBox): project_name = event["project_name"] self._last_project_name = project_name self._model.refresh(project_name) - self._on_status_filter_change() def _on_projects_refresh(self): if self._last_project_name: From df0202742a23256fef5fecb2c99a5f5e1881d46f Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 9 Jul 2024 21:27:56 +0800 Subject: [PATCH 064/163] make sure udim is not a list --- client/ayon_core/plugins/publish/integrate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 1a4cda4dbb..1bad97bc61 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -765,7 +765,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Manage anatomy template data template_data.pop("frame", None) if is_udim: - template_data["udim"] = repre["udim"][0] + template_data["udim"] = repre["udim"] # Construct destination filepath from template template_filled = path_template_obj.format_strict(template_data) repre_context = template_filled.used_values @@ -792,7 +792,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Explicitly store the full list even though template data might # have a different value because it uses just a single udim tile if repre.get("udim"): - repre_context["udim"] = repre.get("udim") # store list + repre_context["udim"] = repre.get("udim")[0] # store list # Use previous representation's id if there is a name match existing = existing_repres_by_name.get(repre["name"].lower()) From 45bfd411678c103eb91d8156b661e3f755c28b8c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 9 Jul 2024 21:29:15 +0800 Subject: [PATCH 065/163] make sure udim is not a list --- server/settings/tools.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/server/settings/tools.py b/server/settings/tools.py index 4f03f554f0..3ed12d3d0a 100644 --- a/server/settings/tools.py +++ b/server/settings/tools.py @@ -572,17 +572,6 @@ DEFAULT_TOOLS_VALUES = { "task_types": [], "task_names": [], "template_name": "tycache" - }, - { - "product_types": [ - "image" - ], - "hosts": [ - "substancepainter" - ], - "task_types": [], - "task_names": [], - "template_name": "substanceImage" } ], "hero_template_name_profiles": [ From 8b2600fb39e4f86931267e089a949209873e5854 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 9 Jul 2024 23:18:48 +0300 Subject: [PATCH 066/163] fix typo --- .../pipeline/workfile/workfile_template_builder.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index bb94d87483..ab19e940d9 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -3,7 +3,7 @@ Build templates are manually prepared using plugin definitions which create placeholders inside the template which are populated on import. -This approach is very explicit to achive very specific build logic that can be +This approach is very explicit to achieve very specific build logic that can be targeted by task types and names. Placeholders are created using placeholder plugins which should care about @@ -87,7 +87,7 @@ class AbstractTemplateBuilder(object): """Abstraction of Template Builder. Builder cares about context, shared data, cache, discovery of plugins - and trigger logic. Provides public api for host workfile build systen. + and trigger logic. Provides public api for host workfile build system. Rest of logic is based on plugins that care about collection and creation of placeholder items. @@ -806,7 +806,7 @@ class AbstractTemplateBuilder(object): ) def get_template_preset(self): - """Unified way how template preset is received usign settings. + """Unified way how template preset is received using settings. Method is dependent on '_get_build_profiles' which should return filter profiles to resolve path to a template. Default implementation looks @@ -1427,7 +1427,7 @@ class PlaceholderLoadMixin(object): placeholder='{"camera":"persp", "lights":True}', tooltip=( "Loader" - "\nDefines a dictionnary of arguments used to load assets." + "\nDefines a dictionary of arguments used to load assets." "\nUseable arguments depend on current placeholder Loader." "\nField should be a valid python dict." " Anything else will be ignored." @@ -1472,7 +1472,7 @@ class PlaceholderLoadMixin(object): ] def parse_loader_args(self, loader_args): - """Helper function to parse string of loader arugments. + """Helper function to parse string of loader arguments. Empty dictionary is returned if conversion fails. From 2e44f15dabeb4b147a25ea8bd6cce4a539831393 Mon Sep 17 00:00:00 2001 From: MustafaJafar Date: Tue, 9 Jul 2024 23:19:32 +0300 Subject: [PATCH 067/163] replace task_name with task_entity --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index ab19e940d9..4a1f3a84da 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1872,7 +1872,7 @@ class PlaceholderCreateMixin(object): creator_plugin.identifier, create_variant, folder_entity, - task_name=task_name, + task_entity, pre_create_data=pre_create_data ) From 88d6a848d3a830d0798b53c5adbaa5500e5a6345 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 12:53:22 +0200 Subject: [PATCH 068/163] fix status filtering after refresh --- .../tools/loader/ui/products_delegates.py | 65 +++++++++++++------ .../tools/loader/ui/products_model.py | 38 +++++++---- .../tools/loader/ui/products_widget.py | 16 +++-- 3 files changed, 81 insertions(+), 38 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index d54164c0f2..869bb5915c 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -1,4 +1,7 @@ import numbers +import uuid +from typing import Dict, Set + from qtpy import QtWidgets, QtCore, QtGui from ayon_core.tools.utils.lib import format_version @@ -74,7 +77,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): class VersionComboBox(QtWidgets.QComboBox): - value_changed = QtCore.Signal(str) + value_changed = QtCore.Signal() def __init__(self, product_id, parent): super().__init__(parent) @@ -105,6 +108,11 @@ class VersionComboBox(QtWidgets.QComboBox): if self.currentIndex() != 0: self.setCurrentIndex(0) + def all_versions_filtered_out(self): + if self._items_by_id: + return self.count() == 0 + return False + def update_versions(self, version_items, current_version_id): self.blockSignals(True) version_items = list(version_items) @@ -129,7 +137,13 @@ class VersionComboBox(QtWidgets.QComboBox): if value == self._current_id: return self._current_id = value - self.value_changed.emit(self._product_id) + self.value_changed.emit() + + +class EditorInfo: + def __init__(self, widget): + self.widget = widget + self.added = False class VersionDelegate(QtWidgets.QStyledItemDelegate): @@ -139,7 +153,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._editor_by_product_id = {} + + self._editor_by_id: Dict[str, EditorInfo] = {} self._statuses_filter = None def displayText(self, value, locale): @@ -149,8 +164,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def set_statuses_filter(self, status_names): self._statuses_filter = set(status_names) - for widget in self._editor_by_product_id.values(): - widget.set_statuses_filter(status_names) + for info in self._editor_by_id.values(): + info.widget.set_statuses_filter(status_names) def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) @@ -209,27 +224,21 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): if not product_id: return + item_id = uuid.uuid4().hex + editor = VersionComboBox(product_id, parent) - self._editor_by_product_id[product_id] = editor - editor.value_changed.connect(self._on_editor_change) - editor.set_statuses_filter(self._statuses_filter) + editor.setProperty("itemId", item_id) - def on_destroy(obj): - self._on_destroy(product_id) + self._editor_by_id[item_id] = EditorInfo(editor) - editor.destroyed.connect(on_destroy) + def editor_changed(): + self._on_editor_change(item_id) + + editor.value_changed.connect(editor_changed) + editor.destroyed.connect(self._on_destroy) return editor - def _on_editor_change(self, product_id): - editor = self._editor_by_product_id[product_id] - - # Update model data - self.commitData.emit(editor) - - def _on_destroy(self, product_id): - self._editor_by_product_id.pop(product_id, None) - def setEditorData(self, editor, index): editor.clear() @@ -237,6 +246,10 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): versions = index.data(VERSION_NAME_EDIT_ROLE) or [] version_id = index.data(VERSION_ID_ROLE) editor.update_versions(versions, version_id) + editor.set_statuses_filter(self._statuses_filter) + + item_id = editor.property("itemId") + self._editor_by_id[item_id].added = True def setModelData(self, editor, model, index): """Apply the integer version back in the model""" @@ -244,6 +257,18 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): version_id = editor.itemData(editor.currentIndex()) model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) + def _on_editor_change(self, item_id): + info = self._editor_by_id.get(item_id) + if info is None or not info.added: + return + + editor = info.widget + self.commitData.emit(editor) + + def _on_destroy(self, obj): + item_id = obj.property("itemId") + self._editor_by_id.pop(item_id, None) + class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): """Delegate for Loaded in Scene state columns. diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index fb15507f1c..b2f8aad307 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -351,11 +351,10 @@ class ProductsModel(QtGui.QStandardItemModel): representation count by version id. sync_availability_by_version_id (Optional[str, Tuple[int, int]]): Mapping of sync availability by version id. - """ + """ model_item.setData(version_item.version_id, VERSION_ID_ROLE) model_item.setData(version_item.version, VERSION_NAME_ROLE) - model_item.setData(version_item.version_id, VERSION_ID_ROLE) model_item.setData(version_item.is_hero, VERSION_HERO_ROLE) model_item.setData( version_item.published_time, VERSION_PUBLISH_TIME_ROLE @@ -398,11 +397,11 @@ class ProductsModel(QtGui.QStandardItemModel): remote_site_icon, repre_count_by_version_id, sync_availability_by_version_id, + last_version_by_product_id, ): model_item = self._items_by_id.get(product_item.product_id) - versions = list(product_item.version_items.values()) - versions.sort() - last_version = versions[-1] + last_version = last_version_by_product_id[product_item.product_id] + statuses = { version_item.status for version_item in product_item.version_items.values() @@ -443,7 +442,7 @@ class ProductsModel(QtGui.QStandardItemModel): def get_last_project_name(self): return self._last_project_name - def refresh(self, project_name, folder_ids): + def refresh(self, project_name, folder_ids, status_names): self._clear() self._last_project_name = project_name @@ -473,16 +472,27 @@ class ProductsModel(QtGui.QStandardItemModel): product_item.product_id: product_item for product_item in product_items } - last_version_id_by_product_id = {} + last_version_by_product_id = {} for product_item in product_items: - versions = list(product_item.version_items.values()) - versions.sort() - last_version = versions[-1] - last_version_id_by_product_id[product_item.product_id] = ( - last_version.version_id + all_versions = list(product_item.version_items.values()) + all_versions.sort() + versions = [ + version_item + for version_item in all_versions + if status_names is None or version_item.status in status_names + ] + if versions: + last_version = versions[-1] + else: + last_version = all_versions[-1] + last_version_by_product_id[product_item.product_id] = ( + last_version ) - version_ids = set(last_version_id_by_product_id.values()) + version_ids = { + version_item.version_id + for version_item in last_version_by_product_id.values() + } repre_count_by_version_id = self._controller.get_versions_representation_count( project_name, version_ids ) @@ -559,6 +569,7 @@ class ProductsModel(QtGui.QStandardItemModel): remote_site_icon, repre_count_by_version_id, sync_availability_by_version_id, + last_version_by_product_id, ) new_items.append(item) @@ -581,6 +592,7 @@ class ProductsModel(QtGui.QStandardItemModel): remote_site_icon, repre_count_by_version_id, sync_availability_by_version_id, + last_version_by_product_id, ) new_merged_items.append(item) merged_product_types.add(product_item.product_type) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index be4a0698e8..b0a8c61f9d 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -34,12 +34,17 @@ from .actions_utils import show_actions_menu class ProductsProxyModel(RecursiveSortFilterProxyModel): def __init__(self, parent=None): - super(ProductsProxyModel, self).__init__(parent) + super().__init__(parent) self._product_type_filters = {} self._statuses_filter = None self._ascending_sort = True + def get_statuses_filter(self): + if self._statuses_filter is None: + return None + return set(self._statuses_filter) + def set_product_type_filters(self, product_type_filters): self._product_type_filters = product_type_filters self.invalidateFilter() @@ -97,13 +102,13 @@ class ProductsProxyModel(RecursiveSortFilterProxyModel): if not self._ascending_sort: output = not output return output - return super(ProductsProxyModel, self).lessThan(left, right) + return super().lessThan(left, right) def sort(self, column, order=None): if order is None: order = QtCore.Qt.AscendingOrder self._ascending_sort = order == QtCore.Qt.AscendingOrder - super(ProductsProxyModel, self).sort(column, order) + super().sort(column, order) class ProductsWidget(QtWidgets.QWidget): @@ -245,8 +250,8 @@ class ProductsWidget(QtWidgets.QWidget): status_names (list[str]): The list of status names. """ - self._products_proxy_model.set_statuses_filter(status_names) self._version_delegate.set_statuses_filter(status_names) + self._products_proxy_model.set_statuses_filter(status_names) def set_product_type_filter(self, product_type_filters): """ @@ -314,7 +319,8 @@ class ProductsWidget(QtWidgets.QWidget): def _refresh_model(self): self._products_model.refresh( self._selected_project_name, - self._selected_folder_ids + self._selected_folder_ids, + self._products_proxy_model.get_statuses_filter() ) def _on_context_menu(self, point): From 7b2408146213c62308d9ae6c84768697b0b4e30c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:50:08 +0200 Subject: [PATCH 069/163] removed unused import --- client/ayon_core/tools/loader/ui/products_delegates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 869bb5915c..50fbba607e 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -1,6 +1,6 @@ import numbers import uuid -from typing import Dict, Set +from typing import Dict from qtpy import QtWidgets, QtCore, QtGui From d36f9cdd9bbd2e160dc0eb614429e68a39210a0c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 Jul 2024 13:50:52 +0200 Subject: [PATCH 070/163] Added additional TODO --- client/ayon_core/host/dirmap.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/host/dirmap.py b/client/ayon_core/host/dirmap.py index fa3996e5fb..cc0186b9ca 100644 --- a/client/ayon_core/host/dirmap.py +++ b/client/ayon_core/host/dirmap.py @@ -220,10 +220,13 @@ class HostDirmap(object): return mapping def _get_site_root_overrides( - self, sitesync_addon, project_name, site_name): + self, sitesync_addon, project_name, site_name + ): """Safely handle root overrides. SiteSync raises ValueError for non local or studio sites. + TODO: could be removed when `get_site_root_overrides` is not raising + an Error but just returns {} """ try: site_roots_overrides = sitesync_addon.get_site_root_overrides( From 1913a33330102cc600e4b1d427cac40962e4f277 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 Jul 2024 14:14:04 +0200 Subject: [PATCH 071/163] Reformat TODO comment Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/host/dirmap.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/host/dirmap.py b/client/ayon_core/host/dirmap.py index cc0186b9ca..b90b414240 100644 --- a/client/ayon_core/host/dirmap.py +++ b/client/ayon_core/host/dirmap.py @@ -225,9 +225,9 @@ class HostDirmap(object): """Safely handle root overrides. SiteSync raises ValueError for non local or studio sites. - TODO: could be removed when `get_site_root_overrides` is not raising - an Error but just returns {} """ + # TODO: could be removed when `get_site_root_overrides` is not raising + # an Error but just returns {} try: site_roots_overrides = sitesync_addon.get_site_root_overrides( project_name, site_name) From c852a429e5e643439b3fda9c6b2df814803b7865 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:16:34 +0200 Subject: [PATCH 072/163] use first udim for single file --- client/ayon_core/plugins/publish/integrate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 1bad97bc61..4808456ba9 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -765,7 +765,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Manage anatomy template data template_data.pop("frame", None) if is_udim: - template_data["udim"] = repre["udim"] + template_data["udim"] = repre["udim"][0] # Construct destination filepath from template template_filled = path_template_obj.format_strict(template_data) repre_context = template_filled.used_values From 6ffe70ddd554390f79548df942843b555707918e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:16:52 +0200 Subject: [PATCH 073/163] don't store udims as list of integers --- client/ayon_core/plugins/publish/integrate.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 4808456ba9..2da33bfb19 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -789,11 +789,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin): if value is not None: repre_context[key] = value - # Explicitly store the full list even though template data might - # have a different value because it uses just a single udim tile - if repre.get("udim"): - repre_context["udim"] = repre.get("udim")[0] # store list - # Use previous representation's id if there is a name match existing = existing_repres_by_name.get(repre["name"].lower()) repre_id = None From 1232fd9e2cc9af3f429d2a0b168cab34c9173d9d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:19:34 +0200 Subject: [PATCH 074/163] auto-fix udim path calculation --- client/ayon_core/pipeline/load/utils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 7f2bec6d34..a2e463ada0 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -639,6 +639,14 @@ def get_representation_path_with_anatomy(repre_entity, anatomy): try: context = repre_entity["context"] context["root"] = anatomy.roots + + # Auto-fix 'udim' being list of integers + # - This is a legacy issue for old representation entities, + # added 24/07/10 + udim = context.get("udim") + if isinstance(udim, list): + context["udim"] = udim[0] + path = StringTemplate.format_strict_template(template, context) except TemplateUnsolved as exc: @@ -681,6 +689,14 @@ def get_representation_path(representation, root=None): try: context = representation["context"] + + # Auto-fix 'udim' being list of integers + # - This is a legacy issue for old representation entities, + # added 24/07/10 + udim = context.get("udim") + if isinstance(udim, list): + context["udim"] = udim[0] + context["root"] = root path = StringTemplate.format_strict_template( template, context From 8203f6eeb2f91a6a775875c1fd22c9403035200a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:41:57 +0200 Subject: [PATCH 075/163] added helper function for compatibility fix --- client/ayon_core/pipeline/load/utils.py | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index a2e463ada0..9ba407193e 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -587,6 +587,21 @@ def switch_container(container, representation, loader_plugin=None): return loader.switch(container, context) +def _fix_representation_context_compatibility(repre_context): + """Helper function to fix representation context compatibility. + + Args: + repre_context (dict): Representation context. + + """ + # Auto-fix 'udim' being list of integers + # - This is a legacy issue for old representation entities, + # added 24/07/10 + udim = repre_context.get("udim") + if isinstance(udim, list): + repre_context["udim"] = udim[0] + + def get_representation_path_from_context(context): """Preparation wrapper using only context as a argument""" from ayon_core.pipeline import get_current_project_name @@ -638,15 +653,9 @@ def get_representation_path_with_anatomy(repre_entity, anatomy): try: context = repre_entity["context"] + _fix_representation_context_compatibility(context) context["root"] = anatomy.roots - # Auto-fix 'udim' being list of integers - # - This is a legacy issue for old representation entities, - # added 24/07/10 - udim = context.get("udim") - if isinstance(udim, list): - context["udim"] = udim[0] - path = StringTemplate.format_strict_template(template, context) except TemplateUnsolved as exc: @@ -690,12 +699,7 @@ def get_representation_path(representation, root=None): try: context = representation["context"] - # Auto-fix 'udim' being list of integers - # - This is a legacy issue for old representation entities, - # added 24/07/10 - udim = context.get("udim") - if isinstance(udim, list): - context["udim"] = udim[0] + _fix_representation_context_compatibility(context) context["root"] = root path = StringTemplate.format_strict_template( From 618a76c6af8ee0f58d8fd280b818428e014ef60f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:00:01 +0200 Subject: [PATCH 076/163] expect values as 'None' and 'QColor' --- client/ayon_core/tools/utils/lib.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/utils/lib.py b/client/ayon_core/tools/utils/lib.py index f31bb82e59..250d267e76 100644 --- a/client/ayon_core/tools/utils/lib.py +++ b/client/ayon_core/tools/utils/lib.py @@ -485,7 +485,10 @@ class _IconsCache: parts = [icon_type, icon_def["path"]] elif icon_type in {"awesome-font", "material-symbols"}: - parts = [icon_type, icon_def["name"], icon_def["color"]] + color = icon_def["color"] or "" + if isinstance(color, QtGui.QColor): + color = color.name() + parts = [icon_type, icon_def["name"] or "", color] return "|".join(parts) @classmethod From 48a620197a5580cc57545761780882b75e6201c8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:29:27 +0200 Subject: [PATCH 077/163] '0.4.1' --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index f0e4b9a10f..04b27096ac 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.1-dev.1" +__version__ = "0.4.1" diff --git a/package.py b/package.py index e5e567b8e8..47b6a3b7f9 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.1-dev.1" +version = "0.4.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index eef5575f6a..19db7f548d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "0.3.1" +version = "0.4.1" description = "" authors = ["Ynput Team "] readme = "README.md" From a20c3084afc0295d4d3cf300ee8417a913d02b69 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:31:01 +0200 Subject: [PATCH 078/163] bump version to '0.4.2-dev.1' --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 04b27096ac..42fd6a5c72 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.1" +__version__ = "0.4.2-dev.1" diff --git a/package.py b/package.py index 47b6a3b7f9..5da8e36064 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.1" +version = "0.4.2-dev.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 19db7f548d..cdedd878a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "0.4.1" +version = "0.4.2-dev.1" description = "" authors = ["Ynput Team "] readme = "README.md" From b6a46f05945784af04b75b62f2d881087298c59c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 10 Jul 2024 17:18:31 +0200 Subject: [PATCH 079/163] removed remainings of 3dsmax addon --- server_addon/max/client/ayon_max/api/lib.py | 589 ------------------ .../max/client/ayon_max/api/plugin.py | 298 --------- .../ayon_max/plugins/create/create_tycache.py | 13 - .../ayon_max/plugins/load/load_tycache.py | 65 -- .../plugins/publish/collect_frame_range.py | 22 - .../plugins/publish/collect_members.py | 26 - .../publish/collect_tycache_attributes.py | 76 --- .../plugins/publish/extract_pointcloud.py | 242 ------- .../plugins/publish/extract_tycache.py | 157 ----- .../plugins/publish/validate_frame_range.py | 90 --- .../plugins/publish/validate_tyflow_data.py | 88 --- .../max/server/settings/publishers.py | 222 ------- 12 files changed, 1888 deletions(-) delete mode 100644 server_addon/max/client/ayon_max/api/lib.py delete mode 100644 server_addon/max/client/ayon_max/api/plugin.py delete mode 100644 server_addon/max/client/ayon_max/plugins/create/create_tycache.py delete mode 100644 server_addon/max/client/ayon_max/plugins/load/load_tycache.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_frame_range.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_members.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/collect_tycache_attributes.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_pointcloud.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/extract_tycache.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_frame_range.py delete mode 100644 server_addon/max/client/ayon_max/plugins/publish/validate_tyflow_data.py delete mode 100644 server_addon/max/server/settings/publishers.py diff --git a/server_addon/max/client/ayon_max/api/lib.py b/server_addon/max/client/ayon_max/api/lib.py deleted file mode 100644 index 7acc18196f..0000000000 --- a/server_addon/max/client/ayon_max/api/lib.py +++ /dev/null @@ -1,589 +0,0 @@ -# -*- coding: utf-8 -*- -"""Library of functions useful for 3dsmax pipeline.""" -import contextlib -import logging -import json -from typing import Any, Dict, Union - -import six - -from ayon_core.pipeline import ( - get_current_project_name, - colorspace -) -from ayon_core.settings import get_project_settings -from ayon_core.pipeline.context_tools import ( - get_current_task_entity -) -from ayon_core.style import load_stylesheet -from pymxs import runtime as rt - - -JSON_PREFIX = "JSON::" -log = logging.getLogger("ayon_max") - - -def get_main_window(): - """Acquire Max's main window""" - from qtpy import QtWidgets - top_widgets = QtWidgets.QApplication.topLevelWidgets() - name = "QmaxApplicationWindow" - for widget in top_widgets: - if ( - widget.inherits("QMainWindow") - and widget.metaObject().className() == name - ): - return widget - raise RuntimeError('Count not find 3dsMax main window.') - - -def imprint(node_name: str, data: dict) -> bool: - node = rt.GetNodeByName(node_name) - if not node: - return False - - for k, v in data.items(): - if isinstance(v, (dict, list)): - rt.SetUserProp(node, k, f"{JSON_PREFIX}{json.dumps(v)}") - else: - rt.SetUserProp(node, k, v) - - return True - - -def lsattr( - attr: str, - value: Union[str, None] = None, - root: Union[str, None] = None) -> list: - """List nodes having attribute with specified value. - - Args: - attr (str): Attribute name to match. - value (str, Optional): Value to match, of omitted, all nodes - with specified attribute are returned no matter of value. - root (str, Optional): Root node name. If omitted, scene root is used. - - Returns: - list of nodes. - """ - root = rt.RootNode if root is None else rt.GetNodeByName(root) - - def output_node(node, nodes): - nodes.append(node) - for child in node.Children: - output_node(child, nodes) - - nodes = [] - output_node(root, nodes) - return [ - n for n in nodes - if rt.GetUserProp(n, attr) == value - ] if value else [ - n for n in nodes - if rt.GetUserProp(n, attr) - ] - - -def read(container) -> dict: - data = {} - props = rt.GetUserPropBuffer(container) - # this shouldn't happen but let's guard against it anyway - if not props: - return data - - for line in props.split("\r\n"): - try: - key, value = line.split("=") - except ValueError: - # if the line cannot be split we can't really parse it - continue - - value = value.strip() - if isinstance(value.strip(), six.string_types) and \ - value.startswith(JSON_PREFIX): - with contextlib.suppress(json.JSONDecodeError): - value = json.loads(value[len(JSON_PREFIX):]) - - # default value behavior - # convert maxscript boolean values - if value == "true": - value = True - elif value == "false": - value = False - - data[key.strip()] = value - - data["instance_node"] = container.Name - - return data - - -@contextlib.contextmanager -def maintained_selection(): - previous_selection = rt.GetCurrentSelection() - try: - yield - finally: - if previous_selection: - rt.Select(previous_selection) - else: - rt.Select() - - -def get_all_children(parent, node_type=None): - """Handy function to get all the children of a given node - - Args: - parent (3dsmax Node1): Node to get all children of. - node_type (None, runtime.class): give class to check for - e.g. rt.FFDBox/rt.GeometryClass etc. - - Returns: - list: list of all children of the parent node - """ - def list_children(node): - children = [] - for c in node.Children: - children.append(c) - children = children + list_children(c) - return children - child_list = list_children(parent) - - return ([x for x in child_list if rt.SuperClassOf(x) == node_type] - if node_type else child_list) - - -def get_current_renderer(): - """ - Notes: - Get current renderer for Max - - Returns: - "{Current Renderer}:{Current Renderer}" - e.g. "Redshift_Renderer:Redshift_Renderer" - """ - return rt.renderers.production - - -def get_default_render_folder(project_setting=None): - return (project_setting["max"] - ["RenderSettings"] - ["default_render_image_folder"]) - - -def set_render_frame_range(start_frame, end_frame): - """ - Note: - Frame range can be specified in different types. Possible values are: - * `1` - Single frame. - * `2` - Active time segment ( animationRange ). - * `3` - User specified Range. - * `4` - User specified Frame pickup string (for example `1,3,5-12`). - - Todo: - Current type is hard-coded, there should be a custom setting for this. - """ - rt.rendTimeType = 3 - if start_frame is not None and end_frame is not None: - rt.rendStart = int(start_frame) - rt.rendEnd = int(end_frame) - - -def get_multipass_setting(project_setting=None): - return (project_setting["max"] - ["RenderSettings"] - ["multipass"]) - - -def set_scene_resolution(width: int, height: int): - """Set the render resolution - - Args: - width(int): value of the width - height(int): value of the height - - Returns: - None - - """ - # make sure the render dialog is closed - # for the update of resolution - # Changing the Render Setup dialog settings should be done - # with the actual Render Setup dialog in a closed state. - if rt.renderSceneDialog.isOpen(): - rt.renderSceneDialog.close() - - rt.renderWidth = width - rt.renderHeight = height - - -def reset_scene_resolution(): - """Apply the scene resolution from the project definition - - scene resolution can be overwritten by a folder if the folder.attrib - contains any information regarding scene resolution. - """ - task_attributes = get_current_task_entity(fields={"attrib"})["attrib"] - width = int(task_attributes["resolutionWidth"]) - height = int(task_attributes["resolutionHeight"]) - - set_scene_resolution(width, height) - - -def get_frame_range(task_entity=None) -> Union[Dict[str, Any], None]: - """Get the current task frame range and handles - - Args: - task_entity (dict): Task Entity. - - Returns: - dict: with frame start, frame end, handle start, handle end. - """ - # Set frame start/end - if task_entity is None: - task_entity = get_current_task_entity(fields={"attrib"}) - task_attributes = task_entity["attrib"] - frame_start = int(task_attributes["frameStart"]) - frame_end = int(task_attributes["frameEnd"]) - handle_start = int(task_attributes["handleStart"]) - handle_end = int(task_attributes["handleEnd"]) - frame_start_handle = frame_start - handle_start - frame_end_handle = frame_end + handle_end - - return { - "frameStart": frame_start, - "frameEnd": frame_end, - "handleStart": handle_start, - "handleEnd": handle_end, - "frameStartHandle": frame_start_handle, - "frameEndHandle": frame_end_handle, - } - - -def reset_frame_range(fps: bool = True): - """Set frame range to current folder. - This is part of 3dsmax documentation: - - animationRange: A System Global variable which lets you get and - set an Interval value that defines the start and end frames - of the Active Time Segment. - frameRate: A System Global variable which lets you get - and set an Integer value that defines the current - scene frame rate in frames-per-second. - """ - if fps: - rt.frameRate = float(get_fps_for_current_context()) - - frame_range = get_frame_range() - - set_timeline( - frame_range["frameStartHandle"], frame_range["frameEndHandle"]) - set_render_frame_range( - frame_range["frameStartHandle"], frame_range["frameEndHandle"]) - - -def get_fps_for_current_context(): - """Get fps that should be set for current context. - - Todos: - - Skip project value. - - Merge logic with 'get_frame_range' and 'reset_scene_resolution' -> - all the values in the functions can be collected at one place as - they have same requirements. - - Returns: - Union[int, float]: FPS value. - """ - task_entity = get_current_task_entity(fields={"attrib"}) - return task_entity["attrib"]["fps"] - - -def reset_unit_scale(): - """Apply the unit scale setting to 3dsMax - """ - project_name = get_current_project_name() - settings = get_project_settings(project_name).get("max") - scene_scale = settings.get("unit_scale_settings", - {}).get("scene_unit_scale") - if scene_scale: - rt.units.DisplayType = rt.Name("Metric") - rt.units.MetricType = rt.Name(scene_scale) - else: - rt.units.DisplayType = rt.Name("Generic") - - -def convert_unit_scale(): - """Convert system unit scale in 3dsMax - for fbx export - - Returns: - str: unit scale - """ - unit_scale_dict = { - "millimeters": "mm", - "centimeters": "cm", - "meters": "m", - "kilometers": "km" - } - current_unit_scale = rt.Execute("units.MetricType as string") - return unit_scale_dict[current_unit_scale] - - -def set_context_setting(): - """Apply the project settings from the project definition - - Settings can be overwritten by an folder if the folder.attrib contains - any information regarding those settings. - - Examples of settings: - frame range - resolution - - Returns: - None - """ - reset_scene_resolution() - reset_frame_range() - reset_colorspace() - reset_unit_scale() - - -def get_max_version(): - """ - Args: - get max version date for deadline - - Returns: - #(25000, 62, 0, 25, 0, 0, 997, 2023, "") - max_info[7] = max version date - """ - max_info = rt.MaxVersion() - return max_info[7] - - -def is_headless(): - """Check if 3dsMax runs in batch mode. - If it returns True, it runs in 3dsbatch.exe - If it returns False, it runs in 3dsmax.exe - """ - return rt.maxops.isInNonInteractiveMode() - - -def set_timeline(frameStart, frameEnd): - """Set frame range for timeline editor in Max - """ - rt.animationRange = rt.interval(int(frameStart), int(frameEnd)) - return rt.animationRange - - -def reset_colorspace(): - """OCIO Configuration - Supports in 3dsMax 2024+ - - """ - if int(get_max_version()) < 2024: - return - - max_config_data = colorspace.get_current_context_imageio_config_preset() - if max_config_data: - ocio_config_path = max_config_data["path"] - colorspace_mgr = rt.ColorPipelineMgr - colorspace_mgr.Mode = rt.Name("OCIO_Custom") - colorspace_mgr.OCIOConfigPath = ocio_config_path - - -def check_colorspace(): - parent = get_main_window() - if parent is None: - log.info("Skipping outdated pop-up " - "because Max main window can't be found.") - if int(get_max_version()) >= 2024: - color_mgr = rt.ColorPipelineMgr - max_config_data = colorspace.get_current_context_imageio_config_preset() - if max_config_data and color_mgr.Mode != rt.Name("OCIO_Custom"): - if not is_headless(): - from ayon_core.tools.utils import SimplePopup - dialog = SimplePopup(parent=parent) - dialog.setWindowTitle("Warning: Wrong OCIO Mode") - dialog.set_message("This scene has wrong OCIO " - "Mode setting.") - dialog.set_button_text("Fix") - dialog.setStyleSheet(load_stylesheet()) - dialog.on_clicked.connect(reset_colorspace) - dialog.show() - -def unique_namespace(namespace, format="%02d", - prefix="", suffix="", con_suffix="CON"): - """Return unique namespace - - Arguments: - namespace (str): Name of namespace to consider - format (str, optional): Formatting of the given iteration number - suffix (str, optional): Only consider namespaces with this suffix. - con_suffix: max only, for finding the name of the master container - - >>> unique_namespace("bar") - # bar01 - >>> unique_namespace(":hello") - # :hello01 - >>> unique_namespace("bar:", suffix="_NS") - # bar01_NS: - - """ - - def current_namespace(): - current = namespace - # When inside a namespace Max adds no trailing : - if not current.endswith(":"): - current += ":" - return current - - # Always check against the absolute namespace root - # There's no clash with :x if we're defining namespace :a:x - ROOT = ":" if namespace.startswith(":") else current_namespace() - - # Strip trailing `:` tokens since we might want to add a suffix - start = ":" if namespace.startswith(":") else "" - end = ":" if namespace.endswith(":") else "" - namespace = namespace.strip(":") - if ":" in namespace: - # Split off any nesting that we don't uniqify anyway. - parents, namespace = namespace.rsplit(":", 1) - start += parents + ":" - ROOT += start - - iteration = 1 - increment_version = True - while increment_version: - nr_namespace = namespace + format % iteration - unique = prefix + nr_namespace + suffix - container_name = f"{unique}:{namespace}{con_suffix}" - if not rt.getNodeByName(container_name): - name_space = start + unique + end - increment_version = False - return name_space - else: - increment_version = True - iteration += 1 - - -def get_namespace(container_name): - """Get the namespace and name of the sub-container - - Args: - container_name (str): the name of master container - - Raises: - RuntimeError: when there is no master container found - - Returns: - namespace (str): namespace of the sub-container - name (str): name of the sub-container - """ - node = rt.getNodeByName(container_name) - if not node: - raise RuntimeError("Master Container Not Found..") - name = rt.getUserProp(node, "name") - namespace = rt.getUserProp(node, "namespace") - return namespace, name - - -def object_transform_set(container_children): - """A function which allows to store the transform of - previous loaded object(s) - Args: - container_children(list): A list of nodes - - Returns: - transform_set (dict): A dict with all transform data of - the previous loaded object(s) - """ - transform_set = {} - for node in container_children: - name = f"{node}.transform" - transform_set[name] = node.pos - name = f"{node}.scale" - transform_set[name] = node.scale - return transform_set - - -def get_plugins() -> list: - """Get all loaded plugins in 3dsMax - - Returns: - plugin_info_list: a list of loaded plugins - """ - manager = rt.PluginManager - count = manager.pluginDllCount - plugin_info_list = [] - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list - - -def update_modifier_node_names(event, node): - """Update the name of the nodes after renaming - - Args: - event (pymxs.MXSWrapperBase): Event Name ( - Mandatory argument for rt.NodeEventCallback) - node (list): Event Number ( - Mandatory argument for rt.NodeEventCallback) - - """ - containers = [ - obj - for obj in rt.Objects - if ( - rt.ClassOf(obj) == rt.Container - and rt.getUserProp(obj, "id") == "pyblish.avalon.instance" - and rt.getUserProp(obj, "productType") not in { - "workfile", "tyflow" - } - ) - ] - if not containers: - return - for container in containers: - ayon_data = container.modifiers[0].openPypeData - updated_node_names = [str(node.node) for node - in ayon_data.all_handles] - rt.setProperty(ayon_data, "sel_list", updated_node_names) - - -@contextlib.contextmanager -def render_resolution(width, height): - """Set render resolution option during context - - Args: - width (int): render width - height (int): render height - """ - current_renderWidth = rt.renderWidth - current_renderHeight = rt.renderHeight - try: - rt.renderWidth = width - rt.renderHeight = height - yield - finally: - rt.renderWidth = current_renderWidth - rt.renderHeight = current_renderHeight - - -@contextlib.contextmanager -def suspended_refresh(): - """Suspended refresh for scene and modify panel redraw. - """ - if is_headless(): - yield - return - rt.disableSceneRedraw() - rt.suspendEditing() - try: - yield - - finally: - rt.enableSceneRedraw() - rt.resumeEditing() diff --git a/server_addon/max/client/ayon_max/api/plugin.py b/server_addon/max/client/ayon_max/api/plugin.py deleted file mode 100644 index e5d12ce87d..0000000000 --- a/server_addon/max/client/ayon_max/api/plugin.py +++ /dev/null @@ -1,298 +0,0 @@ -# -*- coding: utf-8 -*- -"""3dsmax specific AYON/Pyblish plugin definitions.""" -from abc import ABCMeta - -import six -from pymxs import runtime as rt - -from ayon_core.lib import BoolDef -from ayon_core.pipeline import ( - CreatedInstance, - Creator, - CreatorError, - AYON_INSTANCE_ID, - AVALON_INSTANCE_ID, -) - -from .lib import imprint, lsattr, read - -MS_CUSTOM_ATTRIB = """attributes "openPypeData" -( - parameters main rollout:OPparams - ( - all_handles type:#maxObjectTab tabSize:0 tabSizeVariable:on - sel_list type:#stringTab tabSize:0 tabSizeVariable:on - ) - - rollout OPparams "OP Parameters" - ( - listbox list_node "Node References" items:#() - button button_add "Add to Container" - button button_del "Delete from Container" - - fn node_to_name the_node = - ( - handle = the_node.handle - obj_name = the_node.name - handle_name = obj_name + "<" + handle as string + ">" - return handle_name - ) - fn nodes_to_add node = - ( - sceneObjs = #() - if classOf node == Container do return false - n = node as string - for obj in Objects do - ( - tmp_obj = obj as string - append sceneObjs tmp_obj - ) - if sel_list != undefined do - ( - for obj in sel_list do - ( - idx = findItem sceneObjs obj - if idx do - ( - deleteItem sceneObjs idx - ) - ) - ) - idx = findItem sceneObjs n - if idx then return true else false - ) - - fn nodes_to_rmv node = - ( - n = node as string - idx = findItem sel_list n - if idx then return true else false - ) - - on button_add pressed do - ( - current_sel = selectByName title:"Select Objects to add to - the Container" buttontext:"Add" filter:nodes_to_add - if current_sel == undefined then return False - temp_arr = #() - i_node_arr = #() - for c in current_sel do - ( - handle_name = node_to_name c - node_ref = NodeTransformMonitor node:c - idx = finditem list_node.items handle_name - if idx do ( - continue - ) - name = c as string - append temp_arr handle_name - append i_node_arr node_ref - append sel_list name - ) - all_handles = join i_node_arr all_handles - list_node.items = join temp_arr list_node.items - ) - - on button_del pressed do - ( - current_sel = selectByName title:"Select Objects to remove - from the Container" buttontext:"Remove" filter: nodes_to_rmv - if current_sel == undefined or current_sel.count == 0 then - ( - return False - ) - temp_arr = #() - i_node_arr = #() - new_i_node_arr = #() - new_temp_arr = #() - - for c in current_sel do - ( - node_ref = NodeTransformMonitor node:c as string - handle_name = node_to_name c - n = c as string - tmp_all_handles = #() - for i in all_handles do - ( - tmp = i as string - append tmp_all_handles tmp - ) - idx = finditem tmp_all_handles node_ref - if idx do - ( - new_i_node_arr = DeleteItem all_handles idx - - ) - idx = finditem list_node.items handle_name - if idx do - ( - new_temp_arr = DeleteItem list_node.items idx - ) - idx = finditem sel_list n - if idx do - ( - sel_list = DeleteItem sel_list idx - ) - ) - all_handles = join i_node_arr new_i_node_arr - list_node.items = join temp_arr new_temp_arr - ) - - on OPparams open do - ( - if all_handles.count != 0 then - ( - temp_arr = #() - for x in all_handles do - ( - if x.node == undefined do continue - handle_name = node_to_name x.node - append temp_arr handle_name - ) - list_node.items = temp_arr - ) - ) - ) -)""" - - -class MaxCreatorBase(object): - - @staticmethod - def cache_instance_data(shared_data): - if shared_data.get("max_cached_instances") is not None: - return shared_data - - shared_data["max_cached_instances"] = {} - - cached_instances = [] - for id_type in [AYON_INSTANCE_ID, AVALON_INSTANCE_ID]: - cached_instances.extend(lsattr("id", id_type)) - - for i in cached_instances: - creator_id = rt.GetUserProp(i, "creator_identifier") - if creator_id not in shared_data["max_cached_instances"]: - shared_data["max_cached_instances"][creator_id] = [i.name] - else: - shared_data[ - "max_cached_instances"][creator_id].append(i.name) - return shared_data - - @staticmethod - def create_instance_node(node): - """Create instance node. - - If the supplied node is existing node, it will be used to hold the - instance, otherwise new node of type Dummy will be created. - - Args: - node (rt.MXSWrapperBase, str): Node or node name to use. - - Returns: - instance - """ - if isinstance(node, str): - node = rt.Container(name=node) - - attrs = rt.Execute(MS_CUSTOM_ATTRIB) - modifier = rt.EmptyModifier() - rt.addModifier(node, modifier) - node.modifiers[0].name = "OP Data" - rt.custAttributes.add(node.modifiers[0], attrs) - - return node - - -@six.add_metaclass(ABCMeta) -class MaxCreator(Creator, MaxCreatorBase): - selected_nodes = [] - - def create(self, product_name, instance_data, pre_create_data): - if pre_create_data.get("use_selection"): - self.selected_nodes = rt.GetCurrentSelection() - if rt.getNodeByName(product_name): - raise CreatorError(f"'{product_name}' is already created..") - - instance_node = self.create_instance_node(product_name) - instance_data["instance_node"] = instance_node.name - instance = CreatedInstance( - self.product_type, - product_name, - instance_data, - self - ) - if pre_create_data.get("use_selection"): - - node_list = [] - sel_list = [] - for i in self.selected_nodes: - node_ref = rt.NodeTransformMonitor(node=i) - node_list.append(node_ref) - sel_list.append(str(i)) - - # Setting the property - rt.setProperty( - instance_node.modifiers[0].openPypeData, - "all_handles", node_list) - rt.setProperty( - instance_node.modifiers[0].openPypeData, - "sel_list", sel_list) - - self._add_instance_to_context(instance) - imprint(instance_node.name, instance.data_to_store()) - - return instance - - def collect_instances(self): - self.cache_instance_data(self.collection_shared_data) - for instance in self.collection_shared_data["max_cached_instances"].get(self.identifier, []): # noqa - created_instance = CreatedInstance.from_existing( - read(rt.GetNodeByName(instance)), self - ) - self._add_instance_to_context(created_instance) - - def update_instances(self, update_list): - for created_inst, changes in update_list: - instance_node = created_inst.get("instance_node") - new_values = { - key: changes[key].new_value - for key in changes.changed_keys - } - product_name = new_values.get("productName", "") - if product_name and instance_node != product_name: - node = rt.getNodeByName(instance_node) - new_product_name = new_values["productName"] - if rt.getNodeByName(new_product_name): - raise CreatorError( - "The product '{}' already exists.".format( - new_product_name)) - instance_node = new_product_name - created_inst["instance_node"] = instance_node - node.name = instance_node - - imprint( - instance_node, - created_inst.data_to_store(), - ) - - def remove_instances(self, instances): - """Remove specified instance from the scene. - - This is only removing `id` parameter so instance is no longer - instance, because it might contain valuable data for artist. - - """ - for instance in instances: - instance_node = rt.GetNodeByName( - instance.data.get("instance_node")) - if instance_node: - count = rt.custAttributes.count(instance_node.modifiers[0]) - rt.custAttributes.delete(instance_node.modifiers[0], count) - rt.Delete(instance_node) - - self._remove_instance_from_context(instance) - - def get_pre_create_attr_defs(self): - return [ - BoolDef("use_selection", label="Use selection") - ] diff --git a/server_addon/max/client/ayon_max/plugins/create/create_tycache.py b/server_addon/max/client/ayon_max/plugins/create/create_tycache.py deleted file mode 100644 index cbdd94e272..0000000000 --- a/server_addon/max/client/ayon_max/plugins/create/create_tycache.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -"""Creator plugin for creating TyCache.""" -from ayon_max.api import plugin - - -class CreateTyCache(plugin.MaxCreator): - """Creator plugin for TyCache.""" - identifier = "io.openpype.creators.max.tycache" - label = "TyCache" - product_type = "tycache" - icon = "gear" - - settings_category = "max" diff --git a/server_addon/max/client/ayon_max/plugins/load/load_tycache.py b/server_addon/max/client/ayon_max/plugins/load/load_tycache.py deleted file mode 100644 index e087d5599a..0000000000 --- a/server_addon/max/client/ayon_max/plugins/load/load_tycache.py +++ /dev/null @@ -1,65 +0,0 @@ -import os -from ayon_max.api import lib, maintained_selection -from ayon_max.api.lib import ( - unique_namespace, - -) -from ayon_max.api.pipeline import ( - containerise, - get_previous_loaded_object, - update_custom_attribute_data, - remove_container_data -) -from ayon_core.pipeline import get_representation_path, load - - -class TyCacheLoader(load.LoaderPlugin): - """TyCache Loader.""" - - product_types = {"tycache"} - representations = {"tyc"} - order = -8 - icon = "code-fork" - color = "green" - - def load(self, context, name=None, namespace=None, data=None): - """Load tyCache""" - from pymxs import runtime as rt - filepath = os.path.normpath(self.filepath_from_context(context)) - obj = rt.tyCache() - obj.filename = filepath - - namespace = unique_namespace( - name + "_", - suffix="_", - ) - obj.name = f"{namespace}:{obj.name}" - - return containerise( - name, [obj], context, - namespace, loader=self.__class__.__name__) - - def update(self, container, context): - """update the container""" - from pymxs import runtime as rt - - repre_entity = context["representation"] - path = get_representation_path(repre_entity) - node = rt.GetNodeByName(container["instance_node"]) - node_list = get_previous_loaded_object(node) - update_custom_attribute_data(node, node_list) - with maintained_selection(): - for tyc in node_list: - tyc.filename = path - lib.imprint(container["instance_node"], { - "representation": repre_entity["id"] - }) - - def switch(self, container, context): - self.update(container, context) - - def remove(self, container): - """remove the container""" - from pymxs import runtime as rt - node = rt.GetNodeByName(container["instance_node"]) - remove_container_data(node) diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_frame_range.py b/server_addon/max/client/ayon_max/plugins/publish/collect_frame_range.py deleted file mode 100644 index 6fc8de90d1..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_frame_range.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -import pyblish.api -from pymxs import runtime as rt - - -class CollectFrameRange(pyblish.api.InstancePlugin): - """Collect Frame Range.""" - - order = pyblish.api.CollectorOrder + 0.01 - label = "Collect Frame Range" - hosts = ['max'] - families = ["camera", "maxrender", - "pointcache", "pointcloud", - "review", "redshiftproxy"] - - def process(self, instance): - if instance.data["productType"] == "maxrender": - instance.data["frameStartHandle"] = int(rt.rendStart) - instance.data["frameEndHandle"] = int(rt.rendEnd) - else: - instance.data["frameStartHandle"] = int(rt.animationRange.start) - instance.data["frameEndHandle"] = int(rt.animationRange.end) diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_members.py b/server_addon/max/client/ayon_max/plugins/publish/collect_members.py deleted file mode 100644 index 010b3cd3e1..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_members.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect instance members.""" -import pyblish.api -from pymxs import runtime as rt - - -class CollectMembers(pyblish.api.InstancePlugin): - """Collect Set Members.""" - - order = pyblish.api.CollectorOrder + 0.01 - label = "Collect Instance Members" - hosts = ['max'] - - def process(self, instance): - if instance.data["productType"] == "workfile": - self.log.debug( - "Skipping Collecting Members for workfile product type." - ) - return - if instance.data.get("instance_node"): - container = rt.GetNodeByName(instance.data["instance_node"]) - instance.data["members"] = [ - member.node for member - in container.modifiers[0].openPypeData.all_handles - ] - self.log.debug("{}".format(instance.data["members"])) diff --git a/server_addon/max/client/ayon_max/plugins/publish/collect_tycache_attributes.py b/server_addon/max/client/ayon_max/plugins/publish/collect_tycache_attributes.py deleted file mode 100644 index 4855e952d8..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/collect_tycache_attributes.py +++ /dev/null @@ -1,76 +0,0 @@ -import pyblish.api - -from ayon_core.lib import EnumDef, TextDef -from ayon_core.pipeline.publish import AYONPyblishPluginMixin - - -class CollectTyCacheData(pyblish.api.InstancePlugin, - AYONPyblishPluginMixin): - """Collect Channel Attributes for TyCache Export""" - - order = pyblish.api.CollectorOrder + 0.02 - label = "Collect tyCache attribute Data" - hosts = ['max'] - families = ["tycache"] - - def process(self, instance): - attr_values = self.get_attr_values_from_data(instance.data) - attributes = {} - for attr_key in attr_values.get("tycacheAttributes", []): - attributes[attr_key] = True - - for key in ["tycacheLayer", "tycacheObjectName"]: - attributes[key] = attr_values.get(key, "") - - # Collect the selected channel data before exporting - instance.data["tyc_attrs"] = attributes - self.log.debug( - f"Found tycache attributes: {attributes}" - ) - - @classmethod - def get_attribute_defs(cls): - # TODO: Support the attributes with maxObject array - tyc_attr_enum = ["tycacheChanAge", "tycacheChanGroups", - "tycacheChanPos", "tycacheChanRot", - "tycacheChanScale", "tycacheChanVel", - "tycacheChanSpin", "tycacheChanShape", - "tycacheChanMatID", "tycacheChanMapping", - "tycacheChanMaterials", "tycacheChanCustomFloat" - "tycacheChanCustomVector", "tycacheChanCustomTM", - "tycacheChanPhysX", "tycacheMeshBackup", - "tycacheCreateObject", - "tycacheCreateObjectIfNotCreated", - "tycacheAdditionalCloth", - "tycacheAdditionalSkin", - "tycacheAdditionalSkinID", - "tycacheAdditionalSkinIDValue", - "tycacheAdditionalTerrain", - "tycacheAdditionalVDB", - "tycacheAdditionalSplinePaths", - "tycacheAdditionalGeo", - "tycacheAdditionalGeoActivateModifiers", - "tycacheSplines", - "tycacheSplinesAdditionalSplines" - ] - tyc_default_attrs = ["tycacheChanGroups", "tycacheChanPos", - "tycacheChanRot", "tycacheChanScale", - "tycacheChanVel", "tycacheChanShape", - "tycacheChanMatID", "tycacheChanMapping", - "tycacheChanMaterials", - "tycacheCreateObjectIfNotCreated"] - return [ - EnumDef("tycacheAttributes", - tyc_attr_enum, - default=tyc_default_attrs, - multiselection=True, - label="TyCache Attributes"), - TextDef("tycacheLayer", - label="TyCache Layer", - tooltip="Name of tycache layer", - default="$(tyFlowLayer)"), - TextDef("tycacheObjectName", - label="TyCache Object Name", - tooltip="TyCache Object Name", - default="$(tyFlowName)_tyCache") - ] diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_pointcloud.py b/server_addon/max/client/ayon_max/plugins/publish/extract_pointcloud.py deleted file mode 100644 index f763325eb9..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_pointcloud.py +++ /dev/null @@ -1,242 +0,0 @@ -import os - -import pyblish.api -from pymxs import runtime as rt - -from ayon_max.api import maintained_selection -from ayon_core.pipeline import publish - - -class ExtractPointCloud(publish.Extractor): - """ - Extract PRT format with tyFlow operators. - - Notes: - Currently only works for the default partition setting - - Args: - self.export_particle(): sets up all job arguments for attributes - to be exported in MAXscript - - self.get_operators(): get the export_particle operator - - self.get_custom_attr(): get all custom channel attributes from Openpype - setting and sets it as job arguments before exporting - - self.get_files(): get the files with tyFlow naming convention - before publishing - - self.partition_output_name(): get the naming with partition settings. - - self.get_partition(): get partition value - - """ - - order = pyblish.api.ExtractorOrder - 0.2 - label = "Extract Point Cloud" - hosts = ["max"] - families = ["pointcloud"] - settings = [] - - def process(self, instance): - self.settings = self.get_setting(instance) - start = instance.data["frameStartHandle"] - end = instance.data["frameEndHandle"] - self.log.info("Extracting PRT...") - - stagingdir = self.staging_dir(instance) - filename = "{name}.prt".format(**instance.data) - path = os.path.join(stagingdir, filename) - - with maintained_selection(): - job_args = self.export_particle(instance.data["members"], - start, - end, - path) - - for job in job_args: - rt.Execute(job) - - self.log.info("Performing Extraction ...") - if "representations" not in instance.data: - instance.data["representations"] = [] - - self.log.info("Writing PRT with TyFlow Plugin...") - filenames = self.get_files( - instance.data["members"], path, start, end) - self.log.debug(f"filenames: {filenames}") - - partition = self.partition_output_name( - instance.data["members"]) - - representation = { - 'name': 'prt', - 'ext': 'prt', - 'files': filenames if len(filenames) > 1 else filenames[0], - "stagingDir": stagingdir, - "outputName": partition # partition value - } - instance.data["representations"].append(representation) - self.log.info(f"Extracted instance '{instance.name}' to: {path}") - - def export_particle(self, - members, - start, - end, - filepath): - """Sets up all job arguments for attributes. - - Those attributes are to be exported in MAX Script. - - Args: - members (list): Member nodes of the instance. - start (int): Start frame. - end (int): End frame. - filepath (str): Path to PRT file. - - Returns: - list of arguments for MAX Script. - - """ - job_args = [] - opt_list = self.get_operators(members) - for operator in opt_list: - start_frame = f"{operator}.frameStart={start}" - job_args.append(start_frame) - end_frame = f"{operator}.frameEnd={end}" - job_args.append(end_frame) - filepath = filepath.replace("\\", "/") - prt_filename = f'{operator}.PRTFilename="{filepath}"' - job_args.append(prt_filename) - # Partition - mode = f"{operator}.PRTPartitionsMode=2" - job_args.append(mode) - - additional_args = self.get_custom_attr(operator) - job_args.extend(iter(additional_args)) - prt_export = f"{operator}.exportPRT()" - job_args.append(prt_export) - - return job_args - - @staticmethod - def get_operators(members): - """Get Export Particles Operator. - - Args: - members (list): Instance members. - - Returns: - list of particle operators - - """ - opt_list = [] - for member in members: - obj = member.baseobject - # TODO: to see if it can be used maxscript instead - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - sub_anim = rt.GetSubAnim(obj, anim_name) - boolean = rt.IsProperty(sub_anim, "Export_Particles") - if boolean: - event_name = sub_anim.Name - opt = f"${member.Name}.{event_name}.export_particles" - opt_list.append(opt) - - return opt_list - - @staticmethod - def get_setting(instance): - project_setting = instance.context.data["project_settings"] - return project_setting["max"]["PointCloud"] - - def get_custom_attr(self, operator): - """Get Custom Attributes""" - - custom_attr_list = [] - attr_settings = self.settings["attribute"] - for attr in attr_settings: - key = attr["name"] - value = attr["value"] - custom_attr = "{0}.PRTChannels_{1}=True".format(operator, - value) - self.log.debug( - "{0} will be added as custom attribute".format(key) - ) - custom_attr_list.append(custom_attr) - - return custom_attr_list - - def get_files(self, - container, - path, - start_frame, - end_frame): - """Get file names for tyFlow. - - Set the filenames accordingly to the tyFlow file - naming extension for the publishing purpose - - Actual File Output from tyFlow:: - __partof..prt - - e.g. tyFlow_cloth_CCCS_blobbyFill_001__part1of1_00004.prt - - Args: - container: Instance node. - path (str): Output directory. - start_frame (int): Start frame. - end_frame (int): End frame. - - Returns: - list of filenames - - """ - filenames = [] - filename = os.path.basename(path) - orig_name, ext = os.path.splitext(filename) - partition_count, partition_start = self.get_partition(container) - for frame in range(int(start_frame), int(end_frame) + 1): - actual_name = "{}__part{:03}of{}_{:05}".format(orig_name, - partition_start, - partition_count, - frame) - actual_filename = path.replace(orig_name, actual_name) - filenames.append(os.path.basename(actual_filename)) - - return filenames - - def partition_output_name(self, container): - """Get partition output name. - - Partition output name set for mapping - the published file output. - - Todo: - Customizes the setting for the output. - - Args: - container: Instance node. - - Returns: - str: Partition name. - - """ - partition_count, partition_start = self.get_partition(container) - return f"_part{partition_start:03}of{partition_count}" - - def get_partition(self, container): - """Get Partition value. - - Args: - container: Instance node. - - """ - opt_list = self.get_operators(container) - # TODO: This looks strange? Iterating over - # the opt_list but returning from inside? - for operator in opt_list: - count = rt.Execute(f'{operator}.PRTPartitionsCount') - start = rt.Execute(f'{operator}.PRTPartitionsFrom') - - return count, start diff --git a/server_addon/max/client/ayon_max/plugins/publish/extract_tycache.py b/server_addon/max/client/ayon_max/plugins/publish/extract_tycache.py deleted file mode 100644 index 576abe32a2..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/extract_tycache.py +++ /dev/null @@ -1,157 +0,0 @@ -import os - -import pyblish.api -from pymxs import runtime as rt - -from ayon_max.api import maintained_selection -from ayon_core.pipeline import publish - - -class ExtractTyCache(publish.Extractor): - """Extract tycache format with tyFlow operators. - Notes: - - TyCache only works for TyFlow Pro Plugin. - - Methods: - self.get_export_particles_job_args(): sets up all job arguments - for attributes to be exported in MAXscript - - self.get_operators(): get the export_particle operator - - self.get_files(): get the files with tyFlow naming convention - before publishing - """ - - order = pyblish.api.ExtractorOrder - 0.2 - label = "Extract TyCache" - hosts = ["max"] - families = ["tycache"] - - def process(self, instance): - # TODO: let user decide the param - start = int(instance.context.data["frameStart"]) - end = int(instance.context.data.get("frameEnd")) - self.log.debug("Extracting Tycache...") - - stagingdir = self.staging_dir(instance) - filename = "{name}.tyc".format(**instance.data) - path = os.path.join(stagingdir, filename) - filenames = self.get_files(instance, start, end) - additional_attributes = instance.data.get("tyc_attrs", {}) - - with maintained_selection(): - job_args = self.get_export_particles_job_args( - instance.data["members"], - start, end, path, - additional_attributes) - for job in job_args: - rt.Execute(job) - representations = instance.data.setdefault("representations", []) - representation = { - 'name': 'tyc', - 'ext': 'tyc', - 'files': filenames if len(filenames) > 1 else filenames[0], - "stagingDir": stagingdir, - } - representations.append(representation) - - # Get the tyMesh filename for extraction - mesh_filename = f"{instance.name}__tyMesh.tyc" - mesh_repres = { - 'name': 'tyMesh', - 'ext': 'tyc', - 'files': mesh_filename, - "stagingDir": stagingdir, - "outputName": '__tyMesh' - } - representations.append(mesh_repres) - self.log.debug(f"Extracted instance '{instance.name}' to: {filenames}") - - def get_files(self, instance, start_frame, end_frame): - """Get file names for tyFlow in tyCache format. - - Set the filenames accordingly to the tyCache file - naming extension(.tyc) for the publishing purpose - - Actual File Output from tyFlow in tyCache format: - __tyPart_.tyc - - e.g. tycacheMain__tyPart_00000.tyc - - Args: - instance (pyblish.api.Instance): instance. - start_frame (int): Start frame. - end_frame (int): End frame. - - Returns: - filenames(list): list of filenames - - """ - filenames = [] - for frame in range(int(start_frame), int(end_frame) + 1): - filename = f"{instance.name}__tyPart_{frame:05}.tyc" - filenames.append(filename) - return filenames - - def get_export_particles_job_args(self, members, start, end, - filepath, additional_attributes): - """Sets up all job arguments for attributes. - - Those attributes are to be exported in MAX Script. - - Args: - members (list): Member nodes of the instance. - start (int): Start frame. - end (int): End frame. - filepath (str): Output path of the TyCache file. - additional_attributes (dict): channel attributes data - which needed to be exported - - Returns: - list of arguments for MAX Script. - - """ - settings = { - "exportMode": 2, - "frameStart": start, - "frameEnd": end, - "tyCacheFilename": filepath.replace("\\", "/") - } - settings.update(additional_attributes) - - job_args = [] - for operator in self.get_operators(members): - for key, value in settings.items(): - if isinstance(value, str): - # embed in quotes - value = f'"{value}"' - - job_args.append(f"{operator}.{key}={value}") - job_args.append(f"{operator}.exportTyCache()") - return job_args - - @staticmethod - def get_operators(members): - """Get Export Particles Operator. - - Args: - members (list): Instance members. - - Returns: - list of particle operators - - """ - opt_list = [] - for member in members: - obj = member.baseobject - # TODO: see if it can use maxscript instead - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - sub_anim = rt.GetSubAnim(obj, anim_name) - boolean = rt.IsProperty(sub_anim, "Export_Particles") - if boolean: - event_name = sub_anim.Name - opt = f"${member.Name}.{event_name}.export_particles" - opt_list.append(opt) - - return opt_list diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_frame_range.py b/server_addon/max/client/ayon_max/plugins/publish/validate_frame_range.py deleted file mode 100644 index 9a9f22dd3e..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_frame_range.py +++ /dev/null @@ -1,90 +0,0 @@ -import pyblish.api - -from pymxs import runtime as rt -from ayon_core.pipeline import ( - OptionalPyblishPluginMixin -) -from ayon_core.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError, - KnownPublishError -) -from ayon_max.api.lib import get_frame_range, set_timeline - - -class ValidateFrameRange(pyblish.api.InstancePlugin, - OptionalPyblishPluginMixin): - """Validates the frame ranges. - - This is an optional validator checking if the frame range on instance - matches the frame range specified for the folder. - - It also validates render frame ranges of render layers. - - Repair action will change everything to match the folder frame range. - - This can be turned off by the artist to allow custom ranges. - """ - - label = "Validate Frame Range" - order = ValidateContentsOrder - families = ["camera", "maxrender", - "pointcache", "pointcloud", - "review", "redshiftproxy"] - hosts = ["max"] - optional = True - actions = [RepairAction] - - settings_category = "max" - - def process(self, instance): - if not self.is_active(instance.data): - self.log.debug("Skipping Validate Frame Range...") - return - - frame_range = get_frame_range( - instance.data["taskEntity"]) - - inst_frame_start = instance.data.get("frameStartHandle") - inst_frame_end = instance.data.get("frameEndHandle") - if inst_frame_start is None or inst_frame_end is None: - raise KnownPublishError( - "Missing frame start and frame end on " - "instance to to validate." - ) - frame_start_handle = frame_range["frameStartHandle"] - frame_end_handle = frame_range["frameEndHandle"] - errors = [] - if frame_start_handle != inst_frame_start: - errors.append( - f"Start frame ({inst_frame_start}) on instance does not match " # noqa - f"with the start frame ({frame_start_handle}) set on the folder attributes. ") # noqa - if frame_end_handle != inst_frame_end: - errors.append( - f"End frame ({inst_frame_end}) on instance does not match " - f"with the end frame ({frame_end_handle}) " - "from the folder attributes. ") - - if errors: - bullet_point_errors = "\n".join( - "- {}".format(error) for error in errors - ) - report = ( - "Frame range settings are incorrect.\n\n" - f"{bullet_point_errors}\n\n" - "You can use repair action to fix it." - ) - raise PublishValidationError(report, title="Frame Range incorrect") - - @classmethod - def repair(cls, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStartHandle"] - frame_end_handle = frame_range["frameEndHandle"] - - if instance.data["productType"] == "maxrender": - rt.rendStart = frame_start_handle - rt.rendEnd = frame_end_handle - else: - set_timeline(frame_start_handle, frame_end_handle) diff --git a/server_addon/max/client/ayon_max/plugins/publish/validate_tyflow_data.py b/server_addon/max/client/ayon_max/plugins/publish/validate_tyflow_data.py deleted file mode 100644 index 8dd8a1bb68..0000000000 --- a/server_addon/max/client/ayon_max/plugins/publish/validate_tyflow_data.py +++ /dev/null @@ -1,88 +0,0 @@ -import pyblish.api -from ayon_core.pipeline import PublishValidationError -from pymxs import runtime as rt - - -class ValidateTyFlowData(pyblish.api.InstancePlugin): - """Validate TyFlow plugins or relevant operators are set correctly.""" - - order = pyblish.api.ValidatorOrder - families = ["pointcloud", "tycache"] - hosts = ["max"] - label = "TyFlow Data" - - def process(self, instance): - """ - Notes: - 1. Validate the container only include tyFlow objects - 2. Validate if tyFlow operator Export Particle exists - - """ - - invalid_object = self.get_tyflow_object(instance) - if invalid_object: - self.log.error(f"Non tyFlow object found: {invalid_object}") - - invalid_operator = self.get_tyflow_operator(instance) - if invalid_operator: - self.log.error( - "Operator 'Export Particles' not found in tyFlow editor.") - if invalid_object or invalid_operator: - raise PublishValidationError( - "issues occurred", - description="Container should only include tyFlow object " - "and tyflow operator 'Export Particle' should be in " - "the tyFlow editor.") - - def get_tyflow_object(self, instance): - """Get the nodes which are not tyFlow object(s) - and editable mesh(es) - - Args: - instance (pyblish.api.Instance): instance - - Returns: - list: invalid nodes which are not tyFlow - object(s) and editable mesh(es). - """ - container = instance.data["instance_node"] - self.log.debug(f"Validating tyFlow container for {container}") - - allowed_classes = [rt.tyFlow, rt.Editable_Mesh] - return [ - member for member in instance.data["members"] - if rt.ClassOf(member) not in allowed_classes - ] - - def get_tyflow_operator(self, instance): - """Check if the Export Particle Operators in the node - connections. - - Args: - instance (str): instance node - - Returns: - invalid(list): list of invalid nodes which do - not consist of Export Particle Operators as parts - of the node connections - """ - invalid = [] - members = instance.data["members"] - for member in members: - obj = member.baseobject - - # There must be at least one animation with export - # particles enabled - has_export_particles = False - anim_names = rt.GetSubAnimNames(obj) - for anim_name in anim_names: - # get name of the related tyFlow node - sub_anim = rt.GetSubAnim(obj, anim_name) - # check if there is export particle operator - if rt.IsProperty(sub_anim, "Export_Particles"): - has_export_particles = True - break - - if not has_export_particles: - invalid.append(member) - return invalid diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py deleted file mode 100644 index 5e1b348d92..0000000000 --- a/server_addon/max/server/settings/publishers.py +++ /dev/null @@ -1,222 +0,0 @@ -import json -from pydantic import validator - -from ayon_server.settings import BaseSettingsModel, SettingsField -from ayon_server.exceptions import BadRequestException - - -class ValidateAttributesModel(BaseSettingsModel): - enabled: bool = SettingsField(title="ValidateAttributes") - attributes: str = SettingsField( - "{}", title="Attributes", widget="textarea") - - @validator("attributes") - def validate_json(cls, value): - if not value.strip(): - return "{}" - try: - converted_value = json.loads(value) - success = isinstance(converted_value, dict) - except json.JSONDecodeError: - success = False - - if not success: - raise BadRequestException( - "The attibutes can't be parsed as json object" - ) - return value - - -class ValidateCameraAttributesModel(BaseSettingsModel): - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - fov: float = SettingsField(0.0, title="Focal Length") - nearrange: float = SettingsField(0.0, title="Near Range") - farrange: float = SettingsField(0.0, title="Far Range") - nearclip: float = SettingsField(0.0, title="Near Clip") - farclip: float = SettingsField(0.0, title="Far Clip") - - -class FamilyMappingItemModel(BaseSettingsModel): - families: list[str] = SettingsField( - default_factory=list, - title="Families" - ) - plugins: list[str] = SettingsField( - default_factory=list, - title="Plugins" - ) - - -class ValidateModelNameModel(BaseSettingsModel): - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - regex: str = SettingsField( - "(.*)_(?P.*)_(GEO)", - title="Validation regex", - description=( - "Regex for validating model name. You can use named " - " capturing groups:(?P.*) for Asset name" - ) - ) - - -class ValidateLoadedPluginModel(BaseSettingsModel): - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - family_plugins_mapping: list[FamilyMappingItemModel] = SettingsField( - default_factory=list, - title="Family Plugins Mapping" - ) - - -class BasicValidateModel(BaseSettingsModel): - enabled: bool = SettingsField(title="Enabled") - optional: bool = SettingsField(title="Optional") - active: bool = SettingsField(title="Active") - - -class PublishersModel(BaseSettingsModel): - ValidateInstanceInContext: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Validate Instance In Context", - section="Validators" - ) - ValidateFrameRange: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Validate Frame Range" - ) - ValidateAttributes: ValidateAttributesModel = SettingsField( - default_factory=ValidateAttributesModel, - title="Validate Attributes" - ) - ValidateCameraAttributes: ValidateCameraAttributesModel = SettingsField( - default_factory=ValidateCameraAttributesModel, - title="Validate Camera Attributes", - description=( - "If the value of the camera attributes set to 0, " - "the system automatically skips checking it" - ) - ) - ValidateNoAnimation: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Validate No Animation" - ) - ValidateLoadedPlugin: ValidateLoadedPluginModel = SettingsField( - default_factory=ValidateLoadedPluginModel, - title="Validate Loaded Plugin" - ) - ValidateMeshHasUVs: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Validate Mesh Has UVs" - ) - ValidateModelName: ValidateModelNameModel = SettingsField( - default_factory=ValidateModelNameModel, - title="Validate Model Name" - ) - ValidateRenderPasses: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Validate Render Passes" - ) - ExtractModelObj: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Extract OBJ", - section="Extractors" - ) - ExtractModelFbx: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Extract FBX" - ) - ExtractModelUSD: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Extract Geometry (USD)" - ) - ExtractModel: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Extract Geometry (Alembic)" - ) - ExtractMaxSceneRaw: BasicValidateModel = SettingsField( - default_factory=BasicValidateModel, - title="Extract Max Scene (Raw)" - ) - - -DEFAULT_PUBLISH_SETTINGS = { - "ValidateInstanceInContext": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateFrameRange": { - "enabled": True, - "optional": True, - "active": True - }, - "ValidateAttributes": { - "enabled": False, - "attributes": "{}" - }, - "ValidateCameraAttributes": { - "enabled": True, - "optional": True, - "active": False, - "fov": 45.0, - "nearrange": 0.0, - "farrange": 1000.0, - "nearclip": 1.0, - "farclip": 1000.0 - }, - "ValidateModelName": { - "enabled": True, - "optional": True, - "active": False, - "regex": "(.*)_(?P.*)_(GEO)" - }, - "ValidateLoadedPlugin": { - "enabled": False, - "optional": True, - "family_plugins_mapping": [] - }, - "ValidateMeshHasUVs": { - "enabled": True, - "optional": True, - "active": False - }, - "ValidateNoAnimation": { - "enabled": True, - "optional": True, - "active": False, - }, - "ValidateRenderPasses": { - "enabled": True, - "optional": False, - "active": True - }, - "ExtractModelObj": { - "enabled": True, - "optional": True, - "active": False - }, - "ExtractModelFbx": { - "enabled": True, - "optional": True, - "active": False - }, - "ExtractModelUSD": { - "enabled": True, - "optional": True, - "active": False - }, - "ExtractModel": { - "enabled": True, - "optional": True, - "active": True - }, - "ExtractMaxSceneRaw": { - "enabled": True, - "optional": True, - "active": True - } -} From 3d05545f99ee09345f8c971ec88408a302683cdc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 10 Jul 2024 19:30:22 +0200 Subject: [PATCH 080/163] Update client/ayon_core/pipeline/create/creator_plugins.py Co-authored-by: Mustafa Taher --- client/ayon_core/pipeline/create/creator_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index eeadcaf62e..0e6025ad3b 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -25,7 +25,7 @@ from .legacy_create import LegacyCreator if TYPE_CHECKING: from ayon_core.lib import AbstractAttrDef # Avoid cyclic imports - from .context import CreateContext, CreatedInstance, UpdateData + from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401 class CreatorError(Exception): From 5797a3985cf7d8912af58466c7924e5dfb0b7001 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 11 Jul 2024 10:54:15 +0200 Subject: [PATCH 081/163] version delegate does not trigger 'commitData' --- .../tools/loader/ui/products_delegates.py | 20 ++++++------------- .../tools/loader/ui/products_model.py | 12 +++++++++++ .../tools/loader/ui/products_widget.py | 7 ++++++- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 50fbba607e..0ed8fe8fe7 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -77,7 +77,7 @@ class VersionsFilterModel(QtCore.QSortFilterProxyModel): class VersionComboBox(QtWidgets.QComboBox): - value_changed = QtCore.Signal() + value_changed = QtCore.Signal(str, str) def __init__(self, product_id, parent): super().__init__(parent) @@ -137,7 +137,7 @@ class VersionComboBox(QtWidgets.QComboBox): if value == self._current_id: return self._current_id = value - self.value_changed.emit() + self.value_changed.emit(self._product_id, value) class EditorInfo: @@ -149,7 +149,7 @@ class EditorInfo: class VersionDelegate(QtWidgets.QStyledItemDelegate): """A delegate that display version integer formatted as version string.""" - version_changed = QtCore.Signal() + version_changed = QtCore.Signal(str, str) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -231,10 +231,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): self._editor_by_id[item_id] = EditorInfo(editor) - def editor_changed(): - self._on_editor_change(item_id) - - editor.value_changed.connect(editor_changed) + editor.value_changed.connect(self._on_editor_change) editor.destroyed.connect(self._on_destroy) return editor @@ -257,13 +254,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): version_id = editor.itemData(editor.currentIndex()) model.setData(index, version_id, VERSION_NAME_EDIT_ROLE) - def _on_editor_change(self, item_id): - info = self._editor_by_id.get(item_id) - if info is None or not info.added: - return - - editor = info.widget - self.commitData.emit(editor) + def _on_editor_change(self, product_id, version_id): + self.version_changed.emit(product_id, version_id) def _on_destroy(self, obj): item_id = obj.property("itemId") diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index b2f8aad307..734be5dd90 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -148,6 +148,18 @@ class ProductsModel(QtGui.QStandardItemModel): return self._product_items_by_id.get(product_id) + def set_product_version(self, product_id, version_id): + if version_id is None: + return + + product_item = self._items_by_id.get(product_id) + if product_item is None: + return + + self.setData( + product_item.index(), version_id, VERSION_NAME_EDIT_ROLE + ) + def set_enable_grouping(self, enable_grouping): if enable_grouping is self._grouping_enabled: return diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index b0a8c61f9d..e37c327a17 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -115,7 +115,6 @@ class ProductsWidget(QtWidgets.QWidget): refreshed = QtCore.Signal() merged_products_selection_changed = QtCore.Signal() selection_changed = QtCore.Signal() - version_changed = QtCore.Signal() default_widths = ( 200, # Product name 90, # Product type @@ -192,6 +191,9 @@ class ProductsWidget(QtWidgets.QWidget): products_view.selectionModel().selectionChanged.connect( self._on_selection_change) products_model.version_changed.connect(self._on_version_change) + version_delegate.version_changed.connect( + self._on_version_delegate_change + ) controller.register_event_callback( "selection.folders.changed", @@ -442,6 +444,9 @@ class ProductsWidget(QtWidgets.QWidget): def _on_version_change(self): self._on_selection_change() + def _on_version_delegate_change(self, product_id, version_id): + self._products_model.set_product_version(product_id, version_id) + def _on_folders_selection_change(self, event): project_name = event["project_name"] sitesync_enabled = self._controller.is_sitesync_enabled( From 7749ba8fccd4ea7e950ee621506b37dba9d16015 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 12 Jul 2024 16:27:36 +0200 Subject: [PATCH 082/163] Change placeholder text to "Version status filter..." --- client/ayon_core/tools/loader/ui/statuses_combo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/statuses_combo.py b/client/ayon_core/tools/loader/ui/statuses_combo.py index 5587853940..9fe7ab62a5 100644 --- a/client/ayon_core/tools/loader/ui/statuses_combo.py +++ b/client/ayon_core/tools/loader/ui/statuses_combo.py @@ -294,7 +294,7 @@ class StatusesCombobox(CustomPaintMultiselectComboBox): model=model, parent=parent ) - self.set_placeholder_text("Statuses...") + self.set_placeholder_text("Version status filter...") self._model = model self._last_project_name = None self._fully_disabled_filter = False From 0ddab6fbaac7e3715e9bafd42cca26476244fffa Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 13 Jul 2024 01:53:58 +0200 Subject: [PATCH 083/163] Add informative confirm prompt for delete versions to avoid accidental clicks deleting versions and files. --- .../plugins/load/delete_old_versions.py | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index 62302e7123..07df91f706 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -1,6 +1,7 @@ import collections import os import uuid +from typing import Optional import clique import ayon_api @@ -41,6 +42,8 @@ class DeleteOldVersions(load.ProductLoaderPlugin): ) ] + requires_confirmation = True + def delete_whole_dir_paths(self, dir_paths, delete=True): size = 0 @@ -192,6 +195,32 @@ class DeleteOldVersions(load.ProductLoaderPlugin): ) msgBox.exec_() + def confirm(self, + text: str, + informative_text: Optional[str] = None, + detailed_text: Optional[str] = None) -> bool: + """Prompt user for a confirmation""" + + messagebox = QtWidgets.QMessageBox() + messagebox.setWindowFlags( + messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint + ) + messagebox.setIcon(QtWidgets.QMessageBox.Warning) + messagebox.setWindowTitle("Delete Old Versions") + messagebox.setText(text) + if informative_text: + messagebox.setInformativeText(informative_text) + if detailed_text: + messagebox.setDetailedText(detailed_text) + messagebox.setStandardButtons( + QtWidgets.QMessageBox.Yes + | QtWidgets.QMessageBox.Cancel + ) + messagebox.setDefaultButton(QtWidgets.QMessageBox.Cancel) + messagebox.setStyleSheet(style.load_stylesheet()) + messagebox.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + return messagebox.exec_() == QtWidgets.QMessageBox.Yes + def get_data(self, context, versions_count): product_entity = context["product"] folder_entity = context["folder"] @@ -365,19 +394,40 @@ class DeleteOldVersions(load.ProductLoaderPlugin): return size def load(self, contexts, name=None, namespace=None, options=None): + + # Get user options + versions_to_keep = 2 + remove_publish_folder = False + if options: + versions_to_keep = options.get( + "versions_to_keep", versions_to_keep + ) + remove_publish_folder = options.get( + "remove_publish_folder", remove_publish_folder + ) + + # Because we do not want this run by accident we will add an extra + # user confirmation + if self.requires_confirmation: + contexts_list = "\n".join(sorted( + "- {folder[name]} > {product[name]}".format_map(context) + for context in contexts + )) + num_contexts = len(contexts) + s = "s" if num_contexts > 1 else "" + if not self.confirm( + "Are you sure you want to delete versions?\n\n" + f"This will keep only the last {versions_to_keep} " + f"versions for the {num_contexts} selected product{s}.", + informative_text="Warning: This will delete files from disk", + detailed_text=f"Keep only {versions_to_keep} versions for:\n" + f"{contexts_list}" + ): + return + try: size = 0 for count, context in enumerate(contexts): - versions_to_keep = 2 - remove_publish_folder = False - if options: - versions_to_keep = options.get( - "versions_to_keep", versions_to_keep - ) - remove_publish_folder = options.get( - "remove_publish_folder", remove_publish_folder - ) - data = self.get_data(context, versions_to_keep) if not data: continue @@ -408,6 +458,8 @@ class CalculateOldVersions(DeleteOldVersions): ) ] + requires_confirmation = False + def main(self, project_name, data, remove_publish_folder): size = 0 From 2841e20e96f88efac3270f029d84db7a25cf52c4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sat, 13 Jul 2024 02:37:40 +0200 Subject: [PATCH 084/163] Workfile Template Builder: Allow Create Placeholder to define the default 'active' state of the created instance --- client/ayon_core/pipeline/create/context.py | 8 ++++++-- .../pipeline/workfile/workfile_template_builder.py | 14 +++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index ca88b9b63c..934dc66eed 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2043,7 +2043,8 @@ class CreateContext: variant, folder_entity=None, task_entity=None, - pre_create_data=None + pre_create_data=None, + active=True ): """Trigger create of plugins with standartized arguments. @@ -2061,6 +2062,8 @@ class CreateContext: of creation (possible context of created instance/s). task_entity (Dict[str, Any]): Task entity. pre_create_data (Dict[str, Any]): Pre-create attribute values. + active (Optional[bool]): Whether the created instance defaults + to be active or not. Defaults to True. Returns: Any: Output of triggered creator's 'create' method. @@ -2124,7 +2127,8 @@ class CreateContext: "folderPath": folder_entity["path"], "task": task_entity["name"] if task_entity else None, "productType": creator.product_type, - "variant": variant + "variant": variant, + "active": bool(active) } return creator.create( product_name, diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 4a1f3a84da..99f320e9c1 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1797,6 +1797,16 @@ class PlaceholderCreateMixin(object): "\ncompiling of product name." ) ), + attribute_definitions.BoolDef( + "active", + label="Active", + default=options.get("active", True), + tooltip=( + "Active" + "\nDefines whether the created instance will default to " + "active or not." + ) + ), attribute_definitions.UISeparatorDef(), attribute_definitions.NumberDef( "order", @@ -1826,6 +1836,7 @@ class PlaceholderCreateMixin(object): legacy_create = self.builder.use_legacy_creators creator_name = placeholder.data["creator"] create_variant = placeholder.data["create_variant"] + active = placeholder.data.get("active", True) creator_plugin = self.builder.get_creators_by_name()[creator_name] @@ -1873,7 +1884,8 @@ class PlaceholderCreateMixin(object): create_variant, folder_entity, task_entity, - pre_create_data=pre_create_data + pre_create_data=pre_create_data, + active=active ) except: # noqa: E722 From 849ab8b624586db321b3615e5ee6e71aa94b54df Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 10:47:30 +0200 Subject: [PATCH 085/163] Default `active` to None and then do not pass it to the `instance_data` --- client/ayon_core/pipeline/create/context.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 934dc66eed..433142c51e 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2044,7 +2044,7 @@ class CreateContext: folder_entity=None, task_entity=None, pre_create_data=None, - active=True + active=None ): """Trigger create of plugins with standartized arguments. @@ -2063,7 +2063,7 @@ class CreateContext: task_entity (Dict[str, Any]): Task entity. pre_create_data (Dict[str, Any]): Pre-create attribute values. active (Optional[bool]): Whether the created instance defaults - to be active or not. Defaults to True. + to be active or not. Returns: Any: Output of triggered creator's 'create' method. @@ -2127,9 +2127,11 @@ class CreateContext: "folderPath": folder_entity["path"], "task": task_entity["name"] if task_entity else None, "productType": creator.product_type, - "variant": variant, - "active": bool(active) + "variant": variant } + if active is not None: + instance_data["active"] = bool(active) + return creator.create( product_name, instance_data, From d55b756b20dc8021ee62f564548802990de3daee Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 11:41:43 +0200 Subject: [PATCH 086/163] Update client/ayon_core/pipeline/workfile/workfile_template_builder.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- client/ayon_core/pipeline/workfile/workfile_template_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 99f320e9c1..467a24a52f 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -1836,7 +1836,7 @@ class PlaceholderCreateMixin(object): legacy_create = self.builder.use_legacy_creators creator_name = placeholder.data["creator"] create_variant = placeholder.data["create_variant"] - active = placeholder.data.get("active", True) + active = placeholder.data.get("active") creator_plugin = self.builder.get_creators_by_name()[creator_name] From 1ed42d601a27b349e155ac541b84761b8e72799d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 12:07:33 +0200 Subject: [PATCH 087/163] Refactor `confirm` to `_confirm_delete` --- .../plugins/load/delete_old_versions.py | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index 07df91f706..cfa2acbed7 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -1,7 +1,7 @@ import collections import os import uuid -from typing import Optional +from typing import Optional, List, Dict, Any import clique import ayon_api @@ -195,11 +195,26 @@ class DeleteOldVersions(load.ProductLoaderPlugin): ) msgBox.exec_() - def confirm(self, - text: str, - informative_text: Optional[str] = None, - detailed_text: Optional[str] = None) -> bool: - """Prompt user for a confirmation""" + def _confirm_delete(self, + contexts: List[Dict[str, Any]], + versions_to_keep: int) -> bool: + """Prompt user for a deletion confirmation""" + + contexts_list = "\n".join(sorted( + "- {folder[name]} > {product[name]}".format_map(context) + for context in contexts + )) + num_contexts = len(contexts) + s = "s" if num_contexts > 1 else "" + text = ( + "Are you sure you want to delete versions?\n\n" + f"This will keep only the last {versions_to_keep} " + f"versions for the {num_contexts} selected product{s}." + ) + informative_text="Warning: This will delete files from disk" + detailed_text = ( + f"Keep only {versions_to_keep} versions for:\n{contexts_list}" + ) messagebox = QtWidgets.QMessageBox() messagebox.setWindowFlags( @@ -408,22 +423,11 @@ class DeleteOldVersions(load.ProductLoaderPlugin): # Because we do not want this run by accident we will add an extra # user confirmation - if self.requires_confirmation: - contexts_list = "\n".join(sorted( - "- {folder[name]} > {product[name]}".format_map(context) - for context in contexts - )) - num_contexts = len(contexts) - s = "s" if num_contexts > 1 else "" - if not self.confirm( - "Are you sure you want to delete versions?\n\n" - f"This will keep only the last {versions_to_keep} " - f"versions for the {num_contexts} selected product{s}.", - informative_text="Warning: This will delete files from disk", - detailed_text=f"Keep only {versions_to_keep} versions for:\n" - f"{contexts_list}" - ): - return + if ( + self.requires_confirmation + and not self._confirm_delete(contexts, versions_to_keep) + ): + return try: size = 0 From 73dd05b6337f26225f5b2740c1bc465dceb41416 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 12:10:14 +0200 Subject: [PATCH 088/163] Do not make frameless - to look more like a regular pop-up prompt --- client/ayon_core/plugins/load/delete_old_versions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index cfa2acbed7..d4157af877 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -217,9 +217,6 @@ class DeleteOldVersions(load.ProductLoaderPlugin): ) messagebox = QtWidgets.QMessageBox() - messagebox.setWindowFlags( - messagebox.windowFlags() | QtCore.Qt.FramelessWindowHint - ) messagebox.setIcon(QtWidgets.QMessageBox.Warning) messagebox.setWindowTitle("Delete Old Versions") messagebox.setText(text) From b93d0ec8151fdfa56a78c30edfaf3d16874b6387 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 12:10:34 +0200 Subject: [PATCH 089/163] Remove redundant if statements --- client/ayon_core/plugins/load/delete_old_versions.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index d4157af877..e8bdbca0ca 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -220,10 +220,8 @@ class DeleteOldVersions(load.ProductLoaderPlugin): messagebox.setIcon(QtWidgets.QMessageBox.Warning) messagebox.setWindowTitle("Delete Old Versions") messagebox.setText(text) - if informative_text: - messagebox.setInformativeText(informative_text) - if detailed_text: - messagebox.setDetailedText(detailed_text) + messagebox.setInformativeText(informative_text) + messagebox.setDetailedText(detailed_text) messagebox.setStandardButtons( QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel From dc15823cdcf630adf887e10853a286124e218ef0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 12:11:55 +0200 Subject: [PATCH 090/163] Remove unused import --- client/ayon_core/plugins/load/delete_old_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index e8bdbca0ca..fbb3b06cf3 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -1,7 +1,7 @@ import collections import os import uuid -from typing import Optional, List, Dict, Any +from typing import List, Dict, Any import clique import ayon_api From e3e778ec2a2f0176f81700d8147c90f6359a9e32 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 12:12:08 +0200 Subject: [PATCH 091/163] Fix typo --- client/ayon_core/plugins/load/delete_old_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/plugins/load/delete_old_versions.py b/client/ayon_core/plugins/load/delete_old_versions.py index fbb3b06cf3..f8c45baff6 100644 --- a/client/ayon_core/plugins/load/delete_old_versions.py +++ b/client/ayon_core/plugins/load/delete_old_versions.py @@ -48,7 +48,7 @@ class DeleteOldVersions(load.ProductLoaderPlugin): size = 0 for dir_path in dir_paths: - # Delete all files and fodlers in dir path + # Delete all files and folders in dir path for root, dirs, files in os.walk(dir_path, topdown=False): for name in files: file_path = os.path.join(root, name) From 7bef86ac79a246487437e50e6c2f2252491f7bf4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 12:55:57 +0200 Subject: [PATCH 092/163] Enable Validate Outdated Containers by default for Fusion --- server/settings/publish_plugins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/settings/publish_plugins.py b/server/settings/publish_plugins.py index 36bb3f7340..1ca487969f 100644 --- a/server/settings/publish_plugins.py +++ b/server/settings/publish_plugins.py @@ -964,7 +964,8 @@ DEFAULT_PUBLISH_VALUES = { "nuke", "harmony", "photoshop", - "aftereffects" + "aftereffects", + "fusion" ], "enabled": True, "optional": True, From da78fbceff3744b3293d0b21b3766641b7d61400 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:08:55 +0200 Subject: [PATCH 093/163] use 'ABC' instead of 'ABCMeta' --- client/ayon_core/addon/base.py | 6 ++---- client/ayon_core/addon/interfaces.py | 5 +---- client/ayon_core/host/dirmap.py | 7 ++----- client/ayon_core/host/host.py | 6 ++---- client/ayon_core/host/interfaces.py | 6 ++---- client/ayon_core/lib/attribute_definitions.py | 3 +-- client/ayon_core/lib/local_settings.py | 6 ++---- client/ayon_core/pipeline/create/creator_plugins.py | 10 +++------- .../pipeline/publish/abstract_expected_files.py | 6 ++---- .../pipeline/workfile/workfile_template_builder.py | 9 +++------ client/ayon_core/plugins/publish/extract_review.py | 6 ++---- client/ayon_core/tools/common_models/hierarchy.py | 6 ++---- client/ayon_core/tools/launcher/abstract.py | 7 ++----- client/ayon_core/tools/loader/abstract.py | 6 ++---- client/ayon_core/tools/workfiles/abstract.py | 6 ++---- 15 files changed, 30 insertions(+), 65 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index b9ecff4233..308494b4d8 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -9,9 +9,8 @@ import logging import threading import collections from uuid import uuid4 -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -import six import appdirs import ayon_api from semver import VersionInfo @@ -499,8 +498,7 @@ def is_func_marked(func): return getattr(func, _MARKING_ATTR, False) -@six.add_metaclass(ABCMeta) -class AYONAddon(object): +class AYONAddon(ABC): """Base class of AYON addon. Attributes: diff --git a/client/ayon_core/addon/interfaces.py b/client/ayon_core/addon/interfaces.py index 86e0c6e060..b273e7839b 100644 --- a/client/ayon_core/addon/interfaces.py +++ b/client/ayon_core/addon/interfaces.py @@ -1,7 +1,5 @@ from abc import ABCMeta, abstractmethod -import six - from ayon_core import resources @@ -15,8 +13,7 @@ class _AYONInterfaceMeta(ABCMeta): return str(self) -@six.add_metaclass(_AYONInterfaceMeta) -class AYONInterface: +class AYONInterface(metaclass=_AYONInterfaceMeta): """Base class of Interface that can be used as Mixin with abstract parts. This is way how AYON addon can define that contains specific predefined diff --git a/client/ayon_core/host/dirmap.py b/client/ayon_core/host/dirmap.py index b90b414240..19841845e7 100644 --- a/client/ayon_core/host/dirmap.py +++ b/client/ayon_core/host/dirmap.py @@ -7,18 +7,15 @@ exists is used. """ import os -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod import platform -import six - from ayon_core.lib import Logger from ayon_core.addon import AddonsManager from ayon_core.settings import get_project_settings -@six.add_metaclass(ABCMeta) -class HostDirmap(object): +class HostDirmap(ABC): """Abstract class for running dirmap on a workfile in a host. Dirmap is used to translate paths inside of host workfile from one diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 081aafdbe3..5a29de6cd7 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -1,15 +1,13 @@ import os import logging import contextlib -from abc import ABCMeta, abstractproperty -import six +from abc import ABC, abstractproperty # NOTE can't import 'typing' because of issues in Maya 2020 # - shiboken crashes on 'typing' module import -@six.add_metaclass(ABCMeta) -class HostBase(object): +class HostBase(ABC): """Base of host implementation class. Host is pipeline implementation of DCC application. This class should help diff --git a/client/ayon_core/host/interfaces.py b/client/ayon_core/host/interfaces.py index 7157ad6f7e..c077dfeae9 100644 --- a/client/ayon_core/host/interfaces.py +++ b/client/ayon_core/host/interfaces.py @@ -1,5 +1,4 @@ -from abc import ABCMeta, abstractmethod -import six +from abc import ABC, abstractmethod class MissingMethodsError(ValueError): @@ -106,8 +105,7 @@ class ILoadHost: return self.get_containers() -@six.add_metaclass(ABCMeta) -class IWorkfileHost: +class IWorkfileHost(ABC): """Implementation requirements to be able use workfile utils and tool.""" @staticmethod diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 0a9d38ab65..979ecf246f 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -91,8 +91,7 @@ class AbstractAttrDefMeta(ABCMeta): return obj -@six.add_metaclass(AbstractAttrDefMeta) -class AbstractAttrDef(object): +class AbstractAttrDef(metaclass=AbstractAttrDefMeta): """Abstraction of attribute definition. Each attribute definition must have implemented validation and diff --git a/client/ayon_core/lib/local_settings.py b/client/ayon_core/lib/local_settings.py index fd255c997f..54432265d9 100644 --- a/client/ayon_core/lib/local_settings.py +++ b/client/ayon_core/lib/local_settings.py @@ -4,7 +4,7 @@ import os import json import platform from datetime import datetime -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod # disable lru cache in Python 2 try: @@ -24,7 +24,6 @@ try: except ImportError: import ConfigParser as configparser -import six import appdirs import ayon_api @@ -133,8 +132,7 @@ class AYONSecureRegistry: keyring.delete_password(self._name, name) -@six.add_metaclass(ABCMeta) -class ASettingRegistry(): +class ASettingRegistry(ABC): """Abstract class defining structure of **SettingRegistry** class. It is implementing methods to store secure items into keyring, otherwise diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 0e6025ad3b..624f1c9588 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -3,9 +3,7 @@ import copy import collections from typing import TYPE_CHECKING, Optional -from abc import ABCMeta, abstractmethod - -import six +from abc import ABC, abstractmethod from ayon_core.settings import get_project_settings from ayon_core.lib import Logger @@ -38,8 +36,7 @@ class CreatorError(Exception): super(CreatorError, self).__init__(message) -@six.add_metaclass(ABCMeta) -class ProductConvertorPlugin(object): +class ProductConvertorPlugin(ABC): """Helper for conversion of instances created using legacy creators. Conversion from legacy creators would mean to lose legacy instances, @@ -152,8 +149,7 @@ class ProductConvertorPlugin(object): self._create_context.remove_convertor_item(self.identifier) -@six.add_metaclass(ABCMeta) -class BaseCreator: +class BaseCreator(ABC): """Plugin that create and modify instance data before publishing process. We should maybe find better name as creation is only one part of its logic diff --git a/client/ayon_core/pipeline/publish/abstract_expected_files.py b/client/ayon_core/pipeline/publish/abstract_expected_files.py index f9f3c17ef5..fffe723739 100644 --- a/client/ayon_core/pipeline/publish/abstract_expected_files.py +++ b/client/ayon_core/pipeline/publish/abstract_expected_files.py @@ -1,11 +1,9 @@ # -*- coding: utf-8 -*- """Abstract ExpectedFile class definition.""" -from abc import ABCMeta, abstractmethod -import six +from abc import ABC, abstractmethod -@six.add_metaclass(ABCMeta) -class ExpectedFiles: +class ExpectedFiles(ABC): """Class grouping functionality for all supported renderers. Attributes: diff --git a/client/ayon_core/pipeline/workfile/workfile_template_builder.py b/client/ayon_core/pipeline/workfile/workfile_template_builder.py index 4a1f3a84da..33413b465b 100644 --- a/client/ayon_core/pipeline/workfile/workfile_template_builder.py +++ b/client/ayon_core/pipeline/workfile/workfile_template_builder.py @@ -15,9 +15,8 @@ import os import re import collections import copy -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -import six from ayon_api import ( get_folders, get_folder_by_path, @@ -82,8 +81,7 @@ class TemplateLoadFailed(Exception): pass -@six.add_metaclass(ABCMeta) -class AbstractTemplateBuilder(object): +class AbstractTemplateBuilder(ABC): """Abstraction of Template Builder. Builder cares about context, shared data, cache, discovery of plugins @@ -941,8 +939,7 @@ class AbstractTemplateBuilder(object): ) -@six.add_metaclass(ABCMeta) -class PlaceholderPlugin(object): +class PlaceholderPlugin(ABC): """Plugin which care about handling of placeholder items logic. Plugin create and update placeholders in scene and populate them on diff --git a/client/ayon_core/plugins/publish/extract_review.py b/client/ayon_core/plugins/publish/extract_review.py index 1891c25521..c2793f98a2 100644 --- a/client/ayon_core/plugins/publish/extract_review.py +++ b/client/ayon_core/plugins/publish/extract_review.py @@ -4,9 +4,8 @@ import copy import json import shutil import subprocess -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -import six import clique import speedcopy import pyblish.api @@ -1661,8 +1660,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return vf_back -@six.add_metaclass(ABCMeta) -class _OverscanValue: +class _OverscanValue(ABC): def __repr__(self): return "<{}> {}".format(self.__class__.__name__, str(self)) diff --git a/client/ayon_core/tools/common_models/hierarchy.py b/client/ayon_core/tools/common_models/hierarchy.py index f92563db20..6bccb0f468 100644 --- a/client/ayon_core/tools/common_models/hierarchy.py +++ b/client/ayon_core/tools/common_models/hierarchy.py @@ -1,18 +1,16 @@ import time import collections import contextlib -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod import ayon_api -import six from ayon_core.lib import NestedCacheItem HIERARCHY_MODEL_SENDER = "hierarchy.model" -@six.add_metaclass(ABCMeta) -class AbstractHierarchyController: +class AbstractHierarchyController(ABC): @abstractmethod def emit_event(self, topic, data, source): pass diff --git a/client/ayon_core/tools/launcher/abstract.py b/client/ayon_core/tools/launcher/abstract.py index 921fe7bc5b..63ba4cd717 100644 --- a/client/ayon_core/tools/launcher/abstract.py +++ b/client/ayon_core/tools/launcher/abstract.py @@ -1,10 +1,7 @@ -from abc import ABCMeta, abstractmethod - -import six +from abc import ABC, abstractmethod -@six.add_metaclass(ABCMeta) -class AbstractLauncherCommon(object): +class AbstractLauncherCommon(ABC): @abstractmethod def register_event_callback(self, topic, callback): """Register event callback. diff --git a/client/ayon_core/tools/loader/abstract.py b/client/ayon_core/tools/loader/abstract.py index 3a1a23edd7..6a68af1eb5 100644 --- a/client/ayon_core/tools/loader/abstract.py +++ b/client/ayon_core/tools/loader/abstract.py @@ -1,5 +1,4 @@ -from abc import ABCMeta, abstractmethod -import six +from abc import ABC, abstractmethod from ayon_core.lib.attribute_definitions import ( AbstractAttrDef, @@ -347,8 +346,7 @@ class ActionItem: return cls(**data) -@six.add_metaclass(ABCMeta) -class _BaseLoaderController(object): +class _BaseLoaderController(ABC): """Base loader controller abstraction. Abstract base class that is required for both frontend and backed. diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index e949915ab2..b78e987032 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -1,7 +1,6 @@ import os -from abc import ABCMeta, abstractmethod +from abc import ABC, abstractmethod -import six from ayon_core.style import get_default_entity_icon_color @@ -335,8 +334,7 @@ class WorkareaFilepathResult: self.filepath = filepath -@six.add_metaclass(ABCMeta) -class AbstractWorkfilesCommon(object): +class AbstractWorkfilesCommon(ABC): @abstractmethod def is_host_valid(self): """Host is valid for workfiles tool work. From 0701238b531f052bb526429186c263ac31fa1c38 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:09:38 +0200 Subject: [PATCH 094/163] use 'str' instead of 'string_types' --- client/ayon_core/lib/attribute_definitions.py | 21 +++++++++---------- client/ayon_core/lib/path_templates.py | 16 +++++++------- client/ayon_core/pipeline/project_folders.py | 4 +--- client/ayon_core/pipeline/schema/__init__.py | 3 +-- .../plugins/publish/extract_burnin.py | 3 +-- client/ayon_core/style/__init__.py | 3 +-- .../publisher/publish_report_viewer/window.py | 3 +-- 7 files changed, 22 insertions(+), 31 deletions(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 979ecf246f..d89b92fea7 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -6,7 +6,6 @@ import json import copy from abc import ABCMeta, abstractmethod, abstractproperty -import six import clique # Global variable which store attribute definitions by type @@ -348,7 +347,7 @@ class NumberDef(AbstractAttrDef): ) def convert_value(self, value): - if isinstance(value, six.string_types): + if isinstance(value, str): try: value = float(value) except Exception: @@ -395,12 +394,12 @@ class TextDef(AbstractAttrDef): if multiline is None: multiline = False - elif not isinstance(default, six.string_types): + elif not isinstance(default, str): raise TypeError(( - "'default' argument must be a {}, not '{}'" - ).format(six.string_types, type(default))) + f"'default' argument must be a str, not '{type(default)}'" + )) - if isinstance(regex, six.string_types): + if isinstance(regex, str): regex = re.compile(regex) self.multiline = multiline @@ -417,7 +416,7 @@ class TextDef(AbstractAttrDef): ) def convert_value(self, value): - if isinstance(value, six.string_types): + if isinstance(value, str): return value return self.default @@ -735,7 +734,7 @@ class FileDefItem(object): else: output.append(item) - elif isinstance(item, six.string_types): + elif isinstance(item, str): str_filepaths.append(item) else: raise TypeError( @@ -843,7 +842,7 @@ class FileDef(AbstractAttrDef): if isinstance(default, dict): FileDefItem.from_dict(default) - elif isinstance(default, six.string_types): + elif isinstance(default, str): default = FileDefItem.from_paths([default.strip()])[0] else: @@ -882,14 +881,14 @@ class FileDef(AbstractAttrDef): ) def convert_value(self, value): - if isinstance(value, six.string_types) or isinstance(value, dict): + if isinstance(value, str) or isinstance(value, dict): value = [value] if isinstance(value, (tuple, list, set)): string_paths = [] dict_items = [] for item in value: - if isinstance(item, six.string_types): + if isinstance(item, str): string_paths.append(item.strip()) elif isinstance(item, dict): try: diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index a766dbd9c1..2f5282c2fa 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -2,8 +2,6 @@ import os import re import numbers -import six - KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})") KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+") SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") @@ -43,7 +41,7 @@ class TemplateUnsolved(Exception): class StringTemplate(object): """String that can be formatted.""" def __init__(self, template): - if not isinstance(template, six.string_types): + if not isinstance(template, str): raise TypeError("<{}> argument must be a string, not {}.".format( self.__class__.__name__, str(type(template)) )) @@ -63,7 +61,7 @@ class StringTemplate(object): new_parts = [] for part in parts: - if not isinstance(part, six.string_types): + if not isinstance(part, str): new_parts.append(part) continue @@ -113,7 +111,7 @@ class StringTemplate(object): """ result = TemplatePartResult() for part in self._parts: - if isinstance(part, six.string_types): + if isinstance(part, str): result.add_output(part) else: part.format(data, result) @@ -176,7 +174,7 @@ class StringTemplate(object): value = "<>" elif ( len(parts) == 1 - and isinstance(parts[0], six.string_types) + and isinstance(parts[0], str) ): value = "<{}>".format(parts[0]) else: @@ -299,7 +297,7 @@ class TemplatePartResult: self._optional = True def add_output(self, other): - if isinstance(other, six.string_types): + if isinstance(other, str): self._output += other elif isinstance(other, TemplatePartResult): @@ -457,7 +455,7 @@ class FormattingPart: return True for inh_class in type(value).mro(): - if inh_class in six.string_types: + if inh_class in str: return True return False @@ -568,7 +566,7 @@ class OptionalPart: def format(self, data, result): new_result = TemplatePartResult(True) for part in self._parts: - if isinstance(part, six.string_types): + if isinstance(part, str): new_result.add_output(part) else: part.format(data, new_result) diff --git a/client/ayon_core/pipeline/project_folders.py b/client/ayon_core/pipeline/project_folders.py index 811a98ce4b..902b969457 100644 --- a/client/ayon_core/pipeline/project_folders.py +++ b/client/ayon_core/pipeline/project_folders.py @@ -2,8 +2,6 @@ import os import re import json -import six - from ayon_core.settings import get_project_settings from ayon_core.lib import Logger @@ -109,6 +107,6 @@ def get_project_basic_paths(project_name): if not folder_structure: return [] - if isinstance(folder_structure, six.string_types): + if isinstance(folder_structure, str): folder_structure = json.loads(folder_structure) return _list_path_items(folder_structure) diff --git a/client/ayon_core/pipeline/schema/__init__.py b/client/ayon_core/pipeline/schema/__init__.py index db98a6d080..d16755696d 100644 --- a/client/ayon_core/pipeline/schema/__init__.py +++ b/client/ayon_core/pipeline/schema/__init__.py @@ -17,7 +17,6 @@ import json import logging import jsonschema -import six log_ = logging.getLogger(__name__) @@ -44,7 +43,7 @@ def validate(data, schema=None): root, schema = data["schema"].rsplit(":", 1) - if isinstance(schema, six.string_types): + if isinstance(schema, str): schema = _cache[schema + ".json"] resolver = jsonschema.RefResolver( diff --git a/client/ayon_core/plugins/publish/extract_burnin.py b/client/ayon_core/plugins/publish/extract_burnin.py index 93774842ca..58a032a030 100644 --- a/client/ayon_core/plugins/publish/extract_burnin.py +++ b/client/ayon_core/plugins/publish/extract_burnin.py @@ -6,7 +6,6 @@ import platform import shutil import clique -import six import pyblish.api from ayon_core import resources, AYON_CORE_ROOT @@ -456,7 +455,7 @@ class ExtractBurnin(publish.Extractor): sys_name = platform.system().lower() font_filepath = font_filepath.get(sys_name) - if font_filepath and isinstance(font_filepath, six.string_types): + if font_filepath and isinstance(font_filepath, str): font_filepath = font_filepath.format(**os.environ) if not os.path.exists(font_filepath): font_filepath = None diff --git a/client/ayon_core/style/__init__.py b/client/ayon_core/style/__init__.py index 8d3089ef86..064f527f2b 100644 --- a/client/ayon_core/style/__init__.py +++ b/client/ayon_core/style/__init__.py @@ -2,7 +2,6 @@ import os import copy import json import collections -import six from ayon_core import resources @@ -75,7 +74,7 @@ def _convert_color_values_to_objects(value): output[_key] = _convert_color_values_to_objects(_value) return output - if not isinstance(value, six.string_types): + if not isinstance(value, str): raise TypeError(( "Unexpected type in colors data '{}'. Expected 'str' or 'dict'." ).format(str(type(value)))) 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 3ee986e6f7..aedc3b9e31 100644 --- a/client/ayon_core/tools/publisher/publish_report_viewer/window.py +++ b/client/ayon_core/tools/publisher/publish_report_viewer/window.py @@ -1,6 +1,5 @@ import os import json -import six import uuid import appdirs @@ -387,7 +386,7 @@ class LoadedFilesModel(QtGui.QStandardItemModel): if not filepaths: return - if isinstance(filepaths, six.string_types): + if isinstance(filepaths, str): filepaths = [filepaths] filtered_paths = [] From 8eaeb92bf21638a2960007870f398af07f7081a6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:19:26 +0200 Subject: [PATCH 095/163] reraise error in python 3 way --- client/ayon_core/lib/file_transaction.py | 16 ++++++++-------- client/ayon_core/plugins/publish/integrate.py | 10 ++++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/lib/file_transaction.py b/client/ayon_core/lib/file_transaction.py index 81a3b386f6..47b10dd994 100644 --- a/client/ayon_core/lib/file_transaction.py +++ b/client/ayon_core/lib/file_transaction.py @@ -2,7 +2,6 @@ import os import logging import sys import errno -import six from ayon_core.lib import create_hard_link @@ -158,11 +157,13 @@ class FileTransaction(object): def rollback(self): errors = 0 + last_exc = None # Rollback any transferred files for path in self._transferred: try: os.remove(path) - except OSError: + except OSError as exc: + last_exc = exc errors += 1 self.log.error( "Failed to rollback created file: {}".format(path), @@ -172,7 +173,8 @@ class FileTransaction(object): for backup, original in self._backup_to_original.items(): try: os.rename(backup, original) - except OSError: + except OSError as exc: + last_exc = exc errors += 1 self.log.error( "Failed to restore original file: {} -> {}".format( @@ -183,7 +185,7 @@ class FileTransaction(object): self.log.error( "{} errors occurred during rollback.".format(errors), exc_info=True) - six.reraise(*sys.exc_info()) + raise last_exc @property def transferred(self): @@ -200,11 +202,9 @@ class FileTransaction(object): try: os.makedirs(dirname) except OSError as e: - if e.errno == errno.EEXIST: - pass - else: + if e.errno != errno.EEXIST: self.log.critical("An unexpected error occurred.") - six.reraise(*sys.exc_info()) + raise e def _same_paths(self, src, dst): # handles same paths but with C:/project vs c:/project diff --git a/client/ayon_core/plugins/publish/integrate.py b/client/ayon_core/plugins/publish/integrate.py index 2da33bfb19..a2cf910fa6 100644 --- a/client/ayon_core/plugins/publish/integrate.py +++ b/client/ayon_core/plugins/publish/integrate.py @@ -4,7 +4,6 @@ import sys import copy import clique -import six import pyblish.api from ayon_api import ( get_attributes_for_type, @@ -160,15 +159,14 @@ class IntegrateAsset(pyblish.api.InstancePlugin): # Raise DuplicateDestinationError as KnownPublishError # and rollback the transactions file_transactions.rollback() - six.reraise(KnownPublishError, - KnownPublishError(exc), - sys.exc_info()[2]) - except Exception: + raise KnownPublishError(exc).with_traceback(sys.exc_info()[2]) + + except Exception as exc: # clean destination # todo: preferably we'd also rollback *any* changes to the database file_transactions.rollback() self.log.critical("Error when registering", exc_info=True) - six.reraise(*sys.exc_info()) + raise exc # Finalizing can't rollback safely so no use for moving it to # the try, except. From 085c054672098b56cebb91f2007531c077660ef5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:20:06 +0200 Subject: [PATCH 096/163] define metaclass directly --- .../ayon_core/pipeline/publish/abstract_collect_render.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/pipeline/publish/abstract_collect_render.py b/client/ayon_core/pipeline/publish/abstract_collect_render.py index 17cab876b6..bd2d76c39c 100644 --- a/client/ayon_core/pipeline/publish/abstract_collect_render.py +++ b/client/ayon_core/pipeline/publish/abstract_collect_render.py @@ -7,8 +7,6 @@ TODO: use @dataclass when times come. from abc import abstractmethod import attr -import six - import pyblish.api from .publish_plugins import AbstractMetaContextPlugin @@ -122,8 +120,9 @@ class RenderInstance(object): raise ValueError("both tiles X a Y sizes are set to 1") -@six.add_metaclass(AbstractMetaContextPlugin) -class AbstractCollectRender(pyblish.api.ContextPlugin): +class AbstractCollectRender( + pyblish.api.ContextPlugin, metaclass=AbstractMetaContextPlugin +): """Gather all publishable render layers from renderSetup.""" order = pyblish.api.CollectorOrder + 0.01 From 12f31cae954f203ef1db9e4df5ba44bedf6e4a35 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:51:53 +0200 Subject: [PATCH 097/163] fix str type check --- client/ayon_core/lib/path_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index 2f5282c2fa..e04fb95d70 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -455,7 +455,7 @@ class FormattingPart: return True for inh_class in type(value).mro(): - if inh_class in str: + if isinstance(inh_class, str): return True return False From d9d2f1baf1ad055a264493aeea714110009354c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:16:50 +0200 Subject: [PATCH 098/163] fix type check --- client/ayon_core/lib/path_templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index e04fb95d70..a9d858177c 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -455,7 +455,7 @@ class FormattingPart: return True for inh_class in type(value).mro(): - if isinstance(inh_class, str): + if inh_class is str: return True return False From 8bf1d293c32d3dad1f979fb679248e756be3c39e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:17:00 +0200 Subject: [PATCH 099/163] formatting fix --- client/ayon_core/lib/path_templates.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/lib/path_templates.py b/client/ayon_core/lib/path_templates.py index a9d858177c..01a6985a25 100644 --- a/client/ayon_core/lib/path_templates.py +++ b/client/ayon_core/lib/path_templates.py @@ -12,7 +12,7 @@ class TemplateUnsolved(Exception): """Exception for unsolved template when strict is set to True.""" msg = "Template \"{0}\" is unsolved.{1}{2}" - invalid_types_msg = " Keys with invalid DataType: `{0}`." + invalid_types_msg = " Keys with invalid data type: `{0}`." missing_keys_msg = " Missing keys: \"{0}\"." def __init__(self, template, missing_keys, invalid_types): @@ -198,8 +198,9 @@ class StringTemplate(object): new_parts.extend(tmp_parts[idx]) return new_parts + class TemplateResult(str): - """Result of template format with most of information in. + """Result of template format with most of the information in. Args: used_values (dict): Dictionary of template filling data with From 99a8a632b068ce73361ea7ba130c30e9a1730b8d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 16:17:11 +0200 Subject: [PATCH 100/163] simplified type check --- client/ayon_core/lib/attribute_definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index d89b92fea7..360d47ea17 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -881,7 +881,7 @@ class FileDef(AbstractAttrDef): ) def convert_value(self, value): - if isinstance(value, str) or isinstance(value, dict): + if isinstance(value, (str, dict)): value = [value] if isinstance(value, (tuple, list, set)): From 05f6b0f2ce000910004c3ce9913e17ec528bc04b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 15 Jul 2024 17:29:31 +0200 Subject: [PATCH 101/163] Log warning if `active` argument needs converting to bool --- client/ayon_core/pipeline/create/context.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 433142c51e..1c64d22733 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -2130,7 +2130,12 @@ class CreateContext: "variant": variant } if active is not None: - instance_data["active"] = bool(active) + if not isinstance(active, bool): + self.log.warning( + "CreateContext.create 'active' argument is not a bool. " + f"Converting {active} {type(active)} to bool.") + active = bool(active) + instance_data["active"] = active return creator.create( product_name, From 80bc9a38de1c7148f02a657855762df3d8b47ee4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:40:59 +0200 Subject: [PATCH 102/163] bump version to '0.4.2' --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index 42fd6a5c72..aba5ebbf67 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.2-dev.1" +__version__ = "0.4.2" diff --git a/package.py b/package.py index 5da8e36064..e858a1f7bf 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.2-dev.1" +version = "0.4.2" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index cdedd878a0..9b56555f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "0.4.2-dev.1" +version = "0.4.2" description = "" authors = ["Ynput Team "] readme = "README.md" From f4eb054a9da9f9b430924d233ec56cb877d9c7fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 15 Jul 2024 17:41:34 +0200 Subject: [PATCH 103/163] bump version to '0.4.3-dev.1' --- client/ayon_core/version.py | 2 +- package.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/version.py b/client/ayon_core/version.py index aba5ebbf67..a8c42ec80a 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.2" +__version__ = "0.4.3-dev.1" diff --git a/package.py b/package.py index e858a1f7bf..4f2d2b16b4 100644 --- a/package.py +++ b/package.py @@ -1,6 +1,6 @@ name = "core" title = "Core" -version = "0.4.2" +version = "0.4.3-dev.1" client_dir = "ayon_core" diff --git a/pyproject.toml b/pyproject.toml index 9b56555f6a..f8f840d2c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "ayon-core" -version = "0.4.2" +version = "0.4.3-dev.1" description = "" authors = ["Ynput Team "] readme = "README.md" From 9c06d8c8a28ed8f9e27c222cbe844470c6812704 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:07:10 +0200 Subject: [PATCH 104/163] move current tray implementation to 'ui' subfolder --- client/ayon_core/cli_commands.py | 4 ++-- client/ayon_core/tools/tray/{ => ui}/__init__.py | 0 client/ayon_core/tools/tray/{ => ui}/__main__.py | 0 client/ayon_core/tools/tray/{ => ui}/dialogs.py | 0 .../ayon_core/tools/tray/{ => ui}/images/gifts.png | Bin client/ayon_core/tools/tray/{ => ui}/info_widget.py | 0 client/ayon_core/tools/tray/{ => ui}/tray.py | 0 7 files changed, 2 insertions(+), 2 deletions(-) rename client/ayon_core/tools/tray/{ => ui}/__init__.py (100%) rename client/ayon_core/tools/tray/{ => ui}/__main__.py (100%) rename client/ayon_core/tools/tray/{ => ui}/dialogs.py (100%) rename client/ayon_core/tools/tray/{ => ui}/images/gifts.png (100%) rename client/ayon_core/tools/tray/{ => ui}/info_widget.py (100%) rename client/ayon_core/tools/tray/{ => ui}/tray.py (100%) diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 35b7e294de..774ee3e847 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -13,11 +13,11 @@ class Commands: @staticmethod def launch_tray(): from ayon_core.lib import Logger - from ayon_core.tools import tray + from ayon_core.tools.tray.ui import main Logger.set_process_name("Tray") - tray.main() + main() @staticmethod def add_addons(click_func): diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/ui/__init__.py similarity index 100% rename from client/ayon_core/tools/tray/__init__.py rename to client/ayon_core/tools/tray/ui/__init__.py diff --git a/client/ayon_core/tools/tray/__main__.py b/client/ayon_core/tools/tray/ui/__main__.py similarity index 100% rename from client/ayon_core/tools/tray/__main__.py rename to client/ayon_core/tools/tray/ui/__main__.py diff --git a/client/ayon_core/tools/tray/dialogs.py b/client/ayon_core/tools/tray/ui/dialogs.py similarity index 100% rename from client/ayon_core/tools/tray/dialogs.py rename to client/ayon_core/tools/tray/ui/dialogs.py diff --git a/client/ayon_core/tools/tray/images/gifts.png b/client/ayon_core/tools/tray/ui/images/gifts.png similarity index 100% rename from client/ayon_core/tools/tray/images/gifts.png rename to client/ayon_core/tools/tray/ui/images/gifts.png diff --git a/client/ayon_core/tools/tray/info_widget.py b/client/ayon_core/tools/tray/ui/info_widget.py similarity index 100% rename from client/ayon_core/tools/tray/info_widget.py rename to client/ayon_core/tools/tray/ui/info_widget.py diff --git a/client/ayon_core/tools/tray/tray.py b/client/ayon_core/tools/tray/ui/tray.py similarity index 100% rename from client/ayon_core/tools/tray/tray.py rename to client/ayon_core/tools/tray/ui/tray.py From bca296a953624db36552c05e81ee2b0bf6d9d66d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:35:13 +0200 Subject: [PATCH 105/163] more webserver to tray tool --- client/ayon_core/tools/tray/__init__.py | 49 +++++++++++++++++++ .../tray}/webserver/__init__.py | 0 .../tray}/webserver/base_routes.py | 0 .../tray}/webserver/cors_middleware.py | 0 .../tray}/webserver/host_console_listener.py | 0 .../tray}/webserver/server.py | 0 .../tray}/webserver/structures.py | 0 .../tray}/webserver/version.py | 0 .../tray}/webserver/webserver_module.py | 0 9 files changed, 49 insertions(+) create mode 100644 client/ayon_core/tools/tray/__init__.py rename client/ayon_core/{modules => tools/tray}/webserver/__init__.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/base_routes.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/cors_middleware.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/host_console_listener.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/server.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/structures.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/version.py (100%) rename client/ayon_core/{modules => tools/tray}/webserver/webserver_module.py (100%) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py new file mode 100644 index 0000000000..b57461b88f --- /dev/null +++ b/client/ayon_core/tools/tray/__init__.py @@ -0,0 +1,49 @@ +import os +from typing import Optional, Dict, Any + +import ayon_api + + +def _get_default_server_url() -> str: + return os.getenv("AYON_SERVER_URL") + + +def _get_default_variant() -> str: + return ayon_api.get_default_settings_variant() + + +def get_tray_store_dir() -> str: + pass + + +def get_tray_information( + sever_url: str, variant: str +) -> Optional[Dict[str, Any]]: + pass + + +def validate_tray_server(server_url: str) -> bool: + tray_info = get_tray_information(server_url) + if tray_info is None: + return False + return True + + +def get_tray_server_url( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Optional[str]: + if not server_url: + server_url = _get_default_server_url() + if not variant: + variant = _get_default_variant() + + +def is_tray_running( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> bool: + server_url = get_tray_server_url(server_url, variant) + if server_url and validate_tray_server(server_url): + return True + return False diff --git a/client/ayon_core/modules/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py similarity index 100% rename from client/ayon_core/modules/webserver/__init__.py rename to client/ayon_core/tools/tray/webserver/__init__.py diff --git a/client/ayon_core/modules/webserver/base_routes.py b/client/ayon_core/tools/tray/webserver/base_routes.py similarity index 100% rename from client/ayon_core/modules/webserver/base_routes.py rename to client/ayon_core/tools/tray/webserver/base_routes.py diff --git a/client/ayon_core/modules/webserver/cors_middleware.py b/client/ayon_core/tools/tray/webserver/cors_middleware.py similarity index 100% rename from client/ayon_core/modules/webserver/cors_middleware.py rename to client/ayon_core/tools/tray/webserver/cors_middleware.py diff --git a/client/ayon_core/modules/webserver/host_console_listener.py b/client/ayon_core/tools/tray/webserver/host_console_listener.py similarity index 100% rename from client/ayon_core/modules/webserver/host_console_listener.py rename to client/ayon_core/tools/tray/webserver/host_console_listener.py diff --git a/client/ayon_core/modules/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py similarity index 100% rename from client/ayon_core/modules/webserver/server.py rename to client/ayon_core/tools/tray/webserver/server.py diff --git a/client/ayon_core/modules/webserver/structures.py b/client/ayon_core/tools/tray/webserver/structures.py similarity index 100% rename from client/ayon_core/modules/webserver/structures.py rename to client/ayon_core/tools/tray/webserver/structures.py diff --git a/client/ayon_core/modules/webserver/version.py b/client/ayon_core/tools/tray/webserver/version.py similarity index 100% rename from client/ayon_core/modules/webserver/version.py rename to client/ayon_core/tools/tray/webserver/version.py diff --git a/client/ayon_core/modules/webserver/webserver_module.py b/client/ayon_core/tools/tray/webserver/webserver_module.py similarity index 100% rename from client/ayon_core/modules/webserver/webserver_module.py rename to client/ayon_core/tools/tray/webserver/webserver_module.py From 1bc97337540db527f1895a14e24a310ad3a1e47d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 17:25:04 +0200 Subject: [PATCH 106/163] fix webserver import --- client/ayon_core/tools/stdout_broker/broker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/stdout_broker/broker.py b/client/ayon_core/tools/stdout_broker/broker.py index 291936008b..4f7118e2a8 100644 --- a/client/ayon_core/tools/stdout_broker/broker.py +++ b/client/ayon_core/tools/stdout_broker/broker.py @@ -8,7 +8,7 @@ from datetime import datetime import websocket from ayon_core.lib import Logger -from ayon_core.modules.webserver import HostMsgAction +from ayon_core.tools.tray.webserver import HostMsgAction log = Logger.get_logger(__name__) From 28bff3a9d0215ac5ffaeff593250ee40313cca2b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 17:41:23 +0200 Subject: [PATCH 107/163] fix loader tool grouping with status filtering --- client/ayon_core/tools/loader/ui/products_model.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 734be5dd90..9fc89f5fb3 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -127,6 +127,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_project_name = None self._last_folder_ids = [] + self._last_status_names = None self._last_project_statuses = {} self._last_status_icons_by_name = {} @@ -165,7 +166,11 @@ class ProductsModel(QtGui.QStandardItemModel): return self._grouping_enabled = enable_grouping # Ignore change if groups are not available - self.refresh(self._last_project_name, self._last_folder_ids) + self.refresh( + self._last_project_name, + self._last_folder_ids, + self._last_status_names + ) def flags(self, index): # Make the version column editable @@ -459,6 +464,7 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_project_name = project_name self._last_folder_ids = folder_ids + self._last_status_names = status_names status_items = self._controller.get_project_status_items(project_name) self._last_project_statuses = { status_item.name: status_item From 447a20619b374c9a67bb5bbafa9de7d38aed967f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 17:50:53 +0200 Subject: [PATCH 108/163] don't handle status names filter on model refresh --- .../tools/loader/ui/products_model.py | 21 +++++-------------- .../tools/loader/ui/products_widget.py | 3 +-- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 9fc89f5fb3..c4a738d9df 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -127,7 +127,6 @@ class ProductsModel(QtGui.QStandardItemModel): self._last_project_name = None self._last_folder_ids = [] - self._last_status_names = None self._last_project_statuses = {} self._last_status_icons_by_name = {} @@ -168,8 +167,7 @@ class ProductsModel(QtGui.QStandardItemModel): # Ignore change if groups are not available self.refresh( self._last_project_name, - self._last_folder_ids, - self._last_status_names + self._last_folder_ids ) def flags(self, index): @@ -459,12 +457,11 @@ class ProductsModel(QtGui.QStandardItemModel): def get_last_project_name(self): return self._last_project_name - def refresh(self, project_name, folder_ids, status_names): + def refresh(self, project_name, folder_ids): self._clear() self._last_project_name = project_name self._last_folder_ids = folder_ids - self._last_status_names = status_names status_items = self._controller.get_project_status_items(project_name) self._last_project_statuses = { status_item.name: status_item @@ -492,17 +489,9 @@ class ProductsModel(QtGui.QStandardItemModel): } last_version_by_product_id = {} for product_item in product_items: - all_versions = list(product_item.version_items.values()) - all_versions.sort() - versions = [ - version_item - for version_item in all_versions - if status_names is None or version_item.status in status_names - ] - if versions: - last_version = versions[-1] - else: - last_version = all_versions[-1] + versions = list(product_item.version_items.values()) + versions.sort() + last_version = versions[-1] last_version_by_product_id[product_item.product_id] = ( last_version ) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index e37c327a17..5fa2716714 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -321,8 +321,7 @@ class ProductsWidget(QtWidgets.QWidget): def _refresh_model(self): self._products_model.refresh( self._selected_project_name, - self._selected_folder_ids, - self._products_proxy_model.get_statuses_filter() + self._selected_folder_ids ) def _on_context_menu(self, point): From 22f7a9d2af5f4dae6fe314cd3fe48158f37254aa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:08:58 +0200 Subject: [PATCH 109/163] moved tray addons manager to tray --- client/ayon_core/addon/README.md | 4 - client/ayon_core/addon/__init__.py | 2 - client/ayon_core/addon/base.py | 182 ----------------- client/ayon_core/modules/base.py | 4 - client/ayon_core/tools/tray/addons_manager.py | 184 ++++++++++++++++++ client/ayon_core/tools/tray/ui/tray.py | 2 +- 6 files changed, 185 insertions(+), 193 deletions(-) create mode 100644 client/ayon_core/tools/tray/addons_manager.py diff --git a/client/ayon_core/addon/README.md b/client/ayon_core/addon/README.md index e1c04ea0d6..ded2d50e9c 100644 --- a/client/ayon_core/addon/README.md +++ b/client/ayon_core/addon/README.md @@ -86,7 +86,3 @@ AYON addons should contain separated logic of specific kind of implementation, s "inventory": [] } ``` - -### TrayAddonsManager -- inherits from `AddonsManager` -- has specific implementation for AYON Tray and handle `ITrayAddon` methods diff --git a/client/ayon_core/addon/__init__.py b/client/ayon_core/addon/__init__.py index fe8865c730..c7eccd7b6c 100644 --- a/client/ayon_core/addon/__init__.py +++ b/client/ayon_core/addon/__init__.py @@ -11,7 +11,6 @@ from .interfaces import ( from .base import ( AYONAddon, AddonsManager, - TrayAddonsManager, load_addons, ) @@ -27,6 +26,5 @@ __all__ = ( "AYONAddon", "AddonsManager", - "TrayAddonsManager", "load_addons", ) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 308494b4d8..5cabf3e5e0 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -1338,185 +1338,3 @@ class AddonsManager: " 'get_host_module' please use 'get_host_addon' instead." ) return self.get_host_addon(host_name) - - -class TrayAddonsManager(AddonsManager): - # Define order of addons in menu - # TODO find better way how to define order - addons_menu_order = ( - "user", - "ftrack", - "kitsu", - "launcher_tool", - "avalon", - "clockify", - "traypublish_tool", - "log_viewer", - ) - - def __init__(self, settings=None): - super(TrayAddonsManager, self).__init__(settings, initialize=False) - - self.tray_manager = None - - self.doubleclick_callbacks = {} - self.doubleclick_callback = None - - def add_doubleclick_callback(self, addon, callback): - """Register double-click callbacks on tray icon. - - Currently, there is no way how to determine which is launched. Name of - callback can be defined with `doubleclick_callback` attribute. - - Missing feature how to define default callback. - - Args: - addon (AYONAddon): Addon object. - callback (FunctionType): Function callback. - """ - - callback_name = "_".join([addon.name, callback.__name__]) - if callback_name not in self.doubleclick_callbacks: - self.doubleclick_callbacks[callback_name] = callback - if self.doubleclick_callback is None: - self.doubleclick_callback = callback_name - return - - self.log.warning(( - "Callback with name \"{}\" is already registered." - ).format(callback_name)) - - def initialize(self, tray_manager, tray_menu): - self.tray_manager = tray_manager - self.initialize_addons() - self.tray_init() - self.connect_addons() - self.tray_menu(tray_menu) - - def get_enabled_tray_addons(self): - """Enabled tray addons. - - Returns: - list[AYONAddon]: Enabled addons that inherit from tray interface. - """ - - return [ - addon - for addon in self.get_enabled_addons() - if isinstance(addon, ITrayAddon) - ] - - def restart_tray(self): - if self.tray_manager: - self.tray_manager.restart() - - def tray_init(self): - report = {} - time_start = time.time() - prev_start_time = time_start - for addon in self.get_enabled_tray_addons(): - try: - addon._tray_manager = self.tray_manager - addon.tray_init() - addon.tray_initialized = True - except Exception: - self.log.warning( - "Addon \"{}\" crashed on `tray_init`.".format( - addon.name - ), - exc_info=True - ) - - now = time.time() - report[addon.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Tray init"] = report - - def tray_menu(self, tray_menu): - ordered_addons = [] - enabled_by_name = { - addon.name: addon - for addon in self.get_enabled_tray_addons() - } - - for name in self.addons_menu_order: - addon_by_name = enabled_by_name.pop(name, None) - if addon_by_name: - ordered_addons.append(addon_by_name) - ordered_addons.extend(enabled_by_name.values()) - - report = {} - time_start = time.time() - prev_start_time = time_start - for addon in ordered_addons: - if not addon.tray_initialized: - continue - - try: - addon.tray_menu(tray_menu) - except Exception: - # Unset initialized mark - addon.tray_initialized = False - self.log.warning( - "Addon \"{}\" crashed on `tray_menu`.".format( - addon.name - ), - exc_info=True - ) - now = time.time() - report[addon.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Tray menu"] = report - - def start_addons(self): - report = {} - time_start = time.time() - prev_start_time = time_start - for addon in self.get_enabled_tray_addons(): - if not addon.tray_initialized: - if isinstance(addon, ITrayService): - addon.set_service_failed_icon() - continue - - try: - addon.tray_start() - except Exception: - self.log.warning( - "Addon \"{}\" crashed on `tray_start`.".format( - addon.name - ), - exc_info=True - ) - now = time.time() - report[addon.__class__.__name__] = now - prev_start_time - prev_start_time = now - - if self._report is not None: - report[self._report_total_key] = time.time() - time_start - self._report["Addons start"] = report - - def on_exit(self): - for addon in self.get_enabled_tray_addons(): - if addon.tray_initialized: - try: - addon.tray_exit() - except Exception: - self.log.warning( - "Addon \"{}\" crashed on `tray_exit`.".format( - addon.name - ), - exc_info=True - ) - - # DEPRECATED - def get_enabled_tray_modules(self): - return self.get_enabled_tray_addons() - - def start_modules(self): - self.start_addons() diff --git a/client/ayon_core/modules/base.py b/client/ayon_core/modules/base.py index 3f2a7d4ea5..df412d141e 100644 --- a/client/ayon_core/modules/base.py +++ b/client/ayon_core/modules/base.py @@ -3,7 +3,6 @@ from ayon_core.addon import ( AYONAddon, AddonsManager, - TrayAddonsManager, load_addons, ) from ayon_core.addon.base import ( @@ -12,18 +11,15 @@ from ayon_core.addon.base import ( ) ModulesManager = AddonsManager -TrayModulesManager = TrayAddonsManager load_modules = load_addons __all__ = ( "AYONAddon", "AddonsManager", - "TrayAddonsManager", "load_addons", "OpenPypeModule", "OpenPypeAddOn", "ModulesManager", - "TrayModulesManager", "load_modules", ) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py new file mode 100644 index 0000000000..307b5fba34 --- /dev/null +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -0,0 +1,184 @@ +import time +from ayon_core.addon import AddonsManager, ITrayAddon, ITrayService + + +class TrayAddonsManager(AddonsManager): + # Define order of addons in menu + # TODO find better way how to define order + addons_menu_order = ( + "user", + "ftrack", + "kitsu", + "launcher_tool", + "avalon", + "clockify", + "traypublish_tool", + "log_viewer", + ) + + def __init__(self, settings=None): + super(TrayAddonsManager, self).__init__(settings, initialize=False) + + self.tray_manager = None + + self.doubleclick_callbacks = {} + self.doubleclick_callback = None + + def add_doubleclick_callback(self, addon, callback): + """Register double-click callbacks on tray icon. + + Currently, there is no way how to determine which is launched. Name of + callback can be defined with `doubleclick_callback` attribute. + + Missing feature how to define default callback. + + Args: + addon (AYONAddon): Addon object. + callback (FunctionType): Function callback. + """ + + callback_name = "_".join([addon.name, callback.__name__]) + if callback_name not in self.doubleclick_callbacks: + self.doubleclick_callbacks[callback_name] = callback + if self.doubleclick_callback is None: + self.doubleclick_callback = callback_name + return + + self.log.warning(( + "Callback with name \"{}\" is already registered." + ).format(callback_name)) + + def initialize(self, tray_manager, tray_menu): + self.tray_manager = tray_manager + self.initialize_addons() + self.tray_init() + self.connect_addons() + self.tray_menu(tray_menu) + + def get_enabled_tray_addons(self): + """Enabled tray addons. + + Returns: + list[AYONAddon]: Enabled addons that inherit from tray interface. + """ + + return [ + addon + for addon in self.get_enabled_addons() + if isinstance(addon, ITrayAddon) + ] + + def restart_tray(self): + if self.tray_manager: + self.tray_manager.restart() + + def tray_init(self): + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in self.get_enabled_tray_addons(): + try: + addon._tray_manager = self.tray_manager + addon.tray_init() + addon.tray_initialized = True + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_init`.".format( + addon.name + ), + exc_info=True + ) + + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Tray init"] = report + + def tray_menu(self, tray_menu): + ordered_addons = [] + enabled_by_name = { + addon.name: addon + for addon in self.get_enabled_tray_addons() + } + + for name in self.addons_menu_order: + addon_by_name = enabled_by_name.pop(name, None) + if addon_by_name: + ordered_addons.append(addon_by_name) + ordered_addons.extend(enabled_by_name.values()) + + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in ordered_addons: + if not addon.tray_initialized: + continue + + try: + addon.tray_menu(tray_menu) + except Exception: + # Unset initialized mark + addon.tray_initialized = False + self.log.warning( + "Addon \"{}\" crashed on `tray_menu`.".format( + addon.name + ), + exc_info=True + ) + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Tray menu"] = report + + def start_addons(self): + report = {} + time_start = time.time() + prev_start_time = time_start + for addon in self.get_enabled_tray_addons(): + if not addon.tray_initialized: + if isinstance(addon, ITrayService): + addon.set_service_failed_icon() + continue + + try: + addon.tray_start() + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_start`.".format( + addon.name + ), + exc_info=True + ) + now = time.time() + report[addon.__class__.__name__] = now - prev_start_time + prev_start_time = now + + if self._report is not None: + report[self._report_total_key] = time.time() - time_start + self._report["Addons start"] = report + + def on_exit(self): + for addon in self.get_enabled_tray_addons(): + if addon.tray_initialized: + try: + addon.tray_exit() + except Exception: + self.log.warning( + "Addon \"{}\" crashed on `tray_exit`.".format( + addon.name + ), + exc_info=True + ) + + # DEPRECATED + def get_enabled_tray_modules(self): + return self.get_enabled_tray_addons() + + def start_modules(self): + self.start_addons() diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index c0b90dd764..798b76ce80 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -21,12 +21,12 @@ from ayon_core.settings import get_studio_settings from ayon_core.addon import ( ITrayAction, ITrayService, - TrayAddonsManager, ) from ayon_core.tools.utils import ( WrappedCallbackItem, get_ayon_qt_app, ) +from ayon_core.tools.tray.addons_manager import TrayAddonsManager from .info_widget import InfoWidget from .dialogs import ( From fee111dd97b6d90113f2539227b458c7807ff407 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:09:26 +0200 Subject: [PATCH 110/163] removed deprecated methods --- client/ayon_core/tools/tray/addons_manager.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index 307b5fba34..e7c1243c5a 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -175,10 +175,3 @@ class TrayAddonsManager(AddonsManager): ), exc_info=True ) - - # DEPRECATED - def get_enabled_tray_modules(self): - return self.get_enabled_tray_addons() - - def start_modules(self): - self.start_addons() From eec0d4a0c828ece5f9c5d973880f08079681ab04 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:10:52 +0200 Subject: [PATCH 111/163] pass tray manager on initialization --- client/ayon_core/tools/tray/addons_manager.py | 9 ++++----- client/ayon_core/tools/tray/ui/tray.py | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index e7c1243c5a..b05a336eed 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -16,10 +16,10 @@ class TrayAddonsManager(AddonsManager): "log_viewer", ) - def __init__(self, settings=None): - super(TrayAddonsManager, self).__init__(settings, initialize=False) + def __init__(self, tray_manager): + super().__init__(initialize=False) - self.tray_manager = None + self.tray_manager = tray_manager self.doubleclick_callbacks = {} self.doubleclick_callback = None @@ -48,8 +48,7 @@ class TrayAddonsManager(AddonsManager): "Callback with name \"{}\" is already registered." ).format(callback_name)) - def initialize(self, tray_manager, tray_menu): - self.tray_manager = tray_manager + def initialize(self, tray_menu): self.initialize_addons() self.tray_init() self.connect_addons() diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 798b76ce80..613d9c9e2e 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -56,7 +56,7 @@ class TrayManager: update_check_interval = 5 self._update_check_interval = update_check_interval * 60 * 1000 - self._addons_manager = TrayAddonsManager() + self._addons_manager = TrayAddonsManager(self) self.errors = [] @@ -103,7 +103,7 @@ class TrayManager: def initialize_addons(self): """Add addons to tray.""" - self._addons_manager.initialize(self, self.tray_widget.menu) + self._addons_manager.initialize(self.tray_widget.menu) admin_submenu = ITrayAction.admin_submenu(self.tray_widget.menu) self.tray_widget.menu.addMenu(admin_submenu) From 0b71ec7399873d04cbb0ba9ee4bdefebccc28489 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:11:15 +0200 Subject: [PATCH 112/163] make tray manager private attribute --- client/ayon_core/tools/tray/addons_manager.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index b05a336eed..366d2de404 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -19,7 +19,7 @@ class TrayAddonsManager(AddonsManager): def __init__(self, tray_manager): super().__init__(initialize=False) - self.tray_manager = tray_manager + self._tray_manager = tray_manager self.doubleclick_callbacks = {} self.doubleclick_callback = None @@ -68,8 +68,8 @@ class TrayAddonsManager(AddonsManager): ] def restart_tray(self): - if self.tray_manager: - self.tray_manager.restart() + if self._tray_manager: + self._tray_manager.restart() def tray_init(self): report = {} @@ -77,7 +77,7 @@ class TrayAddonsManager(AddonsManager): prev_start_time = time_start for addon in self.get_enabled_tray_addons(): try: - addon._tray_manager = self.tray_manager + addon._tray_manager = self._tray_manager addon.tray_init() addon.tray_initialized = True except Exception: From 50e196d0cfda847c28cec2e7677e4a0fe02b8ec2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:11:53 +0200 Subject: [PATCH 113/163] removed not existing items from menu order --- client/ayon_core/tools/tray/addons_manager.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index 366d2de404..706895ab3c 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -6,14 +6,10 @@ class TrayAddonsManager(AddonsManager): # Define order of addons in menu # TODO find better way how to define order addons_menu_order = ( - "user", "ftrack", "kitsu", "launcher_tool", - "avalon", "clockify", - "traypublish_tool", - "log_viewer", ) def __init__(self, tray_manager): From 74e2a9dc00c679d186394bc701c77fba20b82579 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:13:34 +0200 Subject: [PATCH 114/163] move lib functions to lib.py --- client/ayon_core/tools/tray/__init__.py | 49 ------------------------ client/ayon_core/tools/tray/lib.py | 50 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 client/ayon_core/tools/tray/lib.py diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index b57461b88f..e69de29bb2 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,49 +0,0 @@ -import os -from typing import Optional, Dict, Any - -import ayon_api - - -def _get_default_server_url() -> str: - return os.getenv("AYON_SERVER_URL") - - -def _get_default_variant() -> str: - return ayon_api.get_default_settings_variant() - - -def get_tray_store_dir() -> str: - pass - - -def get_tray_information( - sever_url: str, variant: str -) -> Optional[Dict[str, Any]]: - pass - - -def validate_tray_server(server_url: str) -> bool: - tray_info = get_tray_information(server_url) - if tray_info is None: - return False - return True - - -def get_tray_server_url( - server_url: Optional[str] = None, - variant: Optional[str] = None -) -> Optional[str]: - if not server_url: - server_url = _get_default_server_url() - if not variant: - variant = _get_default_variant() - - -def is_tray_running( - server_url: Optional[str] = None, - variant: Optional[str] = None -) -> bool: - server_url = get_tray_server_url(server_url, variant) - if server_url and validate_tray_server(server_url): - return True - return False diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py new file mode 100644 index 0000000000..52e603daf0 --- /dev/null +++ b/client/ayon_core/tools/tray/lib.py @@ -0,0 +1,50 @@ +@@ -1,49 +0,0 @@ +import os +from typing import Optional, Dict, Any + +import ayon_api + + +def _get_default_server_url() -> str: + return os.getenv("AYON_SERVER_URL") + + +def _get_default_variant() -> str: + return ayon_api.get_default_settings_variant() + + +def get_tray_store_dir() -> str: + pass + + +def get_tray_information( + sever_url: str, variant: str +) -> Optional[Dict[str, Any]]: + pass + + +def validate_tray_server(server_url: str) -> bool: + tray_info = get_tray_information(server_url) + if tray_info is None: + return False + return True + + +def get_tray_server_url( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Optional[str]: + if not server_url: + server_url = _get_default_server_url() + if not variant: + variant = _get_default_variant() + + +def is_tray_running( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> bool: + server_url = get_tray_server_url(server_url, variant) + if server_url and validate_tray_server(server_url): + return True + return False From 6bd87b019d9c20629bd034ba1a36232cf55bd120 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 17 Jul 2024 18:14:20 +0200 Subject: [PATCH 115/163] simplified 'TrayAddonsManager' import --- client/ayon_core/tools/tray/__init__.py | 6 ++++++ client/ayon_core/tools/tray/ui/tray.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index e69de29bb2..534e7100f5 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -0,0 +1,6 @@ +from .addons_manager import TrayAddonsManager + + +__all__ = ( + "TrayAddonsManager", +) diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 613d9c9e2e..3dd822e4c5 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -26,7 +26,7 @@ from ayon_core.tools.utils import ( WrappedCallbackItem, get_ayon_qt_app, ) -from ayon_core.tools.tray.addons_manager import TrayAddonsManager +from ayon_core.tools.tray import TrayAddonsManager from .info_widget import InfoWidget from .dialogs import ( From 182cc138c8c28df5cafc07be463b11b0c59886e2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 10:42:47 +0200 Subject: [PATCH 116/163] store selection selection model to variable to avoid garbage collection --- client/ayon_core/tools/loader/ui/products_widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 5fa2716714..84e764f403 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -188,7 +188,8 @@ class ProductsWidget(QtWidgets.QWidget): products_model.refreshed.connect(self._on_refresh) products_view.customContextMenuRequested.connect( self._on_context_menu) - products_view.selectionModel().selectionChanged.connect( + products_view_sel_model = products_view.selectionModel() + products_view_sel_model.selectionChanged.connect( self._on_selection_change) products_model.version_changed.connect(self._on_version_change) version_delegate.version_changed.connect( From 119d8c838021bb0d181d4838cb3853f090de312f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 10:43:13 +0200 Subject: [PATCH 117/163] fix product types and status names filtering --- .../tools/loader/ui/products_model.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index c4a738d9df..97ab11a07e 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -538,10 +538,11 @@ class ProductsModel(QtGui.QStandardItemModel): for product_name, product_items in groups.items(): group_product_types |= {p.product_type for p in product_items} for product_item in product_items: - group_product_types |= { + group_status_names |= { version_item.status for version_item in product_item.version_items.values() } + group_product_types.add(product_item.product_type) if len(product_items) == 1: top_items.append(product_items[0]) @@ -584,13 +585,15 @@ class ProductsModel(QtGui.QStandardItemModel): product_name, product_items = path_info (merged_color_hex, merged_color_qt) = self._get_next_color() merged_color = qtawesome.icon( - "fa.circle", color=merged_color_qt) + "fa.circle", color=merged_color_qt + ) merged_item = self._get_merged_model_item( product_name, len(product_items), merged_color_hex) merged_item.setData(merged_color, QtCore.Qt.DecorationRole) new_items.append(merged_item) merged_product_types = set() + merged_status_names = set() new_merged_items = [] for product_item in product_items: item = self._get_product_model_item( @@ -603,9 +606,21 @@ class ProductsModel(QtGui.QStandardItemModel): ) new_merged_items.append(item) merged_product_types.add(product_item.product_type) + merged_status_names |= { + version_item.status + for version_item in ( + product_item.version_items.values() + ) + } merged_item.setData( - "|".join(merged_product_types), PRODUCT_TYPE_ROLE) + "|".join(merged_product_types), + PRODUCT_TYPE_ROLE + ) + merged_item.setData( + "|".join(merged_status_names), + STATUS_NAME_FILTER_ROLE + ) if new_merged_items: merged_item.appendRows(new_merged_items) From 58ae174f71dbc179f742417ee0d70d232e589b1e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:33:19 +0200 Subject: [PATCH 118/163] removed unnecessary 'EditorInfo' --- .../tools/loader/ui/products_delegates.py | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_delegates.py b/client/ayon_core/tools/loader/ui/products_delegates.py index 0ed8fe8fe7..9753da37af 100644 --- a/client/ayon_core/tools/loader/ui/products_delegates.py +++ b/client/ayon_core/tools/loader/ui/products_delegates.py @@ -140,12 +140,6 @@ class VersionComboBox(QtWidgets.QComboBox): self.value_changed.emit(self._product_id, value) -class EditorInfo: - def __init__(self, widget): - self.widget = widget - self.added = False - - class VersionDelegate(QtWidgets.QStyledItemDelegate): """A delegate that display version integer formatted as version string.""" @@ -154,7 +148,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._editor_by_id: Dict[str, EditorInfo] = {} + self._editor_by_id: Dict[str, VersionComboBox] = {} self._statuses_filter = None def displayText(self, value, locale): @@ -164,8 +158,8 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): def set_statuses_filter(self, status_names): self._statuses_filter = set(status_names) - for info in self._editor_by_id.values(): - info.widget.set_statuses_filter(status_names) + for widget in self._editor_by_id.values(): + widget.set_statuses_filter(status_names) def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) @@ -229,11 +223,11 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): editor = VersionComboBox(product_id, parent) editor.setProperty("itemId", item_id) - self._editor_by_id[item_id] = EditorInfo(editor) - editor.value_changed.connect(self._on_editor_change) editor.destroyed.connect(self._on_destroy) + self._editor_by_id[item_id] = editor + return editor def setEditorData(self, editor, index): @@ -242,12 +236,10 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): # Current value of the index versions = index.data(VERSION_NAME_EDIT_ROLE) or [] version_id = index.data(VERSION_ID_ROLE) + editor.update_versions(versions, version_id) editor.set_statuses_filter(self._statuses_filter) - item_id = editor.property("itemId") - self._editor_by_id[item_id].added = True - def setModelData(self, editor, model, index): """Apply the integer version back in the model""" From 642304d8bd774dc13ff928aef85a0f9ad4fef74f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:33:53 +0200 Subject: [PATCH 119/163] fomratting change --- client/ayon_core/tools/loader/ui/products_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_widget.py b/client/ayon_core/tools/loader/ui/products_widget.py index 84e764f403..748a1b5fb8 100644 --- a/client/ayon_core/tools/loader/ui/products_widget.py +++ b/client/ayon_core/tools/loader/ui/products_widget.py @@ -186,12 +186,12 @@ class ProductsWidget(QtWidgets.QWidget): products_proxy_model.rowsInserted.connect(self._on_rows_inserted) products_proxy_model.rowsMoved.connect(self._on_rows_moved) products_model.refreshed.connect(self._on_refresh) + products_model.version_changed.connect(self._on_version_change) products_view.customContextMenuRequested.connect( self._on_context_menu) products_view_sel_model = products_view.selectionModel() products_view_sel_model.selectionChanged.connect( self._on_selection_change) - products_model.version_changed.connect(self._on_version_change) version_delegate.version_changed.connect( self._on_version_delegate_change ) From 05e71ef3326947338803b5d6675a9a10f8754f99 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:34:21 +0200 Subject: [PATCH 120/163] fix 'set_product_version' for items under group/s --- client/ayon_core/tools/loader/ui/products_model.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 97ab11a07e..400138ff9a 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -156,9 +156,8 @@ class ProductsModel(QtGui.QStandardItemModel): if product_item is None: return - self.setData( - product_item.index(), version_id, VERSION_NAME_EDIT_ROLE - ) + index = self.indexFromItem(product_item) + self.setData(index, version_id, VERSION_NAME_EDIT_ROLE) def set_enable_grouping(self, enable_grouping): if enable_grouping is self._grouping_enabled: From 0b53b8f33640f002c5c0a747e1b87799c39cc696 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:42:58 +0200 Subject: [PATCH 121/163] fix 'get_product_item_indexes' --- client/ayon_core/tools/loader/ui/products_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/loader/ui/products_model.py b/client/ayon_core/tools/loader/ui/products_model.py index 400138ff9a..bc24d4d7f7 100644 --- a/client/ayon_core/tools/loader/ui/products_model.py +++ b/client/ayon_core/tools/loader/ui/products_model.py @@ -132,7 +132,7 @@ class ProductsModel(QtGui.QStandardItemModel): def get_product_item_indexes(self): return [ - item.index() + self.indexFromItem(item) for item in self._items_by_id.values() ] From f6cca927e1aeb473a12e2065bd775db89c3c80d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:47:12 +0200 Subject: [PATCH 122/163] removed 'TrayModulesManager' import --- client/ayon_core/modules/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/modules/__init__.py b/client/ayon_core/modules/__init__.py index 0dfd7d663c..f4e381f4a0 100644 --- a/client/ayon_core/modules/__init__.py +++ b/client/ayon_core/modules/__init__.py @@ -17,7 +17,6 @@ from .base import ( load_modules, ModulesManager, - TrayModulesManager, ) @@ -38,5 +37,4 @@ __all__ = ( "load_modules", "ModulesManager", - "TrayModulesManager", ) From f80b82add98b8bb7cb60201eb39ea44094ab4db9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:50:55 +0200 Subject: [PATCH 123/163] changed webserver from addon to feature of tray --- client/ayon_core/tools/tray/addons_manager.py | 15 ++ .../tools/tray/webserver/__init__.py | 9 +- .../tools/tray/webserver/base_routes.py | 3 +- .../tray/webserver/host_console_listener.py | 32 ++-- .../ayon_core/tools/tray/webserver/server.py | 19 ++- .../ayon_core/tools/tray/webserver/version.py | 1 - .../tools/tray/webserver/webserver_module.py | 142 +++++++----------- 7 files changed, 99 insertions(+), 122 deletions(-) delete mode 100644 client/ayon_core/tools/tray/webserver/version.py diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index 706895ab3c..ad265298d0 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -1,5 +1,7 @@ import time + from ayon_core.addon import AddonsManager, ITrayAddon, ITrayService +from ayon_core.tools.tray.webserver import TrayWebserver class TrayAddonsManager(AddonsManager): @@ -16,10 +18,15 @@ class TrayAddonsManager(AddonsManager): super().__init__(initialize=False) self._tray_manager = tray_manager + self._tray_webserver = None self.doubleclick_callbacks = {} self.doubleclick_callback = None + def get_doubleclick_callback(self): + callback_name = self.doubleclick_callback + return self.doubleclick_callbacks.get(callback_name) + def add_doubleclick_callback(self, addon, callback): """Register double-click callbacks on tray icon. @@ -68,6 +75,7 @@ class TrayAddonsManager(AddonsManager): self._tray_manager.restart() def tray_init(self): + self._tray_webserver = TrayWebserver(self._tray_manager) report = {} time_start = time.time() prev_start_time = time_start @@ -92,6 +100,11 @@ class TrayAddonsManager(AddonsManager): report[self._report_total_key] = time.time() - time_start self._report["Tray init"] = report + def connect_addons(self): + enabled_addons = self.get_enabled_addons() + self._tray_webserver.connect_with_addons(enabled_addons) + super().connect_addons() + def tray_menu(self, tray_menu): ordered_addons = [] enabled_by_name = { @@ -132,6 +145,7 @@ class TrayAddonsManager(AddonsManager): self._report["Tray menu"] = report def start_addons(self): + self._tray_webserver.start() report = {} time_start = time.time() prev_start_time = time_start @@ -159,6 +173,7 @@ class TrayAddonsManager(AddonsManager): self._report["Addons start"] = report def on_exit(self): + self._tray_webserver.stop() for addon in self.get_enabled_tray_addons(): if addon.tray_initialized: try: diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index 32f2c55f65..db7c2a7c77 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,13 +1,8 @@ -from .version import __version__ from .structures import HostMsgAction -from .webserver_module import ( - WebServerAddon -) +from .webserver_module import TrayWebserver __all__ = ( - "__version__", - "HostMsgAction", - "WebServerAddon", + "TrayWebserver", ) diff --git a/client/ayon_core/tools/tray/webserver/base_routes.py b/client/ayon_core/tools/tray/webserver/base_routes.py index f4f1abe16c..82568c201c 100644 --- a/client/ayon_core/tools/tray/webserver/base_routes.py +++ b/client/ayon_core/tools/tray/webserver/base_routes.py @@ -1,7 +1,6 @@ """Helper functions or classes for Webserver module. -These must not be imported in module itself to not break Python 2 -applications. +These must not be imported in module itself to not break in-DCC process. """ import inspect diff --git a/client/ayon_core/tools/tray/webserver/host_console_listener.py b/client/ayon_core/tools/tray/webserver/host_console_listener.py index 2efd768e24..3ec57d2598 100644 --- a/client/ayon_core/tools/tray/webserver/host_console_listener.py +++ b/client/ayon_core/tools/tray/webserver/host_console_listener.py @@ -22,9 +22,9 @@ class IconType: class HostListener: - def __init__(self, webserver, module): + def __init__(self, webserver, tray_manager): self._window_per_id = {} - self.module = module + self._tray_manager = tray_manager self.webserver = webserver self._window_per_id = {} # dialogs per host name self._action_per_id = {} # QAction per host name @@ -32,8 +32,9 @@ class HostListener: webserver.add_route('*', "/ws/host_listener", self.websocket_handler) def _host_is_connecting(self, host_name, label): - """ Initialize dialog, adds to submenu. """ - services_submenu = self.module._services_submenu + """ Initialize dialog, adds to submenu.""" + ITrayService.services_submenu(self._tray_manager) + services_submenu = self._tray_manager.get_services_submenu() action = QtWidgets.QAction(label, services_submenu) action.triggered.connect(lambda: self.show_widget(host_name)) @@ -73,8 +74,9 @@ class HostListener: Dialog get initialized when 'host_name' is connecting. """ - self.module.execute_in_main_thread( - lambda: self._show_widget(host_name)) + self._tray_manager.execute_in_main_thread( + self._show_widget, host_name + ) def _show_widget(self, host_name): widget = self._window_per_id[host_name] @@ -95,21 +97,23 @@ class HostListener: if action == HostMsgAction.CONNECTING: self._action_per_id[host_name] = None # must be sent to main thread, or action wont trigger - self.module.execute_in_main_thread( - lambda: self._host_is_connecting(host_name, text)) + self._tray_manager.execute_in_main_thread( + self._host_is_connecting, host_name, text + ) elif action == HostMsgAction.CLOSE: # clean close self._close(host_name) await ws.close() elif action == HostMsgAction.INITIALIZED: - self.module.execute_in_main_thread( + self._tray_manager.execute_in_main_thread( # must be queued as _host_is_connecting might not # be triggered/finished yet - lambda: self._set_host_icon(host_name, - IconType.RUNNING)) + self._set_host_icon, host_name, IconType.RUNNING + ) elif action == HostMsgAction.ADD: - self.module.execute_in_main_thread( - lambda: self._add_text(host_name, text)) + self._tray_manager.execute_in_main_thread( + self._add_text, host_name, text + ) elif msg.type == aiohttp.WSMsgType.ERROR: print('ws connection closed with exception %s' % ws.exception()) @@ -131,7 +135,7 @@ class HostListener: def _close(self, host_name): """ Clean close - remove from menu, delete widget.""" - services_submenu = self.module._services_submenu + services_submenu = self._tray_manager.get_services_submenu() action = self._action_per_id.pop(host_name) services_submenu.removeAction(action) widget = self._window_per_id.pop(host_name) diff --git a/client/ayon_core/tools/tray/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py index 99d9badb6a..2e0d1b258c 100644 --- a/client/ayon_core/tools/tray/webserver/server.py +++ b/client/ayon_core/tools/tray/webserver/server.py @@ -1,6 +1,7 @@ import re import threading import asyncio +from typing import Callable, Optional from aiohttp import web @@ -11,7 +12,9 @@ from .cors_middleware import cors_middleware class WebServerManager: """Manger that care about web server thread.""" - def __init__(self, port=None, host=None): + def __init__( + self, port: Optional[int] = None, host: Optional[str] = None + ): self._log = None self.port = port or 8079 @@ -40,14 +43,14 @@ class WebServerManager: return self._log @property - def url(self): - return "http://{}:{}".format(self.host, self.port) + def url(self) -> str: + return f"http://{self.host}:{self.port}" - def add_route(self, *args, **kwargs): - self.app.router.add_route(*args, **kwargs) + def add_route(self, request_method: str, path: str, handler: Callable): + self.app.router.add_route(request_method, path, handler) - def add_static(self, *args, **kwargs): - self.app.router.add_static(*args, **kwargs) + def add_static(self, prefix: str, path: str): + self.app.router.add_static(prefix, path) def start_server(self): if self.webserver_thread and not self.webserver_thread.is_alive(): @@ -68,7 +71,7 @@ class WebServerManager: ) @property - def is_running(self): + def is_running(self) -> bool: if not self.webserver_thread: return False return self.webserver_thread.is_running diff --git a/client/ayon_core/tools/tray/webserver/version.py b/client/ayon_core/tools/tray/webserver/version.py deleted file mode 100644 index 5becc17c04..0000000000 --- a/client/ayon_core/tools/tray/webserver/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "1.0.0" diff --git a/client/ayon_core/tools/tray/webserver/webserver_module.py b/client/ayon_core/tools/tray/webserver/webserver_module.py index 997b6f754c..0a19fd5b07 100644 --- a/client/ayon_core/tools/tray/webserver/webserver_module.py +++ b/client/ayon_core/tools/tray/webserver/webserver_module.py @@ -1,47 +1,62 @@ -"""WebServerAddon spawns aiohttp server in asyncio loop. +"""TrayWebserver spawns aiohttp server in asyncio loop. -Main usage of the module is in AYON tray where make sense to add ability -of other modules to add theirs routes. Module which would want use that -option must have implemented method `webserver_initialization` which must -expect `WebServerManager` object where is possible to add routes or paths -with handlers. +Usage is to add ability to register routes from addons, or for inner calls +of tray. Addon which would want use that option must have implemented method +webserver_initialization` which must expect `WebServerManager` object where +is possible to add routes or paths with handlers. WebServerManager is by default created only in tray. -It is possible to create server manager without using module logic at all -using `create_new_server_manager`. That can be handy for standalone scripts -with predefined host and port and separated routes and logic. - Running multiple servers in one process is not recommended and probably won't work as expected. It is because of few limitations connected to asyncio module. - -When module's `create_server_manager` is called it is also set environment -variable "AYON_WEBSERVER_URL". Which should lead to root access point -of server. """ import os import socket from ayon_core import resources -from ayon_core.addon import AYONAddon, ITrayService +from ayon_core.lib import Logger -from .version import __version__ +from .server import WebServerManager +from .host_console_listener import HostListener -class WebServerAddon(AYONAddon, ITrayService): - name = "webserver" - version = __version__ - label = "WebServer" - +class TrayWebserver: webserver_url_env = "AYON_WEBSERVER_URL" - def initialize(self, settings): - self._server_manager = None - self._host_listener = None - + def __init__(self, tray_manager): + self._log = None + self._tray_manager = tray_manager self._port = self.find_free_port() - self._webserver_url = None + + self._server_manager = WebServerManager(self._port, None) + + webserver_url = self._server_manager.url + self._webserver_url = webserver_url + + self._host_listener = HostListener(self, self._tray_manager) + + static_prefix = "/res" + self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) + statisc_url = "{}{}".format( + self._webserver_url, static_prefix + ) + + os.environ[self.webserver_url_env] = str(webserver_url) + os.environ["AYON_STATICS_SERVER"] = statisc_url + + # Deprecated + os.environ["OPENPYPE_WEBSERVER_URL"] = str(webserver_url) + os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger("TrayWebserver") + return self._log + + def add_route(self, *args, **kwargs): + self._server_manager.add_route(*args, **kwargs) @property def server_manager(self): @@ -73,72 +88,36 @@ class WebServerAddon(AYONAddon, ITrayService): """ return self._webserver_url - def connect_with_addons(self, enabled_modules): + def connect_with_addons(self, enabled_addons): if not self._server_manager: return - for module in enabled_modules: - if not hasattr(module, "webserver_initialization"): + for addon in enabled_addons: + if not hasattr(addon, "webserver_initialization"): continue try: - module.webserver_initialization(self._server_manager) + addon.webserver_initialization(self._server_manager) except Exception: self.log.warning( - ( - "Failed to connect module \"{}\" to webserver." - ).format(module.name), + f"Failed to connect addon \"{addon.name}\" to webserver.", exc_info=True ) - def tray_init(self): - self.create_server_manager() - self._add_resources_statics() - self._add_listeners() + def start(self): + self._start_server() - def tray_start(self): - self.start_server() + def stop(self): + self._stop_server() - def tray_exit(self): - self.stop_server() - - def start_server(self): + def _start_server(self): if self._server_manager is not None: self._server_manager.start_server() - def stop_server(self): + def _stop_server(self): if self._server_manager is not None: self._server_manager.stop_server() - @staticmethod - def create_new_server_manager(port=None, host=None): - """Create webserver manager for passed port and host. - - Args: - port(int): Port on which wil webserver listen. - host(str): Host name or IP address. Default is 'localhost'. - - Returns: - WebServerManager: Prepared manager. - """ - from .server import WebServerManager - - return WebServerManager(port, host) - - def create_server_manager(self): - if self._server_manager is not None: - return - - self._server_manager = self.create_new_server_manager(self._port) - self._server_manager.on_stop_callbacks.append( - self.set_service_failed_icon - ) - - webserver_url = self._server_manager.url - os.environ["OPENPYPE_WEBSERVER_URL"] = str(webserver_url) - os.environ[self.webserver_url_env] = str(webserver_url) - self._webserver_url = webserver_url - @staticmethod def find_free_port( port_from=None, port_to=None, exclude_ports=None, host=None @@ -193,20 +172,3 @@ class WebServerAddon(AYONAddon, ITrayService): break return found_port - - def _add_resources_statics(self): - static_prefix = "/res" - self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) - statisc_url = "{}{}".format( - self._webserver_url, static_prefix - ) - - os.environ["AYON_STATICS_SERVER"] = statisc_url - os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url - - def _add_listeners(self): - from . import host_console_listener - - self._host_listener = host_console_listener.HostListener( - self._server_manager, self - ) From bdd79ea708ac767b2a58c8457c41aca221257597 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:51:38 +0200 Subject: [PATCH 124/163] rename webserver_module.py to webserver.py --- client/ayon_core/tools/tray/webserver/__init__.py | 2 +- .../tools/tray/webserver/{webserver_module.py => webserver.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename client/ayon_core/tools/tray/webserver/{webserver_module.py => webserver.py} (100%) diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index db7c2a7c77..92b5c54e43 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,5 +1,5 @@ from .structures import HostMsgAction -from .webserver_module import TrayWebserver +from .webserver import TrayWebserver __all__ = ( diff --git a/client/ayon_core/tools/tray/webserver/webserver_module.py b/client/ayon_core/tools/tray/webserver/webserver.py similarity index 100% rename from client/ayon_core/tools/tray/webserver/webserver_module.py rename to client/ayon_core/tools/tray/webserver/webserver.py From c0d878aa0382f1ae82659190516caf30663dae2f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:52:48 +0200 Subject: [PATCH 125/163] added option to get services submenu via tray manager --- client/ayon_core/tools/tray/ui/tray.py | 34 ++++++++++++++------------ 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 3dd822e4c5..2b038bcb5d 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -67,12 +67,12 @@ class TrayManager: self._main_thread_callbacks = collections.deque() self._execution_in_progress = None self._closing = False + self._services_submenu = None @property def doubleclick_callback(self): """Double-click callback for Tray icon.""" - callback_name = self._addons_manager.doubleclick_callback - return self._addons_manager.doubleclick_callbacks.get(callback_name) + return self._addons_manager.get_doubleclick_callback() def execute_doubleclick(self): """Execute double click callback in main thread.""" @@ -103,26 +103,26 @@ class TrayManager: def initialize_addons(self): """Add addons to tray.""" - self._addons_manager.initialize(self.tray_widget.menu) + tray_menu = self.tray_widget.menu + self._addons_manager.initialize(tray_menu) - admin_submenu = ITrayAction.admin_submenu(self.tray_widget.menu) - self.tray_widget.menu.addMenu(admin_submenu) + admin_submenu = ITrayAction.admin_submenu(tray_menu) + tray_menu.addMenu(admin_submenu) # Add services if they are - services_submenu = ITrayService.services_submenu( - self.tray_widget.menu - ) - self.tray_widget.menu.addMenu(services_submenu) + services_submenu = ITrayService.services_submenu(tray_menu) + self._services_submenu = services_submenu + tray_menu.addMenu(services_submenu) # Add separator - self.tray_widget.menu.addSeparator() + tray_menu.addSeparator() self._add_version_item() # Add Exit action to menu exit_action = QtWidgets.QAction("Exit", self.tray_widget) exit_action.triggered.connect(self.tray_widget.exit) - self.tray_widget.menu.addAction(exit_action) + tray_menu.addAction(exit_action) # Tell each addon which addons were imported self._addons_manager.start_addons() @@ -147,6 +147,9 @@ class TrayManager: self.execute_in_main_thread(self._startup_validations) + def get_services_submenu(self): + return self._services_submenu + def restart(self): """Restart Tray tool. @@ -319,9 +322,10 @@ class TrayManager: self._update_check_timer.timeout.emit() def _add_version_item(self): + tray_menu = self.tray_widget.menu login_action = QtWidgets.QAction("Login", self.tray_widget) login_action.triggered.connect(self._on_ayon_login) - self.tray_widget.menu.addAction(login_action) + tray_menu.addAction(login_action) version_string = os.getenv("AYON_VERSION", "AYON Info") version_action = QtWidgets.QAction(version_string, self.tray_widget) @@ -333,9 +337,9 @@ class TrayManager: restart_action.triggered.connect(self._on_restart_action) restart_action.setVisible(False) - self.tray_widget.menu.addAction(version_action) - self.tray_widget.menu.addAction(restart_action) - self.tray_widget.menu.addSeparator() + tray_menu.addAction(version_action) + tray_menu.addAction(restart_action) + tray_menu.addSeparator() self._restart_action = restart_action From 996998d53cdecbd81bc7ae5972c884240c672ad1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 15:52:58 +0200 Subject: [PATCH 126/163] use addon variables over module variables --- client/ayon_core/addon/base.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index 5cabf3e5e0..cd952edffc 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -923,20 +923,20 @@ class AddonsManager: report = {} time_start = time.time() prev_start_time = time_start - enabled_modules = self.get_enabled_addons() - self.log.debug("Has {} enabled modules.".format(len(enabled_modules))) - for module in enabled_modules: + enabled_addons = self.get_enabled_addons() + self.log.debug("Has {} enabled addons.".format(len(enabled_addons))) + for addon in enabled_addons: try: - if not is_func_marked(module.connect_with_addons): - module.connect_with_addons(enabled_modules) + if not is_func_marked(addon.connect_with_addons): + addon.connect_with_addons(enabled_addons) - elif hasattr(module, "connect_with_modules"): + 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(module.name)) - module.connect_with_modules(enabled_modules) + ).format(addon.name)) + addon.connect_with_modules(enabled_addons) except Exception: self.log.error( @@ -945,7 +945,7 @@ class AddonsManager: ) now = time.time() - report[module.__class__.__name__] = now - prev_start_time + report[addon.__class__.__name__] = now - prev_start_time prev_start_time = now if self._report is not None: From 5498bccf8545d45151be2180167a5b4409a8633f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 18 Jul 2024 19:02:46 +0200 Subject: [PATCH 127/163] tray is somewhat capable of hangling single tray running --- client/ayon_core/cli_commands.py | 5 +- client/ayon_core/tools/tray/__init__.py | 5 + client/ayon_core/tools/tray/addons_manager.py | 4 + client/ayon_core/tools/tray/lib.py | 195 ++++++++++++++++-- client/ayon_core/tools/tray/ui/tray.py | 45 +++- .../tools/tray/webserver/__init__.py | 2 + .../tray/webserver/host_console_listener.py | 2 - .../ayon_core/tools/tray/webserver/server.py | 19 ++ .../tools/tray/webserver/webserver.py | 10 +- 9 files changed, 254 insertions(+), 33 deletions(-) diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 774ee3e847..9b19620e9a 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -12,10 +12,7 @@ class Commands: """ @staticmethod def launch_tray(): - from ayon_core.lib import Logger - from ayon_core.tools.tray.ui import main - - Logger.set_process_name("Tray") + from ayon_core.tools.tray import main main() diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index 534e7100f5..001b37e129 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,6 +1,11 @@ from .addons_manager import TrayAddonsManager +from .lib import ( + is_tray_running, + main, +) __all__ = ( "TrayAddonsManager", + "main", ) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index ad265298d0..5acf89c06d 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -146,6 +146,7 @@ class TrayAddonsManager(AddonsManager): def start_addons(self): self._tray_webserver.start() + report = {} time_start = time.time() prev_start_time = time_start @@ -185,3 +186,6 @@ class TrayAddonsManager(AddonsManager): ), exc_info=True ) + + def get_tray_webserver(self): + return self._tray_webserver diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 52e603daf0..ba16e5cbc5 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -1,8 +1,27 @@ -@@ -1,49 +0,0 @@ import os -from typing import Optional, Dict, Any +import json +import hashlib +import subprocess +import csv +import time +import signal +from typing import Optional, Dict, Tuple, Any import ayon_api +import requests + +from ayon_core.lib import Logger +from ayon_core.lib.local_settings import get_ayon_appdirs + + +class TrayState: + NOT_RUNNING = 0 + STARTING = 1 + RUNNING = 2 + + +class TrayIsRunningError(Exception): + pass def _get_default_server_url() -> str: @@ -13,38 +32,170 @@ def _get_default_variant() -> str: return ayon_api.get_default_settings_variant() -def get_tray_store_dir() -> str: - pass +def _get_server_and_variant( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Tuple[str, str]: + if not server_url: + server_url = _get_default_server_url() + if not variant: + variant = _get_default_variant() + return server_url, variant -def get_tray_information( - sever_url: str, variant: str +def _windows_pid_is_running(pid: int) -> bool: + args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"] + output = subprocess.check_output(args) + csv_content = csv.DictReader(output.decode("utf-8").splitlines()) + # if "PID" not in csv_content.fieldnames: + # return False + for _ in csv_content: + return True + return False + + +def _create_tray_hash(server_url: str, variant: str) -> str: + data = f"{server_url}|{variant}" + return hashlib.sha256(data.encode()).hexdigest() + + +def get_tray_storage_dir() -> str: + return get_ayon_appdirs("tray") + + +def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: + # TODO implement server side information + response = requests.get(f"{tray_url}/tray") + try: + response.raise_for_status() + except requests.HTTPError: + return None + return response.json() + + +def _get_tray_info_filepath( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> str: + hash_dir = get_tray_storage_dir() + server_url, variant = _get_server_and_variant(server_url, variant) + filename = _create_tray_hash(server_url, variant) + return os.path.join(hash_dir, filename) + + +def get_tray_file_info( + server_url: Optional[str] = None, + variant: Optional[str] = None ) -> Optional[Dict[str, Any]]: - pass - - -def validate_tray_server(server_url: str) -> bool: - tray_info = get_tray_information(server_url) - if tray_info is None: - return False - return True + filepath = _get_tray_info_filepath(server_url, variant) + if not os.path.exists(filepath): + return None + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except Exception: + return None + return data def get_tray_server_url( server_url: Optional[str] = None, variant: Optional[str] = None ) -> Optional[str]: - if not server_url: - server_url = _get_default_server_url() - if not variant: - variant = _get_default_variant() + data = get_tray_file_info(server_url, variant) + if data is None: + return None + return data.get("url") + + +def set_tray_server_url(tray_url: str, started: bool): + filepath = _get_tray_info_filepath() + if os.path.exists(filepath): + info = get_tray_file_info() + if info.get("pid") != os.getpid(): + raise TrayIsRunningError("Tray is already running.") + os.makedirs(os.path.dirname(filepath), exist_ok=True) + data = { + "url": tray_url, + "pid": os.getpid(), + "started": started + } + with open(filepath, "w") as stream: + json.dump(data, stream) + + +def remove_tray_server_url(): + filepath = _get_tray_info_filepath() + if not os.path.exists(filepath): + return + with open(filepath, "r") as stream: + data = json.load(stream) + if data.get("pid") != os.getpid(): + return + os.remove(filepath) + + +def get_tray_information( + server_url: Optional[str] = None, + variant: Optional[str] = None +) -> Optional[Dict[str, Any]]: + tray_url = get_tray_server_url(server_url, variant) + return _get_tray_information(tray_url) + + +def get_tray_state( + server_url: Optional[str] = None, + variant: Optional[str] = None +): + 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() + return TrayState.NOT_RUNNING + return TrayState.RUNNING def is_tray_running( server_url: Optional[str] = None, variant: Optional[str] = None ) -> bool: - server_url = get_tray_server_url(server_url, variant) - if server_url and validate_tray_server(server_url): - return True - return False + state = get_tray_state(server_url, variant) + return state != TrayState.NOT_RUNNING + + +def main(): + from ayon_core.tools.tray.ui import main + + Logger.set_process_name("Tray") + + state = get_tray_state() + if state == TrayState.RUNNING: + # TODO send some information to tray? + print("Tray is already running.") + return + + if state == TrayState.STARTING: + print("Tray is starting.") + return + # TODO try to handle stuck tray? + time.sleep(5) + state = get_tray_state() + if state == TrayState.RUNNING: + return + if state == TrayState.STARTING: + file_info = get_tray_file_info() or {} + pid = file_info.get("pid") + if pid is not None: + os.kill(pid, signal.SIGTERM) + remove_tray_server_url() + + main() + diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 2b038bcb5d..6900e80ed5 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -1,10 +1,12 @@ import os import sys +import time import collections import atexit - +import json import platform +from aiohttp.web_response import Response import ayon_api from qtpy import QtCore, QtGui, QtWidgets @@ -27,6 +29,11 @@ from ayon_core.tools.utils import ( get_ayon_qt_app, ) from ayon_core.tools.tray import TrayAddonsManager +from ayon_core.tools.tray.lib import ( + set_tray_server_url, + remove_tray_server_url, + TrayIsRunningError, +) from .info_widget import InfoWidget from .dialogs import ( @@ -68,6 +75,7 @@ class TrayManager: self._execution_in_progress = None self._closing = False self._services_submenu = None + self._start_time = time.time() @property def doubleclick_callback(self): @@ -105,6 +113,15 @@ class TrayManager: tray_menu = self.tray_widget.menu self._addons_manager.initialize(tray_menu) + webserver = self._addons_manager.get_tray_webserver() + try: + set_tray_server_url(webserver.webserver_url, False) + except TrayIsRunningError: + self.log.error("Tray is already running.") + self.exit() + return + + webserver.add_route("GET", "/tray", self._get_web_tray_info) admin_submenu = ITrayAction.admin_submenu(tray_menu) tray_menu.addMenu(admin_submenu) @@ -125,7 +142,15 @@ class TrayManager: tray_menu.addAction(exit_action) # Tell each addon which addons were imported - self._addons_manager.start_addons() + # TODO Capture only webserver issues (the only thing that can crash). + try: + self._addons_manager.start_addons() + except Exception: + self.log.error( + "Failed to start addons.", + exc_info=True + ) + return self.exit() # Print time report self._addons_manager.print_report() @@ -147,6 +172,8 @@ class TrayManager: self.execute_in_main_thread(self._startup_validations) + set_tray_server_url(webserver.webserver_url, True) + def get_services_submenu(self): return self._services_submenu @@ -213,6 +240,7 @@ class TrayManager: self.tray_widget.exit() def on_exit(self): + remove_tray_server_url() self._addons_manager.on_exit() def execute_in_main_thread(self, callback, *args, **kwargs): @@ -225,6 +253,19 @@ class TrayManager: return item + async def _get_web_tray_info(self, request): + return Response(text=json.dumps({ + "bundle": os.getenv("AYON_BUNDLE_NAME"), + "dev_mode": is_dev_mode_enabled(), + "staging_mode": is_staging_enabled(), + "addons": { + addon.name: addon.version + for addon in self._addons_manager.get_enabled_addons() + }, + "installer_version": os.getenv("AYON_VERSION"), + "running_time": time.time() - self._start_time, + })) + def _on_update_check_timer(self): try: bundles = ayon_api.get_bundles() diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index 92b5c54e43..938e7205b4 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,8 +1,10 @@ from .structures import HostMsgAction +from .base_routes import RestApiEndpoint from .webserver import TrayWebserver __all__ = ( "HostMsgAction", + "RestApiEndpoint", "TrayWebserver", ) diff --git a/client/ayon_core/tools/tray/webserver/host_console_listener.py b/client/ayon_core/tools/tray/webserver/host_console_listener.py index 3ec57d2598..2c1a7ae9b5 100644 --- a/client/ayon_core/tools/tray/webserver/host_console_listener.py +++ b/client/ayon_core/tools/tray/webserver/host_console_listener.py @@ -23,9 +23,7 @@ class IconType: class HostListener: def __init__(self, webserver, tray_manager): - self._window_per_id = {} self._tray_manager = tray_manager - self.webserver = webserver self._window_per_id = {} # dialogs per host name self._action_per_id = {} # QAction per host name diff --git a/client/ayon_core/tools/tray/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py index 2e0d1b258c..5b6e7e52d4 100644 --- a/client/ayon_core/tools/tray/webserver/server.py +++ b/client/ayon_core/tools/tray/webserver/server.py @@ -52,6 +52,25 @@ class WebServerManager: def add_static(self, prefix: str, path: str): self.app.router.add_static(prefix, path) + def add_addon_route( + self, + addon_name: str, + path: str, + request_method: str, + handler: Callable + ) -> str: + path = path.lstrip("/") + full_path = f"/addons/{addon_name}/{path}" + self.app.router.add_route(request_method, full_path, handler) + return full_path + + def add_addon_static( + self, addon_name: str, prefix: str, path: str + ) -> str: + full_path = f"/addons/{addon_name}/{prefix}" + self.app.router.add_static(full_path, path) + return full_path + def start_server(self): if self.webserver_thread and not self.webserver_thread.is_alive(): self.webserver_thread.start() diff --git a/client/ayon_core/tools/tray/webserver/webserver.py b/client/ayon_core/tools/tray/webserver/webserver.py index 0a19fd5b07..a013bdf19a 100644 --- a/client/ayon_core/tools/tray/webserver/webserver.py +++ b/client/ayon_core/tools/tray/webserver/webserver.py @@ -13,6 +13,7 @@ work as expected. It is because of few limitations connected to asyncio module. import os import socket +from typing import Callable from ayon_core import resources from ayon_core.lib import Logger @@ -39,7 +40,7 @@ class TrayWebserver: static_prefix = "/res" self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) statisc_url = "{}{}".format( - self._webserver_url, static_prefix + webserver_url, static_prefix ) os.environ[self.webserver_url_env] = str(webserver_url) @@ -55,8 +56,11 @@ class TrayWebserver: self._log = Logger.get_logger("TrayWebserver") return self._log - def add_route(self, *args, **kwargs): - self._server_manager.add_route(*args, **kwargs) + def add_route(self, request_method: str, path: str, handler: Callable): + self._server_manager.add_route(request_method, path, handler) + + def add_static(self, prefix: str, path: str): + self._server_manager.add_static(prefix, path) @property def server_manager(self): From fbe987c3f42a1c4299622e76d3705679c6b81546 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 19 Jul 2024 12:55:08 +0200 Subject: [PATCH 128/163] don't store '_tray_manager' to traywebserver --- client/ayon_core/tools/tray/webserver/webserver.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/tray/webserver/webserver.py b/client/ayon_core/tools/tray/webserver/webserver.py index a013bdf19a..0a532305e3 100644 --- a/client/ayon_core/tools/tray/webserver/webserver.py +++ b/client/ayon_core/tools/tray/webserver/webserver.py @@ -27,7 +27,6 @@ class TrayWebserver: def __init__(self, tray_manager): self._log = None - self._tray_manager = tray_manager self._port = self.find_free_port() self._server_manager = WebServerManager(self._port, None) @@ -35,7 +34,7 @@ class TrayWebserver: webserver_url = self._server_manager.url self._webserver_url = webserver_url - self._host_listener = HostListener(self, self._tray_manager) + self._host_listener = HostListener(self, tray_manager) static_prefix = "/res" self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) From 1acbd5129e02712550110b20e3c8005de412a3c2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 22 Jul 2024 18:31:54 +0200 Subject: [PATCH 129/163] simplified webserver logic --- client/ayon_core/tools/tray/addons_manager.py | 77 +++++++- client/ayon_core/tools/tray/ui/tray.py | 10 +- .../tools/tray/webserver/__init__.py | 7 +- .../tray/webserver/host_console_listener.py | 2 +- .../ayon_core/tools/tray/webserver/server.py | 78 +++++++- .../tools/tray/webserver/webserver.py | 177 ------------------ 6 files changed, 156 insertions(+), 195 deletions(-) delete mode 100644 client/ayon_core/tools/tray/webserver/webserver.py diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index 5acf89c06d..3e46775fc3 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -1,10 +1,19 @@ +import os import time +from typing import Callable +from ayon_core.resources import RESOURCES_DIR from ayon_core.addon import AddonsManager, ITrayAddon, ITrayService -from ayon_core.tools.tray.webserver import TrayWebserver +from ayon_core.tools.tray.webserver import ( + HostListener, + find_free_port, + WebServerManager, +) class TrayAddonsManager(AddonsManager): + # TODO do not use env variable + webserver_url_env = "AYON_WEBSERVER_URL" # Define order of addons in menu # TODO find better way how to define order addons_menu_order = ( @@ -18,11 +27,17 @@ class TrayAddonsManager(AddonsManager): super().__init__(initialize=False) self._tray_manager = tray_manager - self._tray_webserver = None + + self._host_listener = None + self._server_manager = WebServerManager(find_free_port(), None) self.doubleclick_callbacks = {} self.doubleclick_callback = None + @property + def webserver_url(self): + return self._server_manager.url + def get_doubleclick_callback(self): callback_name = self.doubleclick_callback return self.doubleclick_callbacks.get(callback_name) @@ -57,6 +72,35 @@ class TrayAddonsManager(AddonsManager): self.connect_addons() self.tray_menu(tray_menu) + def add_route(self, request_method: str, path: str, handler: Callable): + self._server_manager.add_route(request_method, path, handler) + + def add_static(self, prefix: str, path: str): + self._server_manager.add_static(prefix, path) + + def add_addon_route( + self, + addon_name: str, + path: str, + request_method: str, + handler: Callable + ) -> str: + return self._server_manager.add_addon_route( + addon_name, + path, + request_method, + handler + ) + + def add_addon_static( + self, addon_name: str, prefix: str, path: str + ) -> str: + return self._server_manager.add_addon_static( + addon_name, + prefix, + path + ) + def get_enabled_tray_addons(self): """Enabled tray addons. @@ -75,7 +119,7 @@ class TrayAddonsManager(AddonsManager): self._tray_manager.restart() def tray_init(self): - self._tray_webserver = TrayWebserver(self._tray_manager) + self._init_tray_webserver() report = {} time_start = time.time() prev_start_time = time_start @@ -101,8 +145,9 @@ class TrayAddonsManager(AddonsManager): self._report["Tray init"] = report def connect_addons(self): - enabled_addons = self.get_enabled_addons() - self._tray_webserver.connect_with_addons(enabled_addons) + self._server_manager.connect_with_addons( + self.get_enabled_addons() + ) super().connect_addons() def tray_menu(self, tray_menu): @@ -145,7 +190,7 @@ class TrayAddonsManager(AddonsManager): self._report["Tray menu"] = report def start_addons(self): - self._tray_webserver.start() + self._server_manager.start_server() report = {} time_start = time.time() @@ -174,7 +219,7 @@ class TrayAddonsManager(AddonsManager): self._report["Addons start"] = report def on_exit(self): - self._tray_webserver.stop() + self._server_manager.stop_server() for addon in self.get_enabled_tray_addons(): if addon.tray_initialized: try: @@ -188,4 +233,20 @@ class TrayAddonsManager(AddonsManager): ) def get_tray_webserver(self): - return self._tray_webserver + # TODO rename/remove method + return self._server_manager + + def _init_tray_webserver(self): + self._host_listener = HostListener(self._server_manager, self) + + webserver_url = self.webserver_url + statisc_url = f"{webserver_url}/res" + + # 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"] = statisc_url + + # Deprecated + os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url + os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 6900e80ed5..eaf1245dd6 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -113,15 +113,17 @@ class TrayManager: tray_menu = self.tray_widget.menu self._addons_manager.initialize(tray_menu) - webserver = self._addons_manager.get_tray_webserver() + webserver_url = self._addons_manager.webserver_url try: - set_tray_server_url(webserver.webserver_url, False) + set_tray_server_url(webserver_url, False) except TrayIsRunningError: self.log.error("Tray is already running.") self.exit() return - webserver.add_route("GET", "/tray", self._get_web_tray_info) + self._addons_manager.add_route( + "GET", "/tray", self._get_web_tray_info + ) admin_submenu = ITrayAction.admin_submenu(tray_menu) tray_menu.addMenu(admin_submenu) @@ -172,7 +174,7 @@ class TrayManager: self.execute_in_main_thread(self._startup_validations) - set_tray_server_url(webserver.webserver_url, True) + set_tray_server_url(webserver_url, True) def get_services_submenu(self): return self._services_submenu diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index 938e7205b4..a81348365f 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,10 +1,13 @@ from .structures import HostMsgAction from .base_routes import RestApiEndpoint -from .webserver import TrayWebserver +from .server import find_free_port, WebServerManager +from .host_console_listener import HostListener __all__ = ( "HostMsgAction", "RestApiEndpoint", - "TrayWebserver", + "find_free_port", + "WebServerManager", + "HostListener", ) diff --git a/client/ayon_core/tools/tray/webserver/host_console_listener.py b/client/ayon_core/tools/tray/webserver/host_console_listener.py index 2c1a7ae9b5..200dde465c 100644 --- a/client/ayon_core/tools/tray/webserver/host_console_listener.py +++ b/client/ayon_core/tools/tray/webserver/host_console_listener.py @@ -27,7 +27,7 @@ class HostListener: self._window_per_id = {} # dialogs per host name self._action_per_id = {} # QAction per host name - webserver.add_route('*', "/ws/host_listener", self.websocket_handler) + webserver.add_route("*", "/ws/host_listener", self.websocket_handler) def _host_is_connecting(self, host_name, label): """ Initialize dialog, adds to submenu.""" diff --git a/client/ayon_core/tools/tray/webserver/server.py b/client/ayon_core/tools/tray/webserver/server.py index 5b6e7e52d4..d2a9b0fc6b 100644 --- a/client/ayon_core/tools/tray/webserver/server.py +++ b/client/ayon_core/tools/tray/webserver/server.py @@ -1,14 +1,74 @@ import re import threading import asyncio +import socket +import random from typing import Callable, Optional from aiohttp import web from ayon_core.lib import Logger +from ayon_core.resources import RESOURCES_DIR + from .cors_middleware import cors_middleware +def find_free_port( + port_from=None, port_to=None, exclude_ports=None, host=None +): + """Find available socket port from entered range. + + It is also possible to only check if entered port is available. + + Args: + port_from (int): Port number which is checked as first. + port_to (int): Last port that is checked in sequence from entered + `port_from`. Only `port_from` is checked if is not entered. + Nothing is processed if is equeal to `port_from`! + exclude_ports (list, tuple, set): List of ports that won't be + checked form entered range. + host (str): Host where will check for free ports. Set to + "localhost" by default. + """ + if port_from is None: + port_from = 8079 + + if port_to is None: + port_to = 65535 + + # Excluded ports (e.g. reserved for other servers/clients) + if exclude_ports is None: + exclude_ports = [] + + # Default host is localhost but it is possible to look for other hosts + if host is None: + host = "localhost" + + found_port = None + while True: + port = random.randint(port_from, port_to) + if port in exclude_ports: + continue + + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind((host, port)) + found_port = port + + except socket.error: + continue + + finally: + if sock: + sock.close() + + if found_port is not None: + break + + return found_port + + class WebServerManager: """Manger that care about web server thread.""" @@ -20,8 +80,6 @@ class WebServerManager: self.port = port or 8079 self.host = host or "localhost" - self.client = None - self.handlers = {} self.on_stop_callbacks = [] self.app = web.Application( @@ -33,9 +91,10 @@ class WebServerManager: ) # add route with multiple methods for single "external app" - self.webserver_thread = WebServerThread(self) + self.add_static("/res", RESOURCES_DIR) + @property def log(self): if self._log is None: @@ -71,6 +130,19 @@ class WebServerManager: self.app.router.add_static(full_path, path) return full_path + def connect_with_addons(self, addons): + for addon in addons: + if not hasattr(addon, "webserver_initialization"): + continue + + try: + addon.webserver_initialization(self) + except Exception: + self.log.warning( + f"Failed to connect addon \"{addon.name}\" to webserver.", + exc_info=True + ) + def start_server(self): if self.webserver_thread and not self.webserver_thread.is_alive(): self.webserver_thread.start() diff --git a/client/ayon_core/tools/tray/webserver/webserver.py b/client/ayon_core/tools/tray/webserver/webserver.py deleted file mode 100644 index 0a532305e3..0000000000 --- a/client/ayon_core/tools/tray/webserver/webserver.py +++ /dev/null @@ -1,177 +0,0 @@ -"""TrayWebserver spawns aiohttp server in asyncio loop. - -Usage is to add ability to register routes from addons, or for inner calls -of tray. Addon which would want use that option must have implemented method -webserver_initialization` which must expect `WebServerManager` object where -is possible to add routes or paths with handlers. - -WebServerManager is by default created only in tray. - -Running multiple servers in one process is not recommended and probably won't -work as expected. It is because of few limitations connected to asyncio module. -""" - -import os -import socket -from typing import Callable - -from ayon_core import resources -from ayon_core.lib import Logger - -from .server import WebServerManager -from .host_console_listener import HostListener - - -class TrayWebserver: - webserver_url_env = "AYON_WEBSERVER_URL" - - def __init__(self, tray_manager): - self._log = None - self._port = self.find_free_port() - - self._server_manager = WebServerManager(self._port, None) - - webserver_url = self._server_manager.url - self._webserver_url = webserver_url - - self._host_listener = HostListener(self, tray_manager) - - static_prefix = "/res" - self._server_manager.add_static(static_prefix, resources.RESOURCES_DIR) - statisc_url = "{}{}".format( - webserver_url, static_prefix - ) - - os.environ[self.webserver_url_env] = str(webserver_url) - os.environ["AYON_STATICS_SERVER"] = statisc_url - - # Deprecated - os.environ["OPENPYPE_WEBSERVER_URL"] = str(webserver_url) - os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger("TrayWebserver") - return self._log - - def add_route(self, request_method: str, path: str, handler: Callable): - self._server_manager.add_route(request_method, path, handler) - - def add_static(self, prefix: str, path: str): - self._server_manager.add_static(prefix, path) - - @property - def server_manager(self): - """ - - Returns: - Union[WebServerManager, None]: Server manager instance. - - """ - return self._server_manager - - @property - def port(self): - """ - - Returns: - int: Port on which is webserver running. - - """ - return self._port - - @property - def webserver_url(self): - """ - - Returns: - str: URL to webserver. - - """ - return self._webserver_url - - def connect_with_addons(self, enabled_addons): - if not self._server_manager: - return - - for addon in enabled_addons: - if not hasattr(addon, "webserver_initialization"): - continue - - try: - addon.webserver_initialization(self._server_manager) - except Exception: - self.log.warning( - f"Failed to connect addon \"{addon.name}\" to webserver.", - exc_info=True - ) - - def start(self): - self._start_server() - - def stop(self): - self._stop_server() - - def _start_server(self): - if self._server_manager is not None: - self._server_manager.start_server() - - def _stop_server(self): - if self._server_manager is not None: - self._server_manager.stop_server() - - @staticmethod - def find_free_port( - port_from=None, port_to=None, exclude_ports=None, host=None - ): - """Find available socket port from entered range. - - It is also possible to only check if entered port is available. - - Args: - port_from (int): Port number which is checked as first. - port_to (int): Last port that is checked in sequence from entered - `port_from`. Only `port_from` is checked if is not entered. - Nothing is processed if is equeal to `port_from`! - exclude_ports (list, tuple, set): List of ports that won't be - checked form entered range. - host (str): Host where will check for free ports. Set to - "localhost" by default. - """ - if port_from is None: - port_from = 8079 - - if port_to is None: - port_to = 65535 - - # Excluded ports (e.g. reserved for other servers/clients) - if exclude_ports is None: - exclude_ports = [] - - # Default host is localhost but it is possible to look for other hosts - if host is None: - host = "localhost" - - found_port = None - for port in range(port_from, port_to + 1): - if port in exclude_ports: - continue - - sock = None - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind((host, port)) - found_port = port - - except socket.error: - continue - - finally: - if sock: - sock.close() - - if found_port is not None: - break - - return found_port From 5a7a54f138b3bd053aa8762e31f4b71dc746dffb Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 22 Jul 2024 18:44:27 +0200 Subject: [PATCH 130/163] move host listener to UI --- client/ayon_core/tools/tray/addons_manager.py | 25 ++++++++----------- .../host_console_listener.py | 6 +++-- client/ayon_core/tools/tray/ui/tray.py | 3 +++ .../tools/tray/webserver/__init__.py | 2 -- 4 files changed, 17 insertions(+), 19 deletions(-) rename client/ayon_core/tools/tray/{webserver => ui}/host_console_listener.py (97%) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index 3e46775fc3..166b8ab5c6 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -2,10 +2,8 @@ import os import time from typing import Callable -from ayon_core.resources import RESOURCES_DIR from ayon_core.addon import AddonsManager, ITrayAddon, ITrayService from ayon_core.tools.tray.webserver import ( - HostListener, find_free_port, WebServerManager, ) @@ -28,15 +26,14 @@ class TrayAddonsManager(AddonsManager): self._tray_manager = tray_manager - self._host_listener = None - self._server_manager = WebServerManager(find_free_port(), None) + self._webserver_manager = WebServerManager(find_free_port(), None) self.doubleclick_callbacks = {} self.doubleclick_callback = None @property def webserver_url(self): - return self._server_manager.url + return self._webserver_manager.url def get_doubleclick_callback(self): callback_name = self.doubleclick_callback @@ -73,10 +70,10 @@ class TrayAddonsManager(AddonsManager): self.tray_menu(tray_menu) def add_route(self, request_method: str, path: str, handler: Callable): - self._server_manager.add_route(request_method, path, handler) + self._webserver_manager.add_route(request_method, path, handler) def add_static(self, prefix: str, path: str): - self._server_manager.add_static(prefix, path) + self._webserver_manager.add_static(prefix, path) def add_addon_route( self, @@ -85,7 +82,7 @@ class TrayAddonsManager(AddonsManager): request_method: str, handler: Callable ) -> str: - return self._server_manager.add_addon_route( + return self._webserver_manager.add_addon_route( addon_name, path, request_method, @@ -95,7 +92,7 @@ class TrayAddonsManager(AddonsManager): def add_addon_static( self, addon_name: str, prefix: str, path: str ) -> str: - return self._server_manager.add_addon_static( + return self._webserver_manager.add_addon_static( addon_name, prefix, path @@ -145,7 +142,7 @@ class TrayAddonsManager(AddonsManager): self._report["Tray init"] = report def connect_addons(self): - self._server_manager.connect_with_addons( + self._webserver_manager.connect_with_addons( self.get_enabled_addons() ) super().connect_addons() @@ -190,7 +187,7 @@ class TrayAddonsManager(AddonsManager): self._report["Tray menu"] = report def start_addons(self): - self._server_manager.start_server() + self._webserver_manager.start_server() report = {} time_start = time.time() @@ -219,7 +216,7 @@ class TrayAddonsManager(AddonsManager): self._report["Addons start"] = report def on_exit(self): - self._server_manager.stop_server() + self._webserver_manager.stop_server() for addon in self.get_enabled_tray_addons(): if addon.tray_initialized: try: @@ -234,11 +231,9 @@ class TrayAddonsManager(AddonsManager): def get_tray_webserver(self): # TODO rename/remove method - return self._server_manager + return self._webserver_manager def _init_tray_webserver(self): - self._host_listener = HostListener(self._server_manager, self) - webserver_url = self.webserver_url statisc_url = f"{webserver_url}/res" diff --git a/client/ayon_core/tools/tray/webserver/host_console_listener.py b/client/ayon_core/tools/tray/ui/host_console_listener.py similarity index 97% rename from client/ayon_core/tools/tray/webserver/host_console_listener.py rename to client/ayon_core/tools/tray/ui/host_console_listener.py index 200dde465c..ed3b3767fe 100644 --- a/client/ayon_core/tools/tray/webserver/host_console_listener.py +++ b/client/ayon_core/tools/tray/ui/host_console_listener.py @@ -22,12 +22,14 @@ class IconType: class HostListener: - def __init__(self, webserver, tray_manager): + def __init__(self, addons_manager, tray_manager): self._tray_manager = tray_manager self._window_per_id = {} # dialogs per host name self._action_per_id = {} # QAction per host name - webserver.add_route("*", "/ws/host_listener", self.websocket_handler) + addons_manager.add_route( + "*", "/ws/host_listener", self.websocket_handler + ) def _host_is_connecting(self, host_name, label): """ Initialize dialog, adds to submenu.""" diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index eaf1245dd6..b46821c7df 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -35,6 +35,7 @@ from ayon_core.tools.tray.lib import ( TrayIsRunningError, ) +from .host_console_listener import HostListener from .info_widget import InfoWidget from .dialogs import ( UpdateDialog, @@ -65,6 +66,8 @@ class TrayManager: self._addons_manager = TrayAddonsManager(self) + self._host_listener = HostListener(self._addons_manager, self) + self.errors = [] self._update_check_timer = None diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index a81348365f..93bfbd6aee 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,7 +1,6 @@ from .structures import HostMsgAction from .base_routes import RestApiEndpoint from .server import find_free_port, WebServerManager -from .host_console_listener import HostListener __all__ = ( @@ -9,5 +8,4 @@ __all__ = ( "RestApiEndpoint", "find_free_port", "WebServerManager", - "HostListener", ) From 3eefe4d7d090fe22628d550a9263927b66d0dd28 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 22 Jul 2024 19:00:07 +0200 Subject: [PATCH 131/163] faster existing tray validation --- client/ayon_core/tools/tray/__init__.py | 2 ++ .../tools/tray/ui/host_console_listener.py | 2 +- client/ayon_core/tools/tray/ui/tray.py | 18 ++++++++++-------- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index 001b37e129..d646880e15 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,3 +1,4 @@ +from .webserver import HostMsgAction from .addons_manager import TrayAddonsManager from .lib import ( is_tray_running, @@ -6,6 +7,7 @@ from .lib import ( __all__ = ( + "HostMsgAction", "TrayAddonsManager", "main", ) diff --git a/client/ayon_core/tools/tray/ui/host_console_listener.py b/client/ayon_core/tools/tray/ui/host_console_listener.py index ed3b3767fe..62bca2f51b 100644 --- a/client/ayon_core/tools/tray/ui/host_console_listener.py +++ b/client/ayon_core/tools/tray/ui/host_console_listener.py @@ -9,7 +9,7 @@ from qtpy import QtWidgets from ayon_core.addon import ITrayService from ayon_core.tools.stdout_broker.window import ConsoleDialog -from .structures import HostMsgAction +from ayon_core.tools.tray import HostMsgAction log = logging.getLogger(__name__) diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index b46821c7df..0ae0e04260 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -80,6 +80,15 @@ class TrayManager: self._services_submenu = None self._start_time = time.time() + try: + set_tray_server_url( + self._addons_manager.webserver_url, False + ) + except TrayIsRunningError: + self.log.error("Tray is already running.") + self.exit() + return + @property def doubleclick_callback(self): """Double-click callback for Tray icon.""" @@ -116,13 +125,6 @@ class TrayManager: tray_menu = self.tray_widget.menu self._addons_manager.initialize(tray_menu) - webserver_url = self._addons_manager.webserver_url - try: - set_tray_server_url(webserver_url, False) - except TrayIsRunningError: - self.log.error("Tray is already running.") - self.exit() - return self._addons_manager.add_route( "GET", "/tray", self._get_web_tray_info @@ -177,7 +179,7 @@ class TrayManager: self.execute_in_main_thread(self._startup_validations) - set_tray_server_url(webserver_url, True) + set_tray_server_url(self._addons_manager.webserver_url, True) def get_services_submenu(self): return self._services_submenu From d027b546d858cdebe299d755bb7071b3d5a8a17b Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:18:31 +0200 Subject: [PATCH 132/163] added option to validate running tray --- client/ayon_core/tools/tray/lib.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index ba16e5cbc5..556d1435f0 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -99,13 +99,23 @@ def get_tray_file_info( def get_tray_server_url( + validate: Optional[bool] = False, server_url: Optional[str] = None, - variant: Optional[str] = None + variant: Optional[str] = None, ) -> Optional[str]: data = get_tray_file_info(server_url, variant) if data is None: return None - return data.get("url") + url = data.get("url") + if not url: + return None + + if not validate: + return url + + if _get_tray_information(url): + return url + return None def set_tray_server_url(tray_url: str, started: bool): From 89ad9afdd38a499a296f7e552f2d7d1699a93a65 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:18:40 +0200 Subject: [PATCH 133/163] added docstrings --- client/ayon_core/tools/tray/lib.py | 92 +++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 556d1435f0..b393ad0564 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -25,10 +25,12 @@ class TrayIsRunningError(Exception): def _get_default_server_url() -> str: + """Get default AYON server url.""" return os.getenv("AYON_SERVER_URL") def _get_default_variant() -> str: + """Get default settings variant.""" return ayon_api.get_default_settings_variant() @@ -55,16 +57,31 @@ def _windows_pid_is_running(pid: int) -> bool: def _create_tray_hash(server_url: str, variant: str) -> str: + """Create tray hash for metadata filename. + + Args: + server_url (str): AYON server url. + variant (str): Settings variant. + + Returns: + str: Hash for metadata filename. + + """ data = f"{server_url}|{variant}" return hashlib.sha256(data.encode()).hexdigest() def get_tray_storage_dir() -> str: + """Get tray storage directory. + + Returns: + 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]]: - # TODO implement server side information response = requests.get(f"{tray_url}/tray") try: response.raise_for_status() @@ -87,6 +104,19 @@ def get_tray_file_info( server_url: Optional[str] = None, variant: Optional[str] = None ) -> Optional[Dict[str, Any]]: + """Get tray information from file. + + Metadata information about running tray that should contain tray + server url. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + Optional[Dict[str, Any]]: Tray information. + + """ filepath = _get_tray_info_filepath(server_url, variant) if not os.path.exists(filepath): return None @@ -103,6 +133,20 @@ def get_tray_server_url( server_url: Optional[str] = None, variant: Optional[str] = None, ) -> Optional[str]: + """Get tray server url. + + Does not validate if tray is running. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + validate (Optional[bool]): Validate if tray is running. + By default, does not validate. + + Returns: + Optional[str]: Tray server url. + + """ data = get_tray_file_info(server_url, variant) if data is None: return None @@ -119,6 +163,16 @@ def get_tray_server_url( def set_tray_server_url(tray_url: str, started: bool): + """Add tray server information file. + + Called from tray logic, do not use on your own. + + Args: + tray_url (str): Webserver url with port. + started (bool): If tray is started. When set to 'False' it means + that tray is starting up. + + """ filepath = _get_tray_info_filepath() if os.path.exists(filepath): info = get_tray_file_info() @@ -135,6 +189,10 @@ def set_tray_server_url(tray_url: str, started: bool): def remove_tray_server_url(): + """Remove tray information file. + + Called from tray logic, do not use on your own. + """ filepath = _get_tray_info_filepath() if not os.path.exists(filepath): return @@ -149,6 +207,16 @@ def get_tray_information( server_url: Optional[str] = None, variant: Optional[str] = None ) -> Optional[Dict[str, Any]]: + """Get information about tray. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + Optional[Dict[str, Any]]: Tray information. + + """ tray_url = get_tray_server_url(server_url, variant) return _get_tray_information(tray_url) @@ -156,7 +224,17 @@ def get_tray_information( def get_tray_state( server_url: Optional[str] = None, variant: Optional[str] = None -): +) -> int: + """Get tray state for AYON server and variant. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + int: Tray state. + + """ file_info = get_tray_file_info(server_url, variant) if file_info is None: return TrayState.NOT_RUNNING @@ -177,6 +255,16 @@ def is_tray_running( server_url: Optional[str] = None, variant: Optional[str] = None ) -> bool: + """Check if tray is running. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + + Returns: + bool: True if tray is running + + """ state = get_tray_state(server_url, variant) return state != TrayState.NOT_RUNNING From 3416c60a65c1bde2e162ba4e2d6816e0fbaac473 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:20:12 +0200 Subject: [PATCH 134/163] added some functions to init for api --- client/ayon_core/tools/tray/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index d646880e15..9dbacc54c2 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,7 +1,10 @@ from .webserver import HostMsgAction from .addons_manager import TrayAddonsManager from .lib import ( + TrayState, + get_tray_state, is_tray_running, + get_tray_server_url, main, ) @@ -9,5 +12,10 @@ from .lib import ( __all__ = ( "HostMsgAction", "TrayAddonsManager", + + "TrayState", + "get_tray_state", + "is_tray_running", + "get_tray_server_url", "main", ) From d482cf7e8afae376dba6e1bd14a61ce8c7cb64b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:40:14 +0200 Subject: [PATCH 135/163] removed unused imports --- client/ayon_core/addon/base.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_core/addon/base.py b/client/ayon_core/addon/base.py index cd952edffc..0ffad2045e 100644 --- a/client/ayon_core/addon/base.py +++ b/client/ayon_core/addon/base.py @@ -22,8 +22,6 @@ from ayon_core.settings import get_studio_settings from .interfaces import ( IPluginPaths, IHostAddon, - ITrayAddon, - ITrayService ) # Files that will be always ignored on addons import From 05a13d63cb56a64680564af5ae9db55875ea975d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:21:18 +0200 Subject: [PATCH 136/163] fix multiple bugs --- client/ayon_core/tools/tray/lib.py | 13 +-- client/ayon_core/tools/tray/ui/tray.py | 112 +++++++++++++++---------- 2 files changed, 74 insertions(+), 51 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index b393ad0564..7b057eeb49 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -82,12 +82,12 @@ def get_tray_storage_dir() -> str: def _get_tray_information(tray_url: str) -> Optional[Dict[str, Any]]: - response = requests.get(f"{tray_url}/tray") try: + response = requests.get(f"{tray_url}/tray") response.raise_for_status() - except requests.HTTPError: + return response.json() + except (requests.HTTPError, requests.ConnectionError): return None - return response.json() def _get_tray_info_filepath( @@ -196,11 +196,12 @@ def remove_tray_server_url(): filepath = _get_tray_info_filepath() if not os.path.exists(filepath): return + with open(filepath, "r") as stream: data = json.load(stream) - if data.get("pid") != os.getpid(): - return - os.remove(filepath) + + if data.get("pid") == os.getpid(): + os.remove(filepath) def get_tray_information( diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 0ae0e04260..51fde675ad 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -62,32 +62,46 @@ class TrayManager: ) if update_check_interval is None: update_check_interval = 5 - self._update_check_interval = update_check_interval * 60 * 1000 + + update_check_interval = update_check_interval * 60 * 1000 + + # create timer loop to check callback functions + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(300) + + update_check_timer = QtCore.QTimer() + if update_check_interval > 0: + update_check_timer.setInterval(update_check_interval) + + main_thread_timer.timeout.connect(self._main_thread_execution) + update_check_timer.timeout.connect(self._on_update_check_timer) self._addons_manager = TrayAddonsManager(self) - self._host_listener = HostListener(self._addons_manager, self) self.errors = [] - self._update_check_timer = None self._outdated_dialog = None - self._main_thread_timer = None + self._update_check_timer = update_check_timer + self._update_check_interval = update_check_interval + self._main_thread_timer = main_thread_timer self._main_thread_callbacks = collections.deque() self._execution_in_progress = None - self._closing = False self._services_submenu = None self._start_time = time.time() + self._closing = False try: set_tray_server_url( self._addons_manager.webserver_url, False ) except TrayIsRunningError: self.log.error("Tray is already running.") - self.exit() - return + self._closing = True + + def is_closing(self): + return self._closing @property def doubleclick_callback(self): @@ -122,6 +136,8 @@ class TrayManager: def initialize_addons(self): """Add addons to tray.""" + if self._closing: + return tray_menu = self.tray_widget.menu self._addons_manager.initialize(tray_menu) @@ -162,24 +178,15 @@ class TrayManager: # Print time report self._addons_manager.print_report() - # create timer loop to check callback functions - main_thread_timer = QtCore.QTimer() - main_thread_timer.setInterval(300) - main_thread_timer.timeout.connect(self._main_thread_execution) - main_thread_timer.start() + self._main_thread_timer.start() - self._main_thread_timer = main_thread_timer - - update_check_timer = QtCore.QTimer() if self._update_check_interval > 0: - update_check_timer.timeout.connect(self._on_update_check_timer) - update_check_timer.setInterval(self._update_check_interval) - update_check_timer.start() - self._update_check_timer = update_check_timer + self._update_check_timer.start() self.execute_in_main_thread(self._startup_validations) - - set_tray_server_url(self._addons_manager.webserver_url, True) + set_tray_server_url( + self._addons_manager.webserver_url, True + ) def get_services_submenu(self): return self._services_submenu @@ -244,7 +251,10 @@ class TrayManager: def exit(self): self._closing = True - self.tray_widget.exit() + if self._main_thread_timer.isActive(): + self.execute_in_main_thread(self.tray_widget.exit) + else: + self.tray_widget.exit() def on_exit(self): remove_tray_server_url() @@ -349,20 +359,24 @@ class TrayManager: ) def _main_thread_execution(self): - if self._execution_in_progress: - return - self._execution_in_progress = True - for _ in range(len(self._main_thread_callbacks)): - if self._main_thread_callbacks: - item = self._main_thread_callbacks.popleft() - try: - item.execute() - except BaseException: - self.log.erorr( - "Main thread execution failed", exc_info=True - ) + try: + if self._execution_in_progress: + return + self._execution_in_progress = True + for _ in range(len(self._main_thread_callbacks)): + if self._main_thread_callbacks: + item = self._main_thread_callbacks.popleft() + try: + item.execute() + except BaseException: + self.log.erorr( + "Main thread execution failed", exc_info=True + ) - self._execution_in_progress = False + self._execution_in_progress = False + + except KeyboardInterrupt: + self.execute_in_main_thread(self.exit) def _startup_validations(self): """Run possible startup validations.""" @@ -476,19 +490,23 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): def __init__(self, parent): icon = QtGui.QIcon(resources.get_ayon_icon_filepath()) - super(SystemTrayIcon, self).__init__(icon, parent) + super().__init__(icon, parent) self._exited = False + self._doubleclick = False + self._click_pos = None + self._initializing_addons = False + # Store parent - QtWidgets.QMainWindow() - self.parent = parent + self._parent = parent # Setup menu in Tray self.menu = QtWidgets.QMenu() self.menu.setStyleSheet(style.load_stylesheet()) # Set addons - self.tray_man = TrayManager(self, self.parent) + self._tray_manager = TrayManager(self, parent) # Add menu to Context of SystemTrayIcon self.setContextMenu(self.menu) @@ -508,10 +526,9 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): click_timer.timeout.connect(self._click_timer_timeout) self._click_timer = click_timer - self._doubleclick = False - self._click_pos = None - self._initializing_addons = False + def is_closing(self) -> bool: + return self._tray_manager.is_closing() @property def initializing_addons(self): @@ -520,7 +537,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): def initialize_addons(self): self._initializing_addons = True try: - self.tray_man.initialize_addons() + self._tray_manager.initialize_addons() finally: self._initializing_addons = False @@ -530,7 +547,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): # Reset bool value self._doubleclick = False if doubleclick: - self.tray_man.execute_doubleclick() + self._tray_manager.execute_doubleclick() else: self._show_context_menu() @@ -544,7 +561,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): def on_systray_activated(self, reason): # show contextMenu if left click if reason == QtWidgets.QSystemTrayIcon.Trigger: - if self.tray_man.doubleclick_callback: + if self._tray_manager.doubleclick_callback: self._click_pos = QtGui.QCursor().pos() self._click_timer.start() else: @@ -563,7 +580,7 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon): self._exited = True self.hide() - self.tray_man.on_exit() + self._tray_manager.on_exit() QtCore.QCoreApplication.exit() @@ -588,6 +605,11 @@ class TrayStarter(QtCore.QObject): self._start_timer = start_timer def _on_start_timer(self): + if self._tray_widget.is_closing(): + self._start_timer.stop() + self._tray_widget.exit() + return + if self._timer_counter == 0: self._timer_counter += 1 splash = self._get_splash() From b5f7162918eb528b4159e07c2445371f7095de1f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:28:50 +0200 Subject: [PATCH 137/163] fix 'set_tray_server_url' --- client/ayon_core/tools/tray/lib.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 7b057eeb49..198382b44c 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -82,6 +82,8 @@ def get_tray_storage_dir() -> str: 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() @@ -173,11 +175,13 @@ def set_tray_server_url(tray_url: str, started: bool): that tray is starting up. """ - filepath = _get_tray_info_filepath() - if os.path.exists(filepath): - info = get_tray_file_info() - if info.get("pid") != os.getpid(): + file_info = get_tray_file_info() + if file_info and file_info.get("pid") != os.getpid(): + tray_url = file_info.get("url") + if _get_tray_information(tray_url): raise TrayIsRunningError("Tray is already running.") + + filepath = _get_tray_info_filepath() os.makedirs(os.path.dirname(filepath), exist_ok=True) data = { "url": tray_url, From ebc1c62f29dcfaed423cf04c3a69e053361b9b4d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 13:39:48 +0200 Subject: [PATCH 138/163] small enhancements --- client/ayon_core/tools/tray/lib.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 198382b44c..66a494b727 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -176,9 +176,9 @@ def set_tray_server_url(tray_url: str, started: bool): """ file_info = get_tray_file_info() - if file_info and file_info.get("pid") != os.getpid(): - tray_url = file_info.get("url") - if _get_tray_information(tray_url): + if file_info and file_info["pid"] != os.getpid(): + tray_url = file_info["url"] + if not file_info["started"] or _get_tray_information(tray_url): raise TrayIsRunningError("Tray is already running.") filepath = _get_tray_info_filepath() @@ -281,14 +281,13 @@ def main(): state = get_tray_state() if state == TrayState.RUNNING: - # TODO send some information to tray? print("Tray is already running.") return if state == TrayState.STARTING: + # TODO try to handle stuck tray? print("Tray is starting.") return - # TODO try to handle stuck tray? time.sleep(5) state = get_tray_state() if state == TrayState.RUNNING: From 3f311759710495b0bfb546ce8a8f716ce8b83554 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 14:58:30 +0200 Subject: [PATCH 139/163] create addons manager only once for cli main --- client/ayon_core/cli.py | 31 ++++++++++++++++++++++++------- client/ayon_core/cli_commands.py | 21 --------------------- 2 files changed, 24 insertions(+), 28 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 60cf5624b0..5046c1bc86 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -12,7 +12,11 @@ import acre from ayon_core import AYON_CORE_ROOT from ayon_core.addon import AddonsManager from ayon_core.settings import get_general_environments -from ayon_core.lib import initialize_ayon_connection, is_running_from_build +from ayon_core.lib import ( + initialize_ayon_connection, + is_running_from_build, + Logger, +) from .cli_commands import Commands @@ -64,7 +68,6 @@ def tray(): Commands.launch_tray() -@Commands.add_addons @main_cli.group(help="Run command line arguments of AYON addons") @click.pass_context def addon(ctx): @@ -245,11 +248,9 @@ def _set_global_environments() -> None: os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" -def _set_addons_environments(): +def _set_addons_environments(addons_manager): """Set global environments for AYON addons.""" - addons_manager = AddonsManager() - # Merge environments with current environments and update values if module_envs := addons_manager.collect_global_environments(): parsed_envs = acre.parse(module_envs) @@ -258,6 +259,21 @@ def _set_addons_environments(): os.environ.update(env) +def _add_addons(addons_manager): + """Modules/Addons can add their cli commands dynamically.""" + log = Logger.get_logger("CLI-AddModules") + for addon_obj in addons_manager.addons: + try: + addon_obj.cli(addon) + + except Exception: + log.warning( + "Failed to add cli command for module \"{}\"".format( + addon_obj.name + ), exc_info=True + ) + + def main(*args, **kwargs): initialize_ayon_connection() python_path = os.getenv("PYTHONPATH", "") @@ -281,8 +297,9 @@ def main(*args, **kwargs): print(" - global AYON ...") _set_global_environments() print(" - for addons ...") - _set_addons_environments() - + addons_manager = AddonsManager() + _set_addons_environments(addons_manager) + _add_addons(addons_manager) try: main_cli(obj={}, prog_name="ayon") except Exception: # noqa diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 35b7e294de..3feb3e2f36 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -19,27 +19,6 @@ class Commands: tray.main() - @staticmethod - def add_addons(click_func): - """Modules/Addons can add their cli commands dynamically.""" - - from ayon_core.lib import Logger - from ayon_core.addon import AddonsManager - - manager = AddonsManager() - log = Logger.get_logger("CLI-AddModules") - for addon in manager.addons: - try: - addon.cli(click_func) - - except Exception: - log.warning( - "Failed to add cli command for module \"{}\"".format( - addon.name - ), exc_info=True - ) - return click_func - @staticmethod def publish(path: str, targets: list=None, gui:bool=False) -> None: """Start headless publishing. From 3333f03a1ecced7b4599a34122242868adc001ed Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:14:59 +0200 Subject: [PATCH 140/163] remove invalid docstring --- client/ayon_core/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 5046c1bc86..eab21d32ad 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -120,7 +120,7 @@ def publish(path, targets, gui): """Start CLI publishing. Publish collects json from path provided as an argument. -S + """ Commands.publish(path, targets, gui) From 065195929d6e5aa03fc7c384e119380f27722039 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:21:15 +0200 Subject: [PATCH 141/163] pass addons manager to callbacks --- client/ayon_core/cli.py | 18 ++++++++++++++---- client/ayon_core/cli_commands.py | 20 +++++++++++--------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index eab21d32ad..fad0482559 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -106,23 +106,30 @@ def extractenvironments(output_json_path, project, asset, task, app, envgroup): 'addon applications extractenvironments ...' instead. """ Commands.extractenvironments( - output_json_path, project, asset, task, app, envgroup + output_json_path, + project, + asset, + task, + app, + envgroup, + ctx.obj["addons_manager"] ) @main_cli.command() +@click.pass_context @click.argument("path", required=True) @click.option("-t", "--targets", help="Targets", default=None, multiple=True) @click.option("-g", "--gui", is_flag=True, help="Show Publish UI", default=False) -def publish(path, targets, gui): +def publish(ctx, path, targets, gui): """Start CLI publishing. Publish collects json from path provided as an argument. """ - Commands.publish(path, targets, gui) + Commands.publish(path, targets, gui, ctx.obj["addons_manager"]) @main_cli.command(context_settings={"ignore_unknown_options": True}) @@ -301,7 +308,10 @@ def main(*args, **kwargs): _set_addons_environments(addons_manager) _add_addons(addons_manager) try: - main_cli(obj={}, prog_name="ayon") + main_cli( + prog_name="ayon", + obj={"addons_manager": addons_manager}, + ) except Exception: # noqa exc_info = sys.exc_info() print("!!! AYON crashed:") diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 3feb3e2f36..874062cd46 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -20,7 +20,12 @@ class Commands: tray.main() @staticmethod - def publish(path: str, targets: list=None, gui:bool=False) -> None: + def publish( + path: str, + targets: list = None, + gui: bool = False, + addons_manager=None, + ) -> None: """Start headless publishing. Publish use json from passed path argument. @@ -81,14 +86,15 @@ class Commands: install_ayon_plugins() - manager = AddonsManager() + if addons_manager is None: + addons_manager = AddonsManager() - publish_paths = manager.collect_plugin_paths()["publish"] + publish_paths = addons_manager.collect_plugin_paths()["publish"] for plugin_path in publish_paths: pyblish.api.register_plugin_path(plugin_path) - applications_addon = manager.get_enabled_addon("applications") + applications_addon = addons_manager.get_enabled_addon("applications") if applications_addon is not None: context = get_global_context() env = applications_addon.get_farm_publish_environment_variables( @@ -137,15 +143,12 @@ class Commands: @staticmethod def extractenvironments( - output_json_path, project, asset, task, app, env_group + output_json_path, project, asset, task, app, env_group, addons_manager ): """Produces json file with environment based on project and app. Called by Deadline plugin to propagate environment into render jobs. """ - - from ayon_core.addon import AddonsManager - warnings.warn( ( "Command 'extractenvironments' is deprecated and will be" @@ -155,7 +158,6 @@ class Commands: DeprecationWarning ) - addons_manager = AddonsManager() applications_addon = addons_manager.get_enabled_addon("applications") if applications_addon is None: raise RuntimeError( From d6c9b33b91ef5fd84a4a8ac573e6fff97cded74d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:29:33 +0200 Subject: [PATCH 142/163] fix 'extractenvironments' --- client/ayon_core/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index fad0482559..6c3006b78a 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -83,6 +83,7 @@ main_cli.set_alias("addon", "module") @main_cli.command() +@click.pass_context @click.argument("output_json_path") @click.option("--project", help="Project name", default=None) @click.option("--asset", help="Folder path", default=None) @@ -91,7 +92,9 @@ main_cli.set_alias("addon", "module") @click.option( "--envgroup", help="Environment group (e.g. \"farm\")", default=None ) -def extractenvironments(output_json_path, project, asset, task, app, envgroup): +def extractenvironments( + ctx, output_json_path, project, asset, task, app, envgroup +): """Extract environment variables for entered context to a json file. Entered output filepath will be created if does not exists. From bb4ae624fb2299d3095f819b13793fedfb681641 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:13:17 +0200 Subject: [PATCH 143/163] Use addons over modules --- client/ayon_core/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 6c3006b78a..e97b8f1c5a 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -271,7 +271,7 @@ def _set_addons_environments(addons_manager): def _add_addons(addons_manager): """Modules/Addons can add their cli commands dynamically.""" - log = Logger.get_logger("CLI-AddModules") + log = Logger.get_logger("CLI-AddAddons") for addon_obj in addons_manager.addons: try: addon_obj.cli(addon) From 9201a6c354f436f0dee071988be7602f2a8cfd26 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:31:40 +0200 Subject: [PATCH 144/163] added typehings --- client/ayon_core/cli_commands.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 874062cd46..900cc237d1 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -2,7 +2,9 @@ """Implementation of AYON commands.""" import os import sys -import warnings +from typing import Optional, List + +from ayon_core.addon import AddonsManager class Commands: @@ -22,9 +24,9 @@ class Commands: @staticmethod def publish( path: str, - targets: list = None, - gui: bool = False, - addons_manager=None, + targets: Optional[List[str]] = None, + gui: Optional[bool] = False, + addons_manager: Optional[AddonsManager] = None, ) -> None: """Start headless publishing. @@ -32,8 +34,9 @@ class Commands: Args: path (str): Path to JSON. - targets (list of str): List of pyblish targets. - gui (bool): Show publish UI. + targets (Optional[List[str]]): List of pyblish targets. + gui (Optional[bool]): Show publish UI. + addons_manager (Optional[AddonsManager]): Addons manager instance. Raises: RuntimeError: When there is no path to process. From e4b6c0c7770acec99e6b217150d0d2bf40d2c50f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 Jul 2024 18:12:25 +0200 Subject: [PATCH 145/163] fix warnings import --- client/ayon_core/cli_commands.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 900cc237d1..ebc559ec4e 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -2,6 +2,7 @@ """Implementation of AYON commands.""" import os import sys +import warnings from typing import Optional, List from ayon_core.addon import AddonsManager From a2850087553b080a7c3036ca9a3b5783500d1e20 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:28:14 +0200 Subject: [PATCH 146/163] fix typo Co-authored-by: Roy Nieterau --- client/ayon_core/tools/tray/addons_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/addons_manager.py index 166b8ab5c6..3fe4bb8dd8 100644 --- a/client/ayon_core/tools/tray/addons_manager.py +++ b/client/ayon_core/tools/tray/addons_manager.py @@ -235,13 +235,13 @@ class TrayAddonsManager(AddonsManager): def _init_tray_webserver(self): webserver_url = self.webserver_url - statisc_url = f"{webserver_url}/res" + statics_url = f"{webserver_url}/res" # 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"] = statisc_url + os.environ["AYON_STATICS_SERVER"] = statics_url # Deprecated os.environ["OPENPYPE_WEBSERVER_URL"] = webserver_url - os.environ["OPENPYPE_STATICS_SERVER"] = statisc_url + os.environ["OPENPYPE_STATICS_SERVER"] = statics_url From 136da2b4709c87bc4c7d076f2a662de07a44adfa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:57:30 +0200 Subject: [PATCH 147/163] exit if another tray is discovered --- client/ayon_core/tools/tray/ui/tray.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 51fde675ad..660c61ac94 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -184,9 +184,13 @@ class TrayManager: self._update_check_timer.start() self.execute_in_main_thread(self._startup_validations) - set_tray_server_url( - self._addons_manager.webserver_url, True - ) + try: + set_tray_server_url( + self._addons_manager.webserver_url, True + ) + except TrayIsRunningError: + self.log.warning("Other tray started meanwhile. Exiting.") + self.exit() def get_services_submenu(self): return self._services_submenu From 131afb6684541dbaebde68a540feecf00ab3a99a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 09:57:59 +0200 Subject: [PATCH 148/163] call 'set_tray_server_url' as soon as possible --- client/ayon_core/tools/tray/lib.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 66a494b727..a3c69480b4 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -164,13 +164,13 @@ def get_tray_server_url( return None -def set_tray_server_url(tray_url: str, started: bool): +def set_tray_server_url(tray_url: Optional[str], started: bool): """Add tray server information file. Called from tray logic, do not use on your own. Args: - tray_url (str): Webserver url with port. + tray_url (Optional[str]): Webserver url with port. started (bool): If tray is started. When set to 'False' it means that tray is starting up. @@ -299,5 +299,12 @@ def main(): os.kill(pid, signal.SIGTERM) remove_tray_server_url() + # Prepare the file with 'pid' information as soon as possible + try: + set_tray_server_url(None, False) + except TrayIsRunningError: + print("Tray is running") + sys.exit(1) + main() From aee9bd3f81547c52481f659b1ed03e36d1635ea2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:47:21 +0200 Subject: [PATCH 149/163] don't override tray_url --- client/ayon_core/tools/tray/lib.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index a3c69480b4..7462c5d7c6 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -177,8 +177,7 @@ def set_tray_server_url(tray_url: Optional[str], started: bool): """ file_info = get_tray_file_info() if file_info and file_info["pid"] != os.getpid(): - tray_url = file_info["url"] - if not file_info["started"] or _get_tray_information(tray_url): + if not file_info["started"] or _get_tray_information(file_info["url"]): raise TrayIsRunningError("Tray is already running.") filepath = _get_tray_info_filepath() From 43f9f5114573ce03da449a05aa960b3af73daf62 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:48:16 +0200 Subject: [PATCH 150/163] 'remove_tray_server_url' has force option --- client/ayon_core/tools/tray/lib.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 7462c5d7c6..555937579f 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -191,19 +191,26 @@ def set_tray_server_url(tray_url: Optional[str], started: bool): json.dump(data, stream) -def remove_tray_server_url(): +def remove_tray_server_url(force: Optional[bool] = False): """Remove tray information file. Called from tray logic, do not use on your own. + + Args: + force (Optional[bool]): Force remove tray information file. + """ filepath = _get_tray_info_filepath() if not os.path.exists(filepath): return - with open(filepath, "r") as stream: - data = json.load(stream) + try: + with open(filepath, "r") as stream: + data = json.load(stream) + except BaseException: + data = {} - if data.get("pid") == os.getpid(): + if force or not data or data.get("pid") == os.getpid(): os.remove(filepath) @@ -250,7 +257,7 @@ def get_tray_state( info = _get_tray_information(tray_url) if not info: # Remove the information as the tray is not running - remove_tray_server_url() + remove_tray_server_url(force=True) return TrayState.NOT_RUNNING return TrayState.RUNNING @@ -296,7 +303,7 @@ def main(): pid = file_info.get("pid") if pid is not None: os.kill(pid, signal.SIGTERM) - remove_tray_server_url() + remove_tray_server_url(force=True) # Prepare the file with 'pid' information as soon as possible try: From 03c93a345d190a30edd9a453d83675fd883810fe Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:51:17 +0200 Subject: [PATCH 151/163] impemented waiting for starting tray --- client/ayon_core/tools/tray/lib.py | 78 ++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 555937579f..e13c682ab0 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -1,6 +1,8 @@ import os +import sys import json import hashlib +import platform import subprocess import csv import time @@ -56,6 +58,28 @@ def _windows_pid_is_running(pid: int) -> bool: return False +def _is_process_running(pid: int) -> bool: + """Check whether process with pid is running.""" + if platform.system().lower() == "windows": + return _windows_pid_is_running(pid) + + if pid == 0: + return True + + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _kill_tray_process(pid: int): + if _is_process_running(pid): + os.kill(pid, signal.SIGTERM) + + def _create_tray_hash(server_url: str, variant: str) -> str: """Create tray hash for metadata filename. @@ -71,6 +95,38 @@ def _create_tray_hash(server_url: str, variant: str) -> str: return hashlib.sha256(data.encode()).hexdigest() +def _wait_for_starting_tray( + server_url: Optional[str] = None, + variant: Optional[str] = None, + timeout: Optional[int] = None +) -> Optional[Dict[str, Any]]: + """Wait for tray to start. + + Args: + server_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + timeout (Optional[int]): Timeout for tray validation. + + Returns: + Optional[Dict[str, Any]]: Tray file information. + + """ + if timeout is None: + timeout = 10 + started_at = time.time() + while True: + data = get_tray_file_info(server_url, variant) + if data is None: + return None + + if data.get("started") is True: + return data + + if time.time() - started_at > timeout: + return None + time.sleep(0.1) + + def get_tray_storage_dir() -> str: """Get tray storage directory. @@ -134,6 +190,7 @@ def get_tray_server_url( validate: Optional[bool] = False, server_url: Optional[str] = None, variant: Optional[str] = None, + timeout: Optional[int] = None ) -> Optional[str]: """Get tray server url. @@ -144,6 +201,7 @@ def get_tray_server_url( variant (Optional[str]): Settings variant. validate (Optional[bool]): Validate if tray is running. By default, does not validate. + timeout (Optional[int]): Timeout for tray start-up. Returns: Optional[str]: Tray server url. @@ -152,6 +210,12 @@ def get_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 @@ -291,18 +355,22 @@ def main(): return if state == TrayState.STARTING: - # TODO try to handle stuck tray? - print("Tray is starting.") - return - time.sleep(5) + print("Tray is starting. Waiting for it to start.") + _wait_for_starting_tray() state = get_tray_state() if state == TrayState.RUNNING: + print("Tray started. Exiting.") return + if 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") if pid is not None: - os.kill(pid, signal.SIGTERM) + _kill_tray_process(pid) remove_tray_server_url(force=True) # Prepare the file with 'pid' information as soon as possible From 90bb6a841be6fbf7a9095f1977f9dfddceee4014 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:49:15 +0200 Subject: [PATCH 152/163] added force option to tray # Conflicts: # client/ayon_core/tools/tray/lib.py --- client/ayon_core/cli.py | 11 +++++++++-- client/ayon_core/cli_commands.py | 6 ------ client/ayon_core/tools/tray/lib.py | 10 +++++++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index e97b8f1c5a..ee993ecd82 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -59,13 +59,20 @@ def main_cli(ctx): @main_cli.command() -def tray(): +@click.option( + "--force", + is_flag=True, + help="Force to start tray and close any existing one.") +def tray(force): """Launch AYON tray. Default action of AYON command is to launch tray widget to control basic aspects of AYON. See documentation for more information. """ - Commands.launch_tray() + + from ayon_core.tools.tray import main + + main(force) @main_cli.group(help="Run command line arguments of AYON addons") diff --git a/client/ayon_core/cli_commands.py b/client/ayon_core/cli_commands.py index 9d871c54b1..8ae1ebb3ba 100644 --- a/client/ayon_core/cli_commands.py +++ b/client/ayon_core/cli_commands.py @@ -13,12 +13,6 @@ class Commands: Most of its methods are called by :mod:`cli` module. """ - @staticmethod - def launch_tray(): - from ayon_core.tools.tray import main - - main() - @staticmethod def publish( path: str, diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index e13c682ab0..752c1ee842 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -344,12 +344,20 @@ def is_tray_running( return state != TrayState.NOT_RUNNING -def main(): +def main(force=False): from ayon_core.tools.tray.ui import main 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") + if pid is not None: + _kill_tray_process(pid) + remove_tray_server_url(force=True) + state = TrayState.NOT_RUNNING + if state == TrayState.RUNNING: print("Tray is already running.") return From d1c85ea2af856063d789e28719641aaa78fd50b0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 16:09:50 +0200 Subject: [PATCH 153/163] added hidden force to main cli --- client/ayon_core/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index ee993ecd82..5936316e2c 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -43,6 +43,7 @@ class AliasedGroup(click.Group): help="Enable debug") @click.option("--verbose", expose_value=False, help=("Change AYON log level (debug - critical or 0-50)")) +@click.option("--force", is_flag=True, expose_value=False, hidden=True) def main_cli(ctx): """AYON is main command serving as entry point to pipeline system. From 9c01ddaf638f19988fefcef76cd6b516d4cfc57c Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 15:47:08 +0200 Subject: [PATCH 154/163] make starting tray check faster --- client/ayon_core/tools/tray/lib.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index e13c682ab0..16a6770d82 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -122,6 +122,10 @@ def _wait_for_starting_tray( if data.get("started") is True: return data + pid = data.get("pid") + if pid and not _is_process_running(pid): + return None + if time.time() - started_at > timeout: return None time.sleep(0.1) From a4de305fde378698ae59cfe4d66472213a7024c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 16:31:10 +0200 Subject: [PATCH 155/163] remove tray filepath if pid is not running --- client/ayon_core/tools/tray/lib.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 16a6770d82..abe8a7a11d 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -278,7 +278,12 @@ def remove_tray_server_url(force: Optional[bool] = False): except BaseException: data = {} - if force or not data or data.get("pid") == os.getpid(): + if ( + force + or not data + or data.get("pid") == os.getpid() + or not _is_process_running(data.get("pid")) + ): os.remove(filepath) From 17e04cd8849216b85557e32293936abce85f7344 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 16:31:23 +0200 Subject: [PATCH 156/163] call 'remove_tray_server_url' in wait for tray to start --- client/ayon_core/tools/tray/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index abe8a7a11d..2c3a577641 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -124,6 +124,7 @@ def _wait_for_starting_tray( pid = data.get("pid") if pid and not _is_process_running(pid): + remove_tray_server_url() return None if time.time() - started_at > timeout: From 715f547adf1e4539b6841d70774754bb143b28fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:11:00 +0200 Subject: [PATCH 157/163] fix possible encoding issues --- client/ayon_core/tools/tray/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 752c1ee842..20770d5136 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -7,6 +7,7 @@ import subprocess import csv import time import signal +import locale from typing import Optional, Dict, Tuple, Any import ayon_api @@ -50,7 +51,8 @@ def _get_server_and_variant( def _windows_pid_is_running(pid: int) -> bool: args = ["tasklist.exe", "/fo", "csv", "/fi", f"PID eq {pid}"] output = subprocess.check_output(args) - csv_content = csv.DictReader(output.decode("utf-8").splitlines()) + encoding = locale.getpreferredencoding() + csv_content = csv.DictReader(output.decode(encoding).splitlines()) # if "PID" not in csv_content.fieldnames: # return False for _ in csv_content: From 5d18e69c7a98d417acfa62ff061905ff46812c39 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 17:25:20 +0200 Subject: [PATCH 158/163] forward force to tray --- client/ayon_core/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/ayon_core/cli.py b/client/ayon_core/cli.py index 5936316e2c..0a9bb2aa9c 100644 --- a/client/ayon_core/cli.py +++ b/client/ayon_core/cli.py @@ -43,8 +43,8 @@ class AliasedGroup(click.Group): help="Enable debug") @click.option("--verbose", expose_value=False, help=("Change AYON log level (debug - critical or 0-50)")) -@click.option("--force", is_flag=True, expose_value=False, hidden=True) -def main_cli(ctx): +@click.option("--force", is_flag=True, hidden=True) +def main_cli(ctx, force): """AYON is main command serving as entry point to pipeline system. It wraps different commands together. @@ -56,7 +56,7 @@ def main_cli(ctx): print(ctx.get_help()) sys.exit(0) else: - ctx.invoke(tray) + ctx.forward(tray) @main_cli.command() From 4511f8db5bb3b44fcac30ba8231e00ebd1c02f1d Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:32:16 +0200 Subject: [PATCH 159/163] move addons manager to ui --- client/ayon_core/tools/tray/__init__.py | 2 -- client/ayon_core/tools/tray/{ => ui}/addons_manager.py | 0 client/ayon_core/tools/tray/ui/tray.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) rename client/ayon_core/tools/tray/{ => ui}/addons_manager.py (100%) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index 9dbacc54c2..c8fcd7841e 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,5 +1,4 @@ from .webserver import HostMsgAction -from .addons_manager import TrayAddonsManager from .lib import ( TrayState, get_tray_state, @@ -11,7 +10,6 @@ from .lib import ( __all__ = ( "HostMsgAction", - "TrayAddonsManager", "TrayState", "get_tray_state", diff --git a/client/ayon_core/tools/tray/addons_manager.py b/client/ayon_core/tools/tray/ui/addons_manager.py similarity index 100% rename from client/ayon_core/tools/tray/addons_manager.py rename to client/ayon_core/tools/tray/ui/addons_manager.py diff --git a/client/ayon_core/tools/tray/ui/tray.py b/client/ayon_core/tools/tray/ui/tray.py index 660c61ac94..2a2c79129b 100644 --- a/client/ayon_core/tools/tray/ui/tray.py +++ b/client/ayon_core/tools/tray/ui/tray.py @@ -28,13 +28,13 @@ from ayon_core.tools.utils import ( WrappedCallbackItem, get_ayon_qt_app, ) -from ayon_core.tools.tray import TrayAddonsManager from ayon_core.tools.tray.lib import ( set_tray_server_url, remove_tray_server_url, TrayIsRunningError, ) +from .addons_manager import TrayAddonsManager from .host_console_listener import HostListener from .info_widget import InfoWidget from .dialogs import ( From a4bb042337daf099c1a4adb5a9414da892b603b8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:32:45 +0200 Subject: [PATCH 160/163] move structures out of webserver --- client/ayon_core/tools/tray/__init__.py | 2 +- client/ayon_core/tools/tray/{webserver => }/structures.py | 0 client/ayon_core/tools/tray/webserver/__init__.py | 2 -- 3 files changed, 1 insertion(+), 3 deletions(-) rename client/ayon_core/tools/tray/{webserver => }/structures.py (100%) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index c8fcd7841e..2490122358 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -1,4 +1,4 @@ -from .webserver import HostMsgAction +from .structures import HostMsgAction from .lib import ( TrayState, get_tray_state, diff --git a/client/ayon_core/tools/tray/webserver/structures.py b/client/ayon_core/tools/tray/structures.py similarity index 100% rename from client/ayon_core/tools/tray/webserver/structures.py rename to client/ayon_core/tools/tray/structures.py diff --git a/client/ayon_core/tools/tray/webserver/__init__.py b/client/ayon_core/tools/tray/webserver/__init__.py index 93bfbd6aee..c40b5b85c3 100644 --- a/client/ayon_core/tools/tray/webserver/__init__.py +++ b/client/ayon_core/tools/tray/webserver/__init__.py @@ -1,10 +1,8 @@ -from .structures import HostMsgAction from .base_routes import RestApiEndpoint from .server import find_free_port, WebServerManager __all__ = ( - "HostMsgAction", "RestApiEndpoint", "find_free_port", "WebServerManager", From 5bf69857378cb1fe653be7fbb774f543ca8d78a6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:33:04 +0200 Subject: [PATCH 161/163] implemented helper function 'make_sure_tray_is_running' to run tray --- client/ayon_core/tools/tray/__init__.py | 2 ++ client/ayon_core/tools/tray/lib.py | 40 ++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/client/ayon_core/tools/tray/__init__.py b/client/ayon_core/tools/tray/__init__.py index 2490122358..2e179f0620 100644 --- a/client/ayon_core/tools/tray/__init__.py +++ b/client/ayon_core/tools/tray/__init__.py @@ -4,6 +4,7 @@ from .lib import ( get_tray_state, is_tray_running, get_tray_server_url, + make_sure_tray_is_running, main, ) @@ -15,5 +16,6 @@ __all__ = ( "get_tray_state", "is_tray_running", "get_tray_server_url", + "make_sure_tray_is_running", "main", ) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 76cf20d3b4..5018dc6620 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -13,7 +13,7 @@ from typing import Optional, Dict, Tuple, Any import ayon_api import requests -from ayon_core.lib import Logger +from ayon_core.lib import Logger, get_ayon_launcher_args, run_detached_process from ayon_core.lib.local_settings import get_ayon_appdirs @@ -356,6 +356,44 @@ def is_tray_running( return state != TrayState.NOT_RUNNING +def make_sure_tray_is_running( + ayon_url: Optional[str] = None, + variant: Optional[str] = None, + env: Optional[Dict[str, str]] = None +): + """Make sure that tray for AYON url and variant is running. + + Args: + ayon_url (Optional[str]): AYON server url. + variant (Optional[str]): Settings variant. + env (Optional[Dict[str, str]]): Environment variables for the process. + + """ + state = get_tray_state(ayon_url, variant) + if state == TrayState.RUNNING: + return + + if state == TrayState.STARTING: + _wait_for_starting_tray(ayon_url, variant) + state = get_tray_state(ayon_url, variant) + if state == TrayState.RUNNING: + return + + args = get_ayon_launcher_args("tray", "--force") + if env is None: + env = os.environ.copy() + + if ayon_url: + env["AYON_SERVER_URL"] = ayon_url + + # TODO maybe handle variant in a better way + if variant: + if variant == "staging": + args.append("--use-staging") + + run_detached_process(args, env=env) + + def main(force=False): from ayon_core.tools.tray.ui import main From adc55dee1a844575c3bf8cc46fd4e3ca26174067 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 Jul 2024 18:38:52 +0200 Subject: [PATCH 162/163] unset QT_API --- client/ayon_core/tools/tray/lib.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/ayon_core/tools/tray/lib.py b/client/ayon_core/tools/tray/lib.py index 5018dc6620..c26f4835b1 100644 --- a/client/ayon_core/tools/tray/lib.py +++ b/client/ayon_core/tools/tray/lib.py @@ -382,6 +382,9 @@ def make_sure_tray_is_running( 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) if ayon_url: env["AYON_SERVER_URL"] = ayon_url From 1d23d076fc49fc4c56bf63a5cb0dc4e4f6b2348a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 25 Jul 2024 10:40:46 +0200 Subject: [PATCH 163/163] fix import in broker --- client/ayon_core/tools/stdout_broker/broker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_core/tools/stdout_broker/broker.py b/client/ayon_core/tools/stdout_broker/broker.py index 4f7118e2a8..c449fa7df9 100644 --- a/client/ayon_core/tools/stdout_broker/broker.py +++ b/client/ayon_core/tools/stdout_broker/broker.py @@ -8,7 +8,7 @@ from datetime import datetime import websocket from ayon_core.lib import Logger -from ayon_core.tools.tray.webserver import HostMsgAction +from ayon_core.tools.tray import HostMsgAction log = Logger.get_logger(__name__)